Compare commits
154 Commits
30bbfa7e11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b81be6614 | |||
|
|
a74abbb0b3 | ||
|
|
20accb9c93 | ||
|
|
46b9db1db3 | ||
|
|
4b017a83bb | ||
|
|
49822f8af7 | ||
|
|
338ca9b4fd | ||
|
|
99f662e2d5 | ||
|
|
5977e0a0dc | ||
|
|
2b0d8470e5 | ||
|
|
351ee9fd52 | ||
|
|
a526a51af8 | ||
|
|
fcde78abad | ||
| 26737f2f11 | |||
|
|
d9fa2bc501 | ||
|
|
5f38996665 | ||
| d70fc9f918 | |||
|
|
14dbd374ec | ||
|
|
dc6e15492a | ||
|
|
45eac0c396 | ||
|
|
ed7d31bf5c | ||
|
|
468d2e7f8c | ||
|
|
2a761b9d47 | ||
|
|
a9e4633b64 | ||
|
|
778988977f | ||
|
|
9a9ff95bf3 | ||
|
|
7517678e87 | ||
| 4281d94d66 | |||
|
|
752e38adf9 | ||
|
|
9c538069e4 | ||
|
|
71fed58af9 | ||
|
|
fee3355a65 | ||
|
|
2ff7f1a13d | ||
|
|
6bf1b1ea87 | ||
|
|
3ef012eb43 | ||
|
|
5df60b236c | ||
|
|
df3c694909 | ||
|
|
a1a1fcf39d | ||
|
|
b40e651be4 | ||
|
|
9427f4e50f | ||
|
|
ed9791c176 | ||
|
|
c6dabafd93 | ||
|
|
e88cca9289 | ||
|
|
d4cf6764b4 | ||
|
|
5a065ae5a1 | ||
|
|
20110168f2 | ||
|
|
f88729cc77 | ||
|
|
d21de1bf78 | ||
|
|
bc4ab58644 | ||
|
|
37e0c29788 | ||
|
|
46ce0f7aab | ||
|
|
128f341399 | ||
|
|
64b97794a6 | ||
|
|
d6eb02bb28 | ||
|
|
a711e4e12a | ||
|
|
05e4c082ed | ||
|
|
b602b5022b | ||
|
|
5249d88df7 | ||
|
|
e553cf1f10 | ||
|
|
0fdded79d7 | ||
| 8dbfde882f | |||
|
|
a6c8b50cea | ||
|
|
11c4750d0e | ||
| 03917cf947 | |||
|
|
9b90080c57 | ||
| 9c6ff3859a | |||
|
|
6f65aa207e | ||
|
|
87bba388dc | ||
|
|
55e2efc222 | ||
|
|
0fa3437661 | ||
|
|
efe1b4f9df | ||
|
|
0dd08874bc | ||
|
|
13818d5844 | ||
|
|
ac73fd5044 | ||
|
|
594af924c7 | ||
|
|
af4137f47f | ||
|
|
ba186d00a1 | ||
|
|
6cd325ce38 | ||
|
|
0c3dcc243a | ||
|
|
e7225a6009 | ||
|
|
0d38a2dc9b | ||
|
|
ba20d6d264 | ||
|
|
6d06f9f877 | ||
|
|
db7ffd3246 | ||
|
|
37a528f0aa | ||
|
|
85b14cd89b | ||
|
|
8fbd6f5935 | ||
|
|
80a9802c42 | ||
|
|
fe5940adbf | ||
|
|
f7fe71f8e3 | ||
|
|
db518a6469 | ||
|
|
5946f66e69 | ||
|
|
2046394906 | ||
|
|
887ca6e5e1 | ||
|
|
c86b5f5db8 | ||
|
|
f0aa89097e | ||
|
|
80feda41a3 | ||
|
|
3a813b019b | ||
|
|
fb6cd495d3 | ||
|
|
44bbac4695 | ||
|
|
8fa376ef94 | ||
|
|
9f84769fba | ||
|
|
1b0451faff | ||
|
|
338f4e106c | ||
|
|
fbf6f3dcb4 | ||
|
|
d516a383e1 | ||
|
|
12718593e3 | ||
|
|
9983be650a | ||
|
|
e85f6639ff | ||
|
|
3a9bd0c465 | ||
|
|
9af81c3f17 | ||
|
|
248ca7d818 | ||
|
|
38f4243739 | ||
|
|
0ca5115d10 | ||
|
|
f8f295e5a0 | ||
|
|
bf79cbb26f | ||
|
|
661f3f0ae3 | ||
|
|
7b8b41021c | ||
|
|
c4daf47628 | ||
|
|
4f4afaebdf | ||
|
|
ea858dfdda | ||
|
|
629dd15628 | ||
|
|
81d228290b | ||
|
|
ff39299499 | ||
|
|
750b8ae7b8 | ||
|
|
7aa1ddd405 | ||
|
|
121eab54d9 | ||
|
|
f134a343be | ||
|
|
b891f4c64b | ||
|
|
e125b2c795 | ||
|
|
d9925da96f | ||
|
|
bd480f9592 | ||
|
|
8d571042d8 | ||
|
|
2a65cedd0a | ||
|
|
560eda6ac2 | ||
|
|
5dbebc2b77 | ||
|
|
98101217db | ||
|
|
cd5abea56c | ||
|
|
7cb9ae9ede | ||
|
|
043db46eaf | ||
|
|
8617f2c117 | ||
|
|
089dc73abe | ||
|
|
cec166182c | ||
|
|
eac47fb99d | ||
|
|
83f2bdcdda | ||
|
|
12d57c59c1 | ||
|
|
d36ab3c993 | ||
|
|
3e8e8a70c7 | ||
|
|
2ee49b7cbd | ||
|
|
10437a2bf3 | ||
|
|
acd656ddd1 | ||
|
|
7f2fcb1797 | ||
|
|
12222634d3 | ||
|
|
0c8b8e989f |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -10,6 +10,9 @@ node_modules
|
||||
/build
|
||||
/dist
|
||||
|
||||
# Git worktrees (isolated development branches)
|
||||
.worktrees
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -43,3 +46,4 @@ storybook-static
|
||||
|
||||
# Tests
|
||||
coverage/
|
||||
.aider*
|
||||
|
||||
79
.storybook/ThemeDecorator.svelte
Normal file
79
.storybook/ThemeDecorator.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<!--
|
||||
Component: ThemeDecorator
|
||||
Storybook decorator that initializes ThemeManager for theme-related stories.
|
||||
Ensures theme management works correctly in Storybook's iframe environment.
|
||||
Includes a floating theme toggle for universal theme switching across all stories.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { IconButton } from '$shared/ui';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import { getContext } from 'svelte';
|
||||
import {
|
||||
onDestroy,
|
||||
onMount,
|
||||
} from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// Get responsive context (set by Decorator)
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
// Initialize themeManager on mount
|
||||
onMount(() => {
|
||||
themeManager.init();
|
||||
|
||||
// Add keyboard shortcut for theme toggle (Cmd/Ctrl+Shift+D)
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'D') {
|
||||
e.preventDefault();
|
||||
themeManager.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
});
|
||||
|
||||
// Clean up themeManager when story unmounts
|
||||
onDestroy(() => {
|
||||
themeManager.destroy();
|
||||
});
|
||||
|
||||
const theme = $derived(themeManager.value);
|
||||
const themeLabel = $derived(theme === 'light' ? 'Light' : 'Dark');
|
||||
</script>
|
||||
|
||||
<!-- Floating Theme Toggle -->
|
||||
<div
|
||||
class="fixed top-4 right-4 z-50 flex items-center gap-2 px-3 py-2 bg-card border border-border shadow-lg rounded-lg"
|
||||
title="Toggle theme (Cmd/Ctrl+Shift+D)"
|
||||
>
|
||||
<span class="text-xs font-medium text-muted-foreground">Theme: {themeLabel}</span>
|
||||
<IconButton
|
||||
onclick={() => themeManager.toggle()}
|
||||
size={responsive?.isMobile ? 'sm' : 'md'}
|
||||
variant="ghost"
|
||||
title="Toggle theme"
|
||||
>
|
||||
{#snippet icon()}
|
||||
{#if theme === 'light'}
|
||||
<MoonIcon class="size-4" />
|
||||
{:else}
|
||||
<SunIcon class="size-4" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<!-- Story Content -->
|
||||
{@render children()}
|
||||
@@ -1,11 +1,74 @@
|
||||
import type { Preview } from '@storybook/svelte-vite';
|
||||
import Decorator from './Decorator.svelte';
|
||||
import StoryStage from './StoryStage.svelte';
|
||||
import ThemeDecorator from './ThemeDecorator.svelte';
|
||||
import '../src/app/styles/app.css';
|
||||
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
viewport: {
|
||||
description: 'Viewport size for responsive design',
|
||||
defaultValue: 'widgetWide',
|
||||
toolbar: {
|
||||
icon: 'view',
|
||||
items: [
|
||||
{
|
||||
value: 'reset',
|
||||
icon: 'refresh',
|
||||
title: 'Reset viewport',
|
||||
},
|
||||
{
|
||||
value: 'mobile1',
|
||||
icon: 'mobile',
|
||||
title: 'iPhone 5/SE',
|
||||
},
|
||||
{
|
||||
value: 'mobile2',
|
||||
icon: 'mobile',
|
||||
title: 'iPhone 14 Pro Max',
|
||||
},
|
||||
{
|
||||
value: 'tablet',
|
||||
icon: 'tablet',
|
||||
title: 'iPad (Portrait)',
|
||||
},
|
||||
{
|
||||
value: 'desktop',
|
||||
icon: 'desktop',
|
||||
title: 'Desktop (Small)',
|
||||
},
|
||||
{
|
||||
value: 'widgetMedium',
|
||||
icon: 'view',
|
||||
title: 'Widget Medium',
|
||||
},
|
||||
{
|
||||
value: 'widgetWide',
|
||||
icon: 'view',
|
||||
title: 'Widget Wide',
|
||||
},
|
||||
{
|
||||
value: 'widgetExtraWide',
|
||||
icon: 'view',
|
||||
title: 'Widget Extra Wide',
|
||||
},
|
||||
{
|
||||
value: 'fullWidth',
|
||||
icon: 'view',
|
||||
title: 'Full Width',
|
||||
},
|
||||
{
|
||||
value: 'fullScreen',
|
||||
icon: 'expand',
|
||||
title: 'Full Screen',
|
||||
},
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
layout: 'padded',
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
@@ -23,7 +86,79 @@ const preview: Preview = {
|
||||
docs: {
|
||||
story: {
|
||||
// This sets the default height for the iframe in Autodocs
|
||||
iframeHeight: '400px',
|
||||
iframeHeight: '600px',
|
||||
},
|
||||
},
|
||||
|
||||
viewport: {
|
||||
viewports: {
|
||||
// Mobile devices
|
||||
mobile1: {
|
||||
name: 'iPhone 5/SE',
|
||||
styles: {
|
||||
width: '320px',
|
||||
height: '568px',
|
||||
},
|
||||
},
|
||||
mobile2: {
|
||||
name: 'iPhone 14 Pro Max',
|
||||
styles: {
|
||||
width: '414px',
|
||||
height: '896px',
|
||||
},
|
||||
},
|
||||
// Tablet
|
||||
tablet: {
|
||||
name: 'iPad (Portrait)',
|
||||
styles: {
|
||||
width: '834px',
|
||||
height: '1112px',
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
name: 'Desktop (Small)',
|
||||
styles: {
|
||||
width: '1024px',
|
||||
height: '1280px',
|
||||
},
|
||||
},
|
||||
// Widget-specific viewports
|
||||
widgetMedium: {
|
||||
name: 'Widget Medium',
|
||||
styles: {
|
||||
width: '768px',
|
||||
height: '800px',
|
||||
},
|
||||
},
|
||||
widgetWide: {
|
||||
name: 'Widget Wide',
|
||||
styles: {
|
||||
width: '1024px',
|
||||
height: '800px',
|
||||
},
|
||||
},
|
||||
widgetExtraWide: {
|
||||
name: 'Widget Extra Wide',
|
||||
styles: {
|
||||
width: '1280px',
|
||||
height: '800px',
|
||||
},
|
||||
},
|
||||
// Full-width viewports
|
||||
fullWidth: {
|
||||
name: 'Full Width',
|
||||
styles: {
|
||||
width: '100%',
|
||||
height: '800px',
|
||||
},
|
||||
},
|
||||
fullScreen: {
|
||||
name: 'Full Screen',
|
||||
styles: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -45,6 +180,13 @@ const preview: Preview = {
|
||||
},
|
||||
|
||||
decorators: [
|
||||
// Outermost: initialize ThemeManager for all stories
|
||||
story => ({
|
||||
Component: ThemeDecorator,
|
||||
props: {
|
||||
children: story(),
|
||||
},
|
||||
}),
|
||||
// Wrap with providers (TooltipProvider, ResponsiveManager)
|
||||
story => ({
|
||||
Component: Decorator,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>glyphdiff</title>
|
||||
<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -61,12 +61,12 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"@tanstack/svelte-query": "^6.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<!--
|
||||
Component: App
|
||||
Application root with query provider and layout
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* App Component
|
||||
|
||||
@@ -11,6 +11,9 @@ import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,125 +1,157 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
/* Base font size */
|
||||
--font-size: 16px;
|
||||
|
||||
/* GLYPHDIFF Swiss Design System */
|
||||
/* Primary Colors */
|
||||
--swiss-beige: #f3f0e9;
|
||||
--swiss-red: #ff3b30;
|
||||
--swiss-black: #1a1a1a;
|
||||
--swiss-white: #ffffff;
|
||||
|
||||
/* Neutral Grays */
|
||||
--neutral-50: #fafafa;
|
||||
--neutral-100: #f5f5f5;
|
||||
--neutral-200: #e5e5e5;
|
||||
--neutral-300: #d4d4d4;
|
||||
--neutral-400: #a3a3a3;
|
||||
--neutral-500: #737373;
|
||||
--neutral-600: #525252;
|
||||
--neutral-700: #404040;
|
||||
--neutral-800: #262626;
|
||||
--neutral-900: #171717;
|
||||
|
||||
/* Dark Mode Backgrounds */
|
||||
--dark-bg: #121212;
|
||||
--dark-card: #1e1e1e;
|
||||
--dark-border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* Light Mode Backgrounds */
|
||||
--light-bg: #f3f0e9;
|
||||
--light-card: #ffffff;
|
||||
--light-border: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-brand: var(--swiss-red);
|
||||
--color-surface: var(--swiss-beige);
|
||||
--color-paper: var(--swiss-white);
|
||||
|
||||
/* Base Tailwind Colors (for compatibility) */
|
||||
--background: #ffffff;
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: #ffffff;
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: #030213;
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.95 0.0058 264.53);
|
||||
--secondary-foreground: #030213;
|
||||
--muted: #ececf0;
|
||||
--muted-foreground: #717182;
|
||||
--accent: #e9ebef;
|
||||
--accent-foreground: #030213;
|
||||
--destructive: #d4183d;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--input: transparent;
|
||||
--input-background: #f3f3f5;
|
||||
--switch-background: #cbced4;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-normal: 400;
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: #030213;
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
--background-20: oklch(1 0 0 / 20%);
|
||||
--background-40: oklch(1 0 0 / 40%);
|
||||
--background-60: oklch(1 0 0 / 60%);
|
||||
--background-80: oklch(1 0 0 / 80%);
|
||||
--background-95: oklch(1 0 0 / 95%);
|
||||
--background-subtle: oklch(0.98 0 0);
|
||||
--background-muted: oklch(0.97 0.002 286.375);
|
||||
/* Spacing Scale (rem-based) */
|
||||
--space-xs: 0.25rem;
|
||||
--space-sm: 0.5rem;
|
||||
--space-md: 0.75rem;
|
||||
--space-lg: 1rem;
|
||||
--space-xl: 1.5rem;
|
||||
--space-2xl: 2rem;
|
||||
--space-3xl: 3rem;
|
||||
--space-4xl: 4rem;
|
||||
|
||||
--text-muted: oklch(0.552 0.016 285.938);
|
||||
--text-subtle: oklch(0.705 0.015 286.067);
|
||||
--text-soft: oklch(0.5 0.01 286);
|
||||
/* Typography Scale */
|
||||
--text-2xs: 0.625rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-8xl: 6rem;
|
||||
|
||||
--border-subtle: oklch(0.95 0.003 286.32);
|
||||
--border-muted: oklch(0.92 0.004 286.32);
|
||||
--border-soft: oklch(0.88 0.005 286.32);
|
||||
|
||||
--gradient-from: oklch(0.98 0.002 286.32);
|
||||
--gradient-via: oklch(1 0 0);
|
||||
--gradient-to: oklch(0.98 0.002 286.32);
|
||||
|
||||
--font-mono: 'Major Mono Display';
|
||||
/* Comparison Font Sizes */
|
||||
--comparison-font-mobile: 3rem;
|
||||
--comparison-font-tablet: 4.5rem;
|
||||
--comparison-font-desktop: 6rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--color-surface: var(--dark-bg);
|
||||
--color-paper: var(--dark-card);
|
||||
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover: oklch(0.145 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-normal: 400;
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
|
||||
--background-20: oklch(0.21 0.006 285.885 / 20%);
|
||||
--background-40: oklch(0.21 0.006 285.885 / 40%);
|
||||
--background-60: oklch(0.21 0.006 285.885 / 60%);
|
||||
--background-80: oklch(0.21 0.006 285.885 / 80%);
|
||||
--background-95: oklch(0.21 0.006 285.885 / 95%);
|
||||
--background-subtle: oklch(0.18 0.005 285.823);
|
||||
--background-muted: oklch(0.274 0.006 286.033);
|
||||
|
||||
--text-muted: oklch(0.705 0.015 286.067);
|
||||
--text-subtle: oklch(0.552 0.016 285.938);
|
||||
--text-soft: oklch(0.8 0.01 286);
|
||||
|
||||
--border-subtle: oklch(1 0 0 / 8%);
|
||||
--border-muted: oklch(1 0 0 / 10%);
|
||||
--border-soft: oklch(1 0 0 / 15%);
|
||||
|
||||
--gradient-from: oklch(0.25 0.005 285.885);
|
||||
--gradient-via: oklch(0.21 0.006 285.885);
|
||||
--gradient-to: oklch(0.25 0.005 285.885);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
@theme {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
@@ -135,14 +167,21 @@
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-input-background: var(--input-background);
|
||||
--color-switch-background: var(--switch-background);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: 0rem;
|
||||
--radius-md: 0rem;
|
||||
--radius-lg: 0rem;
|
||||
--radius-xl: 0rem;
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
@@ -151,35 +190,79 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-background-20: var(--background-20);
|
||||
--color-background-40: var(--background-40);
|
||||
--color-background-60: var(--background-60);
|
||||
--color-background-80: var(--background-80);
|
||||
--color-background-95: var(--background-95);
|
||||
--color-background-subtle: var(--background-subtle);
|
||||
--color-background-muted: var(--background-muted);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-text-subtle: var(--text-subtle);
|
||||
--color-text-soft: var(--text-soft);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-muted: var(--border-muted);
|
||||
--color-border-soft: var(--border-soft);
|
||||
--color-gradient-from: var(--gradient-from);
|
||||
--color-gradient-via: var(--gradient-via);
|
||||
--color-gradient-to: var(--gradient-to);
|
||||
--font-mono: 'Major Mono Display', monospace;
|
||||
--font-sans: 'Karla', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||
|
||||
--color-swiss-beige: var(--swiss-beige);
|
||||
--color-swiss-red: var(--swiss-red);
|
||||
--color-swiss-black: var(--swiss-black);
|
||||
--color-swiss-white: var(--swiss-white);
|
||||
--color-brand: var(--color-brand);
|
||||
--color-surface: var(--color-surface);
|
||||
--color-paper: var(--color-paper);
|
||||
--color-dark-bg: var(--dark-bg);
|
||||
--color-dark-card: var(--dark-card);
|
||||
|
||||
--font-logo: 'Syne', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||
--font-mono: 'Space Mono', monospace;
|
||||
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global utility - useful across your app */
|
||||
@@ -223,85 +306,72 @@
|
||||
animation: nudge 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.barlow {
|
||||
font-family: "Barlow", system-ui, Inter, Roboto, "Segoe UI", Arial, sans-serif;
|
||||
/* ============================================
|
||||
SCROLLBAR STYLES
|
||||
============================================ */
|
||||
|
||||
/* ---- Modern API: color + width (Chrome 121+, FF 64+) ---- */
|
||||
@supports (scrollbar-width: auto) {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(0 0% 70% / 0.4) var(--color-surface);
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: hsl(0 0% 40% / 0.5) var(--color-surface);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(0 0% 70% / 0.4) transparent;
|
||||
/* ---- Webkit layer: runs ON TOP in Chrome, standalone in old Safari ---- */
|
||||
/* Handles things scrollbar-width can't: hiding buttons, exact sizing */
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
display: none; /* kills arrows */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 70% / 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 50% / 0.6);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: hsl(0 0% 40% / 0.8);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb { background: hsl(0 0% 40% / 0.5); }
|
||||
.dark ::-webkit-scrollbar-thumb:hover { background: hsl(0 0% 55% / 0.6); }
|
||||
.dark ::-webkit-scrollbar-thumb:active { background: hsl(0 0% 65% / 0.7); }
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: hsl(0 0% 40% / 0.5) transparent;
|
||||
}
|
||||
|
||||
/* ---- Webkit / Blink ---- */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 70% / 0);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
/* Show thumb when container is hovered or actively scrolling */
|
||||
:hover > ::-webkit-scrollbar-thumb,
|
||||
::-webkit-scrollbar-thumb:hover,
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 70% / 0.4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 50% / 0.6);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: hsl(0 0% 40% / 0.8);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 40% / 0);
|
||||
}
|
||||
|
||||
.dark :hover > ::-webkit-scrollbar-thumb,
|
||||
.dark ::-webkit-scrollbar-thumb:hover,
|
||||
.dark *:hover::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 40% / 0.5);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 55% / 0.6);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:active {
|
||||
background: hsl(0 0% 65% / 0.7);
|
||||
}
|
||||
|
||||
/* ---- Behavior ---- */
|
||||
* {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
.scroll-stable {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<!--
|
||||
Component: Layout
|
||||
Application shell with providers and page wrapper
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Layout Component
|
||||
@@ -11,44 +15,30 @@
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*/
|
||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import GD from '$shared/assets/GD.svg';
|
||||
import { ResponsiveProvider } from '$shared/lib';
|
||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
type Snippet,
|
||||
onDestroy,
|
||||
onMount,
|
||||
} from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
let fontsReady = $state(false);
|
||||
let fontsReady = $state(true);
|
||||
const theme = $derived(themeManager.value);
|
||||
|
||||
/**
|
||||
* Sets fontsReady flag to true when font for the page logo is loaded.
|
||||
*/
|
||||
onMount(async () => {
|
||||
if (!('fonts' in document)) {
|
||||
fontsReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const required = ['100'];
|
||||
|
||||
const missing = required.filter(
|
||||
w => !document.fonts.check(`${w} 1em Barlow`),
|
||||
);
|
||||
|
||||
if (missing.length > 0) {
|
||||
await Promise.all(
|
||||
missing.map(w => document.fonts.load(`${w} 1em Barlow`)),
|
||||
);
|
||||
}
|
||||
fontsReady = true;
|
||||
});
|
||||
onMount(() => themeManager.init());
|
||||
onDestroy(() => themeManager.destroy());
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -70,37 +60,43 @@ onMount(async () => {
|
||||
<link
|
||||
rel="preload"
|
||||
as="style"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
||||
media="print"
|
||||
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Syne:wght@800&display=swap"
|
||||
/>
|
||||
</noscript>
|
||||
<title>Compare Typography & Typefaces | GlyphDiff</title>
|
||||
<title>GlyphDiff | Typography & Typefaces</title>
|
||||
</svelte:head>
|
||||
|
||||
<ResponsiveProvider>
|
||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||
<div
|
||||
id="app-root"
|
||||
class={cn(
|
||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
|
||||
theme === 'dark' ? 'dark' : '',
|
||||
)}
|
||||
>
|
||||
<header>
|
||||
<BreadcrumbHeader />
|
||||
</header>
|
||||
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<main class="flex-1 w-full mx-auto px-4 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative">
|
||||
<TooltipProvider>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</TooltipProvider>
|
||||
</main>
|
||||
<!-- <main class="flex-1 w-full mx-auto relative"> -->
|
||||
<TooltipProvider>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</TooltipProvider>
|
||||
<!-- </main> -->
|
||||
<!-- </ScrollArea> -->
|
||||
<footer></footer>
|
||||
</div>
|
||||
|
||||
@@ -1,2 +1,35 @@
|
||||
export { scrollBreadcrumbsStore } from './model';
|
||||
export { BreadcrumbHeader } from './ui';
|
||||
/**
|
||||
* Breadcrumb entity
|
||||
*
|
||||
* Tracks page sections using Intersection Observer with scroll direction
|
||||
* detection. Sections appear in breadcrumbs when scrolling down and exiting
|
||||
* the viewport top.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
* import { onMount } from 'svelte';
|
||||
*
|
||||
* onMount(() => {
|
||||
* const section = document.getElementById('section');
|
||||
* if (section) {
|
||||
* scrollBreadcrumbsStore.add({
|
||||
* index: 0,
|
||||
* title: 'Section',
|
||||
* element: section
|
||||
* }, 80);
|
||||
* }
|
||||
* });
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
type NavigationAction,
|
||||
scrollBreadcrumbsStore,
|
||||
} from './model';
|
||||
export {
|
||||
BreadcrumbHeader,
|
||||
NavigationWrapper,
|
||||
} from './ui';
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||
export * from './types/types.ts';
|
||||
|
||||
@@ -1,39 +1,240 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
/**
|
||||
* Scroll-based breadcrumb tracking store
|
||||
*
|
||||
* Tracks page sections using Intersection Observer with scroll direction
|
||||
* detection. Sections appear in breadcrumbs when scrolling DOWN and exiting
|
||||
* the viewport top. This creates a natural "breadcrumb trail" as users
|
||||
* scroll through content.
|
||||
*
|
||||
* Features:
|
||||
* - Scroll direction detection (up/down)
|
||||
* - Intersection Observer for efficient tracking
|
||||
* - Smooth scrolling to tracked sections
|
||||
* - Configurable scroll offset for sticky headers
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
*
|
||||
* onMount(() => {
|
||||
* scrollBreadcrumbsStore.add({
|
||||
* index: 0,
|
||||
* title: 'Introduction',
|
||||
* element: document.getElementById('intro')!
|
||||
* }, 80); // 80px offset for sticky header
|
||||
* });
|
||||
* </script>
|
||||
*
|
||||
* <div id="intro">Introduction</div>
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* A breadcrumb item representing a tracked section
|
||||
*/
|
||||
export interface BreadcrumbItem {
|
||||
/**
|
||||
* Index of the item to display
|
||||
*/
|
||||
/** Unique index for ordering */
|
||||
index: number;
|
||||
/**
|
||||
* ID of the item to navigate to
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Title snippet to render
|
||||
*/
|
||||
title: Snippet<[{ className?: string }]>;
|
||||
/** Display title for the breadcrumb */
|
||||
title: string;
|
||||
/** DOM element to track */
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll-based breadcrumb tracking store
|
||||
*
|
||||
* Uses Intersection Observer to detect when sections scroll out of view
|
||||
* and tracks scroll direction to only show sections the user has scrolled
|
||||
* past while moving down the page.
|
||||
*/
|
||||
class ScrollBreadcrumbsStore {
|
||||
/** All tracked breadcrumb items */
|
||||
#items = $state<BreadcrumbItem[]>([]);
|
||||
/** Set of indices that have scrolled past (exited viewport while scrolling down) */
|
||||
#scrolledPast = $state<Set<number>>(new Set());
|
||||
/** Intersection Observer instance */
|
||||
#observer: IntersectionObserver | null = null;
|
||||
/** Offset for smooth scrolling (sticky header height) */
|
||||
#scrollOffset = 0;
|
||||
/** Current scroll direction */
|
||||
#isScrollingDown = $state(false);
|
||||
/** Previous scroll Y position to determine direction */
|
||||
#prevScrollY = 0;
|
||||
/** Throttled scroll handler */
|
||||
#handleScroll: (() => void) | null = null;
|
||||
/** Listener count for cleanup */
|
||||
#listenerCount = 0;
|
||||
|
||||
get items() {
|
||||
// Keep them sorted by index for Swiss orderliness
|
||||
return this.#items.sort((a, b) => a.index - b.index);
|
||||
/**
|
||||
* Updates scroll direction based on current position
|
||||
*/
|
||||
#updateScrollDirection(): void {
|
||||
const currentScrollY = window.scrollY;
|
||||
this.#isScrollingDown = currentScrollY > this.#prevScrollY;
|
||||
this.#prevScrollY = currentScrollY;
|
||||
}
|
||||
add(item: BreadcrumbItem) {
|
||||
if (!this.#items.find(i => i.index === item.index)) {
|
||||
this.#items.push(item);
|
||||
|
||||
/**
|
||||
* Initializes the Intersection Observer
|
||||
*
|
||||
* Tracks when elements enter/exit viewport with zero threshold
|
||||
* (fires as soon as any part of element crosses viewport edge).
|
||||
*/
|
||||
#initObserver(): void {
|
||||
if (this.#observer) return;
|
||||
|
||||
this.#observer = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const entry of entries) {
|
||||
const item = this.#items.find(i => i.element === entry.target);
|
||||
if (!item) continue;
|
||||
|
||||
if (!entry.isIntersecting && this.#isScrollingDown) {
|
||||
// Element exited viewport while scrolling DOWN - add to breadcrumbs
|
||||
this.#scrolledPast = new Set(this.#scrolledPast).add(item.index);
|
||||
} else if (entry.isIntersecting && !this.#isScrollingDown) {
|
||||
// Element entered viewport while scrolling UP - remove from breadcrumbs
|
||||
const newSet = new Set(this.#scrolledPast);
|
||||
newSet.delete(item.index);
|
||||
this.#scrolledPast = newSet;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches scroll listener for direction detection
|
||||
*/
|
||||
#attachScrollListener(): void {
|
||||
if (this.#listenerCount === 0) {
|
||||
this.#handleScroll = () => this.#updateScrollDirection();
|
||||
window.addEventListener('scroll', this.#handleScroll, { passive: true });
|
||||
}
|
||||
this.#listenerCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches scroll listener when no items remain
|
||||
*/
|
||||
#detachScrollListener(): void {
|
||||
this.#listenerCount = Math.max(0, this.#listenerCount - 1);
|
||||
if (this.#listenerCount === 0 && this.#handleScroll) {
|
||||
window.removeEventListener('scroll', this.#handleScroll);
|
||||
this.#handleScroll = null;
|
||||
}
|
||||
}
|
||||
remove(index: number) {
|
||||
|
||||
/**
|
||||
* Disconnects observer and removes scroll listener
|
||||
*/
|
||||
#disconnect(): void {
|
||||
if (this.#observer) {
|
||||
this.#observer.disconnect();
|
||||
this.#observer = null;
|
||||
}
|
||||
this.#detachScrollListener();
|
||||
}
|
||||
|
||||
/** All tracked items sorted by index */
|
||||
get items(): BreadcrumbItem[] {
|
||||
return this.#items.slice().sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
/** Items that have scrolled past viewport top (visible in breadcrumbs) */
|
||||
get scrolledPastItems(): BreadcrumbItem[] {
|
||||
return this.items.filter(item => this.#scrolledPast.has(item.index));
|
||||
}
|
||||
|
||||
/** Index of the most recently scrolled item (active section) */
|
||||
get activeIndex(): number | null {
|
||||
const past = this.scrolledPastItems;
|
||||
return past.length > 0 ? past[past.length - 1].index : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific index has been scrolled past
|
||||
* @param index - Item index to check
|
||||
*/
|
||||
isScrolledPast(index: number): boolean {
|
||||
return this.#scrolledPast.has(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a breadcrumb item to track
|
||||
* @param item - Breadcrumb item with index, title, and element
|
||||
* @param offset - Scroll offset in pixels (for sticky headers)
|
||||
*/
|
||||
add(item: BreadcrumbItem, offset = 0): void {
|
||||
if (this.#items.find(i => i.index === item.index)) return;
|
||||
|
||||
this.#scrollOffset = offset;
|
||||
this.#items.push(item);
|
||||
this.#attachScrollListener();
|
||||
this.#initObserver();
|
||||
// Initialize scroll direction
|
||||
this.#prevScrollY = window.scrollY;
|
||||
this.#observer?.observe(item.element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a breadcrumb item from tracking
|
||||
* @param index - Index of item to remove
|
||||
*/
|
||||
remove(index: number): void {
|
||||
const item = this.#items.find(i => i.index === index);
|
||||
if (!item) return;
|
||||
|
||||
this.#observer?.unobserve(item.element);
|
||||
this.#items = this.#items.filter(i => i.index !== index);
|
||||
|
||||
const newSet = new Set(this.#scrolledPast);
|
||||
newSet.delete(index);
|
||||
this.#scrolledPast = newSet;
|
||||
|
||||
if (this.#items.length === 0) {
|
||||
this.#disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth scroll to a tracked breadcrumb item
|
||||
* @param index - Index of item to scroll to
|
||||
* @param container - Scroll container (window by default)
|
||||
*/
|
||||
scrollTo(index: number, container: HTMLElement | Window = window): void {
|
||||
const item = this.#items.find(i => i.index === index);
|
||||
if (!item) return;
|
||||
|
||||
const rect = item.element.getBoundingClientRect();
|
||||
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
|
||||
const target = rect.top + scrollTop - this.#scrollOffset;
|
||||
|
||||
if (container === window) {
|
||||
window.scrollTo({ top: target, behavior: 'smooth' });
|
||||
} else {
|
||||
(container as HTMLElement).scrollTo({
|
||||
top: target - (container as HTMLElement).getBoundingClientRect().top
|
||||
+ (container as HTMLElement).scrollTop - window.scrollY,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createScrollBreadcrumbsStore() {
|
||||
/**
|
||||
* Creates a new scroll breadcrumbs store instance
|
||||
*/
|
||||
export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
||||
return new ScrollBreadcrumbsStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton scroll breadcrumbs store instance
|
||||
*/
|
||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
type BreadcrumbItem,
|
||||
createScrollBreadcrumbsStore,
|
||||
} from './scrollBreadcrumbsStore.svelte';
|
||||
|
||||
// Mock IntersectionObserver - class variable to track instances
|
||||
let mockObserverInstances: MockIntersectionObserver[] = [];
|
||||
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
root = null;
|
||||
rootMargin = '';
|
||||
thresholds: number[] = [];
|
||||
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
|
||||
readonly observedElements = new Set<Element>();
|
||||
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
this.callbacks.push(callback);
|
||||
if (options?.rootMargin) this.rootMargin = options.rootMargin;
|
||||
if (options?.threshold) {
|
||||
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
|
||||
}
|
||||
mockObserverInstances.push(this);
|
||||
}
|
||||
|
||||
observe(target: Element): void {
|
||||
this.observedElements.add(target);
|
||||
}
|
||||
|
||||
unobserve(target: Element): void {
|
||||
this.observedElements.delete(target);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.observedElements.clear();
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Helper method for tests to trigger intersection changes
|
||||
triggerIntersection(target: Element, isIntersecting: boolean): void {
|
||||
const entry: Partial<IntersectionObserverEntry> = {
|
||||
target,
|
||||
isIntersecting,
|
||||
intersectionRatio: isIntersecting ? 1 : 0,
|
||||
boundingClientRect: {} as DOMRectReadOnly,
|
||||
intersectionRect: {} as DOMRectReadOnly,
|
||||
rootBounds: null,
|
||||
time: Date.now(),
|
||||
};
|
||||
this.callbacks.forEach(cb => cb([entry as IntersectionObserverEntry], this));
|
||||
}
|
||||
}
|
||||
|
||||
describe('ScrollBreadcrumbsStore', () => {
|
||||
let scrollListeners: Array<() => void> = [];
|
||||
let addEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let removeEventListenerSpy: ReturnType<typeof vi.spyOn>;
|
||||
let scrollToSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
// Helper to create mock elements
|
||||
const createMockElement = (): HTMLElement => {
|
||||
const el = document.createElement('div');
|
||||
Object.defineProperty(el, 'getBoundingClientRect', {
|
||||
value: vi.fn(() => ({
|
||||
top: 100,
|
||||
left: 0,
|
||||
bottom: 200,
|
||||
right: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 100,
|
||||
toJSON: () => ({}),
|
||||
})),
|
||||
});
|
||||
return el;
|
||||
};
|
||||
|
||||
// Helper to create breadcrumb item
|
||||
const createItem = (index: number, title: string, element?: HTMLElement): BreadcrumbItem => ({
|
||||
index,
|
||||
title,
|
||||
element: element ?? createMockElement(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockObserverInstances = [];
|
||||
scrollListeners = [];
|
||||
|
||||
// Set up IntersectionObserver mock before creating store
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
|
||||
// Mock window.scrollTo
|
||||
scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
|
||||
// Track scroll event listeners
|
||||
addEventListenerSpy = vi.spyOn(window, 'addEventListener').mockImplementation(
|
||||
(event: string, listener: EventListenerOrEventListenerObject, options?: any) => {
|
||||
if (event === 'scroll') {
|
||||
scrollListeners.push(listener as () => void);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(
|
||||
(event: string, listener: EventListenerOrEventListenerObject) => {
|
||||
if (event === 'scroll') {
|
||||
const index = scrollListeners.indexOf(listener as () => void);
|
||||
if (index > -1) scrollListeners.splice(index, 1);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Adding items', () => {
|
||||
it('should add an item and track it', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
const item = createItem(0, 'Section 1', element);
|
||||
|
||||
store.add(item);
|
||||
|
||||
expect(store.items).toHaveLength(1);
|
||||
expect(store.items[0]).toEqual(item);
|
||||
});
|
||||
|
||||
it('should ignore duplicate indices', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element1 = createMockElement();
|
||||
const element2 = createMockElement();
|
||||
|
||||
store.add(createItem(0, 'First', element1));
|
||||
store.add(createItem(0, 'Second', element2));
|
||||
|
||||
expect(store.items).toHaveLength(1);
|
||||
expect(store.items[0].title).toBe('First');
|
||||
});
|
||||
|
||||
it('should add multiple items with different indices', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
store.add(createItem(1, 'Second'));
|
||||
store.add(createItem(2, 'Third'));
|
||||
|
||||
expect(store.items).toHaveLength(3);
|
||||
expect(store.items.map(i => i.index)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('should attach scroll listener when first item is added', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
expect(scrollListeners).toHaveLength(0);
|
||||
|
||||
store.add(createItem(0, 'First'));
|
||||
|
||||
expect(scrollListeners).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should initialize observer with element', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'Test', element));
|
||||
|
||||
expect(mockObserverInstances).toHaveLength(1);
|
||||
expect(mockObserverInstances[0].observedElements.has(element)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Removing items', () => {
|
||||
it('should remove an item by index', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
store.add(createItem(1, 'Second'));
|
||||
store.add(createItem(2, 'Third'));
|
||||
|
||||
store.remove(1);
|
||||
|
||||
expect(store.items).toHaveLength(2);
|
||||
expect(store.items.map(i => i.index)).toEqual([0, 2]);
|
||||
});
|
||||
|
||||
it('should do nothing when removing non-existent index', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
store.add(createItem(1, 'Second'));
|
||||
|
||||
store.remove(999);
|
||||
|
||||
expect(store.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should unobserve element when removed', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'First', element));
|
||||
|
||||
expect(mockObserverInstances[0].observedElements.has(element)).toBe(true);
|
||||
|
||||
store.remove(0);
|
||||
|
||||
expect(mockObserverInstances[0].observedElements.has(element)).toBe(false);
|
||||
});
|
||||
|
||||
it('should disconnect observer when no items remain', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
store.add(createItem(1, 'Second'));
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalled();
|
||||
const initialCallCount = addEventListenerSpy.mock.calls.length;
|
||||
|
||||
store.remove(0);
|
||||
// addEventListener was called for the first item, still 1 call
|
||||
expect(addEventListenerSpy.mock.calls.length).toBe(initialCallCount);
|
||||
|
||||
store.remove(1);
|
||||
// The listener count should be 0 now - disconnect was called
|
||||
// We verify the observer was disconnected
|
||||
expect(mockObserverInstances[0].observedElements.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should reattach listener when adding after cleanup', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
store.remove(0);
|
||||
|
||||
expect(scrollListeners).toHaveLength(0);
|
||||
|
||||
store.add(createItem(1, 'Second'));
|
||||
expect(scrollListeners).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Intersection Observer behavior', () => {
|
||||
it('should add to scrolledPast when element exits viewport while scrolling down', () => {
|
||||
// Set initial scrollY before creating store/adding items
|
||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
||||
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'First', element));
|
||||
|
||||
// Simulate scrolling down (scrollY increases)
|
||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
|
||||
// Trigger intersection: element exits viewport while scrolling down
|
||||
mockObserverInstances[0].triggerIntersection(element, false);
|
||||
|
||||
expect(store.isScrolledPast(0)).toBe(true);
|
||||
expect(store.scrolledPastItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not add to scrolledPast when not scrolling down', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'First', element));
|
||||
|
||||
// scrollY stays at 0 (not scrolling down)
|
||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
|
||||
// Element exits viewport
|
||||
mockObserverInstances[0].triggerIntersection(element, false);
|
||||
|
||||
expect(store.isScrolledPast(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove from scrolledPast when element enters viewport while scrolling up', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'First', element));
|
||||
|
||||
// First, scroll down and exit viewport
|
||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element, false);
|
||||
|
||||
expect(store.isScrolledPast(0)).toBe(true);
|
||||
|
||||
// Now scroll up (decrease scrollY) and element enters viewport
|
||||
Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element, true);
|
||||
|
||||
expect(store.isScrolledPast(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollTo method', () => {
|
||||
it('should scroll to item by index with window as container', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'First', element));
|
||||
|
||||
store.scrollTo(0, window);
|
||||
|
||||
expect(scrollToSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
behavior: 'smooth',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should do nothing when index does not exist', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
|
||||
store.scrollTo(999);
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use scroll offset when calculating target position', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
|
||||
// Reset the mock to clear previous calls
|
||||
scrollToSpy.mockClear();
|
||||
|
||||
// Create fresh mock element with specific getBoundingClientRect
|
||||
const element = document.createElement('div');
|
||||
const getBoundingClientRectMock = vi.fn(() => ({
|
||||
top: 200,
|
||||
left: 0,
|
||||
bottom: 300,
|
||||
right: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 200,
|
||||
toJSON: () => ({}),
|
||||
}));
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
||||
value: getBoundingClientRectMock,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Add item with 80px offset
|
||||
store.add(createItem(0, 'Third', element), 80);
|
||||
|
||||
store.scrollTo(0);
|
||||
|
||||
// The offset should be subtracted from the element position
|
||||
// 200 - 80 = 120 (but in jsdom, getBoundingClientRect might have different behavior)
|
||||
// Let's just verify smooth behavior is used
|
||||
expect(scrollToSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
behavior: 'smooth',
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that the scroll position is less than the element top (offset was applied)
|
||||
const scrollToCall = scrollToSpy.mock.calls[0][0] as ScrollToOptions;
|
||||
expect((scrollToCall as any).top).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it('should handle HTMLElement container', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'Test', element));
|
||||
|
||||
const container: HTMLElement = {
|
||||
scrollTop: 50,
|
||||
scrollTo: vi.fn(),
|
||||
getBoundingClientRect: () => ({
|
||||
top: 0,
|
||||
bottom: 500,
|
||||
left: 0,
|
||||
right: 400,
|
||||
width: 400,
|
||||
height: 500,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
} as any;
|
||||
|
||||
store.scrollTo(0, container);
|
||||
|
||||
expect(container.scrollTo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
behavior: 'smooth',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Getters', () => {
|
||||
it('should return items sorted by index', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(1, 'Second'));
|
||||
store.add(createItem(0, 'First'));
|
||||
store.add(createItem(2, 'Third'));
|
||||
|
||||
expect(store.items.map(i => i.index)).toEqual([0, 1, 2]);
|
||||
expect(store.items.map(i => i.title)).toEqual(['First', 'Second', 'Third']);
|
||||
});
|
||||
|
||||
it('should return empty scrolledPastItems initially', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
store.add(createItem(1, 'Second'));
|
||||
|
||||
expect(store.scrolledPastItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return items that have been scrolled past', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'First', element));
|
||||
store.add(createItem(1, 'Second'));
|
||||
|
||||
// Simulate scrolling down and element exiting viewport
|
||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element, false);
|
||||
|
||||
expect(store.scrolledPastItems).toHaveLength(1);
|
||||
expect(store.scrolledPastItems[0].index).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeIndex getter', () => {
|
||||
it('should return null when no items are scrolled past', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
store.add(createItem(1, 'Second'));
|
||||
|
||||
expect(store.activeIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the last scrolled item index', () => {
|
||||
// Set initial scroll position
|
||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
||||
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element0 = createMockElement();
|
||||
const element1 = createMockElement();
|
||||
store.add(createItem(0, 'First', element0));
|
||||
store.add(createItem(1, 'Second', element1));
|
||||
|
||||
// Scroll down, first item exits
|
||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element0, false);
|
||||
|
||||
expect(store.activeIndex).toBe(0);
|
||||
|
||||
// Second item exits
|
||||
mockObserverInstances[0].triggerIntersection(element1, false);
|
||||
|
||||
expect(store.activeIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('should update active index when scrolling back up', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element0 = createMockElement();
|
||||
const element1 = createMockElement();
|
||||
store.add(createItem(0, 'First', element0));
|
||||
store.add(createItem(1, 'Second', element1));
|
||||
|
||||
// Scroll past both items
|
||||
Object.defineProperty(window, 'scrollY', { value: 200, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element0, false);
|
||||
mockObserverInstances[0].triggerIntersection(element1, false);
|
||||
|
||||
expect(store.activeIndex).toBe(1);
|
||||
|
||||
// Scroll back up, item 1 enters viewport
|
||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element1, true);
|
||||
|
||||
expect(store.activeIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isScrolledPast', () => {
|
||||
it('should return false for items not scrolled past', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
store.add(createItem(1, 'Second'));
|
||||
|
||||
expect(store.isScrolledPast(0)).toBe(false);
|
||||
expect(store.isScrolledPast(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for scrolled items', () => {
|
||||
// Set initial scroll position
|
||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
||||
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'First', element));
|
||||
store.add(createItem(1, 'Second'));
|
||||
|
||||
// Scroll down, first item exits viewport
|
||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element, false);
|
||||
|
||||
expect(store.isScrolledPast(0)).toBe(true);
|
||||
expect(store.isScrolledPast(1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-existent indices', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
store.add(createItem(0, 'First'));
|
||||
|
||||
expect(store.isScrolledPast(999)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll direction tracking', () => {
|
||||
it('should track scroll direction changes', () => {
|
||||
const store = createScrollBreadcrumbsStore();
|
||||
const element = createMockElement();
|
||||
store.add(createItem(0, 'First', element));
|
||||
|
||||
// Initial scroll position
|
||||
Object.defineProperty(window, 'scrollY', { value: 0, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
|
||||
// Scroll down
|
||||
Object.defineProperty(window, 'scrollY', { value: 100, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element, false);
|
||||
|
||||
// Should be in scrolledPast since we scrolled down
|
||||
expect(store.isScrolledPast(0)).toBe(true);
|
||||
|
||||
// Scroll back up
|
||||
Object.defineProperty(window, 'scrollY', { value: 50, writable: true, configurable: true });
|
||||
scrollListeners.forEach(l => l());
|
||||
mockObserverInstances[0].triggerIntersection(element, true);
|
||||
|
||||
// Should be removed since we scrolled up
|
||||
expect(store.isScrolledPast(0)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
7
src/entities/Breadcrumb/model/types/types.ts
Normal file
7
src/entities/Breadcrumb/model/types/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Navigation action type for breadcrumb components
|
||||
*
|
||||
* A Svelte action that can be attached to navigation elements
|
||||
* for scroll tracking or other behaviors.
|
||||
*/
|
||||
export type NavigationAction = (node: HTMLElement) => void;
|
||||
@@ -3,65 +3,72 @@
|
||||
Fixed header for breadcrumbs navigation for sections in the page
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { smoothScroll } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import {
|
||||
fly,
|
||||
slide,
|
||||
} from 'svelte/transition';
|
||||
import { scrollBreadcrumbsStore } from '../../model';
|
||||
Button,
|
||||
Label,
|
||||
Logo,
|
||||
} from '$shared/ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
import {
|
||||
type BreadcrumbItem,
|
||||
scrollBreadcrumbsStore,
|
||||
} from '../../model';
|
||||
|
||||
const breadcrumbs = $derived(scrollBreadcrumbsStore.scrolledPastItems);
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
function handleClick(item: BreadcrumbItem) {
|
||||
scrollBreadcrumbsStore.scrollTo(item.index);
|
||||
}
|
||||
|
||||
function createButtonText(item: BreadcrumbItem) {
|
||||
const index = String(item.index + 1).padStart(2, '0');
|
||||
if (responsive.isMobileOrTablet) {
|
||||
return index;
|
||||
}
|
||||
|
||||
return `${index} // ${item.title}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if scrollBreadcrumbsStore.items.length > 0}
|
||||
{#if breadcrumbs.length > 0}
|
||||
<div
|
||||
transition:slide={{ duration: 200 }}
|
||||
class="
|
||||
fixed top-0 left-0 right-0 z-100
|
||||
backdrop-blur-lg bg-background-20
|
||||
border-b border-border-muted
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
h-10 sm:h-12
|
||||
fixed top-0 left-0 right-0
|
||||
h-14
|
||||
md:h-16 px-4 md:px-6 lg:px-8
|
||||
flex items-center justify-between
|
||||
z-40
|
||||
bg-surface/90 dark:bg-dark-bg/90 backdrop-blur-md
|
||||
border-b border-black/5 dark:border-white/10
|
||||
"
|
||||
>
|
||||
<div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
|
||||
<h1 class={cn('barlow font-extralight text-sm sm:text-base')}>
|
||||
GLYPHDIFF
|
||||
</h1>
|
||||
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
||||
<Logo />
|
||||
|
||||
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
|
||||
|
||||
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||
<div
|
||||
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
|
||||
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
|
||||
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="font-mono text-[8px] sm:text-[9px] text-text-muted tracking-wider">
|
||||
{String(item.index).padStart(2, '0')}
|
||||
</span>
|
||||
<a href={`#${item.id}`} use:smoothScroll>
|
||||
{@render item.title({
|
||||
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-foreground',
|
||||
})}</a>
|
||||
|
||||
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||
<div class="flex items-center gap-0.5 opacity-40">
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<nav class="flex items-center overflow-x-auto scrollbar-hide">
|
||||
{#each breadcrumbs as item, _ (item.index)}
|
||||
{@const active = scrollBreadcrumbsStore.activeIndex === item.index}
|
||||
{@const text = createButtonText(item)}
|
||||
<div class="ml-1 md:ml-4" transition:slide={{ duration: 200, axis: 'x', easing: cubicOut }}>
|
||||
<Button
|
||||
class="uppercase"
|
||||
variant="tertiary"
|
||||
size="xs"
|
||||
{active}
|
||||
onclick={() => handleClick(item)}
|
||||
>
|
||||
<Label class="text-inherit">
|
||||
{text}
|
||||
</Label>
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
|
||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle hidden sm:block"></div>
|
||||
<span class="font-mono text-[7px] sm:text-[8px] text-text-muted tracking-wider">
|
||||
[{scrollBreadcrumbsStore.items.length}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
Component: NavigationWrapper
|
||||
Wrapper for breadcrumb registration with scroll tracking
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type Snippet } from 'svelte';
|
||||
import {
|
||||
type NavigationAction,
|
||||
scrollBreadcrumbsStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Navigation index
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* Navigation title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Scroll offset
|
||||
* @default 96
|
||||
*/
|
||||
offset?: number;
|
||||
/**
|
||||
* Content snippet
|
||||
*/
|
||||
content: Snippet<[action: NavigationAction]>;
|
||||
}
|
||||
|
||||
const { index, title, offset = 96, content }: Props = $props();
|
||||
|
||||
function registerBreadcrumb(node: HTMLElement) {
|
||||
scrollBreadcrumbsStore.add({ index, title, element: node }, offset);
|
||||
return {
|
||||
destroy() {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{@render content(registerBreadcrumb)}
|
||||
@@ -1,3 +1,2 @@
|
||||
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||
|
||||
export { BreadcrumbHeader };
|
||||
export { default as BreadcrumbHeader } from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||
export { default as NavigationWrapper } from './NavigationWrapper/NavigationWrapper.svelte';
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/**
|
||||
* Fontshare API client
|
||||
*
|
||||
* Handles API requests to Fontshare API for fetching font metadata.
|
||||
* Provides error handling, pagination support, and type-safe responses.
|
||||
*
|
||||
* Pagination: The Fontshare API DOES support pagination via `page` and `limit` parameters.
|
||||
* However, the current implementation uses `fetchAllFontshareFonts()` to fetch all fonts upfront.
|
||||
* For future optimization, consider implementing incremental pagination for large datasets.
|
||||
*
|
||||
* @see https://fontshare.com
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type {
|
||||
FontshareApiModel,
|
||||
FontshareFont,
|
||||
} from '../../model/types/fontshare';
|
||||
|
||||
/**
|
||||
* Fontshare API parameters
|
||||
*/
|
||||
export interface FontshareParams extends QueryParams {
|
||||
/**
|
||||
* Filter by categories (e.g., ["Sans", "Serif", "Display"])
|
||||
*/
|
||||
categories?: string[];
|
||||
/**
|
||||
* Filter by tags (e.g., ["Magazines", "Branding", "Logos"])
|
||||
*/
|
||||
tags?: string[];
|
||||
/**
|
||||
* Page number for pagination (1-indexed)
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Number of items per page
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* Search query to filter fonts
|
||||
*/
|
||||
q?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fontshare API response wrapper
|
||||
* Re-exported from model/types/fontshare for backward compatibility
|
||||
*/
|
||||
export type FontshareResponse = FontshareApiModel;
|
||||
|
||||
/**
|
||||
* Fetch fonts from Fontshare API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Fontshare API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all Sans category fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* categories: ['Sans'],
|
||||
* limit: 50
|
||||
* });
|
||||
*
|
||||
* // Fetch fonts with specific tags
|
||||
* const response = await fetchFontshareFonts({
|
||||
* tags: ['Branding', 'Logos']
|
||||
* });
|
||||
*
|
||||
* // Search fonts
|
||||
* const response = await fetchFontshareFonts({
|
||||
* search: 'Satoshi'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `https://api.fontshare.com/v2/fonts${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<FontshareResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Fontshare fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by slug
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param slug - Font slug (e.g., "satoshi", "general-sans")
|
||||
* @returns Promise resolving to Fontshare font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = await fetchFontshareFontBySlug('satoshi');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchFontshareFontBySlug(
|
||||
slug: string,
|
||||
): Promise<FontshareFont | undefined> {
|
||||
const response = await fetchFontshareFonts();
|
||||
return response.fonts.find(font => font.slug === slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all fonts from Fontshare
|
||||
* Convenience function for fetching all available fonts
|
||||
* Uses pagination to get all items
|
||||
*
|
||||
* @returns Promise resolving to all Fontshare fonts
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const allFonts = await fetchAllFontshareFonts();
|
||||
* console.log(`Found ${allFonts.fonts.length} fonts`);
|
||||
* ```
|
||||
*/
|
||||
export async function fetchAllFontshareFonts(
|
||||
params: FontshareParams = {},
|
||||
): Promise<FontshareResponse> {
|
||||
const allFonts: FontshareFont[] = [];
|
||||
let page = 1;
|
||||
const limit = 100; // Max items per page
|
||||
|
||||
while (true) {
|
||||
const response = await fetchFontshareFonts({
|
||||
...params,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
|
||||
allFonts.push(...response.fonts);
|
||||
|
||||
// Check if we've fetched all items
|
||||
if (response.fonts.length < limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
// Return first response with all items combined
|
||||
const firstResponse = await fetchFontshareFonts({ ...params, page: 1, limit });
|
||||
|
||||
return {
|
||||
...firstResponse,
|
||||
fonts: allFonts,
|
||||
};
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Google Fonts API client
|
||||
*
|
||||
* Handles API requests to Google Fonts API for fetching font metadata.
|
||||
* Provides error handling, retry logic, and type-safe responses.
|
||||
*
|
||||
* Pagination: The Google Fonts API does NOT support pagination parameters.
|
||||
* All fonts matching the query are returned in a single response.
|
||||
* Use category, subset, or sort filters to reduce the result set if needed.
|
||||
*
|
||||
* @see https://developers.google.com/fonts/docs/developer_api
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type {
|
||||
FontItem,
|
||||
GoogleFontsApiModel,
|
||||
} from '../../model/types/google';
|
||||
|
||||
/**
|
||||
* Google Fonts API parameters
|
||||
*/
|
||||
export interface GoogleFontsParams extends QueryParams {
|
||||
/**
|
||||
* Google Fonts API key (required for Google Fonts API v1)
|
||||
*/
|
||||
key?: string;
|
||||
/**
|
||||
* Font family name (to fetch specific font)
|
||||
*/
|
||||
family?: string;
|
||||
/**
|
||||
* Font category filter (e.g., "sans-serif", "serif", "display")
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* Character subset filter (e.g., "latin", "latin-ext", "cyrillic")
|
||||
*/
|
||||
subset?: string;
|
||||
/**
|
||||
* Sort order for results
|
||||
*/
|
||||
sort?: 'alpha' | 'date' | 'popularity' | 'style' | 'trending';
|
||||
/**
|
||||
* Cap the number of fonts returned
|
||||
*/
|
||||
capability?: 'VF' | 'WOFF2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Fonts API response wrapper
|
||||
* Re-exported from model/types/google for backward compatibility
|
||||
*/
|
||||
export type GoogleFontsResponse = GoogleFontsApiModel;
|
||||
|
||||
/**
|
||||
* Simplified font item from Google Fonts API
|
||||
* Re-exported from model/types/google for backward compatibility
|
||||
*/
|
||||
export type GoogleFontItem = FontItem;
|
||||
|
||||
/**
|
||||
* Google Fonts API base URL
|
||||
* Note: Google Fonts API v1 requires an API key. For development/testing without a key,
|
||||
* fonts may not load properly.
|
||||
*/
|
||||
const GOOGLE_FONTS_API_URL = 'https://www.googleapis.com/webfonts/v1/webfonts' as const;
|
||||
|
||||
/**
|
||||
* Fetch fonts from Google Fonts API
|
||||
*
|
||||
* @param params - Query parameters for filtering fonts
|
||||
* @returns Promise resolving to Google Fonts API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all sans-serif fonts sorted by popularity
|
||||
* const response = await fetchGoogleFonts({
|
||||
* category: 'sans-serif',
|
||||
* sort: 'popularity'
|
||||
* });
|
||||
*
|
||||
* // Fetch specific font family
|
||||
* const robotoResponse = await fetchGoogleFonts({
|
||||
* family: 'Roboto'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFonts(
|
||||
params: GoogleFontsParams = {},
|
||||
): Promise<GoogleFontsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${GOOGLE_FONTS_API_URL}${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<GoogleFontsResponse>(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Re-throw ApiError with context
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch Google Fonts: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by family name
|
||||
* Convenience function for fetching a single font
|
||||
*
|
||||
* @param family - Font family name (e.g., "Roboto")
|
||||
* @returns Promise resolving to Google Font item
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = await fetchGoogleFontFamily('Roboto');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchGoogleFontFamily(
|
||||
family: string,
|
||||
): Promise<GoogleFontItem | undefined> {
|
||||
const response = await fetchGoogleFonts({ family });
|
||||
return response.items.find(item => item.family === family);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Exports API clients and normalization utilities
|
||||
*/
|
||||
|
||||
// Proxy API (PRIMARY - NEW)
|
||||
// Proxy API (primary)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
@@ -14,25 +14,3 @@ export type {
|
||||
ProxyFontsParams,
|
||||
ProxyFontsResponse,
|
||||
} from './proxy/proxyFonts';
|
||||
|
||||
// Google Fonts API (DEPRECATED - kept for backward compatibility)
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './google/googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './google/googleFonts';
|
||||
|
||||
// Fontshare API (DEPRECATED - kept for backward compatibility)
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './fontshare/fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './fontshare/fontshare';
|
||||
|
||||
171
src/entities/Font/api/proxy/proxyFonts.test.ts
Normal file
171
src/entities/Font/api/proxy/proxyFonts.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Tests for proxy API client
|
||||
*/
|
||||
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import type { ProxyFontsResponse } from './proxyFonts';
|
||||
|
||||
vi.mock('$shared/api/api', () => ({
|
||||
api: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './proxyFonts';
|
||||
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
|
||||
|
||||
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
|
||||
return {
|
||||
id: 'roboto',
|
||||
family: 'Roboto',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
variants: [],
|
||||
subsets: [],
|
||||
...overrides,
|
||||
} as UnifiedFont;
|
||||
}
|
||||
|
||||
function mockApiGet<T>(data: T) {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({ data, status: 200 });
|
||||
}
|
||||
|
||||
describe('proxyFonts', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.get).mockReset();
|
||||
});
|
||||
|
||||
describe('fetchProxyFonts', () => {
|
||||
test('should fetch fonts with no params', async () => {
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [createMockFont()],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
const result = await fetchProxyFonts();
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith(PROXY_API_URL);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test('should build URL with query params', async () => {
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [createMockFont()],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
await fetchProxyFonts({ provider: 'google', category: 'sans-serif', limit: 20, offset: 0 });
|
||||
|
||||
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
|
||||
expect(calledUrl).toContain('provider=google');
|
||||
expect(calledUrl).toContain('category=sans-serif');
|
||||
expect(calledUrl).toContain('limit=20');
|
||||
expect(calledUrl).toContain('offset=0');
|
||||
});
|
||||
|
||||
test('should throw on invalid response (missing fonts array)', async () => {
|
||||
mockApiGet({ total: 0 });
|
||||
|
||||
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
|
||||
});
|
||||
|
||||
test('should throw on null response data', async () => {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
|
||||
|
||||
await expect(fetchProxyFonts()).rejects.toThrow('Proxy API returned invalid response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProxyFontById', () => {
|
||||
test('should return font matching the ID', async () => {
|
||||
const targetFont = createMockFont({ id: 'satoshi', name: 'Satoshi' });
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [createMockFont(), targetFont],
|
||||
total: 2,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
const result = await fetchProxyFontById('satoshi');
|
||||
|
||||
expect(result).toEqual(targetFont);
|
||||
});
|
||||
|
||||
test('should return undefined when font not found', async () => {
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [createMockFont()],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
const result = await fetchProxyFontById('nonexistent');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should search with the ID as query param', async () => {
|
||||
const mockResponse: ProxyFontsResponse = {
|
||||
fonts: [],
|
||||
total: 0,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
};
|
||||
mockApiGet(mockResponse);
|
||||
|
||||
await fetchProxyFontById('Roboto');
|
||||
|
||||
const calledUrl = vi.mocked(api.get).mock.calls[0][0];
|
||||
expect(calledUrl).toContain('limit=1000');
|
||||
expect(calledUrl).toContain('q=Roboto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFontsByIds', () => {
|
||||
test('should return empty array for empty input', async () => {
|
||||
const result = await fetchFontsByIds([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should call batch endpoint with comma-separated IDs', async () => {
|
||||
const fonts = [createMockFont({ id: 'roboto' }), createMockFont({ id: 'satoshi' })];
|
||||
mockApiGet(fonts);
|
||||
|
||||
const result = await fetchFontsByIds(['roboto', 'satoshi']);
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith(`${PROXY_API_URL}/batch?ids=roboto,satoshi`);
|
||||
expect(result).toEqual(fonts);
|
||||
});
|
||||
|
||||
test('should return empty array when response data is nullish', async () => {
|
||||
vi.mocked(api.get).mockResolvedValueOnce({ data: null, status: 200 });
|
||||
|
||||
const result = await fetchFontsByIds(['roboto']);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,6 @@
|
||||
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
|
||||
* unified format, eliminating the need for client-side normalization.
|
||||
*
|
||||
* Fallback: If proxy API fails, falls back to Fontshare API for development.
|
||||
*
|
||||
* @see https://api.glyphdiff.com/api/v1/fonts
|
||||
*/
|
||||
|
||||
@@ -26,40 +24,37 @@ import type {
|
||||
*/
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
||||
|
||||
/**
|
||||
* Whether to use proxy API (true) or fallback (false)
|
||||
*
|
||||
* Set to true when your proxy API is ready:
|
||||
* const USE_PROXY_API = true;
|
||||
*
|
||||
* Set to false to use Fontshare API as fallback during development:
|
||||
* const USE_PROXY_API = false;
|
||||
*
|
||||
* The app will automatically fall back to Fontshare API if the proxy fails.
|
||||
*/
|
||||
const USE_PROXY_API = true;
|
||||
|
||||
/**
|
||||
* Proxy API parameters
|
||||
*
|
||||
* Maps directly to the proxy API query parameters
|
||||
*
|
||||
* UPDATED: Now supports array values for filters
|
||||
*/
|
||||
export interface ProxyFontsParams extends QueryParams {
|
||||
/**
|
||||
* Font provider filter ("google" or "fontshare")
|
||||
* Omit to fetch from both providers
|
||||
* Font provider filter
|
||||
*
|
||||
* NEW: Supports array of providers (e.g., ["google", "fontshare"])
|
||||
* Backward compatible: Single value still works
|
||||
*/
|
||||
provider?: 'google' | 'fontshare';
|
||||
providers?: string[] | string;
|
||||
|
||||
/**
|
||||
* Font category filter
|
||||
*
|
||||
* NEW: Supports array of categories (e.g., ["serif", "sans-serif"])
|
||||
* Backward compatible: Single value still works
|
||||
*/
|
||||
category?: FontCategory;
|
||||
categories?: string[] | string;
|
||||
|
||||
/**
|
||||
* Character subset filter
|
||||
*
|
||||
* NEW: Supports array of subsets (e.g., ["latin", "cyrillic"])
|
||||
* Backward compatible: Single value still works
|
||||
*/
|
||||
subset?: FontSubset;
|
||||
subsets?: string[] | string;
|
||||
|
||||
/**
|
||||
* Search query (e.g., "roboto", "satoshi")
|
||||
@@ -108,8 +103,6 @@ export interface ProxyFontsResponse {
|
||||
/**
|
||||
* Fetch fonts from proxy API
|
||||
*
|
||||
* If proxy API fails or is unavailable, falls back to Fontshare API for development.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Promise resolving to proxy API response
|
||||
* @throws ApiError when request fails
|
||||
@@ -138,84 +131,16 @@ export interface ProxyFontsResponse {
|
||||
export async function fetchProxyFonts(
|
||||
params: ProxyFontsParams = {},
|
||||
): Promise<ProxyFontsResponse> {
|
||||
// Try proxy API first if enabled
|
||||
if (USE_PROXY_API) {
|
||||
try {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${PROXY_API_URL}${queryString}`;
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${PROXY_API_URL}${queryString}`;
|
||||
|
||||
console.log('[fetchProxyFonts] Fetching from proxy API', { params, url });
|
||||
const response = await api.get<ProxyFontsResponse>(url);
|
||||
|
||||
const response = await api.get<ProxyFontsResponse>(url);
|
||||
|
||||
// Validate response has fonts array
|
||||
if (!response.data || !Array.isArray(response.data.fonts)) {
|
||||
console.error('[fetchProxyFonts] Invalid response from proxy API', response.data);
|
||||
throw new Error('Proxy API returned invalid response');
|
||||
}
|
||||
|
||||
console.log('[fetchProxyFonts] Proxy API success', {
|
||||
count: response.data.fonts.length,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.warn('[fetchProxyFonts] Proxy API failed, using fallback', error);
|
||||
|
||||
// Check if it's a network error or proxy not available
|
||||
const isNetworkError = error instanceof Error
|
||||
&& (error.message.includes('Failed to fetch')
|
||||
|| error.message.includes('Network')
|
||||
|| error.message.includes('404')
|
||||
|| error.message.includes('500'));
|
||||
|
||||
if (isNetworkError) {
|
||||
// Fall back to Fontshare API
|
||||
console.log('[fetchProxyFonts] Using Fontshare API as fallback');
|
||||
return await fetchFontshareFallback(params);
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`);
|
||||
}
|
||||
if (!response.data || !Array.isArray(response.data.fonts)) {
|
||||
throw new Error('Proxy API returned invalid response');
|
||||
}
|
||||
|
||||
// Use Fontshare API directly
|
||||
console.log('[fetchProxyFonts] Using Fontshare API (proxy disabled)');
|
||||
return await fetchFontshareFallback(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to Fontshare API when proxy is unavailable
|
||||
*
|
||||
* Maps proxy API params to Fontshare API params and normalizes response
|
||||
*/
|
||||
async function fetchFontshareFallback(
|
||||
params: ProxyFontsParams,
|
||||
): Promise<ProxyFontsResponse> {
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { fetchFontshareFonts } = await import('$entities/Font/api/fontshare/fontshare');
|
||||
const { normalizeFontshareFonts } = await import('$entities/Font/lib/normalize/normalize');
|
||||
|
||||
// Map proxy params to Fontshare params
|
||||
const fontshareParams = {
|
||||
q: params.q,
|
||||
categories: params.category ? [params.category] : undefined,
|
||||
page: params.offset ? Math.floor(params.offset / (params.limit || 50)) + 1 : undefined,
|
||||
limit: params.limit,
|
||||
};
|
||||
|
||||
const response = await fetchFontshareFonts(fontshareParams);
|
||||
const normalizedFonts = normalizeFontshareFonts(response.fonts);
|
||||
|
||||
return {
|
||||
fonts: normalizedFonts,
|
||||
total: response.count_total,
|
||||
limit: params.limit || response.count,
|
||||
offset: params.offset || 0,
|
||||
};
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,24 +181,9 @@ export async function fetchProxyFontById(
|
||||
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// Use proxy API if enabled
|
||||
if (USE_PROXY_API) {
|
||||
const queryString = ids.join(',');
|
||||
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||
const queryString = ids.join(',');
|
||||
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<UnifiedFont[]>(url);
|
||||
return response.data ?? [];
|
||||
} catch (error) {
|
||||
console.warn('[fetchFontsByIds] Proxy API batch fetch failed, falling back', error);
|
||||
// Fallthrough to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Fetch individually (not efficient but functional for fallback)
|
||||
const results = await Promise.all(
|
||||
ids.map(id => fetchProxyFontById(id)),
|
||||
);
|
||||
|
||||
return results.filter((f): f is UnifiedFont => !!f);
|
||||
const response = await api.get<UnifiedFont[]>(url);
|
||||
return response.data ?? [];
|
||||
}
|
||||
|
||||
@@ -1,136 +1,3 @@
|
||||
// Proxy API (PRIMARY)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './api/proxy/proxyFonts';
|
||||
export type {
|
||||
ProxyFontsParams,
|
||||
ProxyFontsResponse,
|
||||
} from './api/proxy/proxyFonts';
|
||||
|
||||
// Fontshare API (DEPRECATED)
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
fetchFontshareFonts,
|
||||
} from './api/fontshare/fontshare';
|
||||
export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './api/fontshare/fontshare';
|
||||
|
||||
// Google Fonts API (DEPRECATED)
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
} from './api/google/googleFonts';
|
||||
export type {
|
||||
GoogleFontItem,
|
||||
GoogleFontsParams,
|
||||
GoogleFontsResponse,
|
||||
} from './api/google/googleFonts';
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './lib/normalize/normalize';
|
||||
export type {
|
||||
// Domain types
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
// Store types
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFiles,
|
||||
FontItem,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
// Fontshare API types
|
||||
FontshareApiModel,
|
||||
FontshareAxis,
|
||||
FontshareDesigner,
|
||||
FontshareFeature,
|
||||
FontshareFont,
|
||||
FontshareLink,
|
||||
FontsharePublisher,
|
||||
FontshareStyle,
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
FontshareWeight,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
// Google Fonts API types
|
||||
GoogleFontsApiModel,
|
||||
// Normalization types
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './model';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createUnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
} from './model';
|
||||
|
||||
// Mock data helpers for Storybook and testing
|
||||
export {
|
||||
createCategoriesFilter,
|
||||
createErrorState,
|
||||
createGenericFilter,
|
||||
createLoadingState,
|
||||
createMockComparisonStore,
|
||||
// Filter mocks
|
||||
createMockFilter,
|
||||
createMockFontApiResponse,
|
||||
createMockFontStoreState,
|
||||
// Store mocks
|
||||
createMockQueryState,
|
||||
createMockReactiveState,
|
||||
createMockStore,
|
||||
createProvidersFilter,
|
||||
createSubsetsFilter,
|
||||
createSuccessState,
|
||||
FONTHARE_FONTS,
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
generatePaginatedFonts,
|
||||
generateSequentialFilter,
|
||||
GENERIC_FILTERS,
|
||||
getAllMockFonts,
|
||||
getFontsByCategory,
|
||||
getFontsByProvider,
|
||||
GOOGLE_FONTS,
|
||||
MOCK_FILTERS,
|
||||
MOCK_FILTERS_ALL_SELECTED,
|
||||
MOCK_FILTERS_EMPTY,
|
||||
MOCK_FILTERS_SELECTED,
|
||||
MOCK_FONT_STORE_STATES,
|
||||
MOCK_STORES,
|
||||
type MockFilterOptions,
|
||||
type MockFilters,
|
||||
mockFontshareFont,
|
||||
type MockFontshareFontOptions,
|
||||
type MockFontStoreState,
|
||||
// Font mocks
|
||||
mockGoogleFont,
|
||||
// Types
|
||||
type MockGoogleFontOptions,
|
||||
type MockQueryObserverResult,
|
||||
type MockQueryState,
|
||||
mockUnifiedFont,
|
||||
type MockUnifiedFontOptions,
|
||||
UNIFIED_FONTS,
|
||||
} from './lib/mocks';
|
||||
|
||||
// UI elements
|
||||
export {
|
||||
FontApplicator,
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
} from './ui';
|
||||
export * from './api';
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
|
||||
51
src/entities/Font/lib/errors/errors.test.ts
Normal file
51
src/entities/Font/lib/errors/errors.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from './errors';
|
||||
|
||||
describe('FontNetworkError', () => {
|
||||
it('has correct name', () => {
|
||||
const err = new FontNetworkError();
|
||||
expect(err.name).toBe('FontNetworkError');
|
||||
});
|
||||
|
||||
it('is instance of Error', () => {
|
||||
expect(new FontNetworkError()).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('stores cause', () => {
|
||||
const cause = new Error('network down');
|
||||
const err = new FontNetworkError(cause);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it('has default message', () => {
|
||||
expect(new FontNetworkError().message).toBe('Failed to fetch fonts from proxy API');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FontResponseError', () => {
|
||||
it('has correct name', () => {
|
||||
const err = new FontResponseError('response', undefined);
|
||||
expect(err.name).toBe('FontResponseError');
|
||||
});
|
||||
|
||||
it('is instance of Error', () => {
|
||||
expect(new FontResponseError('response.fonts', null)).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('stores field', () => {
|
||||
const err = new FontResponseError('response.fonts', 42);
|
||||
expect(err.field).toBe('response.fonts');
|
||||
});
|
||||
|
||||
it('stores received value', () => {
|
||||
const err = new FontResponseError('response.fonts', 42);
|
||||
expect(err.received).toBe(42);
|
||||
});
|
||||
|
||||
it('message includes field name', () => {
|
||||
const err = new FontResponseError('response.fonts', null);
|
||||
expect(err.message).toContain('response.fonts');
|
||||
});
|
||||
});
|
||||
28
src/entities/Font/lib/errors/errors.ts
Normal file
28
src/entities/Font/lib/errors/errors.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Thrown when the network request to the proxy API fails.
|
||||
* Wraps the underlying fetch error (timeout, DNS failure, connection refused, etc.).
|
||||
*/
|
||||
export class FontNetworkError extends Error {
|
||||
readonly name = 'FontNetworkError';
|
||||
|
||||
constructor(public readonly cause?: unknown) {
|
||||
super('Failed to fetch fonts from proxy API');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when the proxy API returns a response with an unexpected shape.
|
||||
*
|
||||
* @property field - The name of the field that failed validation (e.g. `'response'`, `'response.fonts'`).
|
||||
* @property received - The actual value received at that field, for debugging.
|
||||
*/
|
||||
export class FontResponseError extends Error {
|
||||
readonly name = 'FontResponseError';
|
||||
|
||||
constructor(
|
||||
public readonly field: string,
|
||||
public readonly received: unknown,
|
||||
) {
|
||||
super(`Invalid proxy API response: ${field}`);
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,31 @@ import type {
|
||||
UnifiedFont,
|
||||
} from '../../model';
|
||||
|
||||
/** Valid font weight values (100-900 in increments of 100) */
|
||||
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
|
||||
/**
|
||||
* Constructs a URL for a font based on the provided font and weight.
|
||||
* @param font - The font object.
|
||||
* @param weight - The weight of the font.
|
||||
* @returns The URL for the font.
|
||||
* Gets the URL for a font file at a specific weight
|
||||
*
|
||||
* Constructs the appropriate URL for loading a font file based on
|
||||
* the font object and requested weight. Handles variable fonts and
|
||||
* provides fallbacks for static fonts.
|
||||
*
|
||||
* @param font - Unified font object containing style URLs
|
||||
* @param weight - Font weight (100-900)
|
||||
* @returns URL string for the font file, or undefined if not found
|
||||
* @throws Error if weight is not a valid value (100-900)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const url = getFontUrl(roboto, 700); // Returns URL for Roboto Bold
|
||||
*
|
||||
* // Variable fonts: backend maps weight to VF URL
|
||||
* const vfUrl = getFontUrl(inter, 450); // Returns variable font URL
|
||||
*
|
||||
* // Fallback for missing weights
|
||||
* const fallback = getFontUrl(font, 900); // Falls back to regular/400 if 900 missing
|
||||
* ```
|
||||
*/
|
||||
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
|
||||
if (!SIZES.includes(weight)) {
|
||||
@@ -18,12 +36,11 @@ export function getFontUrl(font: UnifiedFont, weight: number): string | undefine
|
||||
|
||||
const weightKey = weight.toString() as FontWeight;
|
||||
|
||||
// 1. Try exact match (Backend now maps "100".."900" to VF URL if variable)
|
||||
// Try exact match (backend maps weight to VF URL for variable fonts)
|
||||
if (font.styles.variants?.[weightKey]) {
|
||||
return font.styles.variants[weightKey];
|
||||
}
|
||||
|
||||
// 2. Fallbacks for Static Fonts (if exact weight missing)
|
||||
// Try 'regular' or '400' as safe defaults
|
||||
// Fallbacks for static fonts when exact weight is missing
|
||||
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
|
||||
}
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
export {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize/normalize';
|
||||
|
||||
export { getFontUrl } from './getFontUrl/getFontUrl';
|
||||
|
||||
// Mock data helpers for Storybook and testing
|
||||
@@ -25,7 +18,6 @@ export {
|
||||
createProvidersFilter,
|
||||
createSubsetsFilter,
|
||||
createSuccessState,
|
||||
FONTHARE_FONTS,
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
generatePaginatedFonts,
|
||||
@@ -34,7 +26,6 @@ export {
|
||||
getAllMockFonts,
|
||||
getFontsByCategory,
|
||||
getFontsByProvider,
|
||||
GOOGLE_FONTS,
|
||||
MOCK_FILTERS,
|
||||
MOCK_FILTERS_ALL_SELECTED,
|
||||
MOCK_FILTERS_EMPTY,
|
||||
@@ -43,16 +34,20 @@ export {
|
||||
MOCK_STORES,
|
||||
type MockFilterOptions,
|
||||
type MockFilters,
|
||||
mockFontshareFont,
|
||||
type MockFontshareFontOptions,
|
||||
type MockFontStoreState,
|
||||
// Font mocks
|
||||
mockGoogleFont,
|
||||
// Types
|
||||
type MockGoogleFontOptions,
|
||||
type MockQueryObserverResult,
|
||||
type MockQueryState,
|
||||
mockUnifiedFont,
|
||||
type MockUnifiedFontOptions,
|
||||
UNIFIED_FONTS,
|
||||
} from './mocks';
|
||||
|
||||
export {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from './errors/errors';
|
||||
|
||||
export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver';
|
||||
export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver';
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* MOCK FONT FILTER DATA
|
||||
* ============================================================================
|
||||
* Mock font filter data
|
||||
*
|
||||
* Factory functions and preset mock data for font-related filters.
|
||||
* Used in Storybook stories for font filtering components.
|
||||
@@ -36,9 +34,7 @@ import type {
|
||||
import type { Property } from '$shared/lib';
|
||||
import { createFilter } from '$shared/lib';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for creating a mock filter
|
||||
@@ -60,32 +56,7 @@ export interface MockFilters {
|
||||
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FONT CATEGORIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Google Fonts categories
|
||||
*/
|
||||
export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [
|
||||
{ id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' },
|
||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||
{ id: 'display', name: 'Display', value: 'display' },
|
||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Fontshare categories (mapped to common naming)
|
||||
*/
|
||||
export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [
|
||||
{ id: 'sans', name: 'Sans', value: 'sans' },
|
||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||
{ id: 'slab', name: 'Slab', value: 'slab' },
|
||||
{ id: 'display', name: 'Display', value: 'display' },
|
||||
{ id: 'handwritten', name: 'Handwritten', value: 'handwritten' },
|
||||
{ id: 'script', name: 'Script', value: 'script' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Unified categories (combines both providers)
|
||||
@@ -96,11 +67,11 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
||||
{ id: 'display', name: 'Display', value: 'display' },
|
||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting' },
|
||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||
{ id: 'slab', name: 'Slab', value: 'slab' },
|
||||
{ id: 'script', name: 'Script', value: 'script' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// FONT SUBSETS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Common font subsets
|
||||
@@ -114,9 +85,7 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
|
||||
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// FONT PROVIDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Font providers
|
||||
@@ -126,9 +95,7 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
||||
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// FILTER FACTORIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock filter from properties
|
||||
@@ -172,9 +139,7 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
|
||||
return createFilter<FontProvider>({ properties });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRESET FILTERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Preset mock filters - use these directly in stories
|
||||
@@ -251,9 +216,7 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GENERIC FILTER MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock filter with generic string properties
|
||||
|
||||
@@ -38,11 +38,6 @@ import type {
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
} from '$entities/Font/model/types';
|
||||
import type {
|
||||
FontItem,
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
} from '$entities/Font/model/types';
|
||||
import type {
|
||||
FontFeatures,
|
||||
FontMetadata,
|
||||
@@ -50,358 +45,7 @@ import type {
|
||||
UnifiedFont,
|
||||
} from '$entities/Font/model/types';
|
||||
|
||||
// ============================================================================
|
||||
// GOOGLE FONTS MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for creating a mock Google Font
|
||||
*/
|
||||
export interface MockGoogleFontOptions {
|
||||
/** Font family name (default: 'Mock Font') */
|
||||
family?: string;
|
||||
/** Font category (default: 'sans-serif') */
|
||||
category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||
/** Font variants (default: ['regular', '700', 'italic', '700italic']) */
|
||||
variants?: FontVariant[];
|
||||
/** Font subsets (default: ['latin']) */
|
||||
subsets?: string[];
|
||||
/** Font version (default: 'v30') */
|
||||
version?: string;
|
||||
/** Last modified date (default: current ISO date) */
|
||||
lastModified?: string;
|
||||
/** Custom file URLs (if not provided, mock URLs are generated) */
|
||||
files?: Partial<Record<FontVariant, string>>;
|
||||
/** Popularity rank (1 = most popular) */
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mock Google Font
|
||||
*/
|
||||
export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem {
|
||||
const {
|
||||
family = 'Mock Font',
|
||||
category = 'sans-serif',
|
||||
variants = ['regular', '700', 'italic', '700italic'],
|
||||
subsets = ['latin'],
|
||||
version = 'v30',
|
||||
lastModified = new Date().toISOString().split('T')[0],
|
||||
files,
|
||||
popularity = 1,
|
||||
} = options;
|
||||
|
||||
const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`;
|
||||
|
||||
return {
|
||||
family,
|
||||
category,
|
||||
variants: variants as FontVariant[],
|
||||
subsets,
|
||||
version,
|
||||
lastModified,
|
||||
files: files ?? {
|
||||
regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`,
|
||||
'700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`,
|
||||
italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`,
|
||||
'700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`,
|
||||
},
|
||||
menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset Google Font mocks
|
||||
*/
|
||||
export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
|
||||
roboto: mockGoogleFont({
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
||||
popularity: 1,
|
||||
}),
|
||||
openSans: mockGoogleFont({
|
||||
family: 'Open Sans',
|
||||
category: 'sans-serif',
|
||||
variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'],
|
||||
popularity: 2,
|
||||
}),
|
||||
lato: mockGoogleFont({
|
||||
family: 'Lato',
|
||||
category: 'sans-serif',
|
||||
variants: ['100', '300', '400', '700', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
popularity: 3,
|
||||
}),
|
||||
playfairDisplay: mockGoogleFont({
|
||||
family: 'Playfair Display',
|
||||
category: 'serif',
|
||||
variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic'],
|
||||
popularity: 10,
|
||||
}),
|
||||
montserrat: mockGoogleFont({
|
||||
family: 'Montserrat',
|
||||
category: 'sans-serif',
|
||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||
popularity: 4,
|
||||
}),
|
||||
sourceSansPro: mockGoogleFont({
|
||||
family: 'Source Sans Pro',
|
||||
category: 'sans-serif',
|
||||
variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
||||
popularity: 5,
|
||||
}),
|
||||
merriweather: mockGoogleFont({
|
||||
family: 'Merriweather',
|
||||
category: 'serif',
|
||||
variants: ['300', '400', '700', '900', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||
popularity: 15,
|
||||
}),
|
||||
robotoSlab: mockGoogleFont({
|
||||
family: 'Roboto Slab',
|
||||
category: 'serif',
|
||||
variants: ['100', '300', '400', '500', '700', '900'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'],
|
||||
popularity: 8,
|
||||
}),
|
||||
oswald: mockGoogleFont({
|
||||
family: 'Oswald',
|
||||
category: 'sans-serif',
|
||||
variants: ['200', '300', '400', '500', '600', '700'],
|
||||
subsets: ['latin', 'latin-ext', 'vietnamese'],
|
||||
popularity: 6,
|
||||
}),
|
||||
raleway: mockGoogleFont({
|
||||
family: 'Raleway',
|
||||
category: 'sans-serif',
|
||||
variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'],
|
||||
subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'],
|
||||
popularity: 7,
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// FONTHARE MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for creating a mock Fontshare font
|
||||
*/
|
||||
export interface MockFontshareFontOptions {
|
||||
/** Font name (default: 'Mock Font') */
|
||||
name?: string;
|
||||
/** URL-friendly slug (default: derived from name) */
|
||||
slug?: string;
|
||||
/** Font category (default: 'sans') */
|
||||
category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono';
|
||||
/** Script (default: 'latin') */
|
||||
script?: string;
|
||||
/** Whether this is a variable font (default: false) */
|
||||
isVariable?: boolean;
|
||||
/** Font version (default: '1.0') */
|
||||
version?: string;
|
||||
/** Popularity/views count (default: 1000) */
|
||||
views?: number;
|
||||
/** Usage tags */
|
||||
tags?: string[];
|
||||
/** Font weights available */
|
||||
weights?: number[];
|
||||
/** Publisher name */
|
||||
publisher?: string;
|
||||
/** Designer name */
|
||||
designer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock Fontshare style
|
||||
*/
|
||||
function mockFontshareStyle(
|
||||
weight: number,
|
||||
isItalic: boolean,
|
||||
isVariable: boolean,
|
||||
slug: string,
|
||||
): FontshareFont['styles'][number] {
|
||||
const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString();
|
||||
const suffix = isItalic ? 'italic' : '';
|
||||
const variablePrefix = isVariable ? 'variable-' : '';
|
||||
|
||||
return {
|
||||
id: `style-${weight}${isItalic ? '-italic' : ''}`,
|
||||
default: weight === 400 && !isItalic,
|
||||
file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`,
|
||||
is_italic: isItalic,
|
||||
is_variable: isVariable,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel,
|
||||
name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel,
|
||||
native_name: null,
|
||||
number: isVariable ? 0 : weight,
|
||||
weight: isVariable ? 0 : weight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default mock Fontshare font
|
||||
*/
|
||||
export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont {
|
||||
const {
|
||||
name = 'Mock Font',
|
||||
slug = name.toLowerCase().replace(/\s+/g, '-'),
|
||||
category = 'sans',
|
||||
script = 'latin',
|
||||
isVariable = false,
|
||||
version = '1.0',
|
||||
views = 1000,
|
||||
tags = [],
|
||||
weights = [400, 700],
|
||||
publisher = 'Mock Foundry',
|
||||
designer = 'Mock Designer',
|
||||
} = options;
|
||||
|
||||
// Generate styles based on weights and variable setting
|
||||
const styles: FontshareFont['styles'] = isVariable
|
||||
? [
|
||||
mockFontshareStyle(0, false, true, slug),
|
||||
mockFontshareStyle(0, true, true, slug),
|
||||
]
|
||||
: weights.flatMap(weight => [
|
||||
mockFontshareStyle(weight, false, false, slug),
|
||||
mockFontshareStyle(weight, true, false, slug),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: `mock-${slug}`,
|
||||
name,
|
||||
native_name: null,
|
||||
slug,
|
||||
category,
|
||||
script,
|
||||
publisher: {
|
||||
bio: `Mock publisher bio for ${publisher}`,
|
||||
email: null,
|
||||
id: `pub-${slug}`,
|
||||
links: [],
|
||||
name: publisher,
|
||||
},
|
||||
designers: [
|
||||
{
|
||||
bio: `Mock designer bio for ${designer}`,
|
||||
links: [],
|
||||
name: designer,
|
||||
},
|
||||
],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: true,
|
||||
show_latin_metrics: false,
|
||||
license_type: 'ofl',
|
||||
languages: 'English, Spanish, French, German',
|
||||
inserted_at: '2021-03-12T20:49:05Z',
|
||||
story: `<p>A mock font story for ${name}.</p>`,
|
||||
version,
|
||||
views,
|
||||
views_recent: Math.floor(views * 0.1),
|
||||
is_hot: views > 5000,
|
||||
is_new: views < 500,
|
||||
is_shortlisted: null,
|
||||
is_top: views > 10000,
|
||||
axes: isVariable
|
||||
? [
|
||||
{
|
||||
name: 'Weight',
|
||||
property: 'wght',
|
||||
range_default: 400,
|
||||
range_left: 300,
|
||||
range_right: 700,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
font_tags: tags.map(name => ({ name })),
|
||||
features: [],
|
||||
styles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset Fontshare font mocks
|
||||
*/
|
||||
export const FONTHARE_FONTS: Record<string, FontshareFont> = {
|
||||
satoshi: mockFontshareFont({
|
||||
name: 'Satoshi',
|
||||
slug: 'satoshi',
|
||||
category: 'sans',
|
||||
isVariable: true,
|
||||
views: 15000,
|
||||
tags: ['Branding', 'Logos', 'Editorial'],
|
||||
publisher: 'Indian Type Foundry',
|
||||
designer: 'Denis Shelabovets',
|
||||
}),
|
||||
generalSans: mockFontshareFont({
|
||||
name: 'General Sans',
|
||||
slug: 'general-sans',
|
||||
category: 'sans',
|
||||
isVariable: true,
|
||||
views: 12000,
|
||||
tags: ['UI', 'Branding', 'Display'],
|
||||
publisher: 'Indestructible Type',
|
||||
designer: 'Eugene Tantsur',
|
||||
}),
|
||||
clashDisplay: mockFontshareFont({
|
||||
name: 'Clash Display',
|
||||
slug: 'clash-display',
|
||||
category: 'display',
|
||||
isVariable: false,
|
||||
views: 8000,
|
||||
tags: ['Headlines', 'Posters', 'Branding'],
|
||||
weights: [400, 500, 600, 700],
|
||||
publisher: 'Letterogika',
|
||||
designer: 'Matěj Trnka',
|
||||
}),
|
||||
fonta: mockFontshareFont({
|
||||
name: 'Fonta',
|
||||
slug: 'fonta',
|
||||
category: 'serif',
|
||||
isVariable: false,
|
||||
views: 5000,
|
||||
tags: ['Editorial', 'Books', 'Magazines'],
|
||||
weights: [300, 400, 500, 600, 700],
|
||||
publisher: 'Fonta',
|
||||
designer: 'Alexei Vanyashin',
|
||||
}),
|
||||
aileron: mockFontshareFont({
|
||||
name: 'Aileron',
|
||||
slug: 'aileron',
|
||||
category: 'sans',
|
||||
isVariable: false,
|
||||
views: 3000,
|
||||
tags: ['Display', 'Headlines'],
|
||||
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
publisher: 'Sorkin Type',
|
||||
designer: 'Sorkin Type',
|
||||
}),
|
||||
beVietnamPro: mockFontshareFont({
|
||||
name: 'Be Vietnam Pro',
|
||||
slug: 'be-vietnam-pro',
|
||||
category: 'sans',
|
||||
isVariable: true,
|
||||
views: 20000,
|
||||
tags: ['UI', 'App', 'Web'],
|
||||
publisher: 'ildefox',
|
||||
designer: 'Manh Nguyen',
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// UNIFIED FONT MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Options for creating a mock UnifiedFont
|
||||
|
||||
@@ -26,17 +26,11 @@
|
||||
|
||||
// Font mocks
|
||||
export {
|
||||
FONTHARE_FONTS,
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
getAllMockFonts,
|
||||
getFontsByCategory,
|
||||
getFontsByProvider,
|
||||
GOOGLE_FONTS,
|
||||
mockFontshareFont,
|
||||
type MockFontshareFontOptions,
|
||||
mockGoogleFont,
|
||||
type MockGoogleFontOptions,
|
||||
mockUnifiedFont,
|
||||
type MockUnifiedFontOptions,
|
||||
UNIFIED_FONTS,
|
||||
@@ -51,10 +45,8 @@ export {
|
||||
createSubsetsFilter,
|
||||
FONT_PROVIDERS,
|
||||
FONT_SUBSETS,
|
||||
FONTHARE_CATEGORIES,
|
||||
generateSequentialFilter,
|
||||
GENERIC_FILTERS,
|
||||
GOOGLE_CATEGORIES,
|
||||
MOCK_FILTERS,
|
||||
MOCK_FILTERS_ALL_SELECTED,
|
||||
MOCK_FILTERS_EMPTY,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* const successState = createMockQueryState({ status: 'success', data: mockFonts });
|
||||
*
|
||||
* // Use preset stores
|
||||
* const mockFontStore = MOCK_STORES.unifiedFontStore();
|
||||
* const mockFontStore = createMockFontStore();
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -35,9 +35,7 @@ import {
|
||||
generateMockFonts,
|
||||
} from './fonts.mock';
|
||||
|
||||
// ============================================================================
|
||||
// TANSTACK QUERY MOCK TYPES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mock TanStack Query state
|
||||
@@ -83,9 +81,7 @@ export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TANSTACK QUERY MOCK FACTORIES
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock query state for TanStack Query
|
||||
@@ -142,9 +138,7 @@ export function createSuccessState<TData>(data: TData): MockQueryObserverResult<
|
||||
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FONT STORE MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mock UnifiedFontStore state
|
||||
@@ -332,9 +326,7 @@ export const MOCK_FONT_STORE_STATES = {
|
||||
}),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MOCK STORE OBJECT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a mock store object that mimics TanStack Query behavior
|
||||
@@ -467,11 +459,120 @@ export const MOCK_STORES = {
|
||||
resetFilters: () => {},
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Create a mock FontStore object
|
||||
* Matches FontStore's public API for Storybook use
|
||||
*/
|
||||
fontStore: (config: {
|
||||
fonts?: UnifiedFont[];
|
||||
total?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
isError?: boolean;
|
||||
error?: Error | null;
|
||||
hasMore?: boolean;
|
||||
page?: number;
|
||||
} = {}) => {
|
||||
const {
|
||||
fonts: mockFonts = Object.values(UNIFIED_FONTS).slice(0, 5),
|
||||
total: mockTotal = mockFonts.length,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
isLoading = false,
|
||||
isFetching = false,
|
||||
isError = false,
|
||||
error = null,
|
||||
hasMore = false,
|
||||
page = 1,
|
||||
} = config;
|
||||
|
||||
const totalPages = Math.ceil(mockTotal / limit);
|
||||
const state = {
|
||||
params: { limit },
|
||||
};
|
||||
|
||||
return {
|
||||
// State getters
|
||||
get params() {
|
||||
return state.params;
|
||||
},
|
||||
get fonts() {
|
||||
return mockFonts;
|
||||
},
|
||||
get isLoading() {
|
||||
return isLoading;
|
||||
},
|
||||
get isFetching() {
|
||||
return isFetching;
|
||||
},
|
||||
get isError() {
|
||||
return isError;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get isEmpty() {
|
||||
return !isLoading && !isFetching && mockFonts.length === 0;
|
||||
},
|
||||
get pagination() {
|
||||
return {
|
||||
total: mockTotal,
|
||||
limit,
|
||||
offset,
|
||||
hasMore,
|
||||
page,
|
||||
totalPages,
|
||||
};
|
||||
},
|
||||
// Category getters
|
||||
get sansSerifFonts() {
|
||||
return mockFonts.filter(f => f.category === 'sans-serif');
|
||||
},
|
||||
get serifFonts() {
|
||||
return mockFonts.filter(f => f.category === 'serif');
|
||||
},
|
||||
get displayFonts() {
|
||||
return mockFonts.filter(f => f.category === 'display');
|
||||
},
|
||||
get handwritingFonts() {
|
||||
return mockFonts.filter(f => f.category === 'handwriting');
|
||||
},
|
||||
get monospaceFonts() {
|
||||
return mockFonts.filter(f => f.category === 'monospace');
|
||||
},
|
||||
// Lifecycle
|
||||
destroy() {},
|
||||
// Param management
|
||||
setParams(_updates: Record<string, unknown>) {},
|
||||
invalidate() {},
|
||||
// Async operations (no-op for Storybook)
|
||||
refetch() {},
|
||||
prefetch() {},
|
||||
cancel() {},
|
||||
getCachedData() {
|
||||
return mockFonts.length > 0 ? mockFonts : undefined;
|
||||
},
|
||||
setQueryData() {},
|
||||
// Filter shortcuts
|
||||
setProviders() {},
|
||||
setCategories() {},
|
||||
setSubsets() {},
|
||||
setSearch() {},
|
||||
setSort() {},
|
||||
// Pagination navigation
|
||||
nextPage() {},
|
||||
prevPage() {},
|
||||
goToPage() {},
|
||||
setLimit(_limit: number) {
|
||||
state.params.limit = _limit;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// REACTIVE STATE MOCKS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create a reactive state object using Svelte 5 runes pattern
|
||||
@@ -525,9 +626,7 @@ export function createMockComparisonStore(config: {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MOCK DATA GENERATORS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generate paginated font data
|
||||
|
||||
@@ -1,582 +0,0 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import type {
|
||||
FontItem,
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
} from '../../model/types';
|
||||
import {
|
||||
normalizeFontshareFont,
|
||||
normalizeFontshareFonts,
|
||||
normalizeGoogleFont,
|
||||
normalizeGoogleFonts,
|
||||
} from './normalize';
|
||||
|
||||
describe('Font Normalization', () => {
|
||||
describe('normalizeGoogleFont', () => {
|
||||
const mockGoogleFont: GoogleFontItem = {
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular', '700', 'italic', '700italic'],
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
files: {
|
||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
||||
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||
},
|
||||
version: 'v30',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
||||
};
|
||||
|
||||
it('normalizes Google Font to unified model', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.id).toBe('Roboto');
|
||||
expect(result.name).toBe('Roboto');
|
||||
expect(result.provider).toBe('google');
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('maps font variants correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.variants).toEqual(['regular', '700', 'italic', '700italic']);
|
||||
});
|
||||
|
||||
it('maps subsets correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.subsets).toContain('latin');
|
||||
expect(result.subsets).toContain('latin-ext');
|
||||
expect(result.subsets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps style URLs correctly', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.styles.regular).toBeDefined();
|
||||
expect(result.styles.bold).toBeDefined();
|
||||
expect(result.styles.italic).toBeDefined();
|
||||
expect(result.styles.boldItalic).toBeDefined();
|
||||
});
|
||||
|
||||
it('includes metadata', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.metadata.cachedAt).toBeDefined();
|
||||
expect(result.metadata.version).toBe('v30');
|
||||
expect(result.metadata.lastModified).toBe('2022-01-01');
|
||||
});
|
||||
|
||||
it('marks Google Fonts as non-variable', () => {
|
||||
const result = normalizeGoogleFont(mockGoogleFont);
|
||||
|
||||
expect(result.features.isVariable).toBe(false);
|
||||
expect(result.features.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles sans-serif category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'sans-serif' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('handles serif category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'serif' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('serif');
|
||||
});
|
||||
|
||||
it('handles display category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'display' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('display');
|
||||
});
|
||||
|
||||
it('handles handwriting category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'handwriting' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('handles cursive category (maps to handwriting)', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'cursive' as any };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('handles monospace category', () => {
|
||||
const font: FontItem = { ...mockGoogleFont, category: 'monospace' };
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.category).toBe('monospace');
|
||||
});
|
||||
|
||||
it('filters invalid subsets', () => {
|
||||
const font = {
|
||||
...mockGoogleFont,
|
||||
subsets: ['latin', 'latin-ext', 'invalid-subset'],
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.subsets).not.toContain('invalid-subset');
|
||||
expect(result.subsets).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('maps variant weights correctly', () => {
|
||||
const font: GoogleFontItem = {
|
||||
...mockGoogleFont,
|
||||
variants: ['regular', '100', '400', '700', '900'] as any,
|
||||
};
|
||||
const result = normalizeGoogleFont(font);
|
||||
|
||||
expect(result.variants).toContain('regular');
|
||||
expect(result.variants).toContain('100');
|
||||
expect(result.variants).toContain('400');
|
||||
expect(result.variants).toContain('700');
|
||||
expect(result.variants).toContain('900');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFontshareFont', () => {
|
||||
const mockFontshareFont: FontshareFont = {
|
||||
id: '20e9fcdc-1e41-4559-a43d-1ede0adc8896',
|
||||
name: 'Satoshi',
|
||||
native_name: null,
|
||||
slug: 'satoshi',
|
||||
category: 'Sans',
|
||||
script: 'latin',
|
||||
publisher: {
|
||||
bio: 'Indian Type Foundry',
|
||||
email: null,
|
||||
id: 'test-id',
|
||||
links: [],
|
||||
name: 'Indian Type Foundry',
|
||||
},
|
||||
designers: [
|
||||
{
|
||||
bio: 'Designer bio',
|
||||
links: [],
|
||||
name: 'Designer Name',
|
||||
},
|
||||
],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: true,
|
||||
show_latin_metrics: false,
|
||||
license_type: 'itf_ffl',
|
||||
languages: 'Afar, Afrikaans',
|
||||
inserted_at: '2021-03-12T20:49:05Z',
|
||||
story: '<p>Font story</p>',
|
||||
version: '1.0',
|
||||
views: 10000,
|
||||
views_recent: 500,
|
||||
is_hot: true,
|
||||
is_new: false,
|
||||
is_shortlisted: false,
|
||||
is_top: true,
|
||||
axes: [],
|
||||
font_tags: [
|
||||
{ name: 'Branding' },
|
||||
{ name: 'Logos' },
|
||||
],
|
||||
features: [
|
||||
{
|
||||
name: 'Alternate t',
|
||||
on_by_default: false,
|
||||
tag: 'ss01',
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
{
|
||||
id: 'style-id-1',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/satoshi.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-2',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-bold.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Bold',
|
||||
name: 'Bold',
|
||||
native_name: null,
|
||||
number: 700,
|
||||
weight: 700,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-3',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-italic.woff2',
|
||||
is_italic: true,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'style-id-4',
|
||||
default: false,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
||||
is_italic: true,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Bold',
|
||||
name: 'Bold',
|
||||
native_name: null,
|
||||
number: 700,
|
||||
weight: 700,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('normalizes Fontshare font to unified model', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.id).toBe('satoshi');
|
||||
expect(result.name).toBe('Satoshi');
|
||||
expect(result.provider).toBe('fontshare');
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('uses slug as unique identifier', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.id).toBe('satoshi');
|
||||
});
|
||||
|
||||
it('extracts variant names from styles', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.variants).toContain('Regular');
|
||||
expect(result.variants).toContain('Bold');
|
||||
expect(result.variants).toContain('Regularitalic');
|
||||
expect(result.variants).toContain('Bolditalic');
|
||||
});
|
||||
|
||||
it('maps Fontshare Sans to sans-serif category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Sans' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif');
|
||||
});
|
||||
|
||||
it('maps Fontshare Serif to serif category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Serif' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('serif');
|
||||
});
|
||||
|
||||
it('maps Fontshare Display to display category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Display' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('display');
|
||||
});
|
||||
|
||||
it('maps Fontshare Script to handwriting category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Script' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('handwriting');
|
||||
});
|
||||
|
||||
it('maps Fontshare Mono to monospace category', () => {
|
||||
const font = { ...mockFontshareFont, category: 'Mono' };
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('monospace');
|
||||
});
|
||||
|
||||
it('maps style URLs correctly', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.styles.regular).toBe('//cdn.fontshare.com/wf/satoshi.woff2');
|
||||
expect(result.styles.bold).toBe('//cdn.fontshare.com/wf/satoshi-bold.woff2');
|
||||
expect(result.styles.italic).toBe('//cdn.fontshare.com/wf/satoshi-italic.woff2');
|
||||
expect(result.styles.boldItalic).toBe(
|
||||
'//cdn.fontshare.com/wf/satoshi-bolditalic.woff2',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles variable fonts', () => {
|
||||
const variableFont: FontshareFont = {
|
||||
...mockFontshareFont,
|
||||
axes: [
|
||||
{
|
||||
name: 'wght',
|
||||
property: 'wght',
|
||||
range_default: 400,
|
||||
range_left: 300,
|
||||
range_right: 900,
|
||||
},
|
||||
],
|
||||
styles: [
|
||||
{
|
||||
id: 'var-style',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/satoshi-variable.woff2',
|
||||
is_italic: false,
|
||||
is_variable: true,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Variable',
|
||||
name: 'Variable',
|
||||
native_name: null,
|
||||
number: 0,
|
||||
weight: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = normalizeFontshareFont(variableFont);
|
||||
|
||||
expect(result.features.isVariable).toBe(true);
|
||||
expect(result.features.axes).toHaveLength(1);
|
||||
expect(result.features.axes?.[0].name).toBe('wght');
|
||||
});
|
||||
|
||||
it('extracts font tags', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.features.tags).toContain('Branding');
|
||||
expect(result.features.tags).toContain('Logos');
|
||||
expect(result.features.tags).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('includes popularity from views', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.metadata.popularity).toBe(10000);
|
||||
});
|
||||
|
||||
it('includes metadata', () => {
|
||||
const result = normalizeFontshareFont(mockFontshareFont);
|
||||
|
||||
expect(result.metadata.cachedAt).toBeDefined();
|
||||
expect(result.metadata.version).toBe('1.0');
|
||||
expect(result.metadata.lastModified).toBe('2021-03-12T20:49:05Z');
|
||||
});
|
||||
|
||||
it('handles missing subsets gracefully', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
script: 'invalid-script',
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.subsets).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty tags', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
font_tags: [],
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.features.tags).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty axes', () => {
|
||||
const font = {
|
||||
...mockFontshareFont,
|
||||
axes: [],
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.features.isVariable).toBe(false);
|
||||
expect(result.features.axes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeGoogleFonts', () => {
|
||||
it('normalizes array of Google Fonts', () => {
|
||||
const fonts: GoogleFontItem[] = [
|
||||
{
|
||||
family: 'Roboto',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Roboto',
|
||||
},
|
||||
{
|
||||
family: 'Open Sans',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
version: 'v1',
|
||||
lastModified: '2022-01-01',
|
||||
menu: 'https://fonts.googleapis.com/css2?family=Open+Sans',
|
||||
},
|
||||
];
|
||||
|
||||
const result = normalizeGoogleFonts(fonts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Roboto');
|
||||
expect(result[1].name).toBe('Open Sans');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = normalizeGoogleFonts([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeFontshareFonts', () => {
|
||||
it('normalizes array of Fontshare fonts', () => {
|
||||
const fonts: FontshareFont[] = [
|
||||
{
|
||||
...mockMinimalFontshareFont('font1', 'Font 1'),
|
||||
},
|
||||
{
|
||||
...mockMinimalFontshareFont('font2', 'Font 2'),
|
||||
},
|
||||
];
|
||||
|
||||
const result = normalizeFontshareFonts(fonts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].name).toBe('Font 1');
|
||||
expect(result[1].name).toBe('Font 2');
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
const result = normalizeFontshareFonts([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles Google Font with missing optional fields', () => {
|
||||
const font: Partial<GoogleFontItem> = {
|
||||
family: 'Test Font',
|
||||
category: 'sans-serif',
|
||||
variants: ['regular'],
|
||||
subsets: ['latin'],
|
||||
files: { regular: 'url' },
|
||||
};
|
||||
|
||||
const result = normalizeGoogleFont(font as GoogleFontItem);
|
||||
|
||||
expect(result.id).toBe('Test Font');
|
||||
expect(result.metadata.version).toBeUndefined();
|
||||
expect(result.metadata.lastModified).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles Fontshare font with minimal data', () => {
|
||||
const result = normalizeFontshareFont(mockMinimalFontshareFont('slug', 'Name'));
|
||||
|
||||
expect(result.id).toBe('slug');
|
||||
expect(result.name).toBe('Name');
|
||||
expect(result.provider).toBe('fontshare');
|
||||
});
|
||||
|
||||
it('handles unknown Fontshare category', () => {
|
||||
const font = {
|
||||
...mockMinimalFontshareFont('slug', 'Name'),
|
||||
category: 'Unknown Category',
|
||||
};
|
||||
const result = normalizeFontshareFont(font);
|
||||
|
||||
expect(result.category).toBe('sans-serif'); // fallback
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to create minimal Fontshare font mock
|
||||
*/
|
||||
function mockMinimalFontshareFont(slug: string, name: string): FontshareFont {
|
||||
return {
|
||||
id: 'test-id',
|
||||
name,
|
||||
native_name: null,
|
||||
slug,
|
||||
category: 'Sans',
|
||||
script: 'latin',
|
||||
publisher: {
|
||||
bio: '',
|
||||
email: null,
|
||||
id: '',
|
||||
links: [],
|
||||
name: '',
|
||||
},
|
||||
designers: [],
|
||||
related_families: null,
|
||||
display_publisher_as_designer: false,
|
||||
trials_enabled: false,
|
||||
show_latin_metrics: false,
|
||||
license_type: '',
|
||||
languages: '',
|
||||
inserted_at: '',
|
||||
story: '',
|
||||
version: '1.0',
|
||||
views: 0,
|
||||
views_recent: 0,
|
||||
is_hot: false,
|
||||
is_new: false,
|
||||
is_shortlisted: null,
|
||||
is_top: false,
|
||||
axes: [],
|
||||
font_tags: [],
|
||||
features: [],
|
||||
styles: [
|
||||
{
|
||||
id: 'style-id',
|
||||
default: true,
|
||||
file: '//cdn.fontshare.com/wf/test.woff2',
|
||||
is_italic: false,
|
||||
is_variable: false,
|
||||
properties: {},
|
||||
weight: {
|
||||
label: 'Regular',
|
||||
name: 'Regular',
|
||||
native_name: null,
|
||||
number: 400,
|
||||
weight: 400,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* Normalize fonts from Google Fonts and Fontshare to unified model
|
||||
*
|
||||
* Transforms provider-specific font data into a common interface
|
||||
* for consistent handling across the application.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontshareFont,
|
||||
GoogleFontItem,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from '../../model/types';
|
||||
|
||||
/**
|
||||
* Map Google Fonts category to unified FontCategory
|
||||
*/
|
||||
function mapGoogleCategory(category: string): FontCategory {
|
||||
const normalized = category.toLowerCase();
|
||||
if (normalized.includes('sans-serif')) {
|
||||
return 'sans-serif';
|
||||
}
|
||||
if (normalized.includes('serif')) {
|
||||
return 'serif';
|
||||
}
|
||||
if (normalized.includes('display')) {
|
||||
return 'display';
|
||||
}
|
||||
if (normalized.includes('handwriting') || normalized.includes('cursive')) {
|
||||
return 'handwriting';
|
||||
}
|
||||
if (normalized.includes('monospace')) {
|
||||
return 'monospace';
|
||||
}
|
||||
// Default fallback
|
||||
return 'sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Fontshare category to unified FontCategory
|
||||
*/
|
||||
function mapFontshareCategory(category: string): FontCategory {
|
||||
const normalized = category.toLowerCase();
|
||||
if (normalized === 'sans' || normalized === 'sans-serif') {
|
||||
return 'sans-serif';
|
||||
}
|
||||
if (normalized === 'serif') {
|
||||
return 'serif';
|
||||
}
|
||||
if (normalized === 'display') {
|
||||
return 'display';
|
||||
}
|
||||
if (normalized === 'script') {
|
||||
return 'handwriting';
|
||||
}
|
||||
if (normalized === 'mono' || normalized === 'monospace') {
|
||||
return 'monospace';
|
||||
}
|
||||
// Default fallback
|
||||
return 'sans-serif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Google subset to unified FontSubset
|
||||
*/
|
||||
function mapGoogleSubset(subset: string): FontSubset | null {
|
||||
const validSubsets: FontSubset[] = [
|
||||
'latin',
|
||||
'latin-ext',
|
||||
'cyrillic',
|
||||
'greek',
|
||||
'arabic',
|
||||
'devanagari',
|
||||
];
|
||||
return validSubsets.includes(subset as FontSubset)
|
||||
? (subset as FontSubset)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Fontshare script to unified FontSubset
|
||||
*/
|
||||
function mapFontshareScript(script: string): FontSubset | null {
|
||||
const normalized = script.toLowerCase();
|
||||
const mapping: Record<string, FontSubset | null> = {
|
||||
latin: 'latin',
|
||||
'latin-ext': 'latin-ext',
|
||||
cyrillic: 'cyrillic',
|
||||
greek: 'greek',
|
||||
arabic: 'arabic',
|
||||
devanagari: 'devanagari',
|
||||
};
|
||||
return mapping[normalized] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Google Font to unified model
|
||||
*
|
||||
* @param apiFont - Font item from Google Fonts API
|
||||
* @returns Unified font model
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = normalizeGoogleFont({
|
||||
* family: 'Roboto',
|
||||
* category: 'sans-serif',
|
||||
* variants: ['regular', '700'],
|
||||
* subsets: ['latin', 'latin-ext'],
|
||||
* files: { regular: '...', '700': '...' }
|
||||
* });
|
||||
*
|
||||
* console.log(roboto.id); // 'Roboto'
|
||||
* console.log(roboto.provider); // 'google'
|
||||
* ```
|
||||
*/
|
||||
export function normalizeGoogleFont(apiFont: GoogleFontItem): UnifiedFont {
|
||||
const category = mapGoogleCategory(apiFont.category);
|
||||
const subsets = apiFont.subsets
|
||||
.map(mapGoogleSubset)
|
||||
.filter((subset): subset is FontSubset => subset !== null);
|
||||
|
||||
// Map variant files to style URLs
|
||||
const styles: FontStyleUrls = {};
|
||||
for (const [variant, url] of Object.entries(apiFont.files)) {
|
||||
const urlString = url as string; // Type assertion for Record<string, string>
|
||||
if (variant === 'regular' || variant === '400') {
|
||||
styles.regular = urlString;
|
||||
} else if (variant === 'italic' || variant === '400italic') {
|
||||
styles.italic = urlString;
|
||||
} else if (variant === 'bold' || variant === '700') {
|
||||
styles.bold = urlString;
|
||||
} else if (variant === 'bolditalic' || variant === '700italic') {
|
||||
styles.boldItalic = urlString;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiFont.family,
|
||||
name: apiFont.family,
|
||||
provider: 'google',
|
||||
category,
|
||||
subsets,
|
||||
variants: apiFont.variants,
|
||||
styles,
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: apiFont.version,
|
||||
lastModified: apiFont.lastModified,
|
||||
},
|
||||
features: {
|
||||
isVariable: false, // Google Fonts doesn't expose variable font info
|
||||
tags: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Fontshare font to unified model
|
||||
*
|
||||
* @param apiFont - Font item from Fontshare API
|
||||
* @returns Unified font model
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const satoshi = normalizeFontshareFont({
|
||||
* id: 'uuid',
|
||||
* name: 'Satoshi',
|
||||
* slug: 'satoshi',
|
||||
* category: 'Sans',
|
||||
* script: 'latin',
|
||||
* styles: [ ... ]
|
||||
* });
|
||||
*
|
||||
* console.log(satoshi.id); // 'satoshi'
|
||||
* console.log(satoshi.provider); // 'fontshare'
|
||||
* ```
|
||||
*/
|
||||
export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont {
|
||||
const category = mapFontshareCategory(apiFont.category);
|
||||
const subset = mapFontshareScript(apiFont.script);
|
||||
const subsets = subset ? [subset] : [];
|
||||
|
||||
// Extract variant names from styles
|
||||
const variants = apiFont.styles.map(style => {
|
||||
const weightLabel = style.weight.label;
|
||||
const isItalic = style.is_italic;
|
||||
return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant;
|
||||
});
|
||||
|
||||
// Map styles to URLs
|
||||
const styles: FontStyleUrls = {};
|
||||
for (const style of apiFont.styles) {
|
||||
if (style.is_variable) {
|
||||
// Variable font - store as primary variant
|
||||
styles.regular = style.file;
|
||||
break;
|
||||
}
|
||||
|
||||
const weight = style.weight.number;
|
||||
const isItalic = style.is_italic;
|
||||
|
||||
if (weight === 400 && !isItalic) {
|
||||
styles.regular = style.file;
|
||||
} else if (weight === 400 && isItalic) {
|
||||
styles.italic = style.file;
|
||||
} else if (weight >= 700 && !isItalic) {
|
||||
styles.bold = style.file;
|
||||
} else if (weight >= 700 && isItalic) {
|
||||
styles.boldItalic = style.file;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract variable font axes
|
||||
const axes = apiFont.axes.map(axis => ({
|
||||
name: axis.name,
|
||||
property: axis.property,
|
||||
default: axis.range_default,
|
||||
min: axis.range_left,
|
||||
max: axis.range_right,
|
||||
}));
|
||||
|
||||
// Extract tags
|
||||
const tags = apiFont.font_tags.map(tag => tag.name);
|
||||
|
||||
return {
|
||||
id: apiFont.slug,
|
||||
name: apiFont.name,
|
||||
provider: 'fontshare',
|
||||
category,
|
||||
subsets,
|
||||
variants,
|
||||
styles,
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: apiFont.version,
|
||||
lastModified: apiFont.inserted_at,
|
||||
popularity: apiFont.views,
|
||||
},
|
||||
features: {
|
||||
isVariable: apiFont.axes.length > 0,
|
||||
axes: axes.length > 0 ? axes : undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize multiple Google Fonts to unified model
|
||||
*
|
||||
* @param apiFonts - Array of Google Font items
|
||||
* @returns Array of unified fonts
|
||||
*/
|
||||
export function normalizeGoogleFonts(
|
||||
apiFonts: GoogleFontItem[],
|
||||
): UnifiedFont[] {
|
||||
return apiFonts.map(normalizeGoogleFont);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize multiple Fontshare fonts to unified model
|
||||
*
|
||||
* @param apiFonts - Array of Fontshare font items
|
||||
* @returns Array of unified fonts
|
||||
*/
|
||||
export function normalizeFontshareFonts(
|
||||
apiFonts: FontshareFont[],
|
||||
): UnifiedFont[] {
|
||||
return apiFonts.map(normalizeFontshareFont);
|
||||
}
|
||||
|
||||
// Re-export UnifiedFont for backward compatibility
|
||||
export type { UnifiedFont } from '../../model/types/normalize';
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
import { TextLayoutEngine } from '$shared/lib';
|
||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||
import { clearCache } from '@chenglou/pretext';
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import type { FontLoadStatus } from '../../model/types';
|
||||
import { mockUnifiedFont } from '../mocks';
|
||||
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
|
||||
|
||||
// Fixed-width canvas mock: every character is 10px wide regardless of font.
|
||||
// This makes wrapping math predictable: N chars × 10px = N×10 total width.
|
||||
const CHAR_WIDTH = 10;
|
||||
const LINE_HEIGHT = 20;
|
||||
const CONTAINER_WIDTH = 200;
|
||||
const CONTENT_PADDING_X = 32; // p-4 × 2 sides = 32px
|
||||
const CHROME_HEIGHT = 56;
|
||||
const FALLBACK_HEIGHT = 220;
|
||||
const FONT_SIZE_PX = 16;
|
||||
|
||||
describe('createFontRowSizeResolver', () => {
|
||||
let statusMap: Map<string, FontLoadStatus>;
|
||||
let getStatus: (key: string) => FontLoadStatus | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
|
||||
clearCache();
|
||||
statusMap = new Map();
|
||||
getStatus = key => statusMap.get(key);
|
||||
});
|
||||
|
||||
function makeResolver(overrides?: Partial<Parameters<typeof createFontRowSizeResolver>[0]>) {
|
||||
const font = mockUnifiedFont({ id: 'inter', name: 'Inter' });
|
||||
return {
|
||||
font,
|
||||
resolver: createFontRowSizeResolver({
|
||||
getFonts: () => [font],
|
||||
getWeight: () => 400,
|
||||
getPreviewText: () => 'Hello',
|
||||
getContainerWidth: () => CONTAINER_WIDTH,
|
||||
getFontSizePx: () => FONT_SIZE_PX,
|
||||
getLineHeightPx: () => LINE_HEIGHT,
|
||||
getStatus,
|
||||
contentHorizontalPadding: CONTENT_PADDING_X,
|
||||
chromeHeight: CHROME_HEIGHT,
|
||||
fallbackHeight: FALLBACK_HEIGHT,
|
||||
...overrides,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it('returns fallbackHeight when font status is undefined', () => {
|
||||
const { resolver } = makeResolver();
|
||||
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||
});
|
||||
|
||||
it('returns fallbackHeight when font status is "loading"', () => {
|
||||
const { resolver } = makeResolver();
|
||||
statusMap.set('inter@400', 'loading');
|
||||
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||
});
|
||||
|
||||
it('returns fallbackHeight when font status is "error"', () => {
|
||||
const { resolver } = makeResolver();
|
||||
statusMap.set('inter@400', 'error');
|
||||
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||
});
|
||||
|
||||
it('returns fallbackHeight when containerWidth is 0', () => {
|
||||
const { resolver } = makeResolver({ getContainerWidth: () => 0 });
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||
});
|
||||
|
||||
it('returns fallbackHeight when previewText is empty', () => {
|
||||
const { resolver } = makeResolver({ getPreviewText: () => '' });
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||
});
|
||||
|
||||
it('returns fallbackHeight for out-of-bounds rowIndex', () => {
|
||||
const { resolver } = makeResolver();
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
expect(resolver(99)).toBe(FALLBACK_HEIGHT);
|
||||
});
|
||||
|
||||
it('returns computed height (totalHeight + chromeHeight) when font is loaded', () => {
|
||||
const { resolver } = makeResolver();
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
|
||||
// 'Hello' = 5 chars × 10px = 50px. contentWidth = 200 - 32 = 168px. Fits on one line.
|
||||
// totalHeight = 1 × LINE_HEIGHT = 20. result = 20 + CHROME_HEIGHT = 76.
|
||||
const result = resolver(0);
|
||||
expect(result).toBe(LINE_HEIGHT + CHROME_HEIGHT);
|
||||
});
|
||||
|
||||
it('returns increased height when text wraps due to narrow container', () => {
|
||||
// contentWidth = 40 - 32 = 8px — 'Hello' (50px) forces wrapping onto many lines
|
||||
const { resolver } = makeResolver({ getContainerWidth: () => 40 });
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
|
||||
const result = resolver(0);
|
||||
expect(result).toBeGreaterThan(LINE_HEIGHT + CHROME_HEIGHT);
|
||||
});
|
||||
|
||||
it('does not call layout() again on second call with same arguments', () => {
|
||||
const { resolver } = makeResolver();
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
|
||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||
|
||||
resolver(0);
|
||||
resolver(0);
|
||||
|
||||
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
||||
layoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls layout() again when containerWidth changes (cache miss)', () => {
|
||||
let width = CONTAINER_WIDTH;
|
||||
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
|
||||
const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||
|
||||
resolver(0);
|
||||
width = 100;
|
||||
resolver(0);
|
||||
|
||||
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
||||
layoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns greater height when container narrows (more wrapping)', () => {
|
||||
let width = CONTAINER_WIDTH;
|
||||
const { resolver } = makeResolver({ getContainerWidth: () => width });
|
||||
statusMap.set('inter@400', 'loaded');
|
||||
|
||||
const h1 = resolver(0);
|
||||
width = 100; // narrower → more wrapping
|
||||
const h2 = resolver(0);
|
||||
|
||||
expect(h2).toBeGreaterThanOrEqual(h1);
|
||||
});
|
||||
|
||||
it('uses variable font key for variable fonts', () => {
|
||||
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
|
||||
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
|
||||
// Variable fonts use '{id}@vf' key, not '{id}@{weight}'
|
||||
statusMap.set('roboto@vf', 'loaded');
|
||||
const result = resolver(0);
|
||||
expect(result).not.toBe(FALLBACK_HEIGHT);
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns fallbackHeight for variable font when static key is set instead', () => {
|
||||
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
|
||||
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
|
||||
// Setting the static key should NOT unlock computed height for variable fonts
|
||||
statusMap.set('roboto@400', 'loaded');
|
||||
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
|
||||
});
|
||||
});
|
||||
112
src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
Normal file
112
src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { TextLayoutEngine } from '$shared/lib';
|
||||
import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey';
|
||||
import type {
|
||||
FontLoadStatus,
|
||||
UnifiedFont,
|
||||
} from '../../model/types';
|
||||
|
||||
/**
|
||||
* Options for {@link createFontRowSizeResolver}.
|
||||
*
|
||||
* All getter functions are called on every resolver invocation. When called
|
||||
* inside a Svelte `$derived.by` block, any reactive state read within them
|
||||
* (e.g. `SvelteMap.get()`) is automatically tracked as a dependency.
|
||||
*/
|
||||
export interface FontRowSizeResolverOptions {
|
||||
/** Returns the current fonts array. Index `i` corresponds to row `i`. */
|
||||
getFonts: () => UnifiedFont[];
|
||||
/** Returns the active font weight (e.g. 400). */
|
||||
getWeight: () => number;
|
||||
/** Returns the preview text string. */
|
||||
getPreviewText: () => string;
|
||||
/** Returns the scroll container's inner width in pixels. Returns 0 before mount. */
|
||||
getContainerWidth: () => number;
|
||||
/** Returns the font size in pixels (e.g. `controlManager.renderedSize`). */
|
||||
getFontSizePx: () => number;
|
||||
/**
|
||||
* Returns the computed line height in pixels.
|
||||
* Typically `controlManager.height * controlManager.renderedSize`.
|
||||
*/
|
||||
getLineHeightPx: () => number;
|
||||
/**
|
||||
* Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`).
|
||||
*
|
||||
* In production: `(key) => appliedFontsManager.statuses.get(key)`.
|
||||
* Injected for testability — avoids a module-level singleton dependency in tests.
|
||||
* The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context
|
||||
* for reactivity to work. This is satisfied when `itemHeight` is called by
|
||||
* `createVirtualizer`'s `estimateSize`.
|
||||
*/
|
||||
getStatus: (fontKey: string) => FontLoadStatus | undefined;
|
||||
/**
|
||||
* Total horizontal padding of the text content area in pixels.
|
||||
* Use the smallest breakpoint value (mobile `p-4` = 32px) to guarantee
|
||||
* the content width is never over-estimated, keeping the height estimate safe.
|
||||
*/
|
||||
contentHorizontalPadding: number;
|
||||
/** Fixed height in pixels of chrome that is not text content (header bar, etc.). */
|
||||
chromeHeight: number;
|
||||
/** Height in pixels to return when the font is not loaded or container width is 0. */
|
||||
fallbackHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a row-height resolver for `FontSampler` rows in `VirtualList`.
|
||||
*
|
||||
* The returned function is suitable as the `itemHeight` prop of `VirtualList`.
|
||||
* Pass it from the widget layer (`SampleList`) so that typography values from
|
||||
* `controlManager` are injected as getter functions rather than imported directly,
|
||||
* keeping `$entities/Font` free of `$features` dependencies.
|
||||
*
|
||||
* **Reactivity:** When the returned function reads `getStatus()` inside a
|
||||
* `$derived.by` block (as `estimateSize` does in `createVirtualizer`), any
|
||||
* `SvelteMap.get()` call within `getStatus` registers a Svelte 5 dependency.
|
||||
* When a font's status changes to `'loaded'`, `offsets` recomputes automatically —
|
||||
* no DOM snap occurs.
|
||||
*
|
||||
* **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx`
|
||||
* prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated
|
||||
* naturally because a change in any input produces a different cache key.
|
||||
*
|
||||
* @param options - Configuration and getter functions (all injected for testability).
|
||||
* @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`.
|
||||
*/
|
||||
export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number {
|
||||
const engine = new TextLayoutEngine();
|
||||
// Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}`
|
||||
const cache = new Map<string, number>();
|
||||
|
||||
return function resolveRowHeight(rowIndex: number): number {
|
||||
const fonts = options.getFonts();
|
||||
const font = fonts[rowIndex];
|
||||
if (!font) return options.fallbackHeight;
|
||||
|
||||
const containerWidth = options.getContainerWidth();
|
||||
const previewText = options.getPreviewText();
|
||||
|
||||
if (containerWidth <= 0 || !previewText) return options.fallbackHeight;
|
||||
|
||||
const weight = options.getWeight();
|
||||
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
||||
const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable });
|
||||
|
||||
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
|
||||
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
||||
const status = options.getStatus(fontKey);
|
||||
if (status !== 'loaded') return options.fallbackHeight;
|
||||
|
||||
const fontSizePx = options.getFontSizePx();
|
||||
const lineHeightPx = options.getLineHeightPx();
|
||||
const contentWidth = containerWidth - options.contentHorizontalPadding;
|
||||
const fontCssString = `${weight} ${fontSizePx}px "${font.name}"`;
|
||||
|
||||
const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
||||
const result = totalHeight + options.chromeHeight;
|
||||
cache.set(cacheKey, result);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -1,43 +1,7 @@
|
||||
export type {
|
||||
// Domain types
|
||||
FontCategory,
|
||||
FontCollectionFilters,
|
||||
FontCollectionSort,
|
||||
// Store types
|
||||
FontCollectionState,
|
||||
FontFeatures,
|
||||
FontFiles,
|
||||
FontItem,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
// Fontshare API types
|
||||
FontshareApiModel,
|
||||
FontshareAxis,
|
||||
FontshareDesigner,
|
||||
FontshareFeature,
|
||||
FontshareFont,
|
||||
FontshareLink,
|
||||
FontsharePublisher,
|
||||
FontshareStyle,
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
FontshareWeight,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
// Google Fonts API types
|
||||
GoogleFontsApiModel,
|
||||
// Normalization types
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './types';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createUnifiedFontStore,
|
||||
type FontConfigRequest,
|
||||
type UnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
createFontStore,
|
||||
FontStore,
|
||||
fontStore,
|
||||
} from './store';
|
||||
export * from './types';
|
||||
|
||||
@@ -1,73 +1,63 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { AppliedFontsManager } from './appliedFontsStore.svelte';
|
||||
import { FontFetchError } from './errors';
|
||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||
|
||||
// ── Fake collaborators ────────────────────────────────────────────────────────
|
||||
|
||||
class FakeBufferCache {
|
||||
async get(_url: string): Promise<ArrayBuffer> {
|
||||
return new ArrayBuffer(8);
|
||||
}
|
||||
evict(_url: string): void {}
|
||||
clear(): void {}
|
||||
}
|
||||
|
||||
/** Throws {@link FontFetchError} on every `get()` — simulates network/HTTP failure. */
|
||||
class FailingBufferCache {
|
||||
async get(url: string): Promise<never> {
|
||||
throw new FontFetchError(url, new Error('network error'), 500);
|
||||
}
|
||||
evict(_url: string): void {}
|
||||
clear(): void {}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const makeConfig = (id: string, overrides: Partial<{ weight: number; isVariable: boolean }> = {}) => ({
|
||||
id,
|
||||
name: id,
|
||||
url: `https://example.com/${id}.woff2`,
|
||||
weight: 400,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// ── Suite ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AppliedFontsManager', () => {
|
||||
let manager: AppliedFontsManager;
|
||||
let mockFontFaceSet: any;
|
||||
let mockFetch: any;
|
||||
let failUrls: Set<string>;
|
||||
let eviction: FontEvictionPolicy;
|
||||
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
failUrls = new Set();
|
||||
eviction = new FontEvictionPolicy({ ttl: 60000 });
|
||||
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
|
||||
|
||||
mockFontFaceSet = {
|
||||
add: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
// 1. Properly mock FontFace as a constructor function
|
||||
// The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string
|
||||
const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) {
|
||||
this.name = name;
|
||||
this.bufferOrUrl = bufferOrUrl;
|
||||
this.load = vi.fn().mockImplementation(() => {
|
||||
// For error tests, we track which URLs should fail via failUrls
|
||||
// The fetch mock will have already rejected for those URLs
|
||||
return Promise.resolve(this);
|
||||
});
|
||||
});
|
||||
|
||||
vi.stubGlobal('FontFace', MockFontFace);
|
||||
|
||||
// 2. Mock document.fonts safely
|
||||
Object.defineProperty(document, 'fonts', {
|
||||
value: mockFontFaceSet,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => '11111111-1111-1111-1111-111111111111' as any,
|
||||
const MockFontFace = vi.fn(function(this: any, name: string, buffer: BufferSource) {
|
||||
this.name = name;
|
||||
this.buffer = buffer;
|
||||
this.load = vi.fn().mockResolvedValue(this);
|
||||
});
|
||||
vi.stubGlobal('FontFace', MockFontFace);
|
||||
|
||||
// 3. Mock fetch to return fake ArrayBuffer data
|
||||
mockFetch = vi.fn((url: string) => {
|
||||
if (failUrls.has(url)) {
|
||||
return Promise.reject(new Error('Network error'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
clone: () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
}),
|
||||
} as Response);
|
||||
});
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
manager = new AppliedFontsManager();
|
||||
manager = new AppliedFontsManager({ cache: new FakeBufferCache() as any, eviction });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -76,67 +66,267 @@ describe('AppliedFontsManager', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should batch multiple font requests into a single process', async () => {
|
||||
const configs = [
|
||||
{ id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 },
|
||||
{ id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 },
|
||||
];
|
||||
// ── touch() ───────────────────────────────────────────────────────────────
|
||||
|
||||
manager.touch(configs);
|
||||
describe('touch()', () => {
|
||||
it('queues and loads a new font', async () => {
|
||||
manager.touch([makeConfig('roboto')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
// Advance to trigger the 16ms debounced #processQueue
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(manager.getFontStatus('roboto', 400)).toBe('loaded');
|
||||
});
|
||||
|
||||
expect(manager.getFontStatus('lato-400', 400)).toBe('loaded');
|
||||
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
||||
it('batches multiple fonts into a single queue flush', async () => {
|
||||
manager.touch([makeConfig('lato'), makeConfig('inter')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips fonts that are already loaded', async () => {
|
||||
manager.touch([makeConfig('lato')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
manager.touch([makeConfig('lato')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips fonts that are currently loading', async () => {
|
||||
manager.touch([makeConfig('lato')]);
|
||||
// simulate loading state before queue drains
|
||||
manager.statuses.set('lato@400', 'loading');
|
||||
manager.touch([makeConfig('lato')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(mockFontFaceSet.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips fonts that have exhausted retries', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
|
||||
// exhaust all 3 retries
|
||||
for (let i = 0; i < 3; i++) {
|
||||
failManager.statuses.delete('broken@400');
|
||||
failManager.touch([makeConfig('broken')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
}
|
||||
|
||||
failManager.touch([makeConfig('broken')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
||||
expect(mockFontFaceSet.add).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does nothing after manager is destroyed', async () => {
|
||||
manager.destroy();
|
||||
manager.touch([makeConfig('roboto')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(manager.statuses.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle font loading errors gracefully', async () => {
|
||||
// Suppress expected console error for clean test logs
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// ── queue processing ──────────────────────────────────────────────────────
|
||||
|
||||
const failUrl = 'https://example.com/fail.ttf';
|
||||
failUrls.add(failUrl);
|
||||
describe('queue processing', () => {
|
||||
it('filters non-critical weights in data-saver mode', async () => {
|
||||
(navigator as any).connection = { saveData: true };
|
||||
|
||||
const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 };
|
||||
manager.touch([
|
||||
makeConfig('light', { weight: 300 }),
|
||||
makeConfig('regular', { weight: 400 }),
|
||||
makeConfig('bold', { weight: 700 }),
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(manager.getFontStatus('light', 300)).toBeUndefined();
|
||||
expect(manager.getFontStatus('regular', 400)).toBe('loaded');
|
||||
expect(manager.getFontStatus('bold', 700)).toBe('loaded');
|
||||
|
||||
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
||||
spy.mockRestore();
|
||||
delete (navigator as any).connection;
|
||||
});
|
||||
|
||||
it('loads variable fonts in data-saver mode regardless of weight', async () => {
|
||||
(navigator as any).connection = { saveData: true };
|
||||
|
||||
manager.touch([makeConfig('vf', { weight: 300, isVariable: true })]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(manager.getFontStatus('vf', 300, true)).toBe('loaded');
|
||||
|
||||
delete (navigator as any).connection;
|
||||
});
|
||||
});
|
||||
|
||||
it('should purge fonts after TTL expires', async () => {
|
||||
const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 };
|
||||
// ── Phase 1: fetch ────────────────────────────────────────────────────────
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(manager.getFontStatus('ephemeral', 400)).toBe('loaded');
|
||||
describe('Phase 1 — fetch', () => {
|
||||
it('sets status to error on fetch failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
|
||||
// Move clock forward past TTL (5m) and Purge Interval (1m)
|
||||
// advanceTimersByTimeAsync is key here; it handles the promises inside the interval
|
||||
await vi.advanceTimersByTimeAsync(6 * 60 * 1000);
|
||||
failManager.touch([makeConfig('broken')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||
expect(failManager.getFontStatus('broken', 400)).toBe('error');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs a console error on fetch failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const failManager = new AppliedFontsManager({ cache: new FailingBufferCache() as any, eviction });
|
||||
|
||||
failManager.touch([makeConfig('broken')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not set error status or log for aborted fetches', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const abortingCache = {
|
||||
async get(url: string): Promise<never> {
|
||||
throw new FontFetchError(url, Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||
},
|
||||
evict() {},
|
||||
clear() {},
|
||||
};
|
||||
const abortManager = new AppliedFontsManager({ cache: abortingCache as any, eviction });
|
||||
|
||||
abortManager.touch([makeConfig('aborted')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
// status is left as 'loading' (not 'error') — abort is not a retriable failure
|
||||
expect(abortManager.getFontStatus('aborted', 400)).not.toBe('error');
|
||||
expect(consoleSpy).not.toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT purge fonts that are still being "touched"', async () => {
|
||||
const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 };
|
||||
// ── Phase 2: parse ────────────────────────────────────────────────────────
|
||||
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
describe('Phase 2 — parse', () => {
|
||||
it('sets status to error on parse failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const FailingFontFace = vi.fn(function(this: any) {
|
||||
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
|
||||
});
|
||||
vi.stubGlobal('FontFace', FailingFontFace);
|
||||
|
||||
// Advance 4 minutes
|
||||
await vi.advanceTimersByTimeAsync(4 * 60 * 1000);
|
||||
manager.touch([makeConfig('broken')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
// Refresh touch
|
||||
manager.touch([config]);
|
||||
expect(manager.getFontStatus('broken', 400)).toBe('error');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Advance another 2 minutes (Total 6 since start)
|
||||
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
|
||||
it('logs a console error on parse failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const FailingFontFace = vi.fn(function(this: any) {
|
||||
this.load = vi.fn().mockRejectedValue(new Error('parse failed'));
|
||||
});
|
||||
vi.stubGlobal('FontFace', FailingFontFace);
|
||||
|
||||
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||
manager.touch([makeConfig('broken')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── #purgeUnused ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('#purgeUnused', () => {
|
||||
it('evicts fonts after TTL expires', async () => {
|
||||
manager.touch([makeConfig('ephemeral')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(61000);
|
||||
|
||||
expect(manager.getFontStatus('ephemeral', 400)).toBeUndefined();
|
||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the evicted key from the eviction policy', async () => {
|
||||
manager.touch([makeConfig('ephemeral')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(61000);
|
||||
|
||||
expect(Array.from(eviction.keys())).not.toContain('ephemeral@400');
|
||||
});
|
||||
|
||||
it('refreshes TTL when font is re-touched before expiry', async () => {
|
||||
const config = makeConfig('active');
|
||||
manager.touch([config]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(40000);
|
||||
manager.touch([config]); // refresh at t≈40s
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25000); // purge at t≈60s sees only ~20s elapsed → not evicted
|
||||
|
||||
expect(manager.getFontStatus('active', 400)).toBe('loaded');
|
||||
});
|
||||
|
||||
it('does not evict pinned fonts', async () => {
|
||||
manager.touch([makeConfig('pinned')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
manager.pin('pinned', 400);
|
||||
await vi.advanceTimersByTimeAsync(61000);
|
||||
|
||||
expect(manager.getFontStatus('pinned', 400)).toBe('loaded');
|
||||
expect(mockFontFaceSet.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('evicts font after it is unpinned and TTL expires', async () => {
|
||||
manager.touch([makeConfig('toggled')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
manager.pin('toggled', 400);
|
||||
manager.unpin('toggled', 400);
|
||||
await vi.advanceTimersByTimeAsync(61000);
|
||||
|
||||
expect(manager.getFontStatus('toggled', 400)).toBeUndefined();
|
||||
expect(mockFontFaceSet.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── destroy() ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('clears all statuses', async () => {
|
||||
manager.touch([makeConfig('roboto')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
manager.destroy();
|
||||
|
||||
expect(manager.statuses.size).toBe(0);
|
||||
});
|
||||
|
||||
it('removes all loaded fonts from document.fonts', async () => {
|
||||
manager.touch([makeConfig('roboto'), makeConfig('inter')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
manager.destroy();
|
||||
|
||||
expect(mockFontFaceSet.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('prevents further loading after destroy', async () => {
|
||||
manager.destroy();
|
||||
manager.touch([makeConfig('roboto')]);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
expect(manager.statuses.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type FontLoadRequestConfig,
|
||||
type FontLoadStatus,
|
||||
} from '../../types';
|
||||
import {
|
||||
FontFetchError,
|
||||
FontParseError,
|
||||
} from './errors';
|
||||
import {
|
||||
generateFontKey,
|
||||
getEffectiveConcurrency,
|
||||
loadFont,
|
||||
yieldToMainThread,
|
||||
} from './utils';
|
||||
import { FontBufferCache } from './utils/fontBufferCache/FontBufferCache';
|
||||
import { FontEvictionPolicy } from './utils/fontEvictionPolicy/FontEvictionPolicy';
|
||||
import { FontLoadQueue } from './utils/fontLoadQueue/FontLoadQueue';
|
||||
|
||||
/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */
|
||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
/** Configuration for a font load request. */
|
||||
export interface FontConfigRequest {
|
||||
/**
|
||||
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* URL pointing to the font file (typically .ttf or .woff2).
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Variable fonts load once per ID; static fonts load per weight.
|
||||
*/
|
||||
isVariable?: boolean;
|
||||
interface AppliedFontsManagerDeps {
|
||||
cache?: FontBufferCache;
|
||||
eviction?: FontEvictionPolicy;
|
||||
queue?: FontLoadQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,14 +47,16 @@ export interface FontConfigRequest {
|
||||
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||
*/
|
||||
export class AppliedFontsManager {
|
||||
// Injected collaborators - each handles one concern for better testability
|
||||
readonly #cache: FontBufferCache;
|
||||
readonly #eviction: FontEvictionPolicy;
|
||||
readonly #queue: FontLoadQueue;
|
||||
|
||||
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
|
||||
#loadedFonts = new Map<string, FontFace>();
|
||||
|
||||
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
|
||||
#usageTracker = new Map<string, number>();
|
||||
|
||||
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
|
||||
#queue = new Map<string, FontConfigRequest>();
|
||||
// Maps font key → URL so #purgeUnused() can evict from cache
|
||||
#urlByKey = new Map<string, string>();
|
||||
|
||||
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -72,103 +70,95 @@ export class AppliedFontsManager {
|
||||
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||
#pendingType: 'idle' | 'timeout' | null = null;
|
||||
|
||||
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
|
||||
#retryCounts = new Map<string, number>();
|
||||
|
||||
readonly #MAX_RETRIES = 3;
|
||||
readonly #PURGE_INTERVAL = 60000; // 60 seconds
|
||||
readonly #TTL = 5 * 60 * 1000; // 5 minutes
|
||||
readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation
|
||||
readonly #PURGE_INTERVAL = 60000;
|
||||
|
||||
// Reactive status map for Svelte components to track font states
|
||||
statuses = new SvelteMap<string, FontStatus>();
|
||||
statuses = new SvelteMap<string, FontLoadStatus>();
|
||||
|
||||
// Starts periodic cleanup timer (browser-only).
|
||||
constructor() {
|
||||
constructor(
|
||||
{ cache = new FontBufferCache(), eviction = new FontEvictionPolicy(), queue = new FontLoadQueue() }:
|
||||
AppliedFontsManagerDeps = {},
|
||||
) {
|
||||
// Inject collaborators - defaults provided for production, fakes for testing
|
||||
this.#cache = cache;
|
||||
this.#eviction = eviction;
|
||||
this.#queue = queue;
|
||||
if (typeof window !== 'undefined') {
|
||||
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
|
||||
#getFontKey(id: string, weight: number, isVariable: boolean): string {
|
||||
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
|
||||
*
|
||||
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
|
||||
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
|
||||
*/
|
||||
touch(configs: FontConfigRequest[]) {
|
||||
if (this.#abortController.signal.aborted) return;
|
||||
|
||||
const now = Date.now();
|
||||
let hasNewItems = false;
|
||||
|
||||
for (const config of configs) {
|
||||
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
||||
this.#usageTracker.set(key, now);
|
||||
|
||||
const status = this.statuses.get(key);
|
||||
if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue;
|
||||
if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue;
|
||||
|
||||
this.#queue.set(key, config);
|
||||
hasNewItems = true;
|
||||
touch(configs: FontLoadRequestConfig[]) {
|
||||
if (this.#abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const now = Date.now();
|
||||
let hasNewItems = false;
|
||||
|
||||
if (hasNewItems && !this.#timeoutId) {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
this.#timeoutId = requestIdleCallback(
|
||||
() => this.#processQueue(),
|
||||
{ timeout: 150 },
|
||||
) as unknown as ReturnType<typeof setTimeout>;
|
||||
this.#pendingType = 'idle';
|
||||
} else {
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||
this.#pendingType = 'timeout';
|
||||
for (const config of configs) {
|
||||
const key = generateFontKey(config);
|
||||
|
||||
// Update last-used timestamp for LRU eviction policy
|
||||
this.#eviction.touch(key, now);
|
||||
|
||||
const status = this.statuses.get(key);
|
||||
|
||||
// Skip fonts that are already loaded or currently loading
|
||||
if (status === 'loaded' || status === 'loading') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip fonts already in the queue (avoid duplicates)
|
||||
if (this.#queue.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip error fonts that have exceeded max retry count
|
||||
if (status === 'error' && this.#queue.isMaxRetriesReached(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Queue this font for loading
|
||||
this.#queue.enqueue(key, config);
|
||||
hasNewItems = true;
|
||||
}
|
||||
|
||||
if (hasNewItems && !this.#timeoutId) {
|
||||
this.#scheduleProcessing();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
|
||||
async #yieldToMain(): 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();
|
||||
/**
|
||||
* Schedules `#processQueue()` via `requestIdleCallback` (150ms timeout) when available,
|
||||
* falling back to `setTimeout(16ms)` for ~60fps timing.
|
||||
*/
|
||||
#scheduleProcessing(): void {
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
this.#timeoutId = requestIdleCallback(
|
||||
() => this.#processQueue(),
|
||||
{ timeout: 150 },
|
||||
) as unknown as ReturnType<typeof setTimeout>;
|
||||
this.#pendingType = 'idle';
|
||||
} else {
|
||||
await new Promise<void>(resolve => {
|
||||
const ch = new MessageChannel();
|
||||
ch.port1.onmessage = () => resolve();
|
||||
ch.port2.postMessage(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
|
||||
#getEffectiveConcurrency(): number {
|
||||
const nav = navigator as any;
|
||||
const conn = nav.connection;
|
||||
if (!conn) return 4;
|
||||
|
||||
switch (conn.effectiveType) {
|
||||
case 'slow-2g':
|
||||
case '2g':
|
||||
return 1;
|
||||
case '3g':
|
||||
return 2;
|
||||
default:
|
||||
return 4;
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||
this.#pendingType = 'timeout';
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
||||
#shouldDeferNonCritical(): boolean {
|
||||
const nav = navigator as any;
|
||||
return nav.connection?.saveData === true;
|
||||
return (navigator as any).connection?.saveData === true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,148 +169,179 @@ export class AppliedFontsManager {
|
||||
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
|
||||
*/
|
||||
async #processQueue() {
|
||||
// Clear timer flags since we're now processing
|
||||
this.#timeoutId = null;
|
||||
this.#pendingType = null;
|
||||
|
||||
let entries = Array.from(this.#queue.entries());
|
||||
if (!entries.length) return;
|
||||
this.#queue.clear();
|
||||
// Get all queued entries and clear the queue atomically
|
||||
let entries = this.#queue.flush();
|
||||
if (!entries.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In data-saver mode, only load variable fonts and common weights (400, 700)
|
||||
if (this.#shouldDeferNonCritical()) {
|
||||
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||
}
|
||||
|
||||
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
|
||||
const concurrency = this.#getEffectiveConcurrency();
|
||||
// Determine optimal concurrent fetches based on network speed (1-4)
|
||||
const concurrency = getEffectiveConcurrency();
|
||||
const buffers = new Map<string, ArrayBuffer>();
|
||||
|
||||
// ==================== PHASE 1: Concurrent Fetching ====================
|
||||
// Fetch multiple font files in parallel since network I/O is non-blocking
|
||||
for (let i = 0; i < entries.length; i += concurrency) {
|
||||
const chunk = entries.slice(i, i + concurrency);
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map(async ([key, config]) => {
|
||||
this.statuses.set(key, 'loading');
|
||||
const buffer = await this.#fetchFontBuffer(
|
||||
config.url,
|
||||
this.#abortController.signal,
|
||||
);
|
||||
buffers.set(key, buffer);
|
||||
}),
|
||||
);
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
if (results[j].status === 'rejected') {
|
||||
const [key, config] = chunk[j];
|
||||
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
|
||||
this.statuses.set(key, 'error');
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
await this.#fetchChunk(entries.slice(i, i + concurrency), buffers);
|
||||
}
|
||||
|
||||
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
|
||||
// ==================== PHASE 2: Sequential Parsing ====================
|
||||
// Parse buffers one at a time with periodic yields to avoid blocking UI
|
||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||
let lastYield = performance.now();
|
||||
const YIELD_INTERVAL = 8; // ms
|
||||
const YIELD_INTERVAL = 8;
|
||||
|
||||
for (const [key, config] of entries) {
|
||||
const buffer = buffers.get(key);
|
||||
if (!buffer) continue;
|
||||
|
||||
try {
|
||||
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||
const font = new FontFace(config.name, buffer, {
|
||||
weight: weightRange,
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
});
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
this.#loadedFonts.set(key, font);
|
||||
this.statuses.set(key, 'loaded');
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') continue;
|
||||
console.error(`Font parse failed: ${config.name}`, e);
|
||||
this.statuses.set(key, 'error');
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
// Skip fonts that failed to fetch in phase 1
|
||||
if (!buffer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.#processFont(key, config, buffer);
|
||||
|
||||
// Yield to main thread if needed (prevents UI blocking)
|
||||
// Chromium: use isInputPending() for optimal responsiveness
|
||||
// Others: yield every 8ms as fallback
|
||||
const shouldYield = hasInputPending
|
||||
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||
: (performance.now() - lastYield > YIELD_INTERVAL);
|
||||
: performance.now() - lastYield > YIELD_INTERVAL;
|
||||
|
||||
if (shouldYield) {
|
||||
await this.#yieldToMain();
|
||||
await yieldToMainThread();
|
||||
lastYield = performance.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
|
||||
* Cache failures (private browsing, quota limits) are silently ignored.
|
||||
* Fetches a chunk of fonts concurrently and populates `buffers` with successful results.
|
||||
* Each promise carries its own key and config so results need no index correlation.
|
||||
* Aborted fetches are silently skipped; other errors set status to `'error'` and increment retry.
|
||||
*/
|
||||
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#CACHE_NAME);
|
||||
const cached = await cache.match(url);
|
||||
if (cached) return cached.arrayBuffer();
|
||||
async #fetchChunk(
|
||||
chunk: Array<[string, FontLoadRequestConfig]>,
|
||||
buffers: Map<string, ArrayBuffer>,
|
||||
): Promise<void> {
|
||||
const results = await Promise.all(
|
||||
chunk.map(async ([key, config]) => {
|
||||
this.statuses.set(key, 'loading');
|
||||
try {
|
||||
const buffer = await this.#cache.get(config.url, this.#abortController.signal);
|
||||
buffers.set(key, buffer);
|
||||
return { ok: true as const, key };
|
||||
} catch (reason) {
|
||||
return { ok: false as const, key, config, reason };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.ok) continue;
|
||||
const { key, config, reason } = result;
|
||||
const isAbort = reason instanceof FontFetchError
|
||||
&& reason.cause instanceof Error
|
||||
&& reason.cause.name === 'AbortError';
|
||||
if (isAbort) continue;
|
||||
if (reason instanceof FontFetchError) {
|
||||
console.error(`Font fetch failed: ${config.name}`, reason);
|
||||
}
|
||||
} catch {
|
||||
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
||||
this.statuses.set(key, 'error');
|
||||
this.#queue.incrementRetry(key);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#CACHE_NAME);
|
||||
await cache.put(url, response.clone());
|
||||
}
|
||||
} catch {
|
||||
// Cache write failed (quota, storage pressure) — return font anyway
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
|
||||
/**
|
||||
* Parses a fetched buffer into a {@link FontFace}, registers it with `document.fonts`,
|
||||
* and updates reactive status. On failure, sets status to `'error'` and increments the retry count.
|
||||
*/
|
||||
async #processFont(key: string, config: FontLoadRequestConfig, buffer: ArrayBuffer): Promise<void> {
|
||||
try {
|
||||
const font = await loadFont(config, buffer);
|
||||
this.#loadedFonts.set(key, font);
|
||||
this.#urlByKey.set(key, config.url);
|
||||
this.statuses.set(key, 'loaded');
|
||||
} catch (e) {
|
||||
if (e instanceof FontParseError) {
|
||||
console.error(`Font parse failed: ${config.name}`, e);
|
||||
this.statuses.set(key, 'error');
|
||||
this.#queue.incrementRetry(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. Pinned fonts are never evicted. */
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
for (const [key, lastUsed] of this.#usageTracker) {
|
||||
if (now - lastUsed < this.#TTL) continue;
|
||||
// Iterate through all tracked font keys
|
||||
for (const key of this.#eviction.keys()) {
|
||||
// Skip fonts that are still within TTL or are pinned
|
||||
if (!this.#eviction.shouldEvict(key, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove FontFace from document to free memory
|
||||
const font = this.#loadedFonts.get(key);
|
||||
if (font) document.fonts.delete(font);
|
||||
|
||||
// Evict from cache and cleanup URL mapping
|
||||
const url = this.#urlByKey.get(key);
|
||||
if (url) {
|
||||
this.#cache.evict(url);
|
||||
this.#urlByKey.delete(key);
|
||||
}
|
||||
|
||||
// Clean up remaining state
|
||||
this.#loadedFonts.delete(key);
|
||||
this.#usageTracker.delete(key);
|
||||
this.statuses.delete(key);
|
||||
this.#retryCounts.delete(key);
|
||||
this.#eviction.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns current loading status for a font, or undefined if never requested. */
|
||||
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
||||
try {
|
||||
return this.statuses.get(generateFontKey({ id, weight, isVariable }));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pins a font so it is never evicted by #purgeUnused(), regardless of TTL. */
|
||||
pin(id: string, weight: number, isVariable = false): void {
|
||||
this.#eviction.pin(generateFontKey({ id, weight, isVariable }));
|
||||
}
|
||||
|
||||
/** Unpins a font, allowing it to be evicted by #purgeUnused() once its TTL expires. */
|
||||
unpin(id: string, weight: number, isVariable = false): void {
|
||||
this.#eviction.unpin(generateFontKey({ id, weight, isVariable }));
|
||||
}
|
||||
|
||||
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
||||
async ready(): Promise<void> {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch {
|
||||
// document.fonts.ready can reject in some edge cases
|
||||
// (e.g., document unloaded). Silently resolve.
|
||||
}
|
||||
} catch { /* document unloaded */ }
|
||||
}
|
||||
|
||||
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
|
||||
destroy() {
|
||||
// Abort all in-flight network requests
|
||||
this.#abortController.abort();
|
||||
|
||||
// Cancel pending queue processing (idle callback or timeout)
|
||||
if (this.#timeoutId !== null) {
|
||||
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
|
||||
cancelIdleCallback(this.#timeoutId as unknown as number);
|
||||
@@ -331,22 +352,26 @@ export class AppliedFontsManager {
|
||||
this.#pendingType = null;
|
||||
}
|
||||
|
||||
// Stop periodic cleanup timer
|
||||
if (this.#intervalId) {
|
||||
clearInterval(this.#intervalId);
|
||||
this.#intervalId = null;
|
||||
}
|
||||
|
||||
// Remove all loaded fonts from document
|
||||
if (typeof document !== 'undefined') {
|
||||
for (const font of this.#loadedFonts.values()) {
|
||||
document.fonts.delete(font);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all state and collaborators
|
||||
this.#loadedFonts.clear();
|
||||
this.#usageTracker.clear();
|
||||
this.#retryCounts.clear();
|
||||
this.statuses.clear();
|
||||
this.#urlByKey.clear();
|
||||
this.#cache.clear();
|
||||
this.#eviction.clear();
|
||||
this.#queue.clear();
|
||||
this.statuses.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
35
src/entities/Font/model/store/appliedFontsStore/errors.ts
Normal file
35
src/entities/Font/model/store/appliedFontsStore/errors.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Thrown by {@link FontBufferCache} when a font file cannot be retrieved from the network or cache.
|
||||
*
|
||||
* @property url - The URL that was requested.
|
||||
* @property cause - The underlying error, if any.
|
||||
* @property status - HTTP status code. Present on HTTP errors, absent on network failures.
|
||||
*/
|
||||
export class FontFetchError extends Error {
|
||||
readonly name = 'FontFetchError';
|
||||
|
||||
constructor(
|
||||
public readonly url: string,
|
||||
public readonly cause?: unknown,
|
||||
public readonly status?: number,
|
||||
) {
|
||||
super(status ? `HTTP ${status} fetching font: ${url}` : `Network error fetching font: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by {@link loadFont} when a font buffer cannot be parsed into a {@link FontFace}.
|
||||
*
|
||||
* @property fontName - The display name of the font that failed to parse.
|
||||
* @property cause - The underlying error from the FontFace API.
|
||||
*/
|
||||
export class FontParseError extends Error {
|
||||
readonly name = 'FontParseError';
|
||||
|
||||
constructor(
|
||||
public readonly fontName: string,
|
||||
public readonly cause?: unknown,
|
||||
) {
|
||||
super(`Failed to parse font: ${fontName}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { FontFetchError } from '../../errors';
|
||||
import { FontBufferCache } from './FontBufferCache';
|
||||
|
||||
const makeBuffer = () => new ArrayBuffer(8);
|
||||
|
||||
const makeFetcher = (overrides: Partial<Response> = {}) =>
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: () => Promise.resolve(makeBuffer()),
|
||||
clone: () => ({ ok: true, status: 200, arrayBuffer: () => Promise.resolve(makeBuffer()) }),
|
||||
...overrides,
|
||||
} as Response);
|
||||
|
||||
describe('FontBufferCache', () => {
|
||||
let cache: FontBufferCache;
|
||||
let fetcher: ReturnType<typeof makeFetcher>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetcher = makeFetcher();
|
||||
cache = new FontBufferCache({ fetcher });
|
||||
});
|
||||
|
||||
it('returns buffer from memory on second call without fetching', async () => {
|
||||
await cache.get('https://example.com/font.woff2');
|
||||
await cache.get('https://example.com/font.woff2');
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('throws FontFetchError on HTTP error with correct status', async () => {
|
||||
const errorFetcher = makeFetcher({ ok: false, status: 404 });
|
||||
const errorCache = new FontBufferCache({ fetcher: errorFetcher });
|
||||
|
||||
const err = await errorCache.get('https://example.com/font.woff2').catch(e => e);
|
||||
expect(err).toBeInstanceOf(FontFetchError);
|
||||
expect(err.status).toBe(404);
|
||||
});
|
||||
|
||||
it('throws FontFetchError on network failure without status', async () => {
|
||||
const networkFetcher = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
const networkCache = new FontBufferCache({ fetcher: networkFetcher });
|
||||
|
||||
const err = await networkCache.get('https://example.com/font.woff2').catch(e => e);
|
||||
expect(err).toBeInstanceOf(FontFetchError);
|
||||
expect(err.status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('evict removes url from memory so next call fetches again', async () => {
|
||||
await cache.get('https://example.com/font.woff2');
|
||||
cache.evict('https://example.com/font.woff2');
|
||||
await cache.get('https://example.com/font.woff2');
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clear wipes all memory cache entries', async () => {
|
||||
await cache.get('https://example.com/a.woff2');
|
||||
await cache.get('https://example.com/b.woff2');
|
||||
cache.clear();
|
||||
await cache.get('https://example.com/a.woff2');
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { FontFetchError } from '../../errors';
|
||||
|
||||
type Fetcher = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface FontBufferCacheOptions {
|
||||
/** Custom fetch implementation. Defaults to `globalThis.fetch`. Inject in tests for isolation. */
|
||||
fetcher?: Fetcher;
|
||||
/** Cache API cache name. Defaults to `'font-cache-v1'`. */
|
||||
cacheName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-tier font buffer cache: in-memory → Cache API → network.
|
||||
*
|
||||
* - **Tier 1 (memory):** Fastest — no I/O. Populated after first successful fetch.
|
||||
* - **Tier 2 (Cache API):** Persists across page loads. Silently skipped in private browsing.
|
||||
* - **Tier 3 (network):** Raw fetch. Throws {@link FontFetchError} on failure.
|
||||
*
|
||||
* The `fetcher` option is injectable for testing — pass a `vi.fn()` to avoid real network calls.
|
||||
*/
|
||||
export class FontBufferCache {
|
||||
#buffersByUrl = new Map<string, ArrayBuffer>();
|
||||
|
||||
readonly #fetcher: Fetcher;
|
||||
readonly #cacheName: string;
|
||||
|
||||
constructor(
|
||||
{ fetcher = globalThis.fetch.bind(globalThis), cacheName = 'font-cache-v1' }: FontBufferCacheOptions = {},
|
||||
) {
|
||||
this.#fetcher = fetcher;
|
||||
this.#cacheName = cacheName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the font buffer for the given URL using the three-tier strategy.
|
||||
* Stores the result in memory on success.
|
||||
*
|
||||
* @throws {@link FontFetchError} if the network request fails or returns a non-OK response.
|
||||
*/
|
||||
async get(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||
// Tier 1: in-memory (fastest, no I/O)
|
||||
const inMemory = this.#buffersByUrl.get(url);
|
||||
if (inMemory) {
|
||||
return inMemory;
|
||||
}
|
||||
|
||||
// Tier 2: Cache API
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#cacheName);
|
||||
const cached = await cache.match(url);
|
||||
if (cached) {
|
||||
const buffer = await cached.arrayBuffer();
|
||||
this.#buffersByUrl.set(url, buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
||||
}
|
||||
|
||||
// Tier 3: network
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.#fetcher(url, { signal });
|
||||
} catch (cause) {
|
||||
throw new FontFetchError(url, cause);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new FontFetchError(url, undefined, response.status);
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#cacheName);
|
||||
await cache.put(url, response.clone());
|
||||
}
|
||||
} catch {
|
||||
// Cache write failed (quota, storage pressure) — return font anyway
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
this.#buffersByUrl.set(url, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Removes a URL from the in-memory cache. Next call to `get()` will re-fetch. */
|
||||
evict(url: string): void {
|
||||
this.#buffersByUrl.delete(url);
|
||||
}
|
||||
|
||||
/** Clears all in-memory cached buffers. */
|
||||
clear(): void {
|
||||
this.#buffersByUrl.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { FontEvictionPolicy } from './FontEvictionPolicy';
|
||||
|
||||
describe('FontEvictionPolicy', () => {
|
||||
let policy: FontEvictionPolicy;
|
||||
const TTL = 1000;
|
||||
const t0 = 100000;
|
||||
|
||||
beforeEach(() => {
|
||||
policy = new FontEvictionPolicy({ ttl: TTL });
|
||||
});
|
||||
|
||||
it('shouldEvict returns false within TTL', () => {
|
||||
policy.touch('a@400', t0);
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL - 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldEvict returns true at TTL boundary', () => {
|
||||
policy.touch('a@400', t0);
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
|
||||
});
|
||||
|
||||
it('shouldEvict returns false for pinned key regardless of TTL', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.pin('a@400');
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('shouldEvict returns true again after unpin past TTL', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.pin('a@400');
|
||||
policy.unpin('a@400');
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
|
||||
});
|
||||
|
||||
it('shouldEvict returns false for untracked key', () => {
|
||||
expect(policy.shouldEvict('never@touched', t0 + TTL * 100)).toBe(false);
|
||||
});
|
||||
|
||||
it('keys returns all tracked keys', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.touch('b@vf', t0);
|
||||
expect(Array.from(policy.keys())).toEqual(expect.arrayContaining(['a@400', 'b@vf']));
|
||||
});
|
||||
|
||||
it('remove deletes key from tracking so it no longer appears in keys()', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.touch('b@vf', t0);
|
||||
policy.remove('a@400');
|
||||
expect(Array.from(policy.keys())).not.toContain('a@400');
|
||||
expect(Array.from(policy.keys())).toContain('b@vf');
|
||||
});
|
||||
|
||||
it('remove unpins the key so a subsequent touch + TTL would evict it', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.pin('a@400');
|
||||
policy.remove('a@400');
|
||||
// re-touch and check it can be evicted again
|
||||
policy.touch('a@400', t0);
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL)).toBe(true);
|
||||
});
|
||||
|
||||
it('clear resets all state', () => {
|
||||
policy.touch('a@400', t0);
|
||||
policy.pin('a@400');
|
||||
policy.clear();
|
||||
expect(Array.from(policy.keys())).toHaveLength(0);
|
||||
expect(policy.shouldEvict('a@400', t0 + TTL * 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
interface FontEvictionPolicyOptions {
|
||||
/** TTL in milliseconds. Defaults to 5 minutes. */
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks font usage timestamps and pinned keys to determine when a font should be evicted.
|
||||
*
|
||||
* Pure data — no browser APIs. Accepts explicit `now` timestamps so tests
|
||||
* never need fake timers.
|
||||
*/
|
||||
export class FontEvictionPolicy {
|
||||
#usageTracker = new Map<string, number>();
|
||||
#pinnedFonts = new Set<string>();
|
||||
|
||||
readonly #TTL: number;
|
||||
|
||||
constructor({ ttl = 5 * 60 * 1000 }: FontEvictionPolicyOptions = {}) {
|
||||
this.#TTL = ttl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the last-used time for a font key.
|
||||
* @param key - Font key in `{id}@{weight}` or `{id}@vf` format.
|
||||
* @param now - Current timestamp in ms. Defaults to `Date.now()`.
|
||||
*/
|
||||
touch(key: string, now: number = Date.now()): void {
|
||||
this.#usageTracker.set(key, now);
|
||||
}
|
||||
|
||||
/** Pins a font key so it is never evicted regardless of TTL. */
|
||||
pin(key: string): void {
|
||||
this.#pinnedFonts.add(key);
|
||||
}
|
||||
|
||||
/** Unpins a font key, allowing it to be evicted once its TTL expires. */
|
||||
unpin(key: string): void {
|
||||
this.#pinnedFonts.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the font should be evicted.
|
||||
* A font is evicted when its TTL has elapsed and it is not pinned.
|
||||
* Returns `false` for untracked keys.
|
||||
*
|
||||
* @param key - Font key to check.
|
||||
* @param now - Current timestamp in ms (pass explicitly for deterministic tests).
|
||||
*/
|
||||
shouldEvict(key: string, now: number): boolean {
|
||||
const lastUsed = this.#usageTracker.get(key);
|
||||
if (lastUsed === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (this.#pinnedFonts.has(key)) {
|
||||
return false;
|
||||
}
|
||||
return now - lastUsed >= this.#TTL;
|
||||
}
|
||||
|
||||
/** Returns an iterator over all tracked font keys. */
|
||||
keys(): IterableIterator<string> {
|
||||
return this.#usageTracker.keys();
|
||||
}
|
||||
|
||||
/** Removes a font key from tracking. Called by the orchestrator after eviction. */
|
||||
remove(key: string): void {
|
||||
this.#usageTracker.delete(key);
|
||||
this.#pinnedFonts.delete(key);
|
||||
}
|
||||
|
||||
/** Clears all usage timestamps and pinned keys. */
|
||||
clear(): void {
|
||||
this.#usageTracker.clear();
|
||||
this.#pinnedFonts.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
import { FontLoadQueue } from './FontLoadQueue';
|
||||
|
||||
const config = (id: string): FontLoadRequestConfig => ({
|
||||
id,
|
||||
name: id,
|
||||
url: `https://example.com/${id}.woff2`,
|
||||
weight: 400,
|
||||
});
|
||||
|
||||
describe('FontLoadQueue', () => {
|
||||
let queue: FontLoadQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
queue = new FontLoadQueue();
|
||||
});
|
||||
|
||||
it('enqueue returns true for a new key', () => {
|
||||
expect(queue.enqueue('a@400', config('a'))).toBe(true);
|
||||
});
|
||||
|
||||
it('enqueue returns false for an already-queued key', () => {
|
||||
queue.enqueue('a@400', config('a'));
|
||||
expect(queue.enqueue('a@400', config('a'))).toBe(false);
|
||||
});
|
||||
|
||||
it('has returns true after enqueue, false after flush', () => {
|
||||
queue.enqueue('a@400', config('a'));
|
||||
expect(queue.has('a@400')).toBe(true);
|
||||
queue.flush();
|
||||
expect(queue.has('a@400')).toBe(false);
|
||||
});
|
||||
|
||||
it('flush returns all entries and atomically clears the queue', () => {
|
||||
queue.enqueue('a@400', config('a'));
|
||||
queue.enqueue('b@700', config('b'));
|
||||
const entries = queue.flush();
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(queue.has('a@400')).toBe(false);
|
||||
expect(queue.has('b@700')).toBe(false);
|
||||
});
|
||||
|
||||
it('isMaxRetriesReached returns false below MAX_RETRIES', () => {
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
expect(queue.isMaxRetriesReached('a@400')).toBe(false);
|
||||
});
|
||||
|
||||
it('isMaxRetriesReached returns true at MAX_RETRIES (3)', () => {
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
expect(queue.isMaxRetriesReached('a@400')).toBe(true);
|
||||
});
|
||||
|
||||
it('clear resets queue and retry counts', () => {
|
||||
queue.enqueue('a@400', config('a'));
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
queue.incrementRetry('a@400');
|
||||
queue.clear();
|
||||
expect(queue.has('a@400')).toBe(false);
|
||||
expect(queue.isMaxRetriesReached('a@400')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
|
||||
/**
|
||||
* Manages the font load queue and per-font retry counts.
|
||||
*
|
||||
* Scheduling (when to drain the queue) is handled by the orchestrator —
|
||||
* this class is purely concerned with what is queued and whether retries are exhausted.
|
||||
*/
|
||||
export class FontLoadQueue {
|
||||
#queue = new Map<string, FontLoadRequestConfig>();
|
||||
#retryCounts = new Map<string, number>();
|
||||
|
||||
readonly #MAX_RETRIES = 3;
|
||||
|
||||
/**
|
||||
* Adds a font to the queue.
|
||||
* @returns `true` if the key was newly enqueued, `false` if it was already present.
|
||||
*/
|
||||
enqueue(key: string, config: FontLoadRequestConfig): boolean {
|
||||
if (this.#queue.has(key)) {
|
||||
return false;
|
||||
}
|
||||
this.#queue.set(key, config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically snapshots and clears the queue.
|
||||
* @returns All queued entries at the time of the call.
|
||||
*/
|
||||
flush(): Array<[string, FontLoadRequestConfig]> {
|
||||
const entries = Array.from(this.#queue.entries());
|
||||
this.#queue.clear();
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Returns `true` if the key is currently in the queue. */
|
||||
has(key: string): boolean {
|
||||
return this.#queue.has(key);
|
||||
}
|
||||
|
||||
/** Increments the retry count for a font key. */
|
||||
incrementRetry(key: string): void {
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
/** Returns `true` if the font has reached or exceeded the maximum retry limit. */
|
||||
isMaxRetriesReached(key: string): boolean {
|
||||
return (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES;
|
||||
}
|
||||
|
||||
/** Clears all queued fonts and resets all retry counts. */
|
||||
clear(): void {
|
||||
this.#queue.clear();
|
||||
this.#retryCounts.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { generateFontKey } from './generateFontKey';
|
||||
|
||||
describe('generateFontKey', () => {
|
||||
it('should throw an error if font id is not provided', () => {
|
||||
const config = { weight: 400, isVariable: false };
|
||||
// @ts-expect-error
|
||||
expect(() => generateFontKey(config)).toThrow('Font id is required');
|
||||
});
|
||||
|
||||
it('should generate a font key for a variable font', () => {
|
||||
const config = { id: 'Roboto', weight: 400, isVariable: true };
|
||||
expect(generateFontKey(config)).toBe('roboto@vf');
|
||||
});
|
||||
|
||||
it('should throw an error if font weight is not provided and is not a variable font', () => {
|
||||
const config = { id: 'Roboto', isVariable: false };
|
||||
// @ts-expect-error
|
||||
expect(() => generateFontKey(config)).toThrow('Font weight is required');
|
||||
});
|
||||
|
||||
it('should generate a font key for a non-variable font', () => {
|
||||
const config = { id: 'Roboto', weight: 400, isVariable: false };
|
||||
expect(generateFontKey(config)).toBe('roboto@400');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
|
||||
export type PartialConfig = Pick<FontLoadRequestConfig, 'id' | 'weight' | 'isVariable'>;
|
||||
|
||||
/**
|
||||
* Generates a font key for a given font load request configuration.
|
||||
* @param config - The font load request configuration.
|
||||
* @returns The generated font key.
|
||||
*/
|
||||
export function generateFontKey(config: PartialConfig): string {
|
||||
if (!config.id) {
|
||||
throw new Error('Font id is required');
|
||||
}
|
||||
if (config.isVariable) {
|
||||
return `${config.id.toLowerCase()}@vf`;
|
||||
}
|
||||
|
||||
if (!config.weight) {
|
||||
throw new Error('Font weight is required');
|
||||
}
|
||||
return `${config.id.toLowerCase()}@${config.weight}`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import {
|
||||
Concurrency,
|
||||
getEffectiveConcurrency,
|
||||
} from './getEffectiveConcurrency';
|
||||
|
||||
describe('getEffectiveConcurrency', () => {
|
||||
beforeEach(() => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = null;
|
||||
});
|
||||
|
||||
it('should return MAX when connection is not available', () => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = null;
|
||||
expect(getEffectiveConcurrency()).toBe(Concurrency.MAX);
|
||||
});
|
||||
|
||||
it('should return MIN for slow-2g or 2g connection', () => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = { effectiveType: 'slow-2g' };
|
||||
expect(getEffectiveConcurrency()).toBe(Concurrency.MIN);
|
||||
});
|
||||
|
||||
it('should return AVERAGE for 3g connection', () => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = { effectiveType: '3g' };
|
||||
expect(getEffectiveConcurrency()).toBe(Concurrency.AVERAGE);
|
||||
});
|
||||
|
||||
it('should return MAX for other connection types', () => {
|
||||
const nav = navigator as any;
|
||||
nav.connection = { effectiveType: '4g' };
|
||||
expect(getEffectiveConcurrency()).toBe(Concurrency.MAX);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export enum Concurrency {
|
||||
MIN = 1,
|
||||
AVERAGE = 2,
|
||||
MAX = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the amount of fonts for concurrent download based on the user internet connection
|
||||
*/
|
||||
export function getEffectiveConcurrency(): number {
|
||||
const nav = navigator as any;
|
||||
const connection = nav.connection;
|
||||
if (!connection) {
|
||||
return Concurrency.MAX;
|
||||
}
|
||||
|
||||
switch (connection.effectiveType) {
|
||||
case 'slow-2g':
|
||||
case '2g':
|
||||
return Concurrency.MIN;
|
||||
case '3g':
|
||||
return Concurrency.AVERAGE;
|
||||
default:
|
||||
return Concurrency.MAX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { generateFontKey } from './generateFontKey/generateFontKey';
|
||||
export { getEffectiveConcurrency } from './getEffectiveConcurrency/getEffectiveConcurrency';
|
||||
export { loadFont } from './loadFont/loadFont';
|
||||
export { yieldToMainThread } from './yieldToMainThread/yieldToMainThread';
|
||||
@@ -0,0 +1,93 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { FontParseError } from '../../errors';
|
||||
import { loadFont } from './loadFont';
|
||||
|
||||
describe('loadFont', () => {
|
||||
let mockFontInstance: any;
|
||||
let mockFontFaceSet: { add: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockFontFaceSet = { add: vi.fn(), delete: vi.fn() };
|
||||
Object.defineProperty(document, 'fonts', { value: mockFontFaceSet, configurable: true, writable: true });
|
||||
|
||||
const MockFontFace = vi.fn(
|
||||
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
|
||||
this.name = name;
|
||||
this.buffer = buffer;
|
||||
this.options = options;
|
||||
this.load = vi.fn().mockResolvedValue(this);
|
||||
mockFontInstance = this;
|
||||
},
|
||||
);
|
||||
vi.stubGlobal('FontFace', MockFontFace);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('constructs FontFace with exact weight for static fonts', async () => {
|
||||
const buffer = new ArrayBuffer(8);
|
||||
await loadFont({ name: 'Roboto', weight: 400 }, buffer);
|
||||
|
||||
expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '400' }));
|
||||
});
|
||||
|
||||
it('constructs FontFace with weight range for variable fonts', async () => {
|
||||
const buffer = new ArrayBuffer(8);
|
||||
await loadFont({ name: 'Roboto', weight: 400, isVariable: true }, buffer);
|
||||
|
||||
expect(FontFace).toHaveBeenCalledWith('Roboto', buffer, expect.objectContaining({ weight: '100 900' }));
|
||||
});
|
||||
|
||||
it('sets style: normal and display: swap on FontFace options', async () => {
|
||||
await loadFont({ name: 'Lato', weight: 700 }, new ArrayBuffer(8));
|
||||
|
||||
expect(FontFace).toHaveBeenCalledWith(
|
||||
'Lato',
|
||||
expect.anything(),
|
||||
expect.objectContaining({ style: 'normal', display: 'swap' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the buffer as the second argument to FontFace', async () => {
|
||||
const buffer = new ArrayBuffer(16);
|
||||
await loadFont({ name: 'Inter', weight: 400 }, buffer);
|
||||
|
||||
expect(FontFace).toHaveBeenCalledWith('Inter', buffer, expect.anything());
|
||||
});
|
||||
|
||||
it('calls font.load() and adds the font to document.fonts', async () => {
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const result = await loadFont({ name: 'Inter', weight: 400 }, buffer);
|
||||
|
||||
expect(mockFontInstance.load).toHaveBeenCalledOnce();
|
||||
expect(mockFontFaceSet.add).toHaveBeenCalledWith(mockFontInstance);
|
||||
expect(result).toBe(mockFontInstance);
|
||||
});
|
||||
|
||||
it('throws FontParseError when font.load() rejects', async () => {
|
||||
const loadError = new Error('parse failed');
|
||||
const MockFontFace = vi.fn(
|
||||
function(this: any, name: string, buffer: BufferSource, options: FontFaceDescriptors) {
|
||||
this.load = vi.fn().mockRejectedValue(loadError);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal('FontFace', MockFontFace);
|
||||
|
||||
await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf(
|
||||
FontParseError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws FontParseError when document.fonts.add throws', async () => {
|
||||
const addError = new Error('add failed');
|
||||
mockFontFaceSet.add.mockImplementation(() => {
|
||||
throw addError;
|
||||
});
|
||||
|
||||
await expect(loadFont({ name: 'Broken', weight: 400 }, new ArrayBuffer(8))).rejects.toBeInstanceOf(
|
||||
FontParseError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FontLoadRequestConfig } from '../../../../types';
|
||||
import { FontParseError } from '../../errors';
|
||||
|
||||
export type PartialConfig = Pick<FontLoadRequestConfig, 'weight' | 'name' | 'isVariable'>;
|
||||
/**
|
||||
* Loads a font from a buffer and adds it to the document's font collection.
|
||||
* @param config - The font load request configuration.
|
||||
* @param buffer - The buffer containing the font data.
|
||||
* @returns A promise that resolves to the loaded `FontFace`.
|
||||
* @throws {@link FontParseError} When the font buffer cannot be parsed or added to the document font set.
|
||||
*/
|
||||
export async function loadFont(config: PartialConfig, buffer: BufferSource): Promise<FontFace> {
|
||||
try {
|
||||
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||
const font = new FontFace(config.name, buffer, {
|
||||
weight: weightRange,
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
});
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
|
||||
return font;
|
||||
} catch (error) {
|
||||
throw new FontParseError(config.name, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { yieldToMainThread } from './yieldToMainThread';
|
||||
|
||||
describe('yieldToMainThread', () => {
|
||||
it('uses scheduler.yield when available', async () => {
|
||||
const mockYield = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('scheduler', { yield: mockYield });
|
||||
|
||||
await yieldToMainThread();
|
||||
|
||||
expect(mockYield).toHaveBeenCalledOnce();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
it('falls back to MessageChannel when scheduler is unavailable', async () => {
|
||||
// scheduler is not defined in jsdom by default
|
||||
await expect(yieldToMainThread()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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 => {
|
||||
const ch = new MessageChannel();
|
||||
ch.port1.onmessage = () => resolve();
|
||||
ch.port2.postMessage(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
type QueryKey,
|
||||
QueryObserver,
|
||||
type QueryObserverOptions,
|
||||
type QueryObserverResult,
|
||||
} from '@tanstack/query-core';
|
||||
import type { UnifiedFont } from '../types';
|
||||
|
||||
/** */
|
||||
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||
cleanup: () => void;
|
||||
|
||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
||||
#internalParams = $state<TParams>({} as TParams);
|
||||
|
||||
params = $derived.by(() => {
|
||||
let merged = { ...this.#internalParams };
|
||||
|
||||
// Loop through every "Cable" plugged into the store
|
||||
// Loop through every "Cable" plugged into the store
|
||||
for (const getter of this.#bindings) {
|
||||
const bindingResult = getter();
|
||||
merged = { ...merged, ...bindingResult };
|
||||
}
|
||||
|
||||
return merged as TParams;
|
||||
});
|
||||
|
||||
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
||||
protected observer: QueryObserver<UnifiedFont[], Error>;
|
||||
protected qc = queryClient;
|
||||
|
||||
constructor(initialParams: TParams) {
|
||||
this.#internalParams = initialParams;
|
||||
|
||||
this.observer = new QueryObserver(this.qc, this.getOptions());
|
||||
|
||||
// Sync TanStack -> Svelte State
|
||||
this.observer.subscribe(r => {
|
||||
this.result = r;
|
||||
});
|
||||
|
||||
// Sync Svelte State -> TanStack Options
|
||||
this.cleanup = $effect.root(() => {
|
||||
$effect(() => {
|
||||
this.observer.setOptions(this.getOptions());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandatory: Child must define how to fetch data and what the key is.
|
||||
*/
|
||||
protected abstract getQueryKey(params: TParams): QueryKey;
|
||||
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
||||
|
||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||
return {
|
||||
queryKey: this.getQueryKey(params),
|
||||
queryFn: () => this.fetchFn(params),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Common Getters ---
|
||||
get fonts() {
|
||||
return this.result.data ?? [];
|
||||
}
|
||||
get isLoading() {
|
||||
return this.result.isLoading;
|
||||
}
|
||||
get isFetching() {
|
||||
return this.result.isFetching;
|
||||
}
|
||||
get isError() {
|
||||
return this.result.isError;
|
||||
}
|
||||
get isEmpty() {
|
||||
return !this.isLoading && this.fonts.length === 0;
|
||||
}
|
||||
|
||||
// --- Common Actions ---
|
||||
|
||||
addBinding(getter: () => Partial<TParams>) {
|
||||
this.#bindings.push(getter);
|
||||
|
||||
return () => {
|
||||
this.#bindings = this.#bindings.filter(b => b !== getter);
|
||||
};
|
||||
}
|
||||
|
||||
setParams(newParams: Partial<TParams>) {
|
||||
this.#internalParams = { ...this.params, ...newParams };
|
||||
}
|
||||
/**
|
||||
* Invalidate cache and refetch
|
||||
*/
|
||||
invalidate() {
|
||||
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refetch
|
||||
*/
|
||||
async refetch() {
|
||||
await this.observer.refetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch with different params (for hover states, pagination, etc.)
|
||||
*/
|
||||
async prefetch(params: TParams) {
|
||||
await this.qc.prefetchQuery(this.getOptions(params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing queries
|
||||
*/
|
||||
cancel() {
|
||||
this.qc.cancelQueries({
|
||||
queryKey: this.getQueryKey(this.params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for current params
|
||||
*/
|
||||
clearCache() {
|
||||
this.qc.removeQueries({
|
||||
queryKey: this.getQueryKey(this.params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data without triggering fetch
|
||||
*/
|
||||
getCachedData() {
|
||||
return this.qc.getQueryData<UnifiedFont[]>(
|
||||
this.getQueryKey(this.params),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data manually (optimistic updates)
|
||||
*/
|
||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
||||
this.qc.setQueryData(
|
||||
this.getQueryKey(this.params),
|
||||
updater,
|
||||
);
|
||||
}
|
||||
}
|
||||
583
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
583
src/entities/Font/model/store/fontStore/fontStore.svelte.spec.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
import { QueryClient } from '@tanstack/query-core';
|
||||
import { flushSync } from 'svelte';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../../lib/errors/errors';
|
||||
import {
|
||||
generateMixedCategoryFonts,
|
||||
generateMockFonts,
|
||||
} from '../../../lib/mocks/fonts.mock';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
import { FontStore } from './fontStore.svelte';
|
||||
|
||||
vi.mock('$shared/api/queryClient', () => ({
|
||||
queryClient: new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0, gcTime: 0 } },
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../../api', () => ({ fetchProxyFonts: vi.fn() }));
|
||||
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { fetchProxyFonts } from '../../../api';
|
||||
|
||||
const fetch = fetchProxyFonts as ReturnType<typeof vi.fn>;
|
||||
|
||||
type FontPage = { fonts: UnifiedFont[]; total: number; limit: number; offset: number };
|
||||
|
||||
const makeResponse = (
|
||||
fonts: UnifiedFont[],
|
||||
meta: { total?: number; limit?: number; offset?: number } = {},
|
||||
): FontPage => ({
|
||||
fonts,
|
||||
total: meta.total ?? fonts.length,
|
||||
limit: meta.limit ?? 10,
|
||||
offset: meta.offset ?? 0,
|
||||
});
|
||||
|
||||
function makeStore(params = {}) {
|
||||
return new FontStore({ limit: 10, ...params });
|
||||
}
|
||||
|
||||
async function fetchedStore(params = {}, fonts = generateMockFonts(5), meta: Parameters<typeof makeResponse>[1] = {}) {
|
||||
fetch.mockResolvedValue(makeResponse(fonts, meta));
|
||||
const store = makeStore(params);
|
||||
await store.refetch();
|
||||
flushSync();
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('FontStore', () => {
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('construction', () => {
|
||||
it('stores initial params', () => {
|
||||
const store = makeStore({ limit: 20 });
|
||||
expect(store.params.limit).toBe(20);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('defaults limit to 50 when not provided', () => {
|
||||
const store = new FontStore();
|
||||
expect(store.params.limit).toBe(50);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('starts with empty fonts', () => {
|
||||
const store = makeStore();
|
||||
expect(store.fonts).toEqual([]);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('starts with isEmpty false — initial fetch is in progress', () => {
|
||||
// The observer starts fetching immediately on construction.
|
||||
// isEmpty must be false so the UI shows a loader, not "no results".
|
||||
const store = makeStore();
|
||||
expect(store.isEmpty).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('state after fetch', () => {
|
||||
it('exposes loaded fonts', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(7));
|
||||
expect(store.fonts).toHaveLength(7);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('isEmpty is false when fonts are present', async () => {
|
||||
const store = await fetchedStore();
|
||||
expect(store.isEmpty).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('isLoading is false after fetch', async () => {
|
||||
const store = await fetchedStore();
|
||||
expect(store.isLoading).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('isFetching is false after fetch', async () => {
|
||||
const store = await fetchedStore();
|
||||
expect(store.isFetching).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('isError is false on success', async () => {
|
||||
const store = await fetchedStore();
|
||||
expect(store.isError).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('error is null on success', async () => {
|
||||
const store = await fetchedStore();
|
||||
expect(store.error).toBeNull();
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('error states', () => {
|
||||
it('isError is false before any fetch', () => {
|
||||
const store = makeStore();
|
||||
expect(store.isError).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('wraps network failures in FontNetworkError', async () => {
|
||||
fetch.mockRejectedValue(new Error('network down'));
|
||||
const store = makeStore();
|
||||
await store.refetch().catch(() => {});
|
||||
flushSync();
|
||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||
expect(store.isError).toBe(true);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('exposes FontResponseError for falsy response', async () => {
|
||||
const store = makeStore();
|
||||
fetch.mockResolvedValue(null);
|
||||
await store.refetch().catch(() => {});
|
||||
flushSync();
|
||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||
expect((store.error as FontResponseError).field).toBe('response');
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('exposes FontResponseError for missing fonts field', async () => {
|
||||
fetch.mockResolvedValue({ total: 0, limit: 10, offset: 0 });
|
||||
const store = makeStore();
|
||||
await store.refetch().catch(() => {});
|
||||
flushSync();
|
||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||
expect((store.error as FontResponseError).field).toBe('response.fonts');
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('exposes FontResponseError for non-array fonts', async () => {
|
||||
fetch.mockResolvedValue({ fonts: 'bad', total: 0, limit: 10, offset: 0 });
|
||||
const store = makeStore();
|
||||
await store.refetch().catch(() => {});
|
||||
flushSync();
|
||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||
expect((store.error as FontResponseError).received).toBe('bad');
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('font accumulation', () => {
|
||||
it('replaces fonts when refetching the first page', async () => {
|
||||
const store = makeStore();
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
|
||||
await store.refetch();
|
||||
flushSync();
|
||||
|
||||
const second = generateMockFonts(2);
|
||||
fetch.mockResolvedValue(makeResponse(second));
|
||||
await store.refetch();
|
||||
flushSync();
|
||||
|
||||
// refetch at offset=0 re-fetches all pages; only one page loaded → new data replaces old
|
||||
expect(store.fonts).toHaveLength(2);
|
||||
expect(store.fonts[0].id).toBe(second[0].id);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('appends fonts after nextPage', async () => {
|
||||
const page1 = generateMockFonts(3);
|
||||
const store = await fetchedStore({ limit: 3 }, page1, { total: 6, limit: 3, offset: 0 });
|
||||
const page2 = generateMockFonts(3).map((f, i) => ({ ...f, id: `p2-${i}` }));
|
||||
fetch.mockResolvedValue(makeResponse(page2, { total: 6, limit: 3, offset: 3 }));
|
||||
await store.nextPage();
|
||||
flushSync();
|
||||
|
||||
expect(store.fonts).toHaveLength(6);
|
||||
expect(store.fonts.slice(0, 3).map(f => f.id)).toEqual(page1.map(f => f.id));
|
||||
expect(store.fonts.slice(3).map(f => f.id)).toEqual(page2.map(f => f.id));
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('pagination state', () => {
|
||||
it('returns zero-value defaults before any fetch', () => {
|
||||
const store = makeStore();
|
||||
expect(store.pagination).toMatchObject({ total: 0, hasMore: false, page: 1, totalPages: 0 });
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('reflects response metadata after fetch', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
|
||||
expect(store.pagination.total).toBe(30);
|
||||
expect(store.pagination.hasMore).toBe(true);
|
||||
expect(store.pagination.page).toBe(1);
|
||||
expect(store.pagination.totalPages).toBe(3);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('hasMore is false on the last page', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(10), { total: 10, limit: 10, offset: 0 });
|
||||
expect(store.pagination.hasMore).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('page count increments after nextPage', async () => {
|
||||
const store = await fetchedStore({ limit: 10 }, generateMockFonts(10), { total: 30, limit: 10, offset: 0 });
|
||||
expect(store.pagination.page).toBe(1);
|
||||
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||
await store.nextPage();
|
||||
flushSync();
|
||||
expect(store.pagination.page).toBe(2);
|
||||
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('setParams', () => {
|
||||
it('merges updates into existing params', () => {
|
||||
const store = makeStore({ limit: 10 });
|
||||
store.setParams({ limit: 20 });
|
||||
expect(store.params.limit).toBe(20);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('retains unmodified params', () => {
|
||||
const store = makeStore({ limit: 10 });
|
||||
store.setCategories(['serif']);
|
||||
store.setParams({ limit: 25 });
|
||||
expect(store.params.categories).toEqual(['serif']);
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('filter change resets', () => {
|
||||
it('clears accumulated fonts when a filter changes', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(5));
|
||||
store.setSearch('roboto');
|
||||
flushSync();
|
||||
// TQ switches to a new queryKey → data.pages reset → fonts = []
|
||||
expect(store.fonts).toHaveLength(0);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('isEmpty is false immediately after filter change — fetch is in progress', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(5));
|
||||
// Hang the next fetch so we can observe the transitioning state
|
||||
fetch.mockReturnValue(new Promise(() => {}));
|
||||
store.setSearch('roboto');
|
||||
flushSync();
|
||||
// fonts = [] AND isFetching = true → isEmpty must be false (no "no results" flash)
|
||||
expect(store.isEmpty).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('does NOT reset fonts when the same filter value is set again', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(5));
|
||||
store.setCategories(['serif']);
|
||||
flushSync();
|
||||
// First change: clears fonts (expected)
|
||||
store.setCategories(['serif']); // same value — same queryKey — TQ keeps data.pages
|
||||
flushSync();
|
||||
// Because queryKey hasn't changed, TQ returns cached data — fonts restored from cache
|
||||
// (actual font count depends on cache; key assertion is no extra reset)
|
||||
expect(store.isError).toBe(false);
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('staleTime in buildOptions', () => {
|
||||
it('is 5 minutes with no active filters', () => {
|
||||
const store = makeStore();
|
||||
expect((store as any).buildOptions().staleTime).toBe(5 * 60 * 1000);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('is 0 when a search query is active', () => {
|
||||
const store = makeStore();
|
||||
store.setSearch('roboto');
|
||||
expect((store as any).buildOptions().staleTime).toBe(0);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('is 0 when a category filter is active', () => {
|
||||
const store = makeStore();
|
||||
store.setCategories(['serif']);
|
||||
expect((store as any).buildOptions().staleTime).toBe(0);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('gcTime is 10 minutes always', () => {
|
||||
const store = makeStore();
|
||||
expect((store as any).buildOptions().gcTime).toBe(10 * 60 * 1000);
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('buildQueryKey', () => {
|
||||
it('omits empty-string params', () => {
|
||||
const store = makeStore();
|
||||
store.setSearch('');
|
||||
const [root, normalized] = (store as any).buildQueryKey(store.params);
|
||||
expect(root).toBe('fonts');
|
||||
expect(normalized).not.toHaveProperty('q');
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('omits empty-array params', () => {
|
||||
const store = makeStore();
|
||||
store.setProviders([]);
|
||||
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||
expect(normalized).not.toHaveProperty('providers');
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('includes non-empty filter values', () => {
|
||||
const store = makeStore();
|
||||
store.setCategories(['serif']);
|
||||
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||
expect(normalized).toHaveProperty('categories', ['serif']);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('does not include offset (offset is the TQ page param, not a query key component)', () => {
|
||||
const store = makeStore();
|
||||
const [, normalized] = (store as any).buildQueryKey(store.params);
|
||||
expect(normalized).not.toHaveProperty('offset');
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('destroy', () => {
|
||||
it('does not throw', () => {
|
||||
const store = makeStore();
|
||||
expect(() => store.destroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it('is idempotent', () => {
|
||||
const store = makeStore();
|
||||
store.destroy();
|
||||
expect(() => store.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('refetch', () => {
|
||||
it('triggers a fetch', async () => {
|
||||
const store = makeStore();
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(3)));
|
||||
await store.refetch();
|
||||
expect(fetch).toHaveBeenCalled();
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('uses params current at call time', async () => {
|
||||
const store = makeStore({ limit: 10 });
|
||||
store.setParams({ limit: 20 });
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(20)));
|
||||
await store.refetch();
|
||||
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 }));
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('nextPage', () => {
|
||||
let store: FontStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 0 }));
|
||||
store = new FontStore({ limit: 10 });
|
||||
await store.refetch();
|
||||
flushSync();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('fetches the next page and appends fonts', async () => {
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 10 }));
|
||||
await store.nextPage();
|
||||
flushSync();
|
||||
expect(store.fonts).toHaveLength(20);
|
||||
expect(store.pagination.offset).toBe(10);
|
||||
});
|
||||
|
||||
it('is a no-op when hasMore is false', async () => {
|
||||
// Set up a store where all fonts fit in one page (hasMore = false)
|
||||
queryClient.clear();
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(10), { total: 10, limit: 10, offset: 0 }));
|
||||
store = new FontStore({ limit: 10 });
|
||||
await store.refetch();
|
||||
flushSync();
|
||||
|
||||
expect(store.pagination.hasMore).toBe(false);
|
||||
await store.nextPage(); // should not trigger another fetch
|
||||
expect(store.fonts).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('prevPage and goToPage', () => {
|
||||
it('prevPage is a no-op — infinite scroll does not support backward navigation', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(5));
|
||||
store.prevPage();
|
||||
expect(store.fonts).toHaveLength(5); // unchanged
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('goToPage is a no-op — infinite scroll does not support arbitrary page jumps', async () => {
|
||||
const store = await fetchedStore({}, generateMockFonts(5));
|
||||
store.goToPage(3);
|
||||
expect(store.fonts).toHaveLength(5); // unchanged
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('prefetch', () => {
|
||||
it('triggers a fetch for the provided params', async () => {
|
||||
const store = makeStore();
|
||||
fetch.mockResolvedValue(makeResponse(generateMockFonts(5)));
|
||||
await store.prefetch({ limit: 5 });
|
||||
expect(fetch).toHaveBeenCalledWith(expect.objectContaining({ limit: 5, offset: 0 }));
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('getCachedData / setQueryData', () => {
|
||||
it('getCachedData returns undefined before any fetch', () => {
|
||||
queryClient.clear();
|
||||
const store = new FontStore({ limit: 10 });
|
||||
expect(store.getCachedData()).toBeUndefined();
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('getCachedData returns flattened fonts after fetch', async () => {
|
||||
const store = await fetchedStore();
|
||||
expect(store.getCachedData()).toHaveLength(5);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('setQueryData writes to cache', () => {
|
||||
const store = makeStore();
|
||||
const font = generateMockFonts(1)[0];
|
||||
store.setQueryData(() => [font]);
|
||||
expect(store.getCachedData()).toHaveLength(1);
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('setQueryData updater receives existing flattened fonts', async () => {
|
||||
const store = await fetchedStore();
|
||||
const updater = vi.fn((old: UnifiedFont[] | undefined) => old ?? []);
|
||||
store.setQueryData(updater);
|
||||
expect(updater).toHaveBeenCalledWith(expect.any(Array));
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('invalidate', () => {
|
||||
it('calls invalidateQueries', async () => {
|
||||
const store = await fetchedStore();
|
||||
const spy = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
store.invalidate();
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('setLimit', () => {
|
||||
it('updates the limit param', () => {
|
||||
const store = makeStore({ limit: 10 });
|
||||
store.setLimit(25);
|
||||
expect(store.params.limit).toBe(25);
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('filter shortcut methods', () => {
|
||||
let store: FontStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = makeStore();
|
||||
});
|
||||
afterEach(() => {
|
||||
store.destroy();
|
||||
});
|
||||
|
||||
it('setProviders updates providers param', () => {
|
||||
store.setProviders(['google']);
|
||||
expect(store.params.providers).toEqual(['google']);
|
||||
});
|
||||
|
||||
it('setCategories updates categories param', () => {
|
||||
store.setCategories(['serif']);
|
||||
expect(store.params.categories).toEqual(['serif']);
|
||||
});
|
||||
|
||||
it('setSubsets updates subsets param', () => {
|
||||
store.setSubsets(['cyrillic']);
|
||||
expect(store.params.subsets).toEqual(['cyrillic']);
|
||||
});
|
||||
|
||||
it('setSearch sets q param', () => {
|
||||
store.setSearch('roboto');
|
||||
expect(store.params.q).toBe('roboto');
|
||||
});
|
||||
|
||||
it('setSearch with empty string clears q', () => {
|
||||
store.setSearch('roboto');
|
||||
store.setSearch('');
|
||||
expect(store.params.q).toBeUndefined();
|
||||
});
|
||||
|
||||
it('setSort updates sort param', () => {
|
||||
store.setSort('popularity');
|
||||
expect(store.params.sort).toBe('popularity');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
describe('category getters', () => {
|
||||
it('each getter returns only fonts of that category', async () => {
|
||||
const fonts = generateMixedCategoryFonts(2); // 2 of each category = 10 total
|
||||
fetch.mockResolvedValue(makeResponse(fonts, { total: fonts.length, limit: fonts.length }));
|
||||
const store = makeStore({ limit: 50 });
|
||||
await store.refetch();
|
||||
flushSync();
|
||||
|
||||
expect(store.sansSerifFonts.every(f => f.category === 'sans-serif')).toBe(true);
|
||||
expect(store.serifFonts.every(f => f.category === 'serif')).toBe(true);
|
||||
expect(store.displayFonts.every(f => f.category === 'display')).toBe(true);
|
||||
expect(store.handwritingFonts.every(f => f.category === 'handwriting')).toBe(true);
|
||||
expect(store.monospaceFonts.every(f => f.category === 'monospace')).toBe(true);
|
||||
expect(store.sansSerifFonts).toHaveLength(2);
|
||||
|
||||
store.destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
283
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
283
src/entities/Font/model/store/fontStore/fontStore.svelte.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
type InfiniteData,
|
||||
InfiniteQueryObserver,
|
||||
type InfiniteQueryObserverResult,
|
||||
type QueryFunctionContext,
|
||||
} from '@tanstack/query-core';
|
||||
import {
|
||||
type ProxyFontsParams,
|
||||
type ProxyFontsResponse,
|
||||
fetchProxyFonts,
|
||||
} from '../../../api';
|
||||
import {
|
||||
FontNetworkError,
|
||||
FontResponseError,
|
||||
} from '../../../lib/errors/errors';
|
||||
import type { UnifiedFont } from '../../types';
|
||||
|
||||
type PageParam = { offset: number };
|
||||
|
||||
/** Filter params + limit — offset is managed by TQ as a page param, not a user param. */
|
||||
type FontStoreParams = Omit<ProxyFontsParams, 'offset'>;
|
||||
|
||||
type FontStoreResult = InfiniteQueryObserverResult<InfiniteData<ProxyFontsResponse, PageParam>, Error>;
|
||||
|
||||
export class FontStore {
|
||||
#params = $state<FontStoreParams>({ limit: 50 });
|
||||
#result = $state<FontStoreResult>({} as FontStoreResult);
|
||||
#observer: InfiniteQueryObserver<
|
||||
ProxyFontsResponse,
|
||||
Error,
|
||||
InfiniteData<ProxyFontsResponse, PageParam>,
|
||||
readonly unknown[],
|
||||
PageParam
|
||||
>;
|
||||
#qc = queryClient;
|
||||
#unsubscribe: () => void;
|
||||
|
||||
constructor(params: FontStoreParams = {}) {
|
||||
this.#params = { limit: 50, ...params };
|
||||
this.#observer = new InfiniteQueryObserver(this.#qc, this.buildOptions());
|
||||
this.#unsubscribe = this.#observer.subscribe(r => {
|
||||
this.#result = r;
|
||||
});
|
||||
}
|
||||
|
||||
// -- Public state --
|
||||
|
||||
get params(): FontStoreParams {
|
||||
return this.#params;
|
||||
}
|
||||
get fonts(): UnifiedFont[] {
|
||||
return this.#result.data?.pages.flatMap((p: ProxyFontsResponse) => p.fonts) ?? [];
|
||||
}
|
||||
get isLoading(): boolean {
|
||||
return this.#result.isLoading;
|
||||
}
|
||||
get isFetching(): boolean {
|
||||
return this.#result.isFetching;
|
||||
}
|
||||
get isError(): boolean {
|
||||
return this.#result.isError;
|
||||
}
|
||||
|
||||
get error(): Error | null {
|
||||
return this.#result.error ?? null;
|
||||
}
|
||||
// isEmpty is false during loading/fetching so the UI never flashes "no results"
|
||||
// while a fetch is in progress. The !isFetching guard is specifically for the filter-change
|
||||
// transition: fonts clear synchronously → isFetching becomes true → isEmpty stays false.
|
||||
get isEmpty(): boolean {
|
||||
return !this.isLoading && !this.isFetching && this.fonts.length === 0;
|
||||
}
|
||||
|
||||
get pagination() {
|
||||
const pages = this.#result.data?.pages;
|
||||
const last = pages?.at(-1);
|
||||
if (!last) {
|
||||
return {
|
||||
total: 0,
|
||||
limit: this.#params.limit ?? 50,
|
||||
offset: 0,
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
return {
|
||||
total: last.total,
|
||||
limit: last.limit,
|
||||
offset: last.offset,
|
||||
hasMore: this.#result.hasNextPage,
|
||||
page: pages!.length,
|
||||
totalPages: Math.ceil(last.total / last.limit),
|
||||
};
|
||||
}
|
||||
|
||||
// -- Lifecycle --
|
||||
|
||||
destroy() {
|
||||
this.#unsubscribe();
|
||||
this.#observer.destroy();
|
||||
}
|
||||
|
||||
// -- Param management --
|
||||
|
||||
setParams(updates: Partial<FontStoreParams>) {
|
||||
this.#params = { ...this.#params, ...updates };
|
||||
this.#observer.setOptions(this.buildOptions());
|
||||
}
|
||||
invalidate() {
|
||||
this.#qc.invalidateQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||
}
|
||||
|
||||
// -- Async operations --
|
||||
|
||||
async refetch() {
|
||||
await this.#observer.refetch();
|
||||
}
|
||||
|
||||
async prefetch(params: FontStoreParams) {
|
||||
await this.#qc.prefetchInfiniteQuery(this.buildOptions(params));
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.#qc.cancelQueries({ queryKey: this.buildQueryKey(this.#params) });
|
||||
}
|
||||
|
||||
getCachedData(): UnifiedFont[] | undefined {
|
||||
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||
this.buildQueryKey(this.#params),
|
||||
);
|
||||
if (!data) return undefined;
|
||||
return data.pages.flatMap(p => p.fonts);
|
||||
}
|
||||
|
||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
||||
const key = this.buildQueryKey(this.#params);
|
||||
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||
key,
|
||||
old => {
|
||||
const flatFonts = old?.pages.flatMap(p => p.fonts);
|
||||
const newFonts = updater(flatFonts);
|
||||
// Re-distribute the updated fonts back into the existing page structure
|
||||
// Define the first page. If old data exists, we merge into the first page template.
|
||||
const limit = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
|
||||
const template = old?.pages[0] ?? {
|
||||
total: newFonts.length,
|
||||
limit,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
const updatedPage: ProxyFontsResponse = {
|
||||
...template,
|
||||
fonts: newFonts,
|
||||
total: newFonts.length, // Synchronize total with the new font count
|
||||
};
|
||||
|
||||
return {
|
||||
pages: [updatedPage],
|
||||
pageParams: [{ offset: 0 }],
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- Filter shortcuts --
|
||||
|
||||
setProviders(v: ProxyFontsParams['providers']) {
|
||||
this.setParams({ providers: v });
|
||||
}
|
||||
setCategories(v: ProxyFontsParams['categories']) {
|
||||
this.setParams({ categories: v });
|
||||
}
|
||||
setSubsets(v: ProxyFontsParams['subsets']) {
|
||||
this.setParams({ subsets: v });
|
||||
}
|
||||
setSearch(v: string) {
|
||||
this.setParams({ q: v || undefined });
|
||||
}
|
||||
setSort(v: ProxyFontsParams['sort']) {
|
||||
this.setParams({ sort: v });
|
||||
}
|
||||
|
||||
// -- Pagination navigation --
|
||||
|
||||
async nextPage(): Promise<void> {
|
||||
await this.#observer.fetchNextPage();
|
||||
}
|
||||
prevPage(): void {} // no-op: infinite scroll accumulates forward only; method kept for API compatibility
|
||||
goToPage(_page: number): void {} // no-op
|
||||
|
||||
setLimit(limit: number) {
|
||||
this.setParams({ limit });
|
||||
}
|
||||
|
||||
// -- Category views --
|
||||
|
||||
get sansSerifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||
}
|
||||
get serifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'serif');
|
||||
}
|
||||
get displayFonts() {
|
||||
return this.fonts.filter(f => f.category === 'display');
|
||||
}
|
||||
get handwritingFonts() {
|
||||
return this.fonts.filter(f => f.category === 'handwriting');
|
||||
}
|
||||
get monospaceFonts() {
|
||||
return this.fonts.filter(f => f.category === 'monospace');
|
||||
}
|
||||
|
||||
// -- Private helpers (TypeScript-private so tests can spy via `as any`) --
|
||||
|
||||
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
||||
const filtered: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
// Ensure we DO NOT 'continue' or skip the limit key here.
|
||||
// The limit is a fundamental part of the data identity.
|
||||
if (
|
||||
value !== undefined
|
||||
&& value !== null
|
||||
&& value !== ''
|
||||
&& !(Array.isArray(value) && value.length === 0)
|
||||
) {
|
||||
filtered[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return ['fonts', filtered];
|
||||
}
|
||||
|
||||
private buildOptions(params = this.#params) {
|
||||
const activeParams = { ...params };
|
||||
const hasFilters = !!(
|
||||
activeParams.q
|
||||
|| (Array.isArray(activeParams.providers) && activeParams.providers.length > 0)
|
||||
|| (Array.isArray(activeParams.categories) && activeParams.categories.length > 0)
|
||||
|| (Array.isArray(activeParams.subsets) && activeParams.subsets.length > 0)
|
||||
);
|
||||
return {
|
||||
queryKey: this.buildQueryKey(activeParams),
|
||||
queryFn: ({ pageParam }: QueryFunctionContext<readonly unknown[], PageParam>) =>
|
||||
this.fetchPage({ ...activeParams, ...pageParam }),
|
||||
initialPageParam: { offset: 0 } as PageParam,
|
||||
getNextPageParam: (lastPage: ProxyFontsResponse): PageParam | undefined => {
|
||||
const next = lastPage.offset + lastPage.limit;
|
||||
return next < lastPage.total ? { offset: next } : undefined;
|
||||
},
|
||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchPage(params: ProxyFontsParams): Promise<ProxyFontsResponse> {
|
||||
let response: ProxyFontsResponse;
|
||||
try {
|
||||
response = await fetchProxyFonts(params);
|
||||
} catch (cause) {
|
||||
throw new FontNetworkError(cause);
|
||||
}
|
||||
|
||||
if (!response) throw new FontResponseError('response', response);
|
||||
if (!response.fonts) throw new FontResponseError('response.fonts', response.fonts);
|
||||
if (!Array.isArray(response.fonts)) throw new FontResponseError('response.fonts', response.fonts);
|
||||
|
||||
return {
|
||||
fonts: response.fonts,
|
||||
total: response.total ?? 0,
|
||||
limit: response.limit ?? params.limit ?? 50,
|
||||
offset: response.offset ?? params.offset ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createFontStore(params: FontStoreParams = {}): FontStore {
|
||||
return new FontStore(params);
|
||||
}
|
||||
|
||||
export const fontStore = new FontStore({ limit: 50 });
|
||||
@@ -1,20 +1,9 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* UNIFIED FONT STORE EXPORTS
|
||||
* ============================================================================
|
||||
*
|
||||
* Single export point for the unified font store infrastructure.
|
||||
*/
|
||||
// Applied fonts manager
|
||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
// Primary store (unified)
|
||||
// Single FontStore
|
||||
export {
|
||||
createUnifiedFontStore,
|
||||
type UnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
} from './unifiedFontStore.svelte';
|
||||
|
||||
// Applied fonts manager (CSS loading - unchanged)
|
||||
export {
|
||||
appliedFontsManager,
|
||||
type FontConfigRequest,
|
||||
} from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
createFontStore,
|
||||
FontStore,
|
||||
fontStore,
|
||||
} from './fontStore/fontStore.svelte';
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* UNIFIED FONT STORE TYPES
|
||||
* ============================================================================
|
||||
*
|
||||
* Type definitions for the unified font store infrastructure.
|
||||
* Provides types for filters, sorting, and fetch parameters.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontshareParams,
|
||||
GoogleFontsParams,
|
||||
} from '$entities/Font/api';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from '$entities/Font/model/types/common';
|
||||
|
||||
/**
|
||||
* Sort configuration
|
||||
*/
|
||||
export interface FontSort {
|
||||
field: 'name' | 'popularity' | 'category' | 'date';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch params for unified API
|
||||
*/
|
||||
export interface FetchFontsParams {
|
||||
providers?: FontProvider[];
|
||||
categories?: FontCategory[];
|
||||
subsets?: FontSubset[];
|
||||
search?: string;
|
||||
sort?: FontSort;
|
||||
forceRefetch?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-specific params union
|
||||
*/
|
||||
export type ProviderParams = GoogleFontsParams | FontshareParams;
|
||||
@@ -1,377 +0,0 @@
|
||||
/**
|
||||
* Unified font store
|
||||
*
|
||||
* Single source of truth for font data, powered by the proxy API.
|
||||
* Extends BaseFontStore for TanStack Query integration and reactivity.
|
||||
*
|
||||
* Key features:
|
||||
* - Provider-agnostic (proxy API handles provider logic)
|
||||
* - Reactive to filter changes
|
||||
* - Optimistic updates via TanStack Query
|
||||
* - Pagination support
|
||||
* - Provider-specific shortcuts for common operations
|
||||
*/
|
||||
|
||||
import type { QueryObserverOptions } from '@tanstack/query-core';
|
||||
import type { ProxyFontsParams } from '../../api';
|
||||
import { fetchProxyFonts } from '../../api';
|
||||
import type { UnifiedFont } from '../types';
|
||||
import { BaseFontStore } from './baseFontStore.svelte';
|
||||
|
||||
/**
|
||||
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
||||
*
|
||||
* Extends BaseFontStore to provide:
|
||||
* - Reactive state management
|
||||
* - TanStack Query integration for caching
|
||||
* - Dynamic parameter binding for filters
|
||||
* - Pagination support
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const store = new UnifiedFontStore({
|
||||
* provider: 'google',
|
||||
* category: 'sans-serif',
|
||||
* limit: 50
|
||||
* });
|
||||
*
|
||||
* // Access reactive state
|
||||
* $effect(() => {
|
||||
* console.log(store.fonts);
|
||||
* console.log(store.isLoading);
|
||||
* console.log(store.pagination);
|
||||
* });
|
||||
*
|
||||
* // Update parameters
|
||||
* store.setCategory('serif');
|
||||
* store.nextPage();
|
||||
* ```
|
||||
*/
|
||||
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
/**
|
||||
* Store pagination metadata separately from fonts
|
||||
* This is a workaround for TanStack Query's type system
|
||||
*/
|
||||
#paginationMetadata = $state<
|
||||
{
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
} | null
|
||||
>(null);
|
||||
|
||||
/**
|
||||
* Accumulated fonts from all pages (for infinite scroll)
|
||||
*/
|
||||
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
||||
|
||||
/**
|
||||
* Pagination metadata (derived from proxy API response)
|
||||
*/
|
||||
readonly pagination = $derived.by(() => {
|
||||
if (this.#paginationMetadata) {
|
||||
const { total, limit, offset } = this.#paginationMetadata;
|
||||
return {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total,
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
return {
|
||||
total: 0,
|
||||
limit: this.params.limit || 50,
|
||||
offset: this.params.offset || 0,
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
totalPages: 0,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Track previous filter params to detect changes and reset pagination
|
||||
*/
|
||||
#previousFilterParams = $state<string>('');
|
||||
|
||||
/**
|
||||
* Cleanup function for the filter tracking effect
|
||||
*/
|
||||
#filterCleanup: (() => void) | null = null;
|
||||
|
||||
constructor(initialParams: ProxyFontsParams = {}) {
|
||||
super(initialParams);
|
||||
|
||||
// Track filter params (excluding pagination params)
|
||||
// Wrapped in $effect.root() to prevent effect_orphan error
|
||||
this.#filterCleanup = $effect.root(() => {
|
||||
$effect(() => {
|
||||
const filterParams = JSON.stringify({
|
||||
provider: this.params.provider,
|
||||
category: this.params.category,
|
||||
subset: this.params.subset,
|
||||
q: this.params.q,
|
||||
});
|
||||
|
||||
// If filters changed, reset offset to 0
|
||||
if (filterParams !== this.#previousFilterParams) {
|
||||
if (this.#previousFilterParams && this.params.offset !== 0) {
|
||||
this.setParams({ offset: 0 });
|
||||
}
|
||||
this.#previousFilterParams = filterParams;
|
||||
}
|
||||
});
|
||||
|
||||
// Effect: Sync state from Query result (Handles Cache Hits)
|
||||
$effect(() => {
|
||||
const data = this.result.data;
|
||||
const offset = this.params.offset || 0;
|
||||
|
||||
// When we have data and we are at the start (offset 0),
|
||||
// we must ensure accumulatedFonts matches the fresh (or cached) data.
|
||||
// This fixes the issue where cache hits skip fetchFn side-effects.
|
||||
if (offset === 0 && data && data.length > 0) {
|
||||
this.#accumulatedFonts = data;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up both parent and child effects
|
||||
*/
|
||||
destroy() {
|
||||
// Call parent cleanup (TanStack observer effect)
|
||||
super.destroy();
|
||||
|
||||
// Call filter tracking effect cleanup
|
||||
if (this.#filterCleanup) {
|
||||
this.#filterCleanup();
|
||||
this.#filterCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key for TanStack Query caching
|
||||
* Normalizes params to treat empty arrays/strings as undefined
|
||||
*/
|
||||
protected getQueryKey(params: ProxyFontsParams) {
|
||||
// Normalize params to treat empty arrays/strings as undefined
|
||||
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
||||
if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) {
|
||||
return acc;
|
||||
}
|
||||
return { ...acc, [key]: value };
|
||||
}, {});
|
||||
|
||||
// Return a consistent key
|
||||
return ['unifiedFonts', normalized] as const;
|
||||
}
|
||||
|
||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||
const hasFilters = !!(params.q || params.provider || params.category || params.subset);
|
||||
return {
|
||||
queryKey: this.getQueryKey(params),
|
||||
queryFn: () => this.fetchFn(params),
|
||||
staleTime: hasFilters ? 0 : 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch function that calls the proxy API
|
||||
* Returns the full response including pagination metadata
|
||||
*/
|
||||
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
||||
const response = await fetchProxyFonts(params);
|
||||
|
||||
// Validate response structure
|
||||
if (!response) {
|
||||
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
|
||||
throw new Error('Proxy API returned undefined response');
|
||||
}
|
||||
|
||||
if (!response.fonts) {
|
||||
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
|
||||
throw new Error('Proxy API response missing fonts array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(response.fonts)) {
|
||||
console.error('[UnifiedFontStore] response.fonts is not an array', {
|
||||
fonts: response.fonts,
|
||||
});
|
||||
throw new Error('Proxy API fonts is not an array');
|
||||
}
|
||||
|
||||
// Store pagination metadata separately for derived values
|
||||
this.#paginationMetadata = {
|
||||
total: response.total ?? 0,
|
||||
limit: response.limit ?? this.params.limit ?? 50,
|
||||
offset: response.offset ?? this.params.offset ?? 0,
|
||||
};
|
||||
|
||||
// Accumulate fonts for infinite scroll
|
||||
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
||||
// This prevents race conditions and double-setting.
|
||||
if (params.offset !== 0) {
|
||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||
}
|
||||
|
||||
return response.fonts;
|
||||
}
|
||||
|
||||
// --- Getters (proxied from BaseFontStore) ---
|
||||
|
||||
/**
|
||||
* Get all accumulated fonts (for infinite scroll)
|
||||
*/
|
||||
get fonts(): UnifiedFont[] {
|
||||
return this.#accumulatedFonts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if loading initial data
|
||||
*/
|
||||
get isLoading(): boolean {
|
||||
return this.result.isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fetching (including background refetches)
|
||||
*/
|
||||
get isFetching(): boolean {
|
||||
return this.result.isFetching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error occurred
|
||||
*/
|
||||
get isError(): boolean {
|
||||
return this.result.isError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result is empty (not loading and no fonts)
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return !this.isLoading && this.fonts.length === 0;
|
||||
}
|
||||
|
||||
// --- Provider-specific shortcuts ---
|
||||
|
||||
/**
|
||||
* Set provider filter
|
||||
*/
|
||||
setProvider(provider: 'google' | 'fontshare' | undefined) {
|
||||
this.setParams({ provider });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set category filter
|
||||
*/
|
||||
setCategory(category: ProxyFontsParams['category']) {
|
||||
this.setParams({ category });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set subset filter
|
||||
*/
|
||||
setSubset(subset: ProxyFontsParams['subset']) {
|
||||
this.setParams({ subset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set search query
|
||||
*/
|
||||
setSearch(search: string) {
|
||||
this.setParams({ q: search || undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sort order
|
||||
*/
|
||||
setSort(sort: ProxyFontsParams['sort']) {
|
||||
this.setParams({ sort });
|
||||
}
|
||||
|
||||
// --- Pagination methods ---
|
||||
|
||||
/**
|
||||
* Go to next page
|
||||
*/
|
||||
nextPage() {
|
||||
if (this.pagination.hasMore) {
|
||||
this.setParams({
|
||||
offset: this.pagination.offset + this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous page
|
||||
*/
|
||||
prevPage() {
|
||||
if (this.pagination.page > 1) {
|
||||
this.setParams({
|
||||
offset: this.pagination.offset - this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
goToPage(page: number) {
|
||||
if (page >= 1 && page <= this.pagination.totalPages) {
|
||||
this.setParams({
|
||||
offset: (page - 1) * this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set limit (items per page)
|
||||
*/
|
||||
setLimit(limit: number) {
|
||||
this.setParams({ limit });
|
||||
}
|
||||
|
||||
// --- Category shortcuts (for convenience) ---
|
||||
|
||||
get sansSerifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||
}
|
||||
|
||||
get serifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'serif');
|
||||
}
|
||||
|
||||
get displayFonts() {
|
||||
return this.fonts.filter(f => f.category === 'display');
|
||||
}
|
||||
|
||||
get handwritingFonts() {
|
||||
return this.fonts.filter(f => f.category === 'handwriting');
|
||||
}
|
||||
|
||||
get monospaceFonts() {
|
||||
return this.fonts.filter(f => f.category === 'monospace');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create unified font store
|
||||
*/
|
||||
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
|
||||
return new UnifiedFontStore(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for global use
|
||||
* Initialized with a default limit to prevent fetching all fonts at once
|
||||
*/
|
||||
export const unifiedFontStore = new UnifiedFontStore({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* DOMAIN TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
import type { FontCategory as FontshareFontCategory } from './fontshare';
|
||||
import type { FontCategory as GoogleFontCategory } from './google';
|
||||
|
||||
/**
|
||||
* Font category
|
||||
*/
|
||||
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
|
||||
|
||||
/**
|
||||
* Font provider
|
||||
*/
|
||||
export type FontProvider = 'google' | 'fontshare';
|
||||
|
||||
/**
|
||||
* Font subset
|
||||
*/
|
||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
||||
|
||||
/**
|
||||
* Filter state
|
||||
*/
|
||||
export interface FontFilters {
|
||||
providers: FontProvider[];
|
||||
categories: FontCategory[];
|
||||
subsets: FontSubset[];
|
||||
}
|
||||
|
||||
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
|
||||
export type FilterType = CheckboxFilter | 'searchQuery';
|
||||
|
||||
/**
|
||||
* Standard font weights
|
||||
*/
|
||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
|
||||
/**
|
||||
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
||||
*/
|
||||
export type FontWeightItalic = `${FontWeight}italic`;
|
||||
|
||||
/**
|
||||
* All possible font variants
|
||||
* - Numeric weights: "400", "700", etc.
|
||||
* - Italic variants: "400italic", "700italic", etc.
|
||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||
*/
|
||||
export type FontVariant =
|
||||
| FontWeight
|
||||
| FontWeightItalic
|
||||
| 'regular'
|
||||
| 'italic'
|
||||
| 'bold'
|
||||
| 'bolditalic';
|
||||
166
src/entities/Font/model/types/font.ts
Normal file
166
src/entities/Font/model/types/font.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Font domain types
|
||||
*
|
||||
* Shared types for font entities across providers (Google, Fontshare).
|
||||
* Includes categories, subsets, weights, and the unified font model.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unified font category across all providers
|
||||
*/
|
||||
export type FontCategory =
|
||||
| 'sans-serif'
|
||||
| 'serif'
|
||||
| 'display'
|
||||
| 'handwriting'
|
||||
| 'monospace'
|
||||
| 'slab'
|
||||
| 'script';
|
||||
|
||||
/**
|
||||
* Font provider identifier
|
||||
*/
|
||||
export type FontProvider = 'google' | 'fontshare';
|
||||
|
||||
/**
|
||||
* Character subset support
|
||||
*/
|
||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
||||
|
||||
/**
|
||||
* Combined filter state for font queries
|
||||
*/
|
||||
export interface FontFilters {
|
||||
/** Selected font providers */
|
||||
providers: FontProvider[];
|
||||
/** Selected font categories */
|
||||
categories: FontCategory[];
|
||||
/** Selected character subsets */
|
||||
subsets: FontSubset[];
|
||||
}
|
||||
|
||||
/** Filter group identifier */
|
||||
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
||||
|
||||
/** Filter type including search query */
|
||||
export type FilterType = FilterGroup | 'searchQuery';
|
||||
|
||||
/**
|
||||
* Numeric font weights (100-900)
|
||||
*/
|
||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
|
||||
/**
|
||||
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
|
||||
*/
|
||||
export type FontWeightItalic = `${FontWeight}italic`;
|
||||
|
||||
/**
|
||||
* All possible font variant identifiers
|
||||
*
|
||||
* Includes:
|
||||
* - Numeric weights: "400", "700", etc.
|
||||
* - Italic variants: "400italic", "700italic", etc.
|
||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||
*/
|
||||
export type FontVariant =
|
||||
| FontWeight
|
||||
| FontWeightItalic
|
||||
| 'regular'
|
||||
| 'italic'
|
||||
| 'bold'
|
||||
| 'bolditalic';
|
||||
|
||||
/**
|
||||
* Standardized font variant alias
|
||||
*/
|
||||
export type UnifiedFontVariant = FontVariant;
|
||||
|
||||
/**
|
||||
* Font style URLs
|
||||
*/
|
||||
export interface FontStyleUrls {
|
||||
/** Regular weight URL */
|
||||
regular?: string;
|
||||
/** Italic URL */
|
||||
italic?: string;
|
||||
/** Bold weight URL */
|
||||
bold?: string;
|
||||
/** Bold italic URL */
|
||||
boldItalic?: string;
|
||||
/** Additional variant mapping */
|
||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font metadata
|
||||
*/
|
||||
export interface FontMetadata {
|
||||
/** Timestamp when font was cached */
|
||||
cachedAt: number;
|
||||
/** Font version from provider */
|
||||
version?: string;
|
||||
/** Last modified date from provider */
|
||||
lastModified?: string;
|
||||
/** Popularity rank (if available from provider) */
|
||||
popularity?: number;
|
||||
/**
|
||||
* Normalized popularity score (0-100)
|
||||
*
|
||||
* Normalized across all fonts for consistent ranking
|
||||
* Higher values indicate more popular fonts
|
||||
*/
|
||||
popularityScore?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font features (variable fonts, axes, tags)
|
||||
*/
|
||||
export interface FontFeatures {
|
||||
/** Whether this is a variable font */
|
||||
isVariable?: boolean;
|
||||
/** Variable font axes (for Fontshare) */
|
||||
axes?: Array<{
|
||||
name: string;
|
||||
property: string;
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}>;
|
||||
/** Usage tags (for Fontshare) */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified font model
|
||||
*
|
||||
* Combines Google Fonts and Fontshare data into a common interface
|
||||
* for consistent font handling across the application.
|
||||
*/
|
||||
export interface UnifiedFont {
|
||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
||||
id: string;
|
||||
/** Font display name */
|
||||
name: string;
|
||||
/** Font provider (google | fontshare) */
|
||||
provider: FontProvider;
|
||||
/**
|
||||
* Provider badge display name
|
||||
*
|
||||
* Human-readable provider name for UI display
|
||||
* e.g., "Google Fonts" or "Fontshare"
|
||||
*/
|
||||
providerBadge?: string;
|
||||
/** Font category classification */
|
||||
category: FontCategory;
|
||||
/** Supported character subsets */
|
||||
subsets: FontSubset[];
|
||||
/** Available font variants (weights, styles) */
|
||||
variants: UnifiedFontVariant[];
|
||||
/** URL mapping for font file downloads */
|
||||
styles: FontStyleUrls;
|
||||
/** Additional metadata */
|
||||
metadata: FontMetadata;
|
||||
/** Advanced font features */
|
||||
features: FontFeatures;
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* FONTHARE API TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
export const FONTSHARE_API_URL = 'https://api.fontshare.com/v2/fonts' as const;
|
||||
|
||||
export type FontCategory = 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script';
|
||||
|
||||
/**
|
||||
* Model of Fontshare API response
|
||||
* @see https://fontshare.com
|
||||
*
|
||||
* Fontshare API uses 'fonts' key instead of 'items' for the array
|
||||
*/
|
||||
export interface FontshareApiModel {
|
||||
/**
|
||||
* Number of items returned in current page/response
|
||||
*/
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* Total number of items available across all pages
|
||||
*/
|
||||
count_total: number;
|
||||
|
||||
/**
|
||||
* Indicates if there are more items available beyond this page
|
||||
*/
|
||||
has_more: boolean;
|
||||
|
||||
/**
|
||||
* Array of fonts (Fontshare uses 'fonts' key, not 'items')
|
||||
*/
|
||||
fonts: FontshareFont[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual font metadata from Fontshare API
|
||||
*/
|
||||
export interface FontshareFont {
|
||||
/**
|
||||
* Unique identifier for the font
|
||||
* UUID v4 format (e.g., "20e9fcdc-1e41-4559-a43d-1ede0adc8896")
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Display name of the font family
|
||||
* Examples: "Satoshi", "General Sans", "Clash Display"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Native/localized name of the font (if available)
|
||||
* Often null for Latin-script fonts
|
||||
*/
|
||||
native_name: string | null;
|
||||
|
||||
/**
|
||||
* URL-friendly identifier for the font
|
||||
* Used in URLs: e.g., "satoshi", "general-sans", "clash-display"
|
||||
*/
|
||||
slug: string;
|
||||
|
||||
/**
|
||||
* Font category classification
|
||||
* Examples: "Sans", "Serif", "Display", "Script"
|
||||
*/
|
||||
category: string;
|
||||
|
||||
/**
|
||||
* Script/writing system supported by the font
|
||||
* Examples: "latin", "arabic", "devanagari"
|
||||
*/
|
||||
script: string;
|
||||
|
||||
/**
|
||||
* Font publisher/foundry information
|
||||
*/
|
||||
publisher: FontsharePublisher;
|
||||
|
||||
/**
|
||||
* Array of designers who created this font
|
||||
* Multiple designers may have collaborated on a single font
|
||||
*/
|
||||
designers: FontshareDesigner[];
|
||||
|
||||
/**
|
||||
* Related font families (if any)
|
||||
* Often null, as fonts are typically independent
|
||||
*/
|
||||
related_families: string | null;
|
||||
|
||||
/**
|
||||
* Whether to display publisher as the designer instead of individual designers
|
||||
*/
|
||||
display_publisher_as_designer: boolean;
|
||||
|
||||
/**
|
||||
* Whether trial downloads are enabled for this font
|
||||
*/
|
||||
trials_enabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show Latin-specific metrics
|
||||
*/
|
||||
show_latin_metrics: boolean;
|
||||
|
||||
/**
|
||||
* Type of license for this font
|
||||
* Examples: "itf_ffl" (ITF Free Font License)
|
||||
*/
|
||||
license_type: string;
|
||||
|
||||
/**
|
||||
* Comma-separated list of languages supported by this font
|
||||
* Example: "Afar, Afrikaans, Albanian, Aranese, Aromanian, Aymara, ..."
|
||||
*/
|
||||
languages: string;
|
||||
|
||||
/**
|
||||
* ISO 8601 timestamp when the font was added to Fontshare
|
||||
* Format: "2021-03-12T20:49:05Z"
|
||||
*/
|
||||
inserted_at: string;
|
||||
|
||||
/**
|
||||
* HTML-formatted story/description about the font
|
||||
* Contains marketing text, design philosophy, and usage recommendations
|
||||
*/
|
||||
story: string;
|
||||
|
||||
/**
|
||||
* Version of the font family
|
||||
* Format: "1.0", "1.2", etc.
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* Total number of times this font has been viewed
|
||||
*/
|
||||
views: number;
|
||||
|
||||
/**
|
||||
* Number of views in the recent time period
|
||||
*/
|
||||
views_recent: number;
|
||||
|
||||
/**
|
||||
* Whether this font is marked as "hot"/trending
|
||||
*/
|
||||
is_hot: boolean;
|
||||
|
||||
/**
|
||||
* Whether this font is marked as new
|
||||
*/
|
||||
is_new: boolean;
|
||||
|
||||
/**
|
||||
* Whether this font is in the shortlisted collection
|
||||
*/
|
||||
is_shortlisted: boolean | null;
|
||||
|
||||
/**
|
||||
* Whether this font is marked as top/popular
|
||||
*/
|
||||
is_top: boolean;
|
||||
|
||||
/**
|
||||
* Variable font axes (for variable fonts)
|
||||
* Empty array [] for static fonts
|
||||
*/
|
||||
axes: FontshareAxis[];
|
||||
|
||||
/**
|
||||
* Tags/categories for this font
|
||||
* Examples: ["Magazines", "Branding", "Logos", "Posters"]
|
||||
*/
|
||||
font_tags: FontshareTag[];
|
||||
|
||||
/**
|
||||
* OpenType features available in this font
|
||||
*/
|
||||
features: FontshareFeature[];
|
||||
|
||||
/**
|
||||
* Array of available font styles/variants
|
||||
* Each style represents a different font file (weight, italic, variable)
|
||||
*/
|
||||
styles: FontshareStyle[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Publisher/foundry information
|
||||
*/
|
||||
export interface FontsharePublisher {
|
||||
/**
|
||||
* Description/bio of the publisher
|
||||
* Example: "Indian Type Foundry (ITF) creates retail and custom multilingual fonts..."
|
||||
*/
|
||||
bio: string;
|
||||
|
||||
/**
|
||||
* Publisher email (if available)
|
||||
*/
|
||||
email: string | null;
|
||||
|
||||
/**
|
||||
* Unique publisher identifier
|
||||
* UUID format
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Publisher links (social media, website, etc.)
|
||||
*/
|
||||
links: FontshareLink[];
|
||||
|
||||
/**
|
||||
* Publisher name
|
||||
* Example: "Indian Type Foundry"
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Designer information
|
||||
*/
|
||||
export interface FontshareDesigner {
|
||||
/**
|
||||
* Designer bio/description
|
||||
*/
|
||||
bio: string;
|
||||
|
||||
/**
|
||||
* Designer links (Twitter, website, etc.)
|
||||
*/
|
||||
links: FontshareLink[];
|
||||
|
||||
/**
|
||||
* Designer name
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link information
|
||||
*/
|
||||
export interface FontshareLink {
|
||||
/**
|
||||
* Name of the link platform/site
|
||||
* Examples: "Twitter", "GitHub", "Website"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* URL of the link (may be null)
|
||||
*/
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font tag/category
|
||||
*/
|
||||
export interface FontshareTag {
|
||||
/**
|
||||
* Tag name
|
||||
* Examples: "Magazines", "Branding", "Logos", "Posters"
|
||||
*/
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenType feature
|
||||
*/
|
||||
export interface FontshareFeature {
|
||||
/**
|
||||
* Feature name (descriptive name or null)
|
||||
* Examples: "Alternate t", "All Alternates", or null
|
||||
*/
|
||||
name: string | null;
|
||||
|
||||
/**
|
||||
* Whether this feature is on by default
|
||||
*/
|
||||
on_by_default: boolean;
|
||||
|
||||
/**
|
||||
* OpenType feature tag (4-character code)
|
||||
* Examples: "ss01", "frac", "liga", "aalt", "case"
|
||||
*/
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable font axis (for variable fonts)
|
||||
* Defines the range and properties of a variable font axis (e.g., weight)
|
||||
*/
|
||||
export interface FontshareAxis {
|
||||
/**
|
||||
* Name of the axis
|
||||
* Example: "wght" (weight axis)
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* CSS property name for the axis
|
||||
* Example: "wght"
|
||||
*/
|
||||
property: string;
|
||||
|
||||
/**
|
||||
* Default value for the axis
|
||||
* Example: 420.0, 650.0, 700.0
|
||||
*/
|
||||
range_default: number;
|
||||
|
||||
/**
|
||||
* Minimum value for the axis
|
||||
* Example: 300.0, 100.0, 200.0
|
||||
*/
|
||||
range_left: number;
|
||||
|
||||
/**
|
||||
* Maximum value for the axis
|
||||
* Example: 900.0, 700.0, 800.0
|
||||
*/
|
||||
range_right: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual font style/variant
|
||||
* Each style represents a single downloadable font file
|
||||
*/
|
||||
export interface FontshareStyle {
|
||||
/**
|
||||
* Unique identifier for this style
|
||||
* UUID format
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Whether this is the default style for the font family
|
||||
* Typically, one style per font is marked as default
|
||||
*/
|
||||
default: boolean;
|
||||
|
||||
/**
|
||||
* CDN URL to the font file
|
||||
* Protocol-relative URL: "//cdn.fontshare.com/wf/..."
|
||||
* Note: URL starts with "//" (protocol-relative), may need protocol prepended
|
||||
*/
|
||||
file: string;
|
||||
|
||||
/**
|
||||
* Whether this style is italic
|
||||
* false for upright, true for italic styles
|
||||
*/
|
||||
is_italic: boolean;
|
||||
|
||||
/**
|
||||
* Whether this is a variable font
|
||||
* Variable fonts have adjustable axes (weight, slant, etc.)
|
||||
*/
|
||||
is_variable: boolean;
|
||||
|
||||
/**
|
||||
* Typography properties for this style
|
||||
* Contains measurements like cap height, x-height, ascenders/descenders
|
||||
* May be empty object {} for some styles
|
||||
*/
|
||||
properties: FontshareStyleProperties | Record<string, never>;
|
||||
|
||||
/**
|
||||
* Weight information for this style
|
||||
*/
|
||||
weight: FontshareWeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typography/measurement properties for a font style
|
||||
*/
|
||||
export interface FontshareStyleProperties {
|
||||
/**
|
||||
* Distance from baseline to the top of ascenders
|
||||
* Example: 1010, 990, 1000
|
||||
*/
|
||||
ascending_leading: number | null;
|
||||
|
||||
/**
|
||||
* Height of uppercase letters (cap height)
|
||||
* Example: 710, 680, 750
|
||||
*/
|
||||
cap_height: number | null;
|
||||
|
||||
/**
|
||||
* Distance from baseline to the bottom of descenders (negative value)
|
||||
* Example: -203, -186, -220
|
||||
*/
|
||||
descending_leading: number | null;
|
||||
|
||||
/**
|
||||
* Body height of the font
|
||||
* Often null in Fontshare data
|
||||
*/
|
||||
body_height: number | null;
|
||||
|
||||
/**
|
||||
* Maximum character width in the font
|
||||
* Example: 1739, 1739, 1739
|
||||
*/
|
||||
max_char_width: number | null;
|
||||
|
||||
/**
|
||||
* Height of lowercase x-height
|
||||
* Example: 480, 494, 523
|
||||
*/
|
||||
x_height: number | null;
|
||||
|
||||
/**
|
||||
* Maximum Y coordinate (top of ascenders)
|
||||
* Example: 1010, 990, 1026
|
||||
*/
|
||||
y_max: number | null;
|
||||
|
||||
/**
|
||||
* Minimum Y coordinate (bottom of descenders)
|
||||
* Example: -240, -250, -280
|
||||
*/
|
||||
y_min: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weight information for a font style
|
||||
*/
|
||||
export interface FontshareWeight {
|
||||
/**
|
||||
* Display label for the weight
|
||||
* Examples: "Light", "Regular", "Bold", "Variable", "Variable Italic"
|
||||
*/
|
||||
label: string;
|
||||
|
||||
/**
|
||||
* Internal name for the weight
|
||||
* Examples: "Light", "Regular", "Bold", "Variable", "VariableItalic"
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Native/localized name for the weight (if available)
|
||||
* Often null for Latin-script fonts
|
||||
*/
|
||||
native_name: string | null;
|
||||
|
||||
/**
|
||||
* Numeric weight value
|
||||
* Examples: 300, 400, 700, 0 (for variable fonts), 1, 2
|
||||
* Note: This matches the `weight` property
|
||||
*/
|
||||
number: number;
|
||||
|
||||
/**
|
||||
* Numeric weight value (duplicate of `number`)
|
||||
* Appears to be redundant with `number` field
|
||||
*/
|
||||
weight: number;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* GOOGLE FONTS API TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type { FontVariant } from './common';
|
||||
|
||||
export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace';
|
||||
|
||||
/**
|
||||
* Model of google fonts api response
|
||||
*/
|
||||
export interface GoogleFontsApiModel {
|
||||
/**
|
||||
* Array of font items returned by the Google Fonts API
|
||||
* Contains all font families matching the requested query parameters
|
||||
*/
|
||||
items: FontItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual font from Google Fonts API
|
||||
*/
|
||||
export interface FontItem {
|
||||
/**
|
||||
* Font family name (e.g., "Roboto", "Open Sans", "Lato")
|
||||
* This is the name used in CSS font-family declarations
|
||||
*/
|
||||
family: string;
|
||||
|
||||
/**
|
||||
* Font category classification (e.g., "sans-serif", "serif", "display", "handwriting", "monospace")
|
||||
* Useful for grouping and filtering fonts by style
|
||||
*/
|
||||
category: FontCategory;
|
||||
|
||||
/**
|
||||
* Available font variants for this font family
|
||||
* Array of strings representing available weights and styles
|
||||
* Examples: ["regular", "italic", "100", "200", "300", "400", "500", "600", "700", "800", "900", "100italic", "900italic"]
|
||||
* The keys in the `files` object correspond to these variant values
|
||||
*/
|
||||
variants: FontVariant[];
|
||||
|
||||
/**
|
||||
* Supported character subsets for this font
|
||||
* Examples: ["latin", "latin-ext", "cyrillic", "greek", "arabic", "devanagari", "vietnamese", "hebrew", "thai", etc.]
|
||||
* Determines which character sets are included in the font files
|
||||
*/
|
||||
subsets: string[];
|
||||
|
||||
/**
|
||||
* Font version identifier
|
||||
* Format: "v" followed by version number (e.g., "v31", "v20", "v1")
|
||||
* Used to track font updates and cache busting
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* Last modification date of the font
|
||||
* Format: ISO 8601 date string (e.g., "2024-01-15", "2023-12-01")
|
||||
* Indicates when the font was last updated by the font foundry
|
||||
*/
|
||||
lastModified: string;
|
||||
|
||||
/**
|
||||
* Mapping of font variants to their downloadable URLs
|
||||
* Keys correspond to values in the `variants` array
|
||||
* Examples:
|
||||
* - "regular" → "https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Me4W..."
|
||||
* - "700" → "https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmWUlf..."
|
||||
* - "700italic" → "https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzA..."
|
||||
*/
|
||||
files: FontFiles;
|
||||
|
||||
/**
|
||||
* URL to the font menu preview image
|
||||
* Typically a PNG showing the font family name in the font
|
||||
* Example: "https://fonts.gstatic.com/l/font?kit=KFOmCnqEu92Fr1Me4W...&s=i2"
|
||||
*/
|
||||
menu: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for backward compatibility
|
||||
* Google Fonts API font item
|
||||
*/
|
||||
export type GoogleFontItem = FontItem;
|
||||
|
||||
/**
|
||||
* Google Fonts API file mapping
|
||||
* Dynamic keys that match the variants array
|
||||
*
|
||||
* Examples:
|
||||
* - { "regular": "...", "italic": "...", "700": "...", "700italic": "..." }
|
||||
* - { "400": "...", "400italic": "...", "900": "..." }
|
||||
*/
|
||||
export type FontFiles = Partial<Record<FontVariant, string>>;
|
||||
@@ -7,48 +7,23 @@
|
||||
* All imports should use: `import { X } from '$entities/Font/model/types'`
|
||||
*/
|
||||
|
||||
// Domain types
|
||||
// Font domain and model types
|
||||
export type {
|
||||
FilterGroup,
|
||||
FilterType,
|
||||
FontCategory,
|
||||
FontFeatures,
|
||||
FontFilters,
|
||||
FontMetadata,
|
||||
FontProvider,
|
||||
FontStyleUrls,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
FontWeight,
|
||||
FontWeightItalic,
|
||||
} from './common';
|
||||
|
||||
// Google Fonts API types
|
||||
export type {
|
||||
FontFiles,
|
||||
FontItem,
|
||||
GoogleFontItem,
|
||||
GoogleFontsApiModel,
|
||||
} from './google';
|
||||
|
||||
// Fontshare API types
|
||||
export type {
|
||||
FontshareApiModel,
|
||||
FontshareAxis,
|
||||
FontshareDesigner,
|
||||
FontshareFeature,
|
||||
FontshareFont,
|
||||
FontshareLink,
|
||||
FontsharePublisher,
|
||||
FontshareStyle,
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
FontshareWeight,
|
||||
} from './fontshare';
|
||||
export { FONTSHARE_API_URL } from './fontshare';
|
||||
|
||||
// Normalization types
|
||||
export type {
|
||||
FontFeatures,
|
||||
FontMetadata,
|
||||
FontStyleUrls,
|
||||
UnifiedFont,
|
||||
UnifiedFontVariant,
|
||||
} from './normalize';
|
||||
} from './font';
|
||||
|
||||
// Store types
|
||||
export type {
|
||||
@@ -56,3 +31,5 @@ export type {
|
||||
FontCollectionSort,
|
||||
FontCollectionState,
|
||||
} from './store';
|
||||
|
||||
export * from './store/appliedFonts';
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* ============================================================================
|
||||
* NORMALIZATION TYPES
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
FontVariant,
|
||||
} from './common';
|
||||
|
||||
/**
|
||||
* Font variant types (standardized)
|
||||
*/
|
||||
export type UnifiedFontVariant = FontVariant;
|
||||
|
||||
/**
|
||||
* Font style URLs
|
||||
*/
|
||||
export interface LegacyFontStyleUrls {
|
||||
/** Regular weight URL */
|
||||
regular?: string;
|
||||
/** Italic URL */
|
||||
italic?: string;
|
||||
/** Bold weight URL */
|
||||
bold?: string;
|
||||
/** Bold italic URL */
|
||||
boldItalic?: string;
|
||||
}
|
||||
|
||||
export interface FontStyleUrls extends LegacyFontStyleUrls {
|
||||
variants?: Partial<Record<UnifiedFontVariant, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font metadata
|
||||
*/
|
||||
export interface FontMetadata {
|
||||
/** Timestamp when font was cached */
|
||||
cachedAt: number;
|
||||
/** Font version from provider */
|
||||
version?: string;
|
||||
/** Last modified date from provider */
|
||||
lastModified?: string;
|
||||
/** Popularity rank (if available from provider) */
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Font features (variable fonts, axes, tags)
|
||||
*/
|
||||
export interface FontFeatures {
|
||||
/** Whether this is a variable font */
|
||||
isVariable?: boolean;
|
||||
/** Variable font axes (for Fontshare) */
|
||||
axes?: Array<{
|
||||
name: string;
|
||||
property: string;
|
||||
default: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}>;
|
||||
/** Usage tags (for Fontshare) */
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified font model
|
||||
*
|
||||
* Combines Google Fonts and Fontshare data into a common interface
|
||||
* for consistent font handling across the application.
|
||||
*/
|
||||
export interface UnifiedFont {
|
||||
/** Unique identifier (Google: family name, Fontshare: slug) */
|
||||
id: string;
|
||||
/** Font display name */
|
||||
name: string;
|
||||
/** Font provider (google | fontshare) */
|
||||
provider: FontProvider;
|
||||
/** Font category classification */
|
||||
category: FontCategory;
|
||||
/** Supported character subsets */
|
||||
subsets: FontSubset[];
|
||||
/** Available font variants (weights, styles) */
|
||||
variants: UnifiedFontVariant[];
|
||||
/** URL mapping for font file downloads */
|
||||
styles: FontStyleUrls;
|
||||
/** Additional metadata */
|
||||
metadata: FontMetadata;
|
||||
/** Advanced font features */
|
||||
features: FontFeatures;
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
FontCategory,
|
||||
FontProvider,
|
||||
FontSubset,
|
||||
} from './common';
|
||||
import type { UnifiedFont } from './normalize';
|
||||
UnifiedFont,
|
||||
} from './font';
|
||||
|
||||
/**
|
||||
* Font collection state
|
||||
|
||||
30
src/entities/Font/model/types/store/appliedFonts.ts
Normal file
30
src/entities/Font/model/types/store/appliedFonts.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Configuration for a font load request.
|
||||
*/
|
||||
export interface FontLoadRequestConfig {
|
||||
/**
|
||||
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* URL pointing to the font file (typically .ttf or .woff2).
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Variable fonts load once per ID; static fonts load per weight.
|
||||
*/
|
||||
isVariable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state of a font.
|
||||
*/
|
||||
export type FontLoadStatus = 'loading' | 'loaded' | 'error';
|
||||
@@ -16,19 +16,20 @@ import {
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Applied font
|
||||
* Font to apply
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Font weight
|
||||
* @default 400
|
||||
*/
|
||||
weight?: number;
|
||||
/**
|
||||
* Additional classes
|
||||
* CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Children
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
}
|
||||
@@ -44,7 +45,7 @@ const status = $derived(
|
||||
appliedFontsManager.getFontStatus(
|
||||
font.id,
|
||||
weight,
|
||||
font.features.isVariable,
|
||||
font.features?.isVariable,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { type UnifiedFont } from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Object with information about font
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Is element fully visible
|
||||
*/
|
||||
isFullyVisible: boolean;
|
||||
/**
|
||||
* Is element partially visible
|
||||
*/
|
||||
isPartiallyVisible: boolean;
|
||||
/**
|
||||
* From 0 to 1
|
||||
*/
|
||||
proximity: number;
|
||||
/**
|
||||
* Children snippet
|
||||
*/
|
||||
children: Snippet<[font: UnifiedFont]>;
|
||||
}
|
||||
|
||||
const { font, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'pb-1 will-change-transform transition-transform duration-200 ease-out',
|
||||
'hover:scale-[0.98]', // Simple CSS hover effect
|
||||
)}
|
||||
>
|
||||
{@render children?.(font)}
|
||||
</div>
|
||||
@@ -15,11 +15,11 @@ import type {
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getFontUrl } from '../../lib';
|
||||
import {
|
||||
type FontConfigRequest,
|
||||
type FontLoadRequestConfig,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
unifiedFontStore,
|
||||
} from '../../model';
|
||||
import { fontStore } from '../../model/store';
|
||||
|
||||
interface Props extends
|
||||
Omit<
|
||||
@@ -28,11 +28,11 @@ interface Props extends
|
||||
>
|
||||
{
|
||||
/**
|
||||
* Callback for when visible items change
|
||||
* Visible items callback
|
||||
*/
|
||||
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||
/**
|
||||
* Weight of the font
|
||||
* Font weight
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
@@ -50,11 +50,11 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
const isLoading = $derived(
|
||||
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||
fontStore.isFetching || fontStore.isLoading,
|
||||
);
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||
const configs: FontConfigRequest[] = [];
|
||||
const configs: FontLoadRequestConfig[] = [];
|
||||
|
||||
visibleItems.forEach(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
@@ -69,6 +69,7 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-register fonts with the manager
|
||||
appliedFontsManager.touch(configs);
|
||||
|
||||
@@ -81,12 +82,12 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!unifiedFontStore.pagination.hasMore
|
||||
|| unifiedFontStore.isFetching
|
||||
!fontStore.pagination.hasMore
|
||||
|| fontStore.isFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unifiedFontStore.nextPage();
|
||||
fontStore.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,17 +97,17 @@ function loadMore() {
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = unifiedFontStore.pagination;
|
||||
const { hasMore } = fontStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items
|
||||
if (hasMore && !unifiedFontStore.isFetching) {
|
||||
if (hasMore && !fontStore.isFetching) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full h-full">
|
||||
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
||||
{#if skeleton && isLoading && fontStore.fonts.length === 0}
|
||||
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||
<div transition:fade={{ duration: 300 }}>
|
||||
{@render skeleton()}
|
||||
@@ -114,8 +115,8 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
||||
{:else}
|
||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||
<VirtualList
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
items={fontStore.fonts}
|
||||
total={fontStore.pagination.total}
|
||||
isLoading={isLoading}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||
import FontListItem from './FontListItem/FontListItem.svelte';
|
||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||
|
||||
export {
|
||||
FontApplicator,
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
};
|
||||
|
||||
2
src/features/ChangeAppTheme/index.ts
Normal file
2
src/features/ChangeAppTheme/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './model';
|
||||
export * from './ui';
|
||||
1
src/features/ChangeAppTheme/model/index.ts
Normal file
1
src/features/ChangeAppTheme/model/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { themeManager } from './store/ThemeManager/ThemeManager.svelte';
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Theme management with system preference detection
|
||||
*
|
||||
* Manages light/dark theme state with localStorage persistence
|
||||
* and automatic system preference detection. Themes are applied
|
||||
* via CSS class on the document element.
|
||||
*
|
||||
* Features:
|
||||
* - Persists user preference to localStorage
|
||||
* - Detects OS-level theme preference
|
||||
* - Responds to OS theme changes when in "system" mode
|
||||
* - Toggle between light/dark themes
|
||||
* - Reset to follow system preference
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { themeManager } from '$features/ChangeAppTheme';
|
||||
*
|
||||
* // Initialize once on app mount
|
||||
* onMount(() => themeManager.init());
|
||||
* onDestroy(() => themeManager.destroy());
|
||||
* </script>
|
||||
*
|
||||
* <button on:click={() => themeManager.toggle()}>
|
||||
* {themeManager.isDark ? 'Switch to Light' : 'Switch to Dark'}
|
||||
* </button>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
type ThemeSource = 'system' | 'user';
|
||||
|
||||
/**
|
||||
* Theme manager singleton
|
||||
*
|
||||
* Call init() on app mount and destroy() on app unmount.
|
||||
* Use isDark property to conditionally apply styles.
|
||||
*/
|
||||
class ThemeManager {
|
||||
// Private reactive state
|
||||
/** Current theme value ('light' or 'dark') */
|
||||
#theme = $state<Theme>('light');
|
||||
/** Whether theme is controlled by user or follows system */
|
||||
#source = $state<ThemeSource>('system');
|
||||
/** MediaQueryList for detecting system theme changes */
|
||||
#mediaQuery: MediaQueryList | null = null;
|
||||
/** Persistent storage for user's theme preference */
|
||||
#store = createPersistentStore<Theme | null>('glyphdiff:theme', null);
|
||||
/** Bound handler for system theme change events */
|
||||
#systemChangeHandler = this.#onSystemChange.bind(this);
|
||||
|
||||
constructor() {
|
||||
// Derive initial values from stored preference or OS
|
||||
const stored = this.#store.value;
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
this.#theme = stored;
|
||||
this.#source = 'user';
|
||||
} else {
|
||||
this.#theme = this.#getSystemTheme();
|
||||
this.#source = 'system';
|
||||
}
|
||||
}
|
||||
|
||||
/** Current theme value */
|
||||
get value(): Theme {
|
||||
return this.#theme;
|
||||
}
|
||||
|
||||
/** Source of current theme ('system' or 'user') */
|
||||
get source(): ThemeSource {
|
||||
return this.#source;
|
||||
}
|
||||
|
||||
/** Whether dark theme is active */
|
||||
get isDark(): boolean {
|
||||
return this.#theme === 'dark';
|
||||
}
|
||||
|
||||
/** Whether theme is controlled by user (not following system) */
|
||||
get isUserControlled(): boolean {
|
||||
return this.#source === 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize theme manager
|
||||
*
|
||||
* Applies current theme to DOM and sets up system preference listener.
|
||||
* Call once in root component onMount.
|
||||
*/
|
||||
init(): void {
|
||||
this.#applyToDom(this.#theme);
|
||||
this.#mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
this.#mediaQuery.addEventListener('change', this.#systemChangeHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up theme manager
|
||||
*
|
||||
* Removes system preference listener.
|
||||
* Call in root component onDestroy.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.#mediaQuery?.removeEventListener('change', this.#systemChangeHandler);
|
||||
this.#mediaQuery = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set theme explicitly
|
||||
*
|
||||
* Switches to user control and applies specified theme.
|
||||
*
|
||||
* @param theme - Theme to apply ('light' or 'dark')
|
||||
*/
|
||||
setTheme(theme: Theme): void {
|
||||
this.#source = 'user';
|
||||
this.#theme = theme;
|
||||
this.#store.value = theme;
|
||||
this.#applyToDom(theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark themes
|
||||
*/
|
||||
toggle(): void {
|
||||
this.setTheme(this.value === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to follow system preference
|
||||
*
|
||||
* Clears user preference and switches to system theme.
|
||||
*/
|
||||
resetToSystem(): void {
|
||||
this.#store.clear();
|
||||
this.#theme = this.#getSystemTheme();
|
||||
this.#source = 'system';
|
||||
this.#applyToDom(this.#theme);
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
/**
|
||||
* Detect system theme preference
|
||||
* @returns 'dark' if system prefers dark mode, 'light' otherwise
|
||||
*/
|
||||
#getSystemTheme(): Theme {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme to DOM
|
||||
* @param theme - Theme to apply
|
||||
*/
|
||||
#applyToDom(theme: Theme): void {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle system theme change
|
||||
* Only updates if currently following system preference
|
||||
*/
|
||||
#onSystemChange(e: MediaQueryListEvent): void {
|
||||
if (this.#source === 'system') {
|
||||
this.#theme = e.matches ? 'dark' : 'light';
|
||||
this.#applyToDom(this.#theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton theme manager instance
|
||||
*
|
||||
* Use throughout the app for consistent theme state.
|
||||
*/
|
||||
export const themeManager = new ThemeManager();
|
||||
|
||||
/**
|
||||
* ThemeManager class exported for testing purposes
|
||||
* Use the singleton `themeManager` in application code.
|
||||
*/
|
||||
export { ThemeManager };
|
||||
@@ -0,0 +1,726 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
// ============================================================
|
||||
// Mock MediaQueryListEvent for system theme change simulations
|
||||
// Note: Other mocks (ResizeObserver, localStorage, matchMedia) are set up in vitest.setup.unit.ts
|
||||
// ============================================================
|
||||
|
||||
class MockMediaQueryListEvent extends Event {
|
||||
matches: boolean;
|
||||
media: string;
|
||||
|
||||
constructor(type: string, eventInitDict: { matches: boolean; media: string }) {
|
||||
super(type);
|
||||
this.matches = eventInitDict.matches;
|
||||
this.media = eventInitDict.media;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// NOW IT'S SAFE TO IMPORT
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { ThemeManager } from './ThemeManager.svelte';
|
||||
|
||||
/**
|
||||
* Test Suite for ThemeManager
|
||||
*
|
||||
* Tests theme management functionality including:
|
||||
* - Initial state from localStorage or system preference
|
||||
* - Theme setting and persistence
|
||||
* - Toggle functionality
|
||||
* - System preference detection and following
|
||||
* - DOM manipulation for theme application
|
||||
* - MediaQueryList listener management
|
||||
*/
|
||||
|
||||
// Storage key used by ThemeManager
|
||||
const STORAGE_KEY = 'glyphdiff:theme';
|
||||
|
||||
// Helper type for MediaQueryList event handler
|
||||
type MediaQueryListCallback = (this: MediaQueryList, ev: MediaQueryListEvent) => void;
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('ThemeManager', () => {
|
||||
let classListMock: DOMTokenList;
|
||||
let darkClassAdded = false;
|
||||
let mediaQueryListeners: Map<string, Set<MediaQueryListCallback>> = new Map();
|
||||
let matchMediaSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset tracking variables
|
||||
darkClassAdded = false;
|
||||
mediaQueryListeners.clear();
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
|
||||
// Mock documentElement.classList
|
||||
classListMock = {
|
||||
contains: (className: string) => className === 'dark' ? darkClassAdded : false,
|
||||
add: vi.fn((..._classNames: string[]) => {
|
||||
darkClassAdded = true;
|
||||
}),
|
||||
remove: vi.fn((..._classNames: string[]) => {
|
||||
darkClassAdded = false;
|
||||
}),
|
||||
toggle: vi.fn((className: string, force?: boolean) => {
|
||||
if (className === 'dark') {
|
||||
if (force !== undefined) {
|
||||
darkClassAdded = force;
|
||||
} else {
|
||||
darkClassAdded = !darkClassAdded;
|
||||
}
|
||||
return darkClassAdded;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
supports: vi.fn(() => true),
|
||||
entries: vi.fn(() => []),
|
||||
forEach: vi.fn(),
|
||||
keys: vi.fn(() => []),
|
||||
values: vi.fn(() => []),
|
||||
length: 0,
|
||||
item: vi.fn(() => null),
|
||||
replace: vi.fn(() => false),
|
||||
} as unknown as DOMTokenList;
|
||||
|
||||
// Mock document.documentElement
|
||||
if (typeof document !== 'undefined' && document.documentElement) {
|
||||
Object.defineProperty(document.documentElement, 'classList', {
|
||||
configurable: true,
|
||||
get: () => classListMock,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock window.matchMedia with spy to track listeners
|
||||
matchMediaSpy = vi.fn((query: string) => {
|
||||
// Default to light theme (matches = false)
|
||||
const mediaQueryList = {
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
||||
if (!mediaQueryListeners.has(query)) {
|
||||
mediaQueryListeners.set(query, new Set());
|
||||
}
|
||||
mediaQueryListeners.get(query)!.add(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
||||
if (mediaQueryListeners.has(query)) {
|
||||
mediaQueryListeners.get(query)!.delete(listener);
|
||||
}
|
||||
}),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
return mediaQueryList;
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: matchMediaSpy,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to trigger a MediaQueryList change event
|
||||
*/
|
||||
function triggerSystemThemeChange(isDark: boolean) {
|
||||
const query = '(prefers-color-scheme: dark)';
|
||||
const listeners = mediaQueryListeners.get(query);
|
||||
if (listeners) {
|
||||
const event = new MockMediaQueryListEvent('change', {
|
||||
matches: isDark,
|
||||
media: query,
|
||||
});
|
||||
listeners.forEach(listener => listener.call({ matches: isDark, media: query } as MediaQueryList, event));
|
||||
}
|
||||
}
|
||||
|
||||
describe('Constructor - Initial State', () => {
|
||||
it('should initialize with light theme when localStorage is empty and system prefers light', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should initialize with system dark theme when localStorage is empty', () => {
|
||||
// Mock system prefers dark theme
|
||||
matchMediaSpy.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should initialize with stored light theme from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should initialize with stored dark theme from localStorage', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should ignore invalid values in localStorage and use system theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('invalid'));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should handle null in localStorage as system theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(null));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should be in user-controlled mode when localStorage has a valid theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.isUserControlled).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be in user-controlled mode when following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.isUserControlled).toBe(false);
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('init() - Initialization', () => {
|
||||
it('should apply initial theme to DOM on init', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
||||
});
|
||||
|
||||
it('should apply dark theme to DOM when initialized with dark theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should set up MediaQueryList listener on init', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(matchMediaSpy).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
|
||||
});
|
||||
|
||||
it('should not fail if called multiple times', () => {
|
||||
const manager = new ThemeManager();
|
||||
expect(() => {
|
||||
manager.init();
|
||||
manager.init();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy() - Cleanup', () => {
|
||||
it('should remove MediaQueryList listener on destroy', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
manager.destroy();
|
||||
|
||||
const listeners = mediaQueryListeners.get('(prefers-color-scheme: dark)');
|
||||
expect(listeners?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('should not fail if destroy is called before init', () => {
|
||||
const manager = new ThemeManager();
|
||||
expect(() => {
|
||||
manager.destroy();
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not fail if destroy is called multiple times', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
expect(() => {
|
||||
manager.destroy();
|
||||
manager.destroy();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTheme() - Set Explicit Theme', () => {
|
||||
it('should set theme to light and update source to user', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('light');
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should set theme to dark and update source to user', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should save theme to localStorage when set', async () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
||||
});
|
||||
|
||||
it('should apply theme to DOM when set', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should overwrite existing localStorage value', async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
||||
});
|
||||
|
||||
it('should handle switching from light to dark', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('light'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should handle switching from dark to light', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
manager.setTheme('light');
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle() - Toggle Between Themes', () => {
|
||||
it('should toggle from light to dark', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should toggle from dark to light', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should save toggled theme to localStorage', async () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('dark'));
|
||||
});
|
||||
|
||||
it('should apply toggled theme to DOM', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should handle multiple rapid toggles', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
expect(manager.value).toBe('dark');
|
||||
|
||||
manager.toggle();
|
||||
expect(manager.value).toBe('light');
|
||||
|
||||
manager.toggle();
|
||||
expect(manager.value).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetToSystem() - Reset to System Preference', () => {
|
||||
it('should clear localStorage when resetting to system', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('should set source to system after reset', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.source).toBe('system');
|
||||
expect(manager.isUserControlled).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect and apply light system theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
||||
});
|
||||
|
||||
it('should detect and apply dark system theme', () => {
|
||||
// Override matchMedia to return dark preference
|
||||
matchMediaSpy.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should apply system theme to DOM on reset', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('System Theme Change Handling', () => {
|
||||
it('should update theme when system changes to dark while following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
triggerSystemThemeChange(true);
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.isDark).toBe(true);
|
||||
});
|
||||
|
||||
it('should update theme when system changes to light while following system', () => {
|
||||
// Start with dark system theme
|
||||
// Keep the listener tracking while overriding matches behavior
|
||||
matchMediaSpy.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
||||
if (!mediaQueryListeners.has(query)) {
|
||||
mediaQueryListeners.set(query, new Set());
|
||||
}
|
||||
mediaQueryListeners.get(query)!.add(listener);
|
||||
}),
|
||||
removeEventListener: vi.fn((_type: string, listener: MediaQueryListCallback) => {
|
||||
if (mediaQueryListeners.has(query)) {
|
||||
mediaQueryListeners.get(query)!.delete(listener);
|
||||
}
|
||||
}),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
expect(manager.value).toBe('dark');
|
||||
|
||||
// Now change to light
|
||||
triggerSystemThemeChange(false);
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.isDark).toBe(false);
|
||||
});
|
||||
|
||||
it('should update DOM when system theme changes while following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
triggerSystemThemeChange(true);
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should NOT update theme when system changes if user has set theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('light'); // User explicitly sets light
|
||||
manager.init();
|
||||
|
||||
// Simulate system changing to dark
|
||||
triggerSystemThemeChange(true);
|
||||
|
||||
// Theme should remain light because user set it
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('should respond to system changes after resetToSystem', () => {
|
||||
// Start with user-controlled dark theme
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.source).toBe('user');
|
||||
|
||||
// Reset to system (which is light)
|
||||
manager.resetToSystem();
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('system');
|
||||
|
||||
// Now system changes to dark
|
||||
triggerSystemThemeChange(true);
|
||||
|
||||
// Should update because we're back to following system
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('should stop responding to system changes after setTheme is called', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
// System changes to dark
|
||||
triggerSystemThemeChange(true);
|
||||
expect(manager.value).toBe('dark');
|
||||
expect(manager.source).toBe('system');
|
||||
|
||||
// User explicitly sets light
|
||||
manager.setTheme('light');
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('user');
|
||||
|
||||
// System changes again
|
||||
triggerSystemThemeChange(false);
|
||||
|
||||
// Should stay light because user set it
|
||||
expect(manager.value).toBe('light');
|
||||
});
|
||||
|
||||
it('should not trigger updates after destroy is called', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
manager.destroy();
|
||||
|
||||
// This should not cause any updates since listener was removed
|
||||
expect(() => {
|
||||
triggerSystemThemeChange(true);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DOM Interaction', () => {
|
||||
it('should add dark class when applying dark theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
manager.setTheme('dark');
|
||||
|
||||
// Check toggle was called with force=true for dark
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', true);
|
||||
});
|
||||
|
||||
it('should remove dark class when applying light theme', () => {
|
||||
// Start with dark
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
// Switch to light
|
||||
manager.setTheme('light');
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
||||
});
|
||||
|
||||
it('should not add dark class when system prefers light', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.init();
|
||||
|
||||
expect(classListMock.toggle).toHaveBeenCalledWith('dark', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Getter Properties', () => {
|
||||
it('value getter should return current theme', () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify('dark'));
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.value).toBe('dark');
|
||||
});
|
||||
|
||||
it('source getter should return "user" when theme is user-controlled', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(manager.source).toBe('user');
|
||||
});
|
||||
|
||||
it('source getter should return "system" when following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.source).toBe('system');
|
||||
});
|
||||
|
||||
it('isDark getter should return true for dark theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
|
||||
expect(manager.isDark).toBe(true);
|
||||
});
|
||||
|
||||
it('isDark getter should return false for light theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.isDark).toBe(false);
|
||||
});
|
||||
|
||||
it('isUserControlled getter should return true when source is user', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('light');
|
||||
|
||||
expect(manager.isUserControlled).toBe(true);
|
||||
});
|
||||
|
||||
it('isUserControlled getter should return false when source is system', () => {
|
||||
const manager = new ThemeManager();
|
||||
|
||||
expect(manager.isUserControlled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid setTheme calls', async () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
manager.setTheme('light');
|
||||
manager.setTheme('dark');
|
||||
manager.setTheme('light');
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
|
||||
});
|
||||
|
||||
it('should handle toggle immediately followed by setTheme', async () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.toggle();
|
||||
manager.setTheme('light');
|
||||
|
||||
await flushEffects();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBe(JSON.stringify('light'));
|
||||
});
|
||||
|
||||
it('should handle setTheme immediately followed by resetToSystem', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.setTheme('dark');
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle resetToSystem when already following system', () => {
|
||||
const manager = new ThemeManager();
|
||||
manager.resetToSystem();
|
||||
|
||||
expect(manager.value).toBe('light');
|
||||
expect(manager.source).toBe('system');
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Safety', () => {
|
||||
it('should accept "light" as valid theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
expect(() => manager.setTheme('light')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept "dark" as valid theme', () => {
|
||||
const manager = new ThemeManager();
|
||||
expect(() => manager.setTheme('dark')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ThemeSwitch from './ThemeSwitch.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Features/ThemeSwitch',
|
||||
component: ThemeSwitch,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Theme toggle button that switches between light and dark modes. Uses ThemeManager to persist user preference and sync with system preference. Displays sun/moon icon based on current theme.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// ThemeSwitch has no explicit props - it uses themeManager internally
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
|
||||
// Current theme state for display
|
||||
const currentTheme = $derived(themeManager.value);
|
||||
const themeSource = $derived(themeManager.source);
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
<div class="flex items-center justify-center p-8 gap-4">
|
||||
<ThemeSwitch />
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Theme: <span class="font-semibold">{currentTheme}</span>
|
||||
{#if themeSource === 'user'}
|
||||
<span class="text-xs ml-2">(user preference)</span>
|
||||
{:else}
|
||||
<span class="text-xs ml-2">(system preference)</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!--
|
||||
Component: ThemeSwitch
|
||||
Toggles the theme between light and dark mode.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { IconButton } from '$shared/ui';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import { getContext } from 'svelte';
|
||||
import { themeManager } from '../../model';
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
const theme = $derived(themeManager.value);
|
||||
</script>
|
||||
|
||||
<IconButton onclick={() => themeManager.toggle()} size={responsive.isMobile ? 'sm' : 'md'} title="Toggle theme">
|
||||
{#snippet icon()}
|
||||
{#if theme === 'light'}
|
||||
<MoonIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
|
||||
{:else}
|
||||
<SunIcon class={responsive.isMobile ? 'size-4' : 'size-5'} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
1
src/features/ChangeAppTheme/ui/index.ts
Normal file
1
src/features/ChangeAppTheme/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ThemeSwitch } from './ThemeSwitch/ThemeSwitch.svelte';
|
||||
@@ -0,0 +1,116 @@
|
||||
<script module>
|
||||
import Providers from '$shared/lib/storybook/Providers.svelte';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import FontSampler from './FontSampler.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Features/FontSampler',
|
||||
component: FontSampler,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Displays a sample text with a given font in a contenteditable element. Visual design matches FontCard: sharp corners, brand hover accent, header stats showing font properties (size, weight, line height, letter spacing). Staggered entrance animation based on index.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
font: {
|
||||
control: 'object',
|
||||
description: 'Font information object',
|
||||
},
|
||||
text: {
|
||||
control: 'text',
|
||||
description: 'Editable sample text (two-way bindable)',
|
||||
},
|
||||
index: {
|
||||
control: { type: 'number', min: 0 },
|
||||
description: 'Position index — drives the staggered entrance delay',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
|
||||
// Mock fonts for testing
|
||||
const mockArial: UnifiedFont = {
|
||||
id: 'arial',
|
||||
name: 'Arial',
|
||||
provider: 'google',
|
||||
category: 'sans-serif',
|
||||
subsets: ['latin'],
|
||||
variants: ['400', '700'],
|
||||
styles: {
|
||||
regular: '',
|
||||
bold: '',
|
||||
},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: '1.0',
|
||||
popularity: 1,
|
||||
},
|
||||
features: {
|
||||
isVariable: false,
|
||||
},
|
||||
};
|
||||
|
||||
const mockGeorgia: UnifiedFont = {
|
||||
id: 'georgia',
|
||||
name: 'Georgia',
|
||||
provider: 'google',
|
||||
category: 'serif',
|
||||
subsets: ['latin'],
|
||||
variants: ['400', '700'],
|
||||
styles: {
|
||||
regular: '',
|
||||
bold: '',
|
||||
},
|
||||
metadata: {
|
||||
cachedAt: Date.now(),
|
||||
version: '1.0',
|
||||
popularity: 2,
|
||||
},
|
||||
features: {
|
||||
isVariable: false,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
font: mockArial,
|
||||
text: 'The quick brown fox jumps over the lazy dog',
|
||||
index: 0,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<Providers>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<FontSampler {...args} />
|
||||
</div>
|
||||
</Providers>
|
||||
{/snippet}
|
||||
</Story>
|
||||
<Story
|
||||
name="Long Text"
|
||||
args={{
|
||||
font: mockGeorgia,
|
||||
text:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.',
|
||||
index: 1,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<Providers>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<FontSampler {...args} />
|
||||
</div>
|
||||
</Providers>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -1,6 +1,7 @@
|
||||
<!--
|
||||
Component: FontSampler
|
||||
Displays a sample text with a given font in a contenteditable element.
|
||||
Visual design matches FontCard: sharp corners, red hover accent, header stats.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
@@ -9,11 +10,14 @@ import {
|
||||
} from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import {
|
||||
Badge,
|
||||
ContentEditable,
|
||||
Divider,
|
||||
Footnote,
|
||||
// IconButton,
|
||||
Stat,
|
||||
StatGroup,
|
||||
} from '$shared/ui';
|
||||
// import XIcon from '@lucide/svelte/icons/x';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -21,68 +25,124 @@ interface Props {
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Text to display
|
||||
* Sample text
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Index of the font sampler
|
||||
* Position index
|
||||
* @default 0
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Font settings
|
||||
*/
|
||||
fontSize?: number;
|
||||
lineHeight?: number;
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
|
||||
let { font, text = $bindable(), index = 0 }: Props = $props();
|
||||
|
||||
const fontWeight = $derived(controlManager.weight);
|
||||
const fontSize = $derived(controlManager.renderedSize);
|
||||
const lineHeight = $derived(controlManager.height);
|
||||
const letterSpacing = $derived(controlManager.spacing);
|
||||
|
||||
// Adjust the property name to match your UnifiedFont type
|
||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
||||
|
||||
// Extract provider badge with fallback
|
||||
const providerBadge = $derived(
|
||||
font.providerBadge
|
||||
?? (font.provider === 'google' ? 'Google Fonts' : 'Fontshare'),
|
||||
);
|
||||
|
||||
const stats = $derived([
|
||||
{ label: 'SZ', value: `${fontSize}PX` },
|
||||
{ label: 'WGT', value: `${fontWeight}` },
|
||||
{ label: 'LH', value: lineHeight?.toFixed(2) },
|
||||
{ label: 'LTR', value: `${letterSpacing}` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
in:fly={{ y: 20, duration: 400, delay: index * 50 }}
|
||||
class="
|
||||
w-full h-full rounded-xl sm:rounded-2xl
|
||||
group relative
|
||||
w-full h-full
|
||||
bg-paper dark:bg-dark-card
|
||||
border border-black/5 dark:border-white/10
|
||||
hover:border-brand dark:hover:border-brand
|
||||
hover:shadow-brand/10
|
||||
hover:shadow-[5px_5px_0px_0px]
|
||||
transition-all duration-200
|
||||
overflow-hidden
|
||||
flex flex-col
|
||||
bg-background-80
|
||||
border border-border-muted
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
relative overflow-hidden
|
||||
min-h-60
|
||||
rounded-none
|
||||
"
|
||||
style:font-weight={fontWeight}
|
||||
>
|
||||
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-border-subtle flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 sm:gap-2.5">
|
||||
<Footnote>
|
||||
typeface_{String(index).padStart(3, '0')}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
||||
<div class="font-bold text-foreground">
|
||||
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
flex items-center justify-between
|
||||
px-4 sm:px-5 md:px-6 py-3 sm:py-4
|
||||
border-b border-black/5 dark:border-white/10
|
||||
bg-paper dark:bg-dark-card
|
||||
"
|
||||
>
|
||||
<!-- Left: index · name · type badge · provider badge -->
|
||||
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
|
||||
<span class="font-mono text-[0.625rem] tracking-widest text-neutral-400 uppercase leading-none shrink-0">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<Divider orientation="vertical" class="h-3 shrink-0" />
|
||||
|
||||
<span
|
||||
class="font-primary font-bold text-sm text-swiss-black dark:text-neutral-200 leading-none tracking-tight uppercase truncate"
|
||||
>
|
||||
{font.name}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{#if fontType}
|
||||
<Badge size="xs" variant="default" class="text-nowrap font-mono">
|
||||
{fontType}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
<!-- Provider badge -->
|
||||
{#if providerBadge}
|
||||
<Badge size="xs" variant="default" class="text-nowrap font-mono" data-provider={font.provider}>
|
||||
{providerBadge}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<IconButton
|
||||
onclick={removeSample}
|
||||
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
|
||||
<!-- Right: stats, hidden on mobile, fade in on group hover -->
|
||||
<div
|
||||
class="
|
||||
flex-1 min-w-0
|
||||
hidden md:block @container
|
||||
opacity-50 group-hover:opacity-100
|
||||
transition-opacity duration-200 ml-4
|
||||
"
|
||||
>
|
||||
<!-- Switches: narrow → 2×2, wide enough → 1 row -->
|
||||
<div
|
||||
class="
|
||||
max-w-64 ml-auto
|
||||
grid grid-cols-2 gap-x-3 gap-y-2
|
||||
@[160px]:grid-cols-4 @[160px]:gap-y-0
|
||||
items-center
|
||||
"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<XIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
-->
|
||||
{#each stats as stat}
|
||||
<Stat label={stat.label} value={stat.value} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-5 md:p-8 relative z-10">
|
||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
||||
<div class="flex-1 p-4 sm:p-5 md:p-8 flex items-center overflow-hidden bg-paper dark:bg-dark-card relative z-10">
|
||||
<FontApplicator {font} weight={fontWeight}>
|
||||
<ContentEditable
|
||||
bind:text
|
||||
{...restProps}
|
||||
{fontSize}
|
||||
{lineHeight}
|
||||
{letterSpacing}
|
||||
@@ -90,21 +150,27 @@ const letterSpacing = $derived(controlManager.spacing);
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-border-subtle w-full flex flex-row gap-2 sm:gap-4 bg-background mt-auto">
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
|
||||
SZ:{fontSize}PX
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||
WGT:{fontWeight}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||
LH:{lineHeight?.toFixed(2)}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
|
||||
LTR:{letterSpacing}
|
||||
</Footnote>
|
||||
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||
<div class="md:hidden px-4 sm:px-5 py-1.5 sm:py-2 border-t border-black/5 dark:border-white/10 flex gap-2 sm:gap-4 bg-paper dark:bg-dark-card mt-auto">
|
||||
{#each stats as stat, i}
|
||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||
{stat.label}:{stat.value}
|
||||
</Footnote>
|
||||
{#if i < stats.length - 1}
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-black/10 dark:bg-white/10 hidden sm:block"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── Red hover line ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="
|
||||
absolute bottom-0 left-0 right-0
|
||||
w-full h-0.5 bg-brand
|
||||
scale-x-0 group-hover:scale-x-100
|
||||
transition-transform cubic-bezier(0.25, 0.1, 0.25, 1) origin-left duration-400
|
||||
z-10
|
||||
"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
85
src/features/GetFonts/api/filters/filters.ts
Normal file
85
src/features/GetFonts/api/filters/filters.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Proxy API filters
|
||||
*
|
||||
* Fetches filter metadata from GlyphDiff proxy API.
|
||||
* Provides type-safe response handling.
|
||||
*
|
||||
* @see https://api.glyphdiff.com/api/v1/filters
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const;
|
||||
|
||||
/**
|
||||
* Filter metadata type from backend
|
||||
*/
|
||||
export interface FilterMetadata {
|
||||
/** Filter ID (e.g., "providers", "categories", "subsets") */
|
||||
id: string;
|
||||
|
||||
/** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */
|
||||
name: string;
|
||||
|
||||
/** Filter description */
|
||||
description: string;
|
||||
|
||||
/** Filter type */
|
||||
type: 'enum' | 'string' | 'array';
|
||||
|
||||
/** Available filter options */
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter option type
|
||||
*/
|
||||
export interface FilterOption {
|
||||
/** Option ID (e.g., "google", "serif", "latin") */
|
||||
id: string;
|
||||
|
||||
/** Display name (e.g., "Google Fonts", "Serif", "Latin") */
|
||||
name: string;
|
||||
|
||||
/** Option value (e.g., "google", "serif", "latin") */
|
||||
value: string;
|
||||
|
||||
/** Number of fonts with this value */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy filters API response
|
||||
*/
|
||||
export interface ProxyFiltersResponse {
|
||||
/** Array of filter metadata */
|
||||
filters: FilterMetadata[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch filters from proxy API
|
||||
*
|
||||
* @returns Promise resolving to array of filter metadata
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all filters
|
||||
* const filters = await fetchProxyFilters();
|
||||
*
|
||||
* console.log(filters); // [
|
||||
* // { id: "providers", name: "Font Providers", options: [...] },
|
||||
* // { id: "categories", name: "Categories", options: [...] },
|
||||
* // { id: "subsets", name: "Character Subsets", options: [...] }
|
||||
* // ]
|
||||
* ```
|
||||
*/
|
||||
export async function fetchProxyFilters(): Promise<FilterMetadata[]> {
|
||||
const response = await api.get<FilterMetadata[]>(PROXY_API_URL);
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
throw new Error('Proxy API returned invalid response');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
1
src/features/GetFonts/api/index.ts
Normal file
1
src/features/GetFonts/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './filters/filters';
|
||||
@@ -4,14 +4,16 @@ export {
|
||||
mapManagerToParams,
|
||||
} from './lib';
|
||||
|
||||
export {
|
||||
FONT_CATEGORIES,
|
||||
FONT_PROVIDERS,
|
||||
FONT_SUBSETS,
|
||||
} from './model/const/const';
|
||||
|
||||
export { filterManager } from './model/state/manager.svelte';
|
||||
|
||||
export {
|
||||
SORT_MAP,
|
||||
SORT_OPTIONS,
|
||||
type SortApiValue,
|
||||
type SortOption,
|
||||
sortStore,
|
||||
} from './model/store/sortStore.svelte';
|
||||
|
||||
export {
|
||||
FilterControls,
|
||||
Filters,
|
||||
|
||||
@@ -1,14 +1,40 @@
|
||||
/**
|
||||
* Filter manager for font filtering
|
||||
*
|
||||
* Manages multiple filter groups (providers, categories, subsets)
|
||||
* with debounced search input. Provides reactive state for filter
|
||||
* selections and convenience methods for bulk operations.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const manager = createFilterManager({
|
||||
* queryValue: '',
|
||||
* groups: [
|
||||
* { id: 'providers', label: 'Provider', properties: [...] },
|
||||
* { id: 'categories', label: 'Category', properties: [...] }
|
||||
* ]
|
||||
* });
|
||||
*
|
||||
* $: searchQuery = manager.debouncedQueryValue;
|
||||
* $: hasFilters = manager.hasAnySelection;
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { createFilter } from '$shared/lib';
|
||||
import { createDebouncedState } from '$shared/lib/helpers';
|
||||
import type { FilterConfig } from '../../model';
|
||||
import type {
|
||||
FilterConfig,
|
||||
FilterGroupConfig,
|
||||
} from '../../model';
|
||||
|
||||
/**
|
||||
* Create a filter manager instance.
|
||||
* - Uses debounce to update search query for better performance.
|
||||
* - Manages filter instances for each group.
|
||||
* Creates a filter manager instance
|
||||
*
|
||||
* @param config - Configuration for the filter manager.
|
||||
* @returns - An instance of the filter manager.
|
||||
* Manages multiple filter groups with debounced search. Each group
|
||||
* contains filterable properties that can be selected/deselected.
|
||||
*
|
||||
* @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>) {
|
||||
const search = createDebouncedState(config.queryValue ?? '');
|
||||
@@ -28,37 +54,68 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
|
||||
);
|
||||
|
||||
return {
|
||||
// Getter for queryValue (immediate value for UI)
|
||||
/**
|
||||
* Replace all filter groups with new config
|
||||
* Used when dynamic filter data loads from backend
|
||||
*/
|
||||
setGroups(newGroups: FilterGroupConfig<TValue>[]) {
|
||||
groups.length = 0;
|
||||
groups.push(
|
||||
...newGroups.map(g => ({
|
||||
id: g.id,
|
||||
label: g.label,
|
||||
instance: createFilter({ properties: g.properties }),
|
||||
})),
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Current search query value (immediate, for UI binding)
|
||||
* Updates instantly as user types
|
||||
*/
|
||||
get queryValue() {
|
||||
return search.immediate;
|
||||
},
|
||||
|
||||
// Setter for queryValue
|
||||
/**
|
||||
* Set the search query value
|
||||
*/
|
||||
set queryValue(value) {
|
||||
search.immediate = value;
|
||||
},
|
||||
|
||||
// Getter for queryValue (debounced value for logic)
|
||||
/**
|
||||
* Debounced search query value (for API calls)
|
||||
* Updates after delay to reduce API requests
|
||||
*/
|
||||
get debouncedQueryValue() {
|
||||
return search.debounced;
|
||||
},
|
||||
|
||||
// Direct array reference (reactive)
|
||||
/**
|
||||
* All filter groups (reactive)
|
||||
*/
|
||||
get groups() {
|
||||
return groups;
|
||||
},
|
||||
|
||||
// Derived values
|
||||
/**
|
||||
* Whether any filter has an active selection
|
||||
*/
|
||||
get hasAnySelection() {
|
||||
return hasAnySelection;
|
||||
},
|
||||
|
||||
// Global action
|
||||
/**
|
||||
* Deselect all filters across all groups
|
||||
*/
|
||||
deselectAllGlobal: () => {
|
||||
groups.forEach(group => group.instance.deselectAll());
|
||||
},
|
||||
|
||||
// Helper to get group by id
|
||||
/**
|
||||
* Get a specific filter group by ID
|
||||
* @param id - Group identifier
|
||||
*/
|
||||
getGroup: (id: string) => {
|
||||
return groups.find(g => g.id === id);
|
||||
},
|
||||
|
||||
784
src/features/GetFonts/lib/filterManager/filterManager.test.ts
Normal file
784
src/features/GetFonts/lib/filterManager/filterManager.test.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
import type { Property } from '$shared/lib';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { createFilterManager } from './filterManager.svelte';
|
||||
|
||||
/**
|
||||
* Test Suite for createFilterManager Helper Function
|
||||
*
|
||||
* This suite tests the filter manager logic including:
|
||||
* - Debounced query state (immediate vs delayed)
|
||||
* - Filter group creation and management
|
||||
* - hasAnySelection derived state
|
||||
* - getGroup() method
|
||||
* - deselectAllGlobal() method
|
||||
*
|
||||
* Mocking Strategy:
|
||||
* - We test the actual implementation without mocking createDebouncedState
|
||||
* and createFilter since they are simple reactive helpers
|
||||
* - For timing tests, we use vi.useFakeTimers() to control debounce delays
|
||||
*
|
||||
* NOTE: Svelte 5's $derived runs in microtasks, so we need to flush effects
|
||||
* after state changes to test reactive behavior. This is a limitation of unit
|
||||
* testing Svelte 5 reactive code in Node.js.
|
||||
*/
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// Helper to create test properties
|
||||
function createTestProperties(count: number, selectedIndices: number[] = []): Property<string>[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `prop-${i}`,
|
||||
name: `Property ${i}`,
|
||||
value: `value-${i}`,
|
||||
selected: selectedIndices.includes(i),
|
||||
}));
|
||||
}
|
||||
|
||||
// Helper to create test filter groups
|
||||
function createTestGroups(count: number, propertiesPerGroup = 3) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `group-${i}`,
|
||||
label: `Group ${i}`,
|
||||
properties: createTestProperties(propertiesPerGroup),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('createFilterManager - Initialization', () => {
|
||||
it('creates manager with empty query value', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(2),
|
||||
});
|
||||
|
||||
expect(manager.queryValue).toBe('');
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
});
|
||||
|
||||
it('creates manager with initial query value', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: 'search term',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(manager.queryValue).toBe('search term');
|
||||
expect(manager.debouncedQueryValue).toBe('search term');
|
||||
});
|
||||
|
||||
it('creates manager with undefined query value (defaults to empty string)', () => {
|
||||
const manager = createFilterManager({
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(manager.queryValue).toBe('');
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
});
|
||||
|
||||
it('creates filter groups for each config group', () => {
|
||||
const groups = createTestGroups(3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(manager.groups).toHaveLength(3);
|
||||
expect(manager.groups[0].id).toBe('group-0');
|
||||
expect(manager.groups[1].id).toBe('group-1');
|
||||
expect(manager.groups[2].id).toBe('group-2');
|
||||
});
|
||||
|
||||
it('creates filter instances for each group', () => {
|
||||
const groups = createTestGroups(2, 5);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
manager.groups.forEach(group => {
|
||||
expect(group.instance).toBeDefined();
|
||||
expect(group.instance.properties).toHaveLength(5);
|
||||
expect(typeof group.instance.toggleProperty).toBe('function');
|
||||
expect(typeof group.instance.selectAll).toBe('function');
|
||||
expect(typeof group.instance.deselectAll).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves group labels', () => {
|
||||
const groups = [
|
||||
{ id: 'providers', label: 'Providers', properties: createTestProperties(2) },
|
||||
{ id: 'categories', label: 'Categories', properties: createTestProperties(3) },
|
||||
];
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(manager.groups[0].label).toBe('Providers');
|
||||
expect(manager.groups[1].label).toBe('Categories');
|
||||
});
|
||||
|
||||
it('handles single group', () => {
|
||||
const groups = createTestGroups(1);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(manager.groups).toHaveLength(1);
|
||||
expect(manager.groups[0].id).toBe('group-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - Debounced Query', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('immediate query value updates instantly', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
manager.queryValue = 'new search';
|
||||
|
||||
expect(manager.queryValue).toBe('new search');
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
});
|
||||
|
||||
it('debounced query value updates after default delay (300ms)', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
manager.queryValue = 'search term';
|
||||
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
|
||||
vi.advanceTimersByTime(299);
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(manager.debouncedQueryValue).toBe('search term');
|
||||
});
|
||||
|
||||
it('rapid query changes reset the debounce timer', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
manager.queryValue = 'a';
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
manager.queryValue = 'ab';
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
manager.queryValue = 'abc';
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
expect(manager.queryValue).toBe('abc');
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(manager.debouncedQueryValue).toBe('abc');
|
||||
});
|
||||
|
||||
it('handles empty string in query', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: 'initial',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
manager.queryValue = '';
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(manager.queryValue).toBe('');
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
});
|
||||
|
||||
it('preserves initial query value until changed', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: 'initial search',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(manager.queryValue).toBe('initial search');
|
||||
expect(manager.debouncedQueryValue).toBe('initial search');
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
|
||||
expect(manager.queryValue).toBe('initial search');
|
||||
expect(manager.debouncedQueryValue).toBe('initial search');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - hasAnySelection Derived State', () => {
|
||||
it('returns false when no filters are selected', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(3, 3),
|
||||
});
|
||||
|
||||
expect(manager.hasAnySelection).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when one filter in one group is selected', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
|
||||
// Verify underlying state changed
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
// hasAnySelection derived state requires reactive environment
|
||||
// This is tested in component/E2E tests
|
||||
});
|
||||
|
||||
it('returns true when multiple filters across groups are selected', () => {
|
||||
const groups = createTestGroups(3, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
manager.groups[1].instance.selectProperty('prop-1');
|
||||
manager.groups[2].instance.selectProperty('prop-2');
|
||||
|
||||
// Verify underlying state changed
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(1);
|
||||
expect(manager.groups[2].instance.selectedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('returns false after deselecting all filters', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
|
||||
manager.groups[0].instance.deselectAll();
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('reacts to selection changes in individual groups', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
|
||||
manager.groups[1].instance.selectProperty('prop-1');
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(1);
|
||||
|
||||
manager.groups[0].instance.deselectProperty('prop-0');
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(0);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(1); // Still selected
|
||||
|
||||
manager.groups[1].instance.deselectProperty('prop-1');
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('handles groups with initially selected properties', () => {
|
||||
const groups = [
|
||||
{
|
||||
id: 'group-0',
|
||||
label: 'Group 0',
|
||||
properties: createTestProperties(3, [0, 1]),
|
||||
},
|
||||
{
|
||||
id: 'group-1',
|
||||
label: 'Group 1',
|
||||
properties: createTestProperties(3, []),
|
||||
},
|
||||
];
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(manager.hasAnySelection).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when all groups are empty', () => {
|
||||
const groups = [
|
||||
{ id: 'group-0', label: 'Group 0', properties: [] },
|
||||
{ id: 'group-1', label: 'Group 1', properties: [] },
|
||||
];
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(manager.hasAnySelection).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - getGroup() Method', () => {
|
||||
it('returns the correct group by ID', () => {
|
||||
const groups = createTestGroups(3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
const group = manager.getGroup('group-1');
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?.id).toBe('group-1');
|
||||
expect(group?.label).toBe('Group 1');
|
||||
});
|
||||
|
||||
it('returns undefined for non-existent group ID', () => {
|
||||
const groups = createTestGroups(2);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
const group = manager.getGroup('non-existent');
|
||||
|
||||
expect(group).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns group with accessible filter instance', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
const group = manager.getGroup('group-0');
|
||||
|
||||
expect(group?.instance).toBeDefined();
|
||||
expect(group?.instance.properties).toHaveLength(3);
|
||||
|
||||
group?.instance.selectProperty('prop-0');
|
||||
expect(group?.instance.selectedProperties).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns first group when requested', () => {
|
||||
const groups = createTestGroups(3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
const group = manager.getGroup('group-0');
|
||||
|
||||
expect(group?.id).toBe('group-0');
|
||||
expect(group?.label).toBe('Group 0');
|
||||
});
|
||||
|
||||
it('returns last group when requested', () => {
|
||||
const groups = createTestGroups(5);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
const group = manager.getGroup('group-4');
|
||||
|
||||
expect(group?.id).toBe('group-4');
|
||||
expect(group?.label).toBe('Group 4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - deselectAllGlobal() Method', () => {
|
||||
it('deselects all filters across all groups', () => {
|
||||
const groups = createTestGroups(3, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
// Select some filters in each group
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
manager.groups[1].instance.selectProperty('prop-1');
|
||||
manager.groups[2].instance.selectProperty('prop-2');
|
||||
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(1);
|
||||
expect(manager.groups[2].instance.selectedCount).toBe(1);
|
||||
|
||||
manager.deselectAllGlobal();
|
||||
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(0);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(0);
|
||||
expect(manager.groups[2].instance.selectedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('handles deselecting when nothing is selected', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(() => manager.deselectAllGlobal()).not.toThrow();
|
||||
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(0);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(0);
|
||||
expect(manager.hasAnySelection).toBe(false);
|
||||
});
|
||||
|
||||
it('handles deselecting with empty groups', () => {
|
||||
const groups = [
|
||||
{ id: 'group-0', label: 'Group 0', properties: [] },
|
||||
{ id: 'group-1', label: 'Group 1', properties: [] },
|
||||
];
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(() => manager.deselectAllGlobal()).not.toThrow();
|
||||
expect(manager.hasAnySelection).toBe(false);
|
||||
});
|
||||
|
||||
it('can select filters after global deselect', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
// Select and then deselect
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
manager.deselectAllGlobal();
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(0);
|
||||
|
||||
// Select again
|
||||
manager.groups[0].instance.selectProperty('prop-1');
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('handles partially selected groups', () => {
|
||||
const groups = createTestGroups(3, 5);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
// Partial selection in each group
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
manager.groups[0].instance.selectProperty('prop-1');
|
||||
manager.groups[1].instance.selectProperty('prop-2');
|
||||
manager.groups[2].instance.selectProperty('prop-4');
|
||||
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(2);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(1);
|
||||
expect(manager.groups[2].instance.selectedCount).toBe(1);
|
||||
|
||||
manager.deselectAllGlobal();
|
||||
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(0);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(0);
|
||||
expect(manager.groups[2].instance.selectedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - Complex Scenarios', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('handles query changes and filter selections together', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
manager.queryValue = 'search';
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
|
||||
expect(manager.queryValue).toBe('search');
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(manager.debouncedQueryValue).toBe('search');
|
||||
});
|
||||
|
||||
it('handles real-world filtering workflow', () => {
|
||||
const groups = [
|
||||
{
|
||||
id: 'categories',
|
||||
label: 'Categories',
|
||||
properties: [
|
||||
{ id: 'sans', name: 'Sans Serif', value: 'sans-serif' },
|
||||
{ id: 'serif', name: 'Serif', value: 'serif' },
|
||||
{ id: 'display', name: 'Display', value: 'display' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'subsets',
|
||||
label: 'Subsets',
|
||||
properties: [
|
||||
{ id: 'latin', name: 'Latin', value: 'latin' },
|
||||
{ id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
// Initial state
|
||||
expect(manager.hasAnySelection).toBe(false);
|
||||
|
||||
// Select a category
|
||||
const categoryGroup = manager.getGroup('categories');
|
||||
categoryGroup?.instance.selectProperty('sans');
|
||||
expect(categoryGroup?.instance.selectedCount).toBe(1);
|
||||
|
||||
// Type in search
|
||||
manager.queryValue = 'roboto';
|
||||
expect(manager.queryValue).toBe('roboto');
|
||||
expect(manager.debouncedQueryValue).toBe('');
|
||||
|
||||
// Wait for debounce
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(manager.debouncedQueryValue).toBe('roboto');
|
||||
|
||||
// Clear all filters
|
||||
manager.deselectAllGlobal();
|
||||
expect(categoryGroup?.instance.selectedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('manages multiple independent filter groups correctly', () => {
|
||||
const groups = createTestGroups(4, 5);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
// Select different filters in different groups
|
||||
manager.groups[0].instance.selectProperty('prop-0');
|
||||
manager.groups[1].instance.selectAll();
|
||||
manager.groups[2].instance.selectProperty('prop-2');
|
||||
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(5);
|
||||
expect(manager.groups[2].instance.selectedCount).toBe(1);
|
||||
expect(manager.groups[3].instance.selectedCount).toBe(0);
|
||||
|
||||
// Deselect all globally
|
||||
manager.deselectAllGlobal();
|
||||
|
||||
manager.groups.forEach(group => {
|
||||
expect(group.instance.selectedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles toggle operations via getGroup', () => {
|
||||
const groups = createTestGroups(2, 3);
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
const group = manager.getGroup('group-0');
|
||||
expect(group?.instance.selectedCount).toBe(0);
|
||||
|
||||
group?.instance.toggleProperty('prop-0');
|
||||
expect(group?.instance.selectedCount).toBe(1);
|
||||
|
||||
group?.instance.toggleProperty('prop-0');
|
||||
expect(group?.instance.selectedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - Interface Compliance', () => {
|
||||
it('exposes queryValue getter', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: 'test',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
const _ = manager.queryValue;
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('exposes queryValue setter', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: 'test',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
manager.queryValue = 'new value';
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('exposes debouncedQueryValue getter', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: 'test',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
const _ = manager.debouncedQueryValue;
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('exposes groups getter', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
const _ = manager.groups;
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('exposes hasAnySelection getter', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
const _ = manager.hasAnySelection;
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('exposes getGroup method', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(typeof manager.getGroup).toBe('function');
|
||||
});
|
||||
|
||||
it('exposes deselectAllGlobal method', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
expect(typeof manager.deselectAllGlobal).toBe('function');
|
||||
});
|
||||
|
||||
it('does not expose debouncedQueryValue setter', () => {
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: createTestGroups(1),
|
||||
});
|
||||
|
||||
// TypeScript should prevent this, but we can check the runtime behavior
|
||||
expect(manager).not.toHaveProperty('set debouncedQueryValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFilterManager - Edge Cases', () => {
|
||||
it('handles single property groups', () => {
|
||||
const groups: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
properties: Property<string>[];
|
||||
}> = [
|
||||
{
|
||||
id: 'single-prop-group',
|
||||
label: 'Single Property',
|
||||
properties: [{ id: 'only', name: 'Only', value: 'only-value' }],
|
||||
},
|
||||
];
|
||||
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(manager.groups).toHaveLength(1);
|
||||
expect(manager.groups[0].instance.properties).toHaveLength(1);
|
||||
expect(manager.hasAnySelection).toBe(false);
|
||||
|
||||
manager.groups[0].instance.selectProperty('only');
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('handles groups with duplicate property IDs (same ID, different groups)', () => {
|
||||
const groups = [
|
||||
{
|
||||
id: 'group-0',
|
||||
label: 'Group 0',
|
||||
properties: [{ id: 'same-id', name: 'Same 0', value: 'value-0' }],
|
||||
},
|
||||
{
|
||||
id: 'group-1',
|
||||
label: 'Group 1',
|
||||
properties: [{ id: 'same-id', name: 'Same 1', value: 'value-1' }],
|
||||
},
|
||||
];
|
||||
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
// Each group should have its own filter instance
|
||||
expect(manager.groups[0].instance.properties[0].id).toBe('same-id');
|
||||
expect(manager.groups[1].instance.properties[0].id).toBe('same-id');
|
||||
|
||||
// Selecting in one group should not affect the other
|
||||
manager.groups[0].instance.selectProperty('same-id');
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(1);
|
||||
expect(manager.groups[1].instance.selectedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('handles initially selected properties in groups', () => {
|
||||
const groups = [
|
||||
{
|
||||
id: 'preselected',
|
||||
label: 'Preselected',
|
||||
properties: createTestProperties(3, [0, 2]),
|
||||
},
|
||||
];
|
||||
|
||||
const manager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups,
|
||||
});
|
||||
|
||||
expect(manager.hasAnySelection).toBe(true);
|
||||
expect(manager.groups[0].instance.selectedCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,7 @@ import type { FilterManager } from '../filterManager/filterManager.svelte';
|
||||
/**
|
||||
* Maps filter manager to proxy API parameters.
|
||||
*
|
||||
* Transforms UI filter state into proxy API query parameters.
|
||||
* Handles conversion from filter groups to API-specific 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
|
||||
@@ -15,13 +14,18 @@ import type { FilterManager } from '../filterManager/filterManager.svelte';
|
||||
* // Example filter manager state:
|
||||
* // {
|
||||
* // queryValue: 'roboto',
|
||||
* // providers: ['google'],
|
||||
* // categories: ['sans-serif'],
|
||||
* // providers: ['google', 'fontshare'],
|
||||
* // categories: ['sans-serif', 'serif'],
|
||||
* // subsets: ['latin']
|
||||
* // }
|
||||
*
|
||||
* const params = mapManagerToParams(manager);
|
||||
* // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' }
|
||||
* // Returns: {
|
||||
* // providers: ['google', 'fontshare'],
|
||||
* // categories: ['sans-serif', 'serif'],
|
||||
* // subsets: ['latin'],
|
||||
* // q: 'roboto'
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
||||
@@ -33,22 +37,17 @@ export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsPa
|
||||
// Search query (debounced)
|
||||
q: manager.debouncedQueryValue || undefined,
|
||||
|
||||
// Provider filter (single value - proxy API doesn't support array)
|
||||
// Use first provider if multiple selected, or undefined if none/all selected
|
||||
provider: providers && providers.length === 1
|
||||
? (providers[0] as 'google' | 'fontshare')
|
||||
// NEW: Support arrays - send all selected values
|
||||
providers: providers && providers.length > 0
|
||||
? providers as string[]
|
||||
: undefined,
|
||||
|
||||
// Category filter (single value - proxy API doesn't support array)
|
||||
// Use first category if multiple selected, or undefined if none/all selected
|
||||
category: categories && categories.length === 1
|
||||
? (categories[0] as ProxyFontsParams['category'])
|
||||
categories: categories && categories.length > 0
|
||||
? categories as string[]
|
||||
: undefined,
|
||||
|
||||
// Subset filter (single value - proxy API doesn't support array)
|
||||
// Use first subset if multiple selected, or undefined if none/all selected
|
||||
subset: subsets && subsets.length === 1
|
||||
? (subsets[0] as ProxyFontsParams['subset'])
|
||||
subsets: subsets && subsets.length > 0
|
||||
? subsets as string[]
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,4 +3,13 @@ export type {
|
||||
FilterGroupConfig,
|
||||
} from './types/filter';
|
||||
|
||||
export { filtersStore } from './state/filters.svelte';
|
||||
export { filterManager } from './state/manager.svelte';
|
||||
|
||||
export {
|
||||
SORT_MAP,
|
||||
SORT_OPTIONS,
|
||||
type SortApiValue,
|
||||
type SortOption,
|
||||
sortStore,
|
||||
} from './store/sortStore.svelte';
|
||||
|
||||
122
src/features/GetFonts/model/state/filters.svelte.ts
Normal file
122
src/features/GetFonts/model/state/filters.svelte.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Filters store for dynamic filter metadata
|
||||
*
|
||||
* Fetches and caches filter metadata from /api/v1/filters endpoint.
|
||||
* Provides reactive access to filter data for providers, categories, and subsets.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { filtersStore } from '$features/GetFonts';
|
||||
*
|
||||
* // Access filters (reactive)
|
||||
* $: filters = filtersStore.filters;
|
||||
* $: isLoading = filtersStore.isLoading;
|
||||
* $: error = filtersStore.error;
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters';
|
||||
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters';
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import {
|
||||
type QueryKey,
|
||||
QueryObserver,
|
||||
type QueryObserverOptions,
|
||||
type QueryObserverResult,
|
||||
} from '@tanstack/query-core';
|
||||
|
||||
/**
|
||||
* Filters store wrapping TanStack Query
|
||||
*
|
||||
* Fetches and caches filter metadata using fetchProxyFilters()
|
||||
* Provides reactive access to filter data
|
||||
*/
|
||||
class FiltersStore {
|
||||
/** TanStack Query result state */
|
||||
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
|
||||
|
||||
/** TanStack Query observer instance */
|
||||
protected observer: QueryObserver<FilterMetadata[], Error>;
|
||||
|
||||
/** Shared query client */
|
||||
protected qc = queryClient;
|
||||
|
||||
/**
|
||||
* Creates a new filters store
|
||||
*/
|
||||
constructor() {
|
||||
this.observer = new QueryObserver(this.qc, this.getOptions());
|
||||
|
||||
// Sync TanStack Query state -> Svelte state
|
||||
this.observer.subscribe(r => {
|
||||
this.result = r;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key for TanStack Query caching
|
||||
*/
|
||||
protected getQueryKey(): QueryKey {
|
||||
return ['filters'] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch function for filter metadata
|
||||
* Uses fetchProxyFilters() from proxy API
|
||||
*/
|
||||
protected async fetchFn(): Promise<FilterMetadata[]> {
|
||||
return await fetchProxyFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* TanStack Query options
|
||||
*/
|
||||
protected getOptions(): QueryObserverOptions<FilterMetadata[], Error> {
|
||||
return {
|
||||
queryKey: this.getQueryKey(),
|
||||
queryFn: () => this.fetchFn(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all filters
|
||||
*/
|
||||
get filters(): FilterMetadata[] {
|
||||
return this.result.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get loading state
|
||||
*/
|
||||
get isLoading(): boolean {
|
||||
return this.result.isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error state
|
||||
*/
|
||||
get isError(): boolean {
|
||||
return this.result.isError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message
|
||||
*/
|
||||
get error(): string | null {
|
||||
return this.result.error?.message ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up observer subscription
|
||||
*/
|
||||
destroy() {
|
||||
this.observer.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance
|
||||
*/
|
||||
export const filtersStore = new FiltersStore();
|
||||
@@ -1,29 +1,39 @@
|
||||
/**
|
||||
* 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 {
|
||||
FONT_CATEGORIES,
|
||||
FONT_PROVIDERS,
|
||||
FONT_SUBSETS,
|
||||
} from '../const/const';
|
||||
import { filtersStore } from './filters.svelte';
|
||||
|
||||
const initialConfig = {
|
||||
export const filterManager = createFilterManager({
|
||||
queryValue: '',
|
||||
groups: [
|
||||
{
|
||||
id: 'providers',
|
||||
label: 'Font provider',
|
||||
properties: FONT_PROVIDERS,
|
||||
},
|
||||
{
|
||||
id: 'subsets',
|
||||
label: 'Font subset',
|
||||
properties: FONT_SUBSETS,
|
||||
},
|
||||
{
|
||||
id: 'categories',
|
||||
label: 'Font category',
|
||||
properties: FONT_CATEGORIES,
|
||||
},
|
||||
],
|
||||
};
|
||||
groups: [],
|
||||
});
|
||||
|
||||
export const filterManager = createFilterManager(initialConfig);
|
||||
/**
|
||||
* 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,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
41
src/features/GetFonts/model/store/sortStore.svelte.ts
Normal file
41
src/features/GetFonts/model/store/sortStore.svelte.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Sort store — manages the current sort option for font listings.
|
||||
*
|
||||
* Display labels are mapped to API values through SORT_MAP so that
|
||||
* the UI layer never has to know about the wire format.
|
||||
*/
|
||||
|
||||
export type SortOption = 'Name' | 'Popularity' | 'Newest';
|
||||
|
||||
export const SORT_OPTIONS: SortOption[] = ['Name', 'Popularity', 'Newest'] as const;
|
||||
|
||||
export const SORT_MAP: Record<SortOption, 'name' | 'popularity' | 'lastModified'> = {
|
||||
Name: 'name',
|
||||
Popularity: 'popularity',
|
||||
Newest: 'lastModified',
|
||||
};
|
||||
|
||||
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
||||
|
||||
function createSortStore(initial: SortOption = 'Popularity') {
|
||||
let current = $state<SortOption>(initial);
|
||||
|
||||
return {
|
||||
/** Current display label (e.g. 'Popularity') */
|
||||
get value() {
|
||||
return current;
|
||||
},
|
||||
|
||||
/** Mapped API value (e.g. 'popularity') */
|
||||
get apiValue(): SortApiValue {
|
||||
return SORT_MAP[current];
|
||||
},
|
||||
|
||||
/** Set the active sort option by its display label */
|
||||
set(option: SortOption) {
|
||||
current = option;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const sortStore = createSortStore();
|
||||
@@ -1,14 +1,14 @@
|
||||
<!--
|
||||
Component: Filters
|
||||
Renders a list of CheckboxFilter components for each filter group.
|
||||
Renders a list of FilterGroup components for each filter group.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { CheckboxFilter } from '$shared/ui';
|
||||
import { FilterGroup } from '$shared/ui';
|
||||
import { filterManager } from '../../model';
|
||||
</script>
|
||||
|
||||
{#each filterManager.groups as group (group.id)}
|
||||
<CheckboxFilter
|
||||
<FilterGroup
|
||||
displayedLabel={group.label}
|
||||
filter={group.instance}
|
||||
/>
|
||||
|
||||
@@ -1,46 +1,94 @@
|
||||
<!--
|
||||
Component: FiltersControl
|
||||
Renders a group of action buttons for filter operations.
|
||||
- Reset: Clears all active filters (outline variant for secondary action)
|
||||
Component: FilterControls
|
||||
Sort options + Reset_Filters button.
|
||||
Sits below the filter list, separated by a top border.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import { fontStore } from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import Rotate from '@lucide/svelte/icons/rotate-ccw';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { Tween } from 'svelte/motion';
|
||||
import { filterManager } from '../../model';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
} from 'svelte';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
filterManager,
|
||||
sortStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className }: Props = $props();
|
||||
const {
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
|
||||
const transform = new Tween(
|
||||
{ scale: 1, rotate: 0 },
|
||||
{ duration: 150, easing: cubicOut },
|
||||
);
|
||||
$effect(() => {
|
||||
const apiSort = sortStore.apiValue;
|
||||
untrack(() => fontStore.setSort(apiSort));
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||
|
||||
function handleReset() {
|
||||
filterManager.deselectAllGlobal();
|
||||
|
||||
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
|
||||
transform.set({ scale: 1, rotate: 0 });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('flex flex-row gap-2', className)}
|
||||
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
|
||||
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',
|
||||
'border-t border-foreground/5 dark:border-white/10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<!-- Sort By label + options -->
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center gap-2 md:gap-8 w-full md:w-auto">
|
||||
<Label variant="muted" size="sm">Sort By:</Label>
|
||||
|
||||
<div class="flex gap-3 md:gap-4">
|
||||
{#each SORT_OPTIONS as option}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||
active={sortStore.value === option}
|
||||
onclick={() => sortStore.set(option)}
|
||||
class={cn(
|
||||
'font-bold uppercase tracking-wide font-primary, px-0',
|
||||
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset_Filters -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group flex flex-1 cursor-pointer gap-1"
|
||||
onclick={handleClick}
|
||||
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||
onclick={handleReset}
|
||||
class={cn(
|
||||
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
|
||||
isMobileOrTabletPortrait && 'px-0',
|
||||
)}
|
||||
iconPosition="left"
|
||||
>
|
||||
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
|
||||
Reset
|
||||
{#snippet icon()}
|
||||
<RefreshCwIcon class="size-3 transition-transform duration-300 group-hover:rotate-180" />
|
||||
{/snippet}
|
||||
Reset_Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* Typography control manager
|
||||
*
|
||||
* Manages a collection of typography controls (font size, weight, line height,
|
||||
* letter spacing) with persistent storage. Supports responsive scaling
|
||||
* through a multiplier system.
|
||||
*
|
||||
* The font size control uses a multiplier system to allow responsive scaling
|
||||
* while preserving the user's base size preference. The multiplier is applied
|
||||
* when displaying/editing, but the base size is what's stored.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
@@ -17,14 +29,37 @@ import {
|
||||
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||
|
||||
/**
|
||||
* A control with its instance
|
||||
*/
|
||||
export interface Control extends ControlOnlyFields<ControlId> {
|
||||
instance: TypographyControl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage schema for typography settings
|
||||
*/
|
||||
export interface TypographySettings {
|
||||
fontSize: number;
|
||||
fontWeight: number;
|
||||
lineHeight: number;
|
||||
letterSpacing: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typography control manager class
|
||||
*
|
||||
* Manages multiple typography controls with persistent storage and
|
||||
* responsive scaling support for font size.
|
||||
*/
|
||||
export class TypographyControlManager {
|
||||
/** Map of controls keyed by ID */
|
||||
#controls = new SvelteMap<string, Control>();
|
||||
/** Responsive multiplier for font size display */
|
||||
#multiplier = $state(1);
|
||||
/** Persistent storage for settings */
|
||||
#storage: PersistentStore<TypographySettings>;
|
||||
/** Base font size (user preference, unscaled) */
|
||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||
|
||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||
@@ -85,6 +120,9 @@ export class TypographyControlManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets initial value for a control from storage or defaults
|
||||
*/
|
||||
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
||||
if (id === 'font_weight') return saved.fontWeight;
|
||||
@@ -93,11 +131,17 @@ export class TypographyControlManager {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Getters / Setters ---
|
||||
|
||||
/** Current multiplier for responsive scaling */
|
||||
get multiplier() {
|
||||
return this.#multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the multiplier and update font size display
|
||||
*
|
||||
* When multiplier changes, the font size control's display value
|
||||
* is updated to reflect the new scale while preserving base size.
|
||||
*/
|
||||
set multiplier(value: number) {
|
||||
if (this.#multiplier === value) return;
|
||||
this.#multiplier = value;
|
||||
@@ -109,7 +153,10 @@ export class TypographyControlManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** The scaled size for CSS usage */
|
||||
/**
|
||||
* The scaled size for CSS usage
|
||||
* Returns baseSize * multiplier for actual rendering
|
||||
*/
|
||||
get renderedSize() {
|
||||
return this.#baseSize * this.#multiplier;
|
||||
}
|
||||
@@ -118,6 +165,7 @@ export class TypographyControlManager {
|
||||
get baseSize() {
|
||||
return this.#baseSize;
|
||||
}
|
||||
|
||||
set baseSize(val: number) {
|
||||
this.#baseSize = val;
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
@@ -162,6 +210,9 @@ export class TypographyControlManager {
|
||||
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all controls to default values
|
||||
*/
|
||||
reset() {
|
||||
this.#storage.clear();
|
||||
const defaults = this.#storage.value;
|
||||
@@ -185,21 +236,11 @@ export class TypographyControlManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage schema for typography settings
|
||||
*/
|
||||
export interface TypographySettings {
|
||||
fontSize: number;
|
||||
fontWeight: number;
|
||||
lineHeight: number;
|
||||
letterSpacing: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typography control manager that handles a collection of typography controls.
|
||||
* Creates a typography control manager
|
||||
*
|
||||
* @param configs - Array of control configurations.
|
||||
* @param storageId - Persistent storage identifier.
|
||||
* @returns - Typography control manager instance.
|
||||
* @param configs - Array of control configurations
|
||||
* @param storageId - Persistent storage identifier
|
||||
* @returns Typography control manager instance
|
||||
*/
|
||||
export function createTypographyControlManager(
|
||||
configs: ControlModel<ControlId>[],
|
||||
|
||||
723
src/features/SetupFont/lib/controlManager/controlManager.test.ts
Normal file
723
src/features/SetupFont/lib/controlManager/controlManager.test.ts
Normal file
@@ -0,0 +1,723 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
} from '../../model';
|
||||
import {
|
||||
TypographyControlManager,
|
||||
type TypographySettings,
|
||||
} from './controlManager.svelte';
|
||||
|
||||
/**
|
||||
* Test Strategy for TypographyControlManager
|
||||
*
|
||||
* This test suite validates the TypographyControlManager state management logic.
|
||||
* These are unit tests for the manager logic, separate from component rendering.
|
||||
*
|
||||
* NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects
|
||||
* after state changes to test reactive behavior. This is a limitation of unit
|
||||
* testing Svelte 5 reactive code in Node.js.
|
||||
*
|
||||
* Test Coverage:
|
||||
* 1. Initialization: Loading from storage, creating controls with correct values
|
||||
* 2. Multiplier System: Changing multiplier updates font size display
|
||||
* 3. Base Size Proxy: UI changes update #baseSize via the proxy effect
|
||||
* 4. Storage Sync: Changes to controls sync to storage (via $effect)
|
||||
* 5. Reset Functionality: Clearing storage resets all controls
|
||||
* 6. Rendered Size: base * multiplier calculation
|
||||
* 7. Control Getters: Return correct control instances
|
||||
*/
|
||||
|
||||
// Helper to flush Svelte effects (they run in microtasks)
|
||||
async function flushEffects() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('TypographyControlManager - Unit Tests', () => {
|
||||
let mockStorage: TypographySettings;
|
||||
let mockPersistentStore: {
|
||||
value: TypographySettings;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const createMockPersistentStore = (initialValue: TypographySettings) => {
|
||||
let value = initialValue;
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v: TypographySettings) {
|
||||
value = v;
|
||||
},
|
||||
clear() {
|
||||
value = {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontWeight: DEFAULT_FONT_WEIGHT,
|
||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock storage with default values before each test
|
||||
mockStorage = {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontWeight: DEFAULT_FONT_WEIGHT,
|
||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('creates manager with default values from storage', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
|
||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
|
||||
});
|
||||
|
||||
it('creates manager with saved values from storage', () => {
|
||||
mockStorage = {
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.8,
|
||||
letterSpacing: 0.05,
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.baseSize).toBe(72);
|
||||
expect(manager.weight).toBe(700);
|
||||
expect(manager.height).toBe(1.8);
|
||||
expect(manager.spacing).toBe(0.05);
|
||||
});
|
||||
|
||||
it('initializes font size control with base size multiplied by current multiplier (1)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE);
|
||||
});
|
||||
|
||||
it('returns all controls via controls getter', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
const controls = manager.controls;
|
||||
expect(controls).toHaveLength(4);
|
||||
expect(controls.map(c => c.id)).toEqual([
|
||||
'font_size',
|
||||
'font_weight',
|
||||
'line_height',
|
||||
'letter_spacing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns individual controls via specific getters', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.sizeControl).toBeDefined();
|
||||
expect(manager.weightControl).toBeDefined();
|
||||
expect(manager.heightControl).toBeDefined();
|
||||
expect(manager.spacingControl).toBeDefined();
|
||||
|
||||
// Control instances have value, min, max, step, isAtMax, isAtMin, increase, decrease
|
||||
expect(manager.sizeControl).toHaveProperty('value');
|
||||
expect(manager.weightControl).toHaveProperty('value');
|
||||
expect(manager.heightControl).toHaveProperty('value');
|
||||
expect(manager.spacingControl).toHaveProperty('value');
|
||||
});
|
||||
|
||||
it('control instances have expected interface', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
const ctrl = manager.sizeControl!;
|
||||
expect(typeof ctrl.value).toBe('number');
|
||||
expect(typeof ctrl.min).toBe('number');
|
||||
expect(typeof ctrl.max).toBe('number');
|
||||
expect(typeof ctrl.step).toBe('number');
|
||||
expect(typeof ctrl.isAtMax).toBe('boolean');
|
||||
expect(typeof ctrl.isAtMin).toBe('boolean');
|
||||
expect(typeof ctrl.increase).toBe('function');
|
||||
expect(typeof ctrl.decrease).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiplier System', () => {
|
||||
it('has default multiplier of 1', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.multiplier).toBe(1);
|
||||
});
|
||||
|
||||
it('updates multiplier when set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.multiplier = 0.75;
|
||||
expect(manager.multiplier).toBe(0.75);
|
||||
|
||||
manager.multiplier = 0.5;
|
||||
expect(manager.multiplier).toBe(0.5);
|
||||
});
|
||||
|
||||
it('does not update multiplier if set to same value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
const originalSizeValue = manager.sizeControl?.value;
|
||||
|
||||
manager.multiplier = 1; // Same as default
|
||||
|
||||
expect(manager.sizeControl?.value).toBe(originalSizeValue);
|
||||
});
|
||||
|
||||
it('updates font size control display value when multiplier changes', () => {
|
||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
// Initial state: base = 48, multiplier = 1, display = 48
|
||||
expect(manager.baseSize).toBe(48);
|
||||
expect(manager.sizeControl?.value).toBe(48);
|
||||
|
||||
// Change multiplier to 0.75
|
||||
manager.multiplier = 0.75;
|
||||
// Display should be 48 * 0.75 = 36
|
||||
expect(manager.sizeControl?.value).toBe(36);
|
||||
|
||||
// Change multiplier to 0.5
|
||||
manager.multiplier = 0.5;
|
||||
// Display should be 48 * 0.5 = 24
|
||||
expect(manager.sizeControl?.value).toBe(24);
|
||||
|
||||
// Base size should remain unchanged
|
||||
expect(manager.baseSize).toBe(48);
|
||||
});
|
||||
|
||||
it('updates font size control display value when multiplier increases', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
// Start with multiplier 0.5
|
||||
manager.multiplier = 0.5;
|
||||
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
|
||||
|
||||
// Increase to 0.75
|
||||
manager.multiplier = 0.75;
|
||||
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.75);
|
||||
|
||||
// Increase to 1.0
|
||||
manager.multiplier = 1;
|
||||
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Base Size Setter', () => {
|
||||
it('updates baseSize when set directly', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.baseSize = 72;
|
||||
|
||||
expect(manager.baseSize).toBe(72);
|
||||
});
|
||||
|
||||
it('updates size control value when baseSize is set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.baseSize = 60;
|
||||
|
||||
expect(manager.sizeControl?.value).toBe(60);
|
||||
});
|
||||
|
||||
it('applies multiplier to size control when baseSize is set', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.multiplier = 0.5;
|
||||
manager.baseSize = 60;
|
||||
|
||||
expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendered Size Calculation', () => {
|
||||
it('calculates renderedSize as baseSize * multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 1);
|
||||
});
|
||||
|
||||
it('updates renderedSize when multiplier changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.multiplier = 0.5;
|
||||
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.5);
|
||||
|
||||
manager.multiplier = 0.75;
|
||||
expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.75);
|
||||
});
|
||||
|
||||
it('updates renderedSize when baseSize changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.baseSize = 72;
|
||||
expect(manager.renderedSize).toBe(72);
|
||||
|
||||
manager.multiplier = 0.5;
|
||||
expect(manager.renderedSize).toBe(36);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Base Size Proxy Effect (UI -> baseSize)', () => {
|
||||
// NOTE: The proxy effect that updates baseSize when the control value changes
|
||||
// runs in a $effect, which is asynchronous in unit tests. We test the
|
||||
// synchronous behavior here (baseSize setter) and note that the full
|
||||
// proxy effect behavior should be tested in E2E tests.
|
||||
|
||||
it('does NOT immediately update baseSize from control change (effect is async)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
const originalBaseSize = manager.baseSize;
|
||||
|
||||
// Change the control value directly
|
||||
manager.sizeControl!.value = 60;
|
||||
|
||||
// baseSize is NOT updated immediately because the effect runs in microtasks
|
||||
expect(manager.baseSize).toBe(originalBaseSize);
|
||||
});
|
||||
|
||||
it('updates baseSize via direct setter (synchronous)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.baseSize = 60;
|
||||
|
||||
expect(manager.baseSize).toBe(60);
|
||||
expect(manager.sizeControl?.value).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage Sync (Controls -> Storage)', () => {
|
||||
// NOTE: Storage sync happens via $effect which runs in microtasks.
|
||||
// In unit tests, we verify the initial sync and test async behavior.
|
||||
|
||||
it('has initial values in storage from constructor', () => {
|
||||
mockStorage = {
|
||||
fontSize: 60,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.6,
|
||||
letterSpacing: 0.02,
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
// Initial values are loaded from storage
|
||||
expect(manager.baseSize).toBe(60);
|
||||
expect(manager.weight).toBe(500);
|
||||
expect(manager.height).toBe(1.6);
|
||||
expect(manager.spacing).toBe(0.02);
|
||||
});
|
||||
|
||||
it('syncs to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.baseSize = 72;
|
||||
|
||||
// Storage is NOT updated immediately
|
||||
expect(mockPersistentStore.value.fontSize).toBe(DEFAULT_FONT_SIZE);
|
||||
|
||||
// After flushing effects, storage should be updated
|
||||
await flushEffects();
|
||||
expect(mockPersistentStore.value.fontSize).toBe(72);
|
||||
});
|
||||
|
||||
it('syncs control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.weightControl!.value = 700;
|
||||
|
||||
// After flushing effects
|
||||
await flushEffects();
|
||||
expect(mockPersistentStore.value.fontWeight).toBe(700);
|
||||
});
|
||||
|
||||
it('syncs height control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.heightControl!.value = 1.8;
|
||||
|
||||
await flushEffects();
|
||||
expect(mockPersistentStore.value.lineHeight).toBe(1.8);
|
||||
});
|
||||
|
||||
it('syncs spacing control changes to storage after effect flush (async)', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.spacingControl!.value = 0.05;
|
||||
|
||||
await flushEffects();
|
||||
expect(mockPersistentStore.value.letterSpacing).toBe(0.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Control Value Getters', () => {
|
||||
it('returns current weight value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||
|
||||
manager.weightControl!.value = 700;
|
||||
expect(manager.weight).toBe(700);
|
||||
});
|
||||
|
||||
it('returns current height value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||
|
||||
manager.heightControl!.value = 1.8;
|
||||
expect(manager.height).toBe(1.8);
|
||||
});
|
||||
|
||||
it('returns current spacing value', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
|
||||
|
||||
manager.spacingControl!.value = 0.05;
|
||||
expect(manager.spacing).toBe(0.05);
|
||||
});
|
||||
|
||||
it('returns default value when control is not found', () => {
|
||||
// Create a manager with empty configs (no controls)
|
||||
const manager = new TypographyControlManager([], mockPersistentStore);
|
||||
|
||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset Functionality', () => {
|
||||
it('resets all controls to default values', () => {
|
||||
mockStorage = {
|
||||
fontSize: 72,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.8,
|
||||
letterSpacing: 0.05,
|
||||
};
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
// Modify values
|
||||
manager.baseSize = 80;
|
||||
manager.weightControl!.value = 900;
|
||||
manager.heightControl!.value = 2.0;
|
||||
manager.spacingControl!.value = 0.1;
|
||||
|
||||
// Reset
|
||||
manager.reset();
|
||||
|
||||
// Check all values are reset to defaults
|
||||
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
|
||||
expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT);
|
||||
expect(manager.height).toBe(DEFAULT_LINE_HEIGHT);
|
||||
expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING);
|
||||
});
|
||||
|
||||
it('calls storage.clear() on reset', () => {
|
||||
const clearSpy = vi.fn();
|
||||
mockPersistentStore = {
|
||||
get value() {
|
||||
return mockStorage;
|
||||
},
|
||||
set value(v: TypographySettings) {
|
||||
mockStorage = v;
|
||||
},
|
||||
clear: clearSpy,
|
||||
};
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.reset();
|
||||
|
||||
expect(clearSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects multiplier when resetting font size control', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.multiplier = 0.5;
|
||||
manager.baseSize = 80;
|
||||
|
||||
manager.reset();
|
||||
|
||||
// Font size control should show default * multiplier
|
||||
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
|
||||
expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Scenarios', () => {
|
||||
it('handles changing multiplier then modifying baseSize', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
// Change multiplier
|
||||
manager.multiplier = 0.5;
|
||||
expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5);
|
||||
|
||||
// Change baseSize
|
||||
manager.baseSize = 60;
|
||||
expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5
|
||||
expect(manager.baseSize).toBe(60);
|
||||
|
||||
// Change multiplier again
|
||||
manager.multiplier = 1;
|
||||
expect(manager.sizeControl?.value).toBe(60); // 60 * 1
|
||||
expect(manager.baseSize).toBe(60);
|
||||
});
|
||||
|
||||
it('maintains correct renderedSize throughout changes', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
// Initial: 48 * 1 = 48
|
||||
expect(manager.renderedSize).toBe(48);
|
||||
|
||||
// Change baseSize: 60 * 1 = 60
|
||||
manager.baseSize = 60;
|
||||
expect(manager.renderedSize).toBe(60);
|
||||
|
||||
// Change multiplier: 60 * 0.5 = 30
|
||||
manager.multiplier = 0.5;
|
||||
expect(manager.renderedSize).toBe(30);
|
||||
|
||||
// Change baseSize again: 72 * 0.5 = 36
|
||||
manager.baseSize = 72;
|
||||
expect(manager.renderedSize).toBe(36);
|
||||
});
|
||||
|
||||
it('handles multiple control changes in sequence', async () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
// Change multiple controls
|
||||
manager.baseSize = 72;
|
||||
manager.weightControl!.value = 700;
|
||||
manager.heightControl!.value = 1.8;
|
||||
manager.spacingControl!.value = 0.05;
|
||||
|
||||
// After flushing effects, verify all are synced to storage
|
||||
await flushEffects();
|
||||
expect(mockPersistentStore.value.fontSize).toBe(72);
|
||||
expect(mockPersistentStore.value.fontWeight).toBe(700);
|
||||
expect(mockPersistentStore.value.lineHeight).toBe(1.8);
|
||||
expect(mockPersistentStore.value.letterSpacing).toBe(0.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles multiplier of 1 (no change)', () => {
|
||||
mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 };
|
||||
mockPersistentStore = createMockPersistentStore(mockStorage);
|
||||
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.multiplier = 1;
|
||||
|
||||
expect(manager.sizeControl?.value).toBe(48);
|
||||
expect(manager.baseSize).toBe(48);
|
||||
});
|
||||
|
||||
it('handles very small multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.baseSize = 100;
|
||||
manager.multiplier = 0.1;
|
||||
|
||||
expect(manager.sizeControl?.value).toBe(10);
|
||||
expect(manager.renderedSize).toBe(10);
|
||||
});
|
||||
|
||||
it('handles large base size with multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.baseSize = 100;
|
||||
manager.multiplier = 0.75;
|
||||
|
||||
expect(manager.sizeControl?.value).toBe(75);
|
||||
expect(manager.renderedSize).toBe(75);
|
||||
});
|
||||
|
||||
it('handles floating point precision in multiplier', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
manager.baseSize = 48;
|
||||
manager.multiplier = 0.5;
|
||||
|
||||
// 48 * 0.5 = 24 (exact, no rounding needed)
|
||||
expect(manager.sizeControl?.value).toBe(24);
|
||||
expect(manager.renderedSize).toBe(24);
|
||||
|
||||
// 48 * 0.33 = 15.84 -> rounds to 16 (step precision is 1)
|
||||
manager.multiplier = 0.33;
|
||||
expect(manager.sizeControl?.value).toBe(16);
|
||||
expect(manager.renderedSize).toBeCloseTo(15.84);
|
||||
});
|
||||
|
||||
it('handles control methods (increase/decrease)', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
const initialWeight = manager.weight;
|
||||
manager.weightControl!.increase();
|
||||
expect(manager.weight).toBe(initialWeight + 100);
|
||||
|
||||
manager.weightControl!.decrease();
|
||||
expect(manager.weight).toBe(initialWeight);
|
||||
});
|
||||
|
||||
it('handles control boundary conditions', () => {
|
||||
const manager = new TypographyControlManager(
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
mockPersistentStore,
|
||||
);
|
||||
|
||||
const sizeCtrl = manager.sizeControl!;
|
||||
|
||||
// Test min boundary
|
||||
sizeCtrl.value = 5;
|
||||
expect(sizeCtrl.value).toBe(sizeCtrl.min); // Should clamp to MIN_FONT_SIZE (8)
|
||||
|
||||
// Test max boundary
|
||||
sizeCtrl.value = 200;
|
||||
expect(sizeCtrl.value).toBe(sizeCtrl.max); // Should clamp to MAX_FONT_SIZE (100)
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Font Size',
|
||||
controlLabel: 'Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
@@ -54,7 +54,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Font Weight',
|
||||
controlLabel: 'Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
@@ -65,7 +65,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Line Height',
|
||||
controlLabel: 'Leading',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
@@ -76,7 +76,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Letter Spacing',
|
||||
controlLabel: 'Tracking',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
<!--
|
||||
Component: TypographyMenu
|
||||
Provides a menu for selecting and configuring typography settings
|
||||
- On mobile the menu is displayed as a drawer
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import {
|
||||
Content as ItemContent,
|
||||
Root as ItemRoot,
|
||||
} from '$shared/shadcn/ui/item';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
ComboControlV2,
|
||||
Drawer,
|
||||
IconButton,
|
||||
} from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { crossfade } from 'svelte/transition';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
controlManager,
|
||||
} from '../model';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const { class: className, hidden = false }: Props = $props();
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
const [send, receive] = crossfade({
|
||||
duration: 300,
|
||||
easing: cubicOut,
|
||||
fallback(node, params) {
|
||||
// If it can't find a pair, it falls back to a simple fade/slide
|
||||
return {
|
||||
duration: 300,
|
||||
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the common font size multiplier based on the current responsive state.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!responsive) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
controlManager.multiplier = MULTIPLIER_S;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
controlManager.multiplier = MULTIPLIER_M;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
default:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'w-auto max-screen z-10 flex justify-center',
|
||||
hidden && 'hidden',
|
||||
className,
|
||||
)}
|
||||
in:receive={{ key: 'panel' }}
|
||||
out:send={{ key: 'panel' }}
|
||||
>
|
||||
{#if responsive.isMobile}
|
||||
<Drawer>
|
||||
{#snippet trigger({ onClick })}
|
||||
<IconButton onclick={onClick}>
|
||||
{#snippet icon({ className })}
|
||||
<SlidersIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<Label
|
||||
class="mt-6 mb-12 px-2"
|
||||
text="Typography Controls"
|
||||
align="center"
|
||||
/>
|
||||
<div class={cn(className, 'flex flex-col gap-8')}>
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControlV2
|
||||
control={control.instance}
|
||||
orientation="horizontal"
|
||||
label={control.controlLabel}
|
||||
reduced
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
{:else}
|
||||
<ItemRoot
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
|
||||
>
|
||||
<ItemContent class="flex flex-row justify-center items-center max-w-full sm:max-w-max">
|
||||
<div class="sm:py-2 sm:px-10 flex flex-row items-center gap-2">
|
||||
<div class="flex flex-row gap-3">
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControlV2
|
||||
control={control.instance}
|
||||
increaseLabel={control.increaseLabel}
|
||||
decreaseLabel={control.decreaseLabel}
|
||||
controlLabel={control.controlLabel}
|
||||
orientation="vertical"
|
||||
showScale={false}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</ItemRoot>
|
||||
{/if}
|
||||
</div>
|
||||
193
src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte
Normal file
193
src/features/SetupFont/ui/TypographyMenu/TypographyMenu.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<!--
|
||||
Component: TypographyMenu
|
||||
Floating controls bar for typography settings.
|
||||
Warm surface, sharp corners, Settings icon header, dividers between units.
|
||||
Mobile: popover with slider controls anchored to settings button.
|
||||
Desktop: inline bar with combo controls.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Button,
|
||||
ComboControl,
|
||||
ControlGroup,
|
||||
Slider,
|
||||
} from '$shared/ui';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Popover } from 'bits-ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
controlManager,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Hidden state
|
||||
* @default false
|
||||
*/
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const { class: className, hidden = false }: Props = $props();
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
/**
|
||||
* Sets the common font size multiplier based on the current responsive state.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!responsive) return;
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
controlManager.multiplier = MULTIPLIER_S;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
controlManager.multiplier = MULTIPLIER_M;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
default:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !hidden}
|
||||
{#if responsive.isMobile}
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={cn(
|
||||
'inline-flex items-center justify-center',
|
||||
'size-8 p-0',
|
||||
'border border-transparent rounded-none',
|
||||
'transition-colors duration-150',
|
||||
'hover:bg-white/50 dark:hover:bg-white/5',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
||||
isOpen && 'bg-paper dark:bg-dark-card border-black/5 dark:border-white/10 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Settings2Icon class="size-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
class={cn(
|
||||
'z-50 w-72',
|
||||
'bg-surface dark:bg-dark-card',
|
||||
'border border-black/5 dark:border-white/10',
|
||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
|
||||
'rounded-none p-4',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=top]:slide-in-from-bottom-2',
|
||||
'data-[side=bottom]:slide-in-from-top-2',
|
||||
)}
|
||||
interactOutsideBehavior="close"
|
||||
escapeKeydownBehavior="close"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Settings2Icon size={12} class="text-swiss-red" />
|
||||
<span
|
||||
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
|
||||
>
|
||||
CONTROLS
|
||||
</span>
|
||||
</div>
|
||||
<Popover.Close>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="inline-flex items-center justify-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="Close controls"
|
||||
>
|
||||
<XIcon class="size-3.5 text-neutral-500" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Popover.Close>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ControlGroup label={control.controlLabel ?? ''}>
|
||||
<Slider
|
||||
bind:value={control.instance.value}
|
||||
min={control.instance.min}
|
||||
max={control.instance.max}
|
||||
step={control.instance.step}
|
||||
/>
|
||||
</ControlGroup>
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
{:else}
|
||||
<div
|
||||
class={cn('w-full md:w-auto', className)}
|
||||
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
<div
|
||||
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-black/5 dark:border-white/10',
|
||||
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
||||
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
||||
)}
|
||||
>
|
||||
<!-- Header: icon + label -->
|
||||
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
||||
<Settings2Icon
|
||||
size={14}
|
||||
class="text-swiss-red"
|
||||
/>
|
||||
<span
|
||||
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
||||
>
|
||||
GLOBAL_CONTROLS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Controls with dividers between each -->
|
||||
{#each controlManager.controls as control, i (control.id)}
|
||||
{#if i > 0}
|
||||
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<ComboControl
|
||||
control={control.instance}
|
||||
label={control.controlLabel}
|
||||
increaseLabel={control.increaseLabel}
|
||||
decreaseLabel={control.decreaseLabel}
|
||||
controlLabel={control.controlLabel}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1 +1 @@
|
||||
export { default as TypographyMenu } from './TypographyMenu.svelte';
|
||||
export { default as TypographyMenu } from './TypographyMenu/TypographyMenu.svelte';
|
||||
|
||||
@@ -4,149 +4,22 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Logo,
|
||||
Section,
|
||||
} from '$shared/ui';
|
||||
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||
import { FontSearch } from '$widgets/FontSearch';
|
||||
import { SampleList } from '$widgets/SampleList';
|
||||
import CodeIcon from '@lucide/svelte/icons/code';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
|
||||
import ScanSearchIcon from '@lucide/svelte/icons/search';
|
||||
import {
|
||||
type Snippet,
|
||||
getContext,
|
||||
} from 'svelte';
|
||||
import { ComparisonView } from '$widgets/ComparisonView';
|
||||
import { FontSearchSection } from '$widgets/FontSearch';
|
||||
import { SampleListSection } from '$widgets/SampleList';
|
||||
import { cubicIn } from 'svelte/easing';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let searchContainer: HTMLElement;
|
||||
|
||||
let isExpanded = $state(true);
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
function handleTitleStatusChanged(
|
||||
index: number,
|
||||
isPast: boolean,
|
||||
title?: Snippet<[{ className?: string }]>,
|
||||
id?: string,
|
||||
) {
|
||||
if (isPast && title) {
|
||||
scrollBreadcrumbsStore.add({ index, title, id });
|
||||
} else {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
}
|
||||
|
||||
return () => {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Font List -->
|
||||
<div
|
||||
class="p-2 sm:p-3 md:p-4 h-full grid gap-3 sm:gap-4 grid-cols-[max-content_1fr]"
|
||||
class="h-full flex flex-col gap-3 sm:gap-4"
|
||||
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||
>
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<CodeIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet description({ className })}
|
||||
<span class={className}> Project_Codename </span>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, 'col-start-0 col-span-2')}>
|
||||
<Logo />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={1}
|
||||
id="optical_comparator"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<EyeIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h1 class={className}>
|
||||
Optical<br />Comparator
|
||||
</h1>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<ComparisonSlider />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={2}
|
||||
id="query_module"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<ScanSearchIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h2 class={className}>
|
||||
Query<br />Module
|
||||
</h2>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={3}
|
||||
id="sample_set"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<LineSquiggleIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h2 class={className}>
|
||||
Sample<br />Set
|
||||
</h2>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<SampleList />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
<section class="w-auto">
|
||||
<ComparisonView />
|
||||
</section>
|
||||
<main class="w-full pt-0 pb-10 sm:px-6 sm:pt-16 sm:pb-12 md:px-8 md:pt-32 md:pb-16 lg:px-10 lg:pt-48 lg:pb-20 xl:px-16">
|
||||
<FontSearchSection />
|
||||
<SampleListSection index={1} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
/* Tells the browser to skip rendering off-screen content */
|
||||
content-visibility: auto;
|
||||
/* Helps the browser reserve space without calculating everything */
|
||||
contain-intrinsic-size: 1px 1000px;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user