Compare commits
89 Commits
30bbfa7e11
...
9c6ff3859a
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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 type { Preview } from '@storybook/svelte-vite';
|
||||||
import Decorator from './Decorator.svelte';
|
import Decorator from './Decorator.svelte';
|
||||||
import StoryStage from './StoryStage.svelte';
|
import StoryStage from './StoryStage.svelte';
|
||||||
|
import ThemeDecorator from './ThemeDecorator.svelte';
|
||||||
import '../src/app/styles/app.css';
|
import '../src/app/styles/app.css';
|
||||||
|
|
||||||
const preview: Preview = {
|
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: {
|
parameters: {
|
||||||
layout: 'fullscreen',
|
layout: 'padded',
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
@@ -23,7 +86,79 @@ const preview: Preview = {
|
|||||||
docs: {
|
docs: {
|
||||||
story: {
|
story: {
|
||||||
// This sets the default height for the iframe in Autodocs
|
// This sets the default height for the iframe in Autodocs
|
||||||
iframeHeight: '400px',
|
iframeHeight: '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: [
|
decorators: [
|
||||||
|
// Outermost: initialize ThemeManager for all stories
|
||||||
|
story => ({
|
||||||
|
Component: ThemeDecorator,
|
||||||
|
props: {
|
||||||
|
children: story(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
// Wrap with providers (TooltipProvider, ResponsiveManager)
|
// Wrap with providers (TooltipProvider, ResponsiveManager)
|
||||||
story => ({
|
story => ({
|
||||||
Component: Decorator,
|
Component: Decorator,
|
||||||
|
|||||||
@@ -61,7 +61,6 @@
|
|||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vaul-svelte": "^1.0.0-next.7",
|
|
||||||
"vite": "^7.2.6",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
<!--
|
||||||
|
Component: App
|
||||||
|
Application root with query provider and layout
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* App Component
|
* App Component
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { QueryClientProvider } from '@tanstack/svelte-query';
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Content snippet
|
||||||
|
*/
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +1,157 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
/* Base font size */
|
||||||
--background: oklch(1 0 0);
|
--font-size: 16px;
|
||||||
--foreground: oklch(0.141 0.005 285.823);
|
|
||||||
--card: oklch(1 0 0);
|
/* GLYPHDIFF Swiss Design System */
|
||||||
--card-foreground: oklch(0.141 0.005 285.823);
|
/* 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: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.21 0.006 285.885);
|
--primary: #030213;
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(1 0 0);
|
||||||
--secondary: oklch(0.967 0.001 286.375);
|
--secondary: oklch(0.95 0.0058 264.53);
|
||||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
--secondary-foreground: #030213;
|
||||||
--muted: oklch(0.967 0.001 286.375);
|
--muted: #ececf0;
|
||||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
--muted-foreground: #717182;
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--accent: #e9ebef;
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--accent-foreground: #030213;
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: #d4183d;
|
||||||
--border: oklch(0.92 0.004 286.32);
|
--destructive-foreground: #ffffff;
|
||||||
--input: oklch(0.92 0.004 286.32);
|
--border: rgba(0, 0, 0, 0.1);
|
||||||
--ring: oklch(0.705 0.015 286.067);
|
--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-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--radius: 0rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
--sidebar-primary: #030213;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
|
||||||
--background-20: oklch(1 0 0 / 20%);
|
/* Spacing Scale (rem-based) */
|
||||||
--background-40: oklch(1 0 0 / 40%);
|
--space-xs: 0.25rem;
|
||||||
--background-60: oklch(1 0 0 / 60%);
|
--space-sm: 0.5rem;
|
||||||
--background-80: oklch(1 0 0 / 80%);
|
--space-md: 0.75rem;
|
||||||
--background-95: oklch(1 0 0 / 95%);
|
--space-lg: 1rem;
|
||||||
--background-subtle: oklch(0.98 0 0);
|
--space-xl: 1.5rem;
|
||||||
--background-muted: oklch(0.97 0.002 286.375);
|
--space-2xl: 2rem;
|
||||||
|
--space-3xl: 3rem;
|
||||||
|
--space-4xl: 4rem;
|
||||||
|
|
||||||
--text-muted: oklch(0.552 0.016 285.938);
|
/* Typography Scale */
|
||||||
--text-subtle: oklch(0.705 0.015 286.067);
|
--text-2xs: 0.625rem;
|
||||||
--text-soft: oklch(0.5 0.01 286);
|
--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);
|
/* Comparison Font Sizes */
|
||||||
--border-muted: oklch(0.92 0.004 286.32);
|
--comparison-font-mobile: 3rem;
|
||||||
--border-soft: oklch(0.88 0.005 286.32);
|
--comparison-font-tablet: 4.5rem;
|
||||||
|
--comparison-font-desktop: 6rem;
|
||||||
--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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.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);
|
--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);
|
--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);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.92 0.004 286.32);
|
--primary: oklch(0.985 0 0);
|
||||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.274 0.006 286.033);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.274 0.006 286.033);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.274 0.006 286.033);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--border: oklch(0.269 0 0);
|
||||||
--ring: oklch(0.552 0.016 285.938);
|
--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-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--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-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--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-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
|
|
||||||
--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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme {
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
@@ -135,14 +167,21 @@
|
|||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
|
--color-input-background: var(--input-background);
|
||||||
|
--color-switch-background: var(--switch-background);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
|
--radius-sm: 0rem;
|
||||||
|
--radius-md: 0rem;
|
||||||
|
--radius-lg: 0rem;
|
||||||
|
--radius-xl: 0rem;
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
@@ -151,35 +190,79 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-background-20: var(--background-20);
|
|
||||||
--color-background-40: var(--background-40);
|
--color-swiss-beige: var(--swiss-beige);
|
||||||
--color-background-60: var(--background-60);
|
--color-swiss-red: var(--swiss-red);
|
||||||
--color-background-80: var(--background-80);
|
--color-swiss-black: var(--swiss-black);
|
||||||
--color-background-95: var(--background-95);
|
--color-swiss-white: var(--swiss-white);
|
||||||
--color-background-subtle: var(--background-subtle);
|
--color-brand: var(--color-brand);
|
||||||
--color-background-muted: var(--background-muted);
|
--color-surface: var(--color-surface);
|
||||||
--color-text-muted: var(--text-muted);
|
--color-paper: var(--color-paper);
|
||||||
--color-text-subtle: var(--text-subtle);
|
--color-dark-bg: var(--dark-bg);
|
||||||
--color-text-soft: var(--text-soft);
|
--color-dark-card: var(--dark-card);
|
||||||
--color-border-subtle: var(--border-subtle);
|
|
||||||
--color-border-muted: var(--border-muted);
|
--font-logo: 'Syne', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||||
--color-border-soft: var(--border-soft);
|
--font-mono: 'Space Mono', monospace;
|
||||||
--color-gradient-from: var(--gradient-from);
|
--font-primary: 'Space Grotesk', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif;
|
||||||
--color-gradient-via: var(--gradient-via);
|
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||||
font-optical-sizing: auto;
|
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 */
|
/* Global utility - useful across your app */
|
||||||
@@ -223,40 +306,41 @@
|
|||||||
animation: nudge 10s ease-in-out infinite;
|
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-width: thin;
|
||||||
scrollbar-color: hsl(0 0% 70% / 0.4) transparent;
|
scrollbar-color: hsl(0 0% 70% / 0.4) var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark * {
|
.dark * {
|
||||||
scrollbar-color: hsl(0 0% 40% / 0.5) transparent;
|
scrollbar-color: hsl(0 0% 40% / 0.5) var(--color-surface);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Webkit / Blink ---- */
|
/* ---- 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 {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-button {
|
||||||
|
display: none; /* kills arrows */
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-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);
|
background: hsl(0 0% 70% / 0.4);
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
@@ -268,40 +352,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-corner {
|
::-webkit-scrollbar-corner {
|
||||||
background: transparent;
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode */
|
.dark ::-webkit-scrollbar-thumb { background: hsl(0 0% 40% / 0.5); }
|
||||||
.dark ::-webkit-scrollbar-thumb {
|
.dark ::-webkit-scrollbar-thumb:hover { background: hsl(0 0% 55% / 0.6); }
|
||||||
background: hsl(0 0% 40% / 0);
|
.dark ::-webkit-scrollbar-thumb:active { background: hsl(0 0% 65% / 0.7); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark :hover > ::-webkit-scrollbar-thumb,
|
html {
|
||||||
.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 ---- */
|
|
||||||
* {
|
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scrollbar-gutter: stable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html {
|
html { scroll-behavior: auto; }
|
||||||
scroll-behavior: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overscroll-behavior-y: none;
|
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">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Layout Component
|
* Layout Component
|
||||||
@@ -11,44 +15,30 @@
|
|||||||
* - Footer area (currently empty, reserved for future use)
|
* - Footer area (currently empty, reserved for future use)
|
||||||
*/
|
*/
|
||||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||||
|
import { themeManager } from '$features/ChangeAppTheme';
|
||||||
import GD from '$shared/assets/GD.svg';
|
import GD from '$shared/assets/GD.svg';
|
||||||
import { ResponsiveProvider } from '$shared/lib';
|
import { ResponsiveProvider } from '$shared/lib';
|
||||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
|
||||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import {
|
import {
|
||||||
type Snippet,
|
type Snippet,
|
||||||
|
onDestroy,
|
||||||
onMount,
|
onMount,
|
||||||
} from 'svelte';
|
} from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Content snippet
|
||||||
|
*/
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children }: Props = $props();
|
let { children }: Props = $props();
|
||||||
let fontsReady = $state(false);
|
let fontsReady = $state(true);
|
||||||
|
const theme = $derived(themeManager.value);
|
||||||
|
|
||||||
/**
|
onMount(() => themeManager.init());
|
||||||
* Sets fontsReady flag to true when font for the page logo is loaded.
|
onDestroy(() => themeManager.destroy());
|
||||||
*/
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -70,37 +60,43 @@ onMount(async () => {
|
|||||||
<link
|
<link
|
||||||
rel="preload"
|
rel="preload"
|
||||||
as="style"
|
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
|
<link
|
||||||
rel="stylesheet"
|
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"
|
media="print"
|
||||||
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
||||||
/>
|
/>
|
||||||
<noscript>
|
<noscript>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
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>
|
</noscript>
|
||||||
<title>Compare Typography & Typefaces | GlyphDiff</title>
|
<title>GlyphDiff | Typography & Typefaces</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<ResponsiveProvider>
|
<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>
|
<header>
|
||||||
<BreadcrumbHeader />
|
<BreadcrumbHeader />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
<!-- <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">
|
<!-- <main class="flex-1 w-full mx-auto relative"> -->
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{#if fontsReady}
|
{#if fontsReady}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</main>
|
<!-- </main> -->
|
||||||
<!-- </ScrollArea> -->
|
<!-- </ScrollArea> -->
|
||||||
<footer></footer>
|
<footer></footer>
|
||||||
</div>
|
</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 './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 {
|
export interface BreadcrumbItem {
|
||||||
/**
|
/** Unique index for ordering */
|
||||||
* Index of the item to display
|
|
||||||
*/
|
|
||||||
index: number;
|
index: number;
|
||||||
/**
|
/** Display title for the breadcrumb */
|
||||||
* ID of the item to navigate to
|
title: string;
|
||||||
*/
|
/** DOM element to track */
|
||||||
id?: string;
|
element: HTMLElement;
|
||||||
/**
|
|
||||||
* Title snippet to render
|
|
||||||
*/
|
|
||||||
title: Snippet<[{ className?: string }]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
class ScrollBreadcrumbsStore {
|
||||||
|
/** All tracked breadcrumb items */
|
||||||
#items = $state<BreadcrumbItem[]>([]);
|
#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
|
* Updates scroll direction based on current position
|
||||||
return this.#items.sort((a, b) => a.index - b.index);
|
*/
|
||||||
|
#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)) {
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.#items.push(item);
|
||||||
|
this.#attachScrollListener();
|
||||||
|
this.#initObserver();
|
||||||
|
// Initialize scroll direction
|
||||||
|
this.#prevScrollY = window.scrollY;
|
||||||
|
this.#observer?.observe(item.element);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
remove(index: number) {
|
/**
|
||||||
|
* 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);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createScrollBreadcrumbsStore() {
|
/**
|
||||||
|
* 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new scroll breadcrumbs store instance
|
||||||
|
*/
|
||||||
|
export function createScrollBreadcrumbsStore(): ScrollBreadcrumbsStore {
|
||||||
return new ScrollBreadcrumbsStore();
|
return new ScrollBreadcrumbsStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton scroll breadcrumbs store instance
|
||||||
|
*/
|
||||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
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
|
Fixed header for breadcrumbs navigation for sections in the page
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { smoothScroll } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import {
|
import {
|
||||||
fly,
|
Button,
|
||||||
slide,
|
Label,
|
||||||
} from 'svelte/transition';
|
Logo,
|
||||||
import { scrollBreadcrumbsStore } from '../../model';
|
} 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>
|
</script>
|
||||||
|
|
||||||
{#if scrollBreadcrumbsStore.items.length > 0}
|
{#if breadcrumbs.length > 0}
|
||||||
<div
|
<div
|
||||||
transition:slide={{ duration: 200 }}
|
transition:slide={{ duration: 200 }}
|
||||||
class="
|
class="
|
||||||
fixed top-0 left-0 right-0 z-100
|
fixed top-0 left-0 right-0
|
||||||
backdrop-blur-lg bg-background-20
|
h-14
|
||||||
border-b border-border-muted
|
md:h-16 px-4 md:px-6 lg:px-8
|
||||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
flex items-center justify-between
|
||||||
h-10 sm:h-12
|
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">
|
<div class="max-w-8xl px-4 sm:px-6 h-full w-full flex items-center justify-between gap-2 sm:gap-4">
|
||||||
<h1 class={cn('barlow font-extralight text-sm sm:text-base')}>
|
<Logo />
|
||||||
GLYPHDIFF
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
|
<nav class="flex items-center overflow-x-auto scrollbar-hide">
|
||||||
|
{#each breadcrumbs as item, _ (item.index)}
|
||||||
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
|
{@const active = scrollBreadcrumbsStore.activeIndex === item.index}
|
||||||
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
{@const text = createButtonText(item)}
|
||||||
<div
|
<div class="ml-1 md:ml-4" transition:slide={{ duration: 200, axis: 'x', easing: cubicOut }}>
|
||||||
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
|
<Button
|
||||||
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
|
class="uppercase"
|
||||||
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
|
variant="tertiary"
|
||||||
|
size="xs"
|
||||||
|
{active}
|
||||||
|
onclick={() => handleClick(item)}
|
||||||
>
|
>
|
||||||
<span class="font-mono text-[8px] sm:text-[9px] text-text-muted tracking-wider">
|
<Label class="text-inherit">
|
||||||
{String(item.index).padStart(2, '0')}
|
{text}
|
||||||
</span>
|
</Label>
|
||||||
<a href={`#${item.id}`} use:smoothScroll>
|
</Button>
|
||||||
{@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}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 { default as BreadcrumbHeader } from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||||
|
export { default as NavigationWrapper } from './NavigationWrapper/NavigationWrapper.svelte';
|
||||||
export { BreadcrumbHeader };
|
|
||||||
|
|||||||
@@ -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
|
* Exports API clients and normalization utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Proxy API (PRIMARY - NEW)
|
// Proxy API (primary)
|
||||||
export {
|
export {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
@@ -14,25 +14,3 @@ export type {
|
|||||||
ProxyFontsParams,
|
ProxyFontsParams,
|
||||||
ProxyFontsResponse,
|
ProxyFontsResponse,
|
||||||
} from './proxy/proxyFonts';
|
} 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
|
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
|
||||||
* unified format, eliminating the need for client-side normalization.
|
* 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
|
* @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;
|
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
|
* Proxy API parameters
|
||||||
*
|
*
|
||||||
* Maps directly to the proxy API query parameters
|
* Maps directly to the proxy API query parameters
|
||||||
|
*
|
||||||
|
* UPDATED: Now supports array values for filters
|
||||||
*/
|
*/
|
||||||
export interface ProxyFontsParams extends QueryParams {
|
export interface ProxyFontsParams extends QueryParams {
|
||||||
/**
|
/**
|
||||||
* Font provider filter ("google" or "fontshare")
|
* Font provider filter
|
||||||
* Omit to fetch from both providers
|
*
|
||||||
|
* NEW: Supports array of providers (e.g., ["google", "fontshare"])
|
||||||
|
* Backward compatible: Single value still works
|
||||||
*/
|
*/
|
||||||
provider?: 'google' | 'fontshare';
|
providers?: string[] | string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font category filter
|
* 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
|
* 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")
|
* Search query (e.g., "roboto", "satoshi")
|
||||||
@@ -108,8 +103,6 @@ export interface ProxyFontsResponse {
|
|||||||
/**
|
/**
|
||||||
* Fetch fonts from proxy API
|
* 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
|
* @param params - Query parameters for filtering and pagination
|
||||||
* @returns Promise resolving to proxy API response
|
* @returns Promise resolving to proxy API response
|
||||||
* @throws ApiError when request fails
|
* @throws ApiError when request fails
|
||||||
@@ -138,84 +131,16 @@ export interface ProxyFontsResponse {
|
|||||||
export async function fetchProxyFonts(
|
export async function fetchProxyFonts(
|
||||||
params: ProxyFontsParams = {},
|
params: ProxyFontsParams = {},
|
||||||
): Promise<ProxyFontsResponse> {
|
): Promise<ProxyFontsResponse> {
|
||||||
// Try proxy API first if enabled
|
|
||||||
if (USE_PROXY_API) {
|
|
||||||
try {
|
|
||||||
const queryString = buildQueryString(params);
|
const queryString = buildQueryString(params);
|
||||||
const url = `${PROXY_API_URL}${queryString}`;
|
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)) {
|
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');
|
throw new Error('Proxy API returned invalid response');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[fetchProxyFonts] Proxy API success', {
|
|
||||||
count: response.data.fonts.length,
|
|
||||||
});
|
|
||||||
return response.data;
|
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)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -256,24 +181,9 @@ export async function fetchProxyFontById(
|
|||||||
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
// Use proxy API if enabled
|
|
||||||
if (USE_PROXY_API) {
|
|
||||||
const queryString = ids.join(',');
|
const queryString = ids.join(',');
|
||||||
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.get<UnifiedFont[]>(url);
|
const response = await api.get<UnifiedFont[]>(url);
|
||||||
return response.data ?? [];
|
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Proxy API (PRIMARY)
|
// Proxy API (primary)
|
||||||
export {
|
export {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
@@ -9,32 +9,9 @@ export type {
|
|||||||
ProxyFontsResponse,
|
ProxyFontsResponse,
|
||||||
} from './api/proxy/proxyFonts';
|
} 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 {
|
export {
|
||||||
normalizeFontshareFont,
|
normalizeFontshareFont,
|
||||||
normalizeFontshareFonts,
|
normalizeFontshareFonts,
|
||||||
normalizeGoogleFont,
|
|
||||||
normalizeGoogleFonts,
|
|
||||||
} from './lib/normalize/normalize';
|
} from './lib/normalize/normalize';
|
||||||
export type {
|
export type {
|
||||||
// Domain types
|
// Domain types
|
||||||
@@ -65,8 +42,6 @@ export type {
|
|||||||
FontVariant,
|
FontVariant,
|
||||||
FontWeight,
|
FontWeight,
|
||||||
FontWeightItalic,
|
FontWeightItalic,
|
||||||
// Google Fonts API types
|
|
||||||
GoogleFontsApiModel,
|
|
||||||
// Normalization types
|
// Normalization types
|
||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
UnifiedFontVariant,
|
UnifiedFontVariant,
|
||||||
@@ -131,6 +106,5 @@ export {
|
|||||||
// UI elements
|
// UI elements
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
FontListItem,
|
|
||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
} from './ui';
|
} from './ui';
|
||||||
|
|||||||
@@ -3,13 +3,31 @@ import type {
|
|||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
|
/** Valid font weight values (100-900 in increments of 100) */
|
||||||
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a URL for a font based on the provided font and weight.
|
* Gets the URL for a font file at a specific weight
|
||||||
* @param font - The font object.
|
*
|
||||||
* @param weight - The weight of the font.
|
* Constructs the appropriate URL for loading a font file based on
|
||||||
* @returns The URL for the font.
|
* 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 {
|
export function getFontUrl(font: UnifiedFont, weight: number): string | undefined {
|
||||||
if (!SIZES.includes(weight)) {
|
if (!SIZES.includes(weight)) {
|
||||||
@@ -18,12 +36,11 @@ export function getFontUrl(font: UnifiedFont, weight: number): string | undefine
|
|||||||
|
|
||||||
const weightKey = weight.toString() as FontWeight;
|
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]) {
|
if (font.styles.variants?.[weightKey]) {
|
||||||
return font.styles.variants[weightKey];
|
return font.styles.variants[weightKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallbacks for Static Fonts (if exact weight missing)
|
// Fallbacks for static fonts when exact weight is missing
|
||||||
// Try 'regular' or '400' as safe defaults
|
|
||||||
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
|
return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Mock font filter data
|
||||||
* MOCK FONT FILTER DATA
|
|
||||||
* ============================================================================
|
|
||||||
*
|
*
|
||||||
* Factory functions and preset mock data for font-related filters.
|
* Factory functions and preset mock data for font-related filters.
|
||||||
* Used in Storybook stories for font filtering components.
|
* Used in Storybook stories for font filtering components.
|
||||||
@@ -36,9 +34,7 @@ import type {
|
|||||||
import type { Property } from '$shared/lib';
|
import type { Property } from '$shared/lib';
|
||||||
import { createFilter } from '$shared/lib';
|
import { createFilter } from '$shared/lib';
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TYPE DEFINITIONS
|
// TYPE DEFINITIONS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating a mock filter
|
* Options for creating a mock filter
|
||||||
@@ -60,9 +56,7 @@ export interface MockFilters {
|
|||||||
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
subsets: ReturnType<typeof createFilter<FontSubset>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FONT CATEGORIES
|
// FONT CATEGORIES
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google Fonts categories
|
* Google Fonts categories
|
||||||
@@ -98,9 +92,7 @@ export const UNIFIED_CATEGORIES: Property<FontCategory>[] = [
|
|||||||
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
{ id: 'monospace', name: 'Monospace', value: 'monospace' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FONT SUBSETS
|
// FONT SUBSETS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common font subsets
|
* Common font subsets
|
||||||
@@ -114,9 +106,7 @@ export const FONT_SUBSETS: Property<FontSubset>[] = [
|
|||||||
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
{ id: 'devanagari', name: 'Devanagari', value: 'devanagari' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FONT PROVIDERS
|
// FONT PROVIDERS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font providers
|
* Font providers
|
||||||
@@ -126,9 +116,7 @@ export const FONT_PROVIDERS: Property<FontProvider>[] = [
|
|||||||
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
{ id: 'fontshare', name: 'Fontshare', value: 'fontshare' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FILTER FACTORIES
|
// FILTER FACTORIES
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock filter from properties
|
* Create a mock filter from properties
|
||||||
@@ -172,9 +160,7 @@ export function createProvidersFilter(options?: { selected?: FontProvider[] }) {
|
|||||||
return createFilter<FontProvider>({ properties });
|
return createFilter<FontProvider>({ properties });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PRESET FILTERS
|
// PRESET FILTERS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Preset mock filters - use these directly in stories
|
* Preset mock filters - use these directly in stories
|
||||||
@@ -251,9 +237,7 @@ export const MOCK_FILTERS_ALL_SELECTED: MockFilters = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// GENERIC FILTER MOCKS
|
// GENERIC FILTER MOCKS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock filter with generic string properties
|
* Create a mock filter with generic string properties
|
||||||
|
|||||||
@@ -50,9 +50,7 @@ import type {
|
|||||||
UnifiedFont,
|
UnifiedFont,
|
||||||
} from '$entities/Font/model/types';
|
} from '$entities/Font/model/types';
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// GOOGLE FONTS MOCKS
|
// GOOGLE FONTS MOCKS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating a mock Google Font
|
* Options for creating a mock Google Font
|
||||||
@@ -186,9 +184,7 @@ export const GOOGLE_FONTS: Record<string, GoogleFontItem> = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FONTHARE MOCKS
|
// FONTHARE MOCKS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating a mock Fontshare font
|
* Options for creating a mock Fontshare font
|
||||||
@@ -399,9 +395,7 @@ export const FONTHARE_FONTS: Record<string, FontshareFont> = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// UNIFIED FONT MOCKS
|
// UNIFIED FONT MOCKS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating a mock UnifiedFont
|
* Options for creating a mock UnifiedFont
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ import {
|
|||||||
generateMockFonts,
|
generateMockFonts,
|
||||||
} from './fonts.mock';
|
} from './fonts.mock';
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TANSTACK QUERY MOCK TYPES
|
// TANSTACK QUERY MOCK TYPES
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock TanStack Query state
|
* Mock TanStack Query state
|
||||||
@@ -83,9 +81,7 @@ export interface MockQueryObserverResult<TData = unknown, TError = Error> {
|
|||||||
isPaused?: boolean;
|
isPaused?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// TANSTACK QUERY MOCK FACTORIES
|
// TANSTACK QUERY MOCK FACTORIES
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock query state for TanStack Query
|
* 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 });
|
return createMockQueryState<TData>({ status: 'success', data, error: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FONT STORE MOCKS
|
// FONT STORE MOCKS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mock UnifiedFontStore state
|
* Mock UnifiedFontStore state
|
||||||
@@ -332,9 +326,7 @@ export const MOCK_FONT_STORE_STATES = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MOCK STORE OBJECT
|
// MOCK STORE OBJECT
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a mock store object that mimics TanStack Query behavior
|
* Create a mock store object that mimics TanStack Query behavior
|
||||||
@@ -469,9 +461,7 @@ export const MOCK_STORES = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// REACTIVE STATE MOCKS
|
// REACTIVE STATE MOCKS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a reactive state object using Svelte 5 runes pattern
|
* Create a reactive state object using Svelte 5 runes pattern
|
||||||
@@ -525,9 +515,7 @@ export function createMockComparisonStore(config: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// MOCK DATA GENERATORS
|
// MOCK DATA GENERATORS
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate paginated font data
|
* Generate paginated font data
|
||||||
|
|||||||
@@ -7,41 +7,64 @@ import {
|
|||||||
} from '@tanstack/query-core';
|
} from '@tanstack/query-core';
|
||||||
import type { UnifiedFont } from '../types';
|
import type { UnifiedFont } from '../types';
|
||||||
|
|
||||||
/** */
|
/**
|
||||||
|
* Base class for font stores using TanStack Query
|
||||||
|
*
|
||||||
|
* Provides reactive font data fetching with caching, automatic refetching,
|
||||||
|
* and parameter binding. Extended by UnifiedFontStore for provider-agnostic
|
||||||
|
* font fetching.
|
||||||
|
*
|
||||||
|
* @template TParams - Type of query parameters
|
||||||
|
*/
|
||||||
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||||
|
/**
|
||||||
|
* Cleanup function for effects
|
||||||
|
* Call destroy() to remove effects and prevent memory leaks
|
||||||
|
*/
|
||||||
cleanup: () => void;
|
cleanup: () => void;
|
||||||
|
|
||||||
|
/** Reactive parameter bindings from external sources */
|
||||||
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
#bindings = $state<(() => Partial<TParams>)[]>([]);
|
||||||
|
/** Internal parameter state */
|
||||||
#internalParams = $state<TParams>({} as TParams);
|
#internalParams = $state<TParams>({} as TParams);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merged params from internal state and all bindings
|
||||||
|
* Automatically updates when bindings or internal params change
|
||||||
|
*/
|
||||||
params = $derived.by(() => {
|
params = $derived.by(() => {
|
||||||
let merged = { ...this.#internalParams };
|
let merged = { ...this.#internalParams };
|
||||||
|
|
||||||
// Loop through every "Cable" plugged into the store
|
// Merge all binding results into params
|
||||||
// Loop through every "Cable" plugged into the store
|
|
||||||
for (const getter of this.#bindings) {
|
for (const getter of this.#bindings) {
|
||||||
const bindingResult = getter();
|
const bindingResult = getter();
|
||||||
merged = { ...merged, ...bindingResult };
|
merged = { ...merged, ...bindingResult };
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged as TParams;
|
return merged as TParams;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** TanStack Query result state */
|
||||||
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
protected result = $state<QueryObserverResult<UnifiedFont[], Error>>({} as any);
|
||||||
|
/** TanStack Query observer instance */
|
||||||
protected observer: QueryObserver<UnifiedFont[], Error>;
|
protected observer: QueryObserver<UnifiedFont[], Error>;
|
||||||
|
/** Shared query client */
|
||||||
protected qc = queryClient;
|
protected qc = queryClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new base font store
|
||||||
|
* @param initialParams - Initial query parameters
|
||||||
|
*/
|
||||||
constructor(initialParams: TParams) {
|
constructor(initialParams: TParams) {
|
||||||
this.#internalParams = initialParams;
|
this.#internalParams = initialParams;
|
||||||
|
|
||||||
this.observer = new QueryObserver(this.qc, this.getOptions());
|
this.observer = new QueryObserver(this.qc, this.getOptions());
|
||||||
|
|
||||||
// Sync TanStack -> Svelte State
|
// Sync TanStack Query state -> Svelte state
|
||||||
this.observer.subscribe(r => {
|
this.observer.subscribe(r => {
|
||||||
this.result = r;
|
this.result = r;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync Svelte State -> TanStack Options
|
// Sync Svelte state changes -> TanStack Query options
|
||||||
this.cleanup = $effect.root(() => {
|
this.cleanup = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
this.observer.setOptions(this.getOptions());
|
this.observer.setOptions(this.getOptions());
|
||||||
@@ -50,11 +73,21 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mandatory: Child must define how to fetch data and what the key is.
|
* Must be implemented by child class
|
||||||
|
* Returns the query key for TanStack Query caching
|
||||||
*/
|
*/
|
||||||
protected abstract getQueryKey(params: TParams): QueryKey;
|
protected abstract getQueryKey(params: TParams): QueryKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must be implemented by child class
|
||||||
|
* Fetches font data from API
|
||||||
|
*/
|
||||||
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
protected abstract fetchFn(params: TParams): Promise<UnifiedFont[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets TanStack Query options
|
||||||
|
* @param params - Query parameters (defaults to current params)
|
||||||
|
*/
|
||||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||||
return {
|
return {
|
||||||
queryKey: this.getQueryKey(params),
|
queryKey: this.getQueryKey(params),
|
||||||
@@ -64,25 +97,36 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Common Getters ---
|
/** Array of fonts (empty array if loading/error) */
|
||||||
get fonts() {
|
get fonts() {
|
||||||
return this.result.data ?? [];
|
return this.result.data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether currently fetching initial data */
|
||||||
get isLoading() {
|
get isLoading() {
|
||||||
return this.result.isLoading;
|
return this.result.isLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether any fetch is in progress (including refetches) */
|
||||||
get isFetching() {
|
get isFetching() {
|
||||||
return this.result.isFetching;
|
return this.result.isFetching;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether last fetch resulted in an error */
|
||||||
get isError() {
|
get isError() {
|
||||||
return this.result.isError;
|
return this.result.isError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether no fonts are loaded (not loading and empty array) */
|
||||||
get isEmpty() {
|
get isEmpty() {
|
||||||
return !this.isLoading && this.fonts.length === 0;
|
return !this.isLoading && this.fonts.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Common Actions ---
|
/**
|
||||||
|
* Add a reactive parameter binding
|
||||||
|
* @param getter - Function that returns partial params to merge
|
||||||
|
* @returns Unbind function to remove the binding
|
||||||
|
*/
|
||||||
addBinding(getter: () => Partial<TParams>) {
|
addBinding(getter: () => Partial<TParams>) {
|
||||||
this.#bindings.push(getter);
|
this.#bindings.push(getter);
|
||||||
|
|
||||||
@@ -91,9 +135,14 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update query parameters
|
||||||
|
* @param newParams - Partial params to merge with existing
|
||||||
|
*/
|
||||||
setParams(newParams: Partial<TParams>) {
|
setParams(newParams: Partial<TParams>) {
|
||||||
this.#internalParams = { ...this.params, ...newParams };
|
this.#internalParams = { ...this.params, ...newParams };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invalidate cache and refetch
|
* Invalidate cache and refetch
|
||||||
*/
|
*/
|
||||||
@@ -101,19 +150,22 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
|||||||
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
|
this.qc.invalidateQueries({ queryKey: this.getQueryKey(this.params) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up effects and observers
|
||||||
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manually refetch
|
* Manually trigger a refetch
|
||||||
*/
|
*/
|
||||||
async refetch() {
|
async refetch() {
|
||||||
await this.observer.refetch();
|
await this.observer.refetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefetch with different params (for hover states, pagination, etc.)
|
* Prefetch data with different parameters
|
||||||
*/
|
*/
|
||||||
async prefetch(params: TParams) {
|
async prefetch(params: TParams) {
|
||||||
await this.qc.prefetchQuery(this.getOptions(params));
|
await this.qc.prefetchQuery(this.getOptions(params));
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -43,7 +43,7 @@ import { BaseFontStore } from './baseFontStore.svelte';
|
|||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* // Update parameters
|
* // Update parameters
|
||||||
* store.setCategory('serif');
|
* store.setCategories(['serif']);
|
||||||
* store.nextPage();
|
* store.nextPage();
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@@ -108,17 +108,21 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
this.#filterCleanup = $effect.root(() => {
|
this.#filterCleanup = $effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const filterParams = JSON.stringify({
|
const filterParams = JSON.stringify({
|
||||||
provider: this.params.provider,
|
providers: this.params.providers,
|
||||||
category: this.params.category,
|
categories: this.params.categories,
|
||||||
subset: this.params.subset,
|
subsets: this.params.subsets,
|
||||||
q: this.params.q,
|
q: this.params.q,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If filters changed, reset offset to 0
|
// If filters changed, reset offset and invalidate cache
|
||||||
if (filterParams !== this.#previousFilterParams) {
|
if (filterParams !== this.#previousFilterParams) {
|
||||||
if (this.#previousFilterParams && this.params.offset !== 0) {
|
if (this.#previousFilterParams) {
|
||||||
|
if (this.params.offset !== 0) {
|
||||||
this.setParams({ offset: 0 });
|
this.setParams({ offset: 0 });
|
||||||
}
|
}
|
||||||
|
this.#accumulatedFonts = [];
|
||||||
|
this.invalidate();
|
||||||
|
}
|
||||||
this.#previousFilterParams = filterParams;
|
this.#previousFilterParams = filterParams;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -170,7 +174,7 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
protected getOptions(params = this.params): QueryObserverOptions<UnifiedFont[], Error> {
|
||||||
const hasFilters = !!(params.q || params.provider || params.category || params.subset);
|
const hasFilters = !!(params.q || params.providers || params.categories || params.subsets);
|
||||||
return {
|
return {
|
||||||
queryKey: this.getQueryKey(params),
|
queryKey: this.getQueryKey(params),
|
||||||
queryFn: () => this.fetchFn(params),
|
queryFn: () => this.fetchFn(params),
|
||||||
@@ -221,8 +225,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
return response.fonts;
|
return response.fonts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Getters (proxied from BaseFontStore) ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all accumulated fonts (for infinite scroll)
|
* Get all accumulated fonts (for infinite scroll)
|
||||||
*/
|
*/
|
||||||
@@ -258,27 +260,25 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
return !this.isLoading && this.fonts.length === 0;
|
return !this.isLoading && this.fonts.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Provider-specific shortcuts ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set provider filter
|
* Set providers filter
|
||||||
*/
|
*/
|
||||||
setProvider(provider: 'google' | 'fontshare' | undefined) {
|
setProviders(providers: ProxyFontsParams['providers']) {
|
||||||
this.setParams({ provider });
|
this.setParams({ providers });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set category filter
|
* Set categories filter
|
||||||
*/
|
*/
|
||||||
setCategory(category: ProxyFontsParams['category']) {
|
setCategories(categories: ProxyFontsParams['categories']) {
|
||||||
this.setParams({ category });
|
this.setParams({ categories });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set subset filter
|
* Set subsets filter
|
||||||
*/
|
*/
|
||||||
setSubset(subset: ProxyFontsParams['subset']) {
|
setSubsets(subsets: ProxyFontsParams['subsets']) {
|
||||||
this.setParams({ subset });
|
this.setParams({ subsets });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,8 +295,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
this.setParams({ sort });
|
this.setParams({ sort });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Pagination methods ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to next page
|
* Go to next page
|
||||||
*/
|
*/
|
||||||
@@ -337,8 +335,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
|||||||
this.setParams({ limit });
|
this.setParams({ limit });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Category shortcuts (for convenience) ---
|
|
||||||
|
|
||||||
get sansSerifFonts() {
|
get sansSerifFonts() {
|
||||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,60 @@
|
|||||||
/**
|
/**
|
||||||
* ============================================================================
|
* Common font domain types
|
||||||
* DOMAIN TYPES
|
*
|
||||||
* ============================================================================
|
* Shared types for font entities across providers (Google, Fontshare).
|
||||||
|
* Includes categories, subsets, weights, and filter types.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FontCategory as FontshareFontCategory } from './fontshare';
|
import type { FontCategory as FontshareFontCategory } from './fontshare';
|
||||||
import type { FontCategory as GoogleFontCategory } from './google';
|
import type { FontCategory as GoogleFontCategory } from './google';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font category
|
* Unified font category across all providers
|
||||||
*/
|
*/
|
||||||
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
|
export type FontCategory = GoogleFontCategory | FontshareFontCategory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font provider
|
* Font provider identifier
|
||||||
*/
|
*/
|
||||||
export type FontProvider = 'google' | 'fontshare';
|
export type FontProvider = 'google' | 'fontshare';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Font subset
|
* Character subset support
|
||||||
*/
|
*/
|
||||||
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
export type FontSubset = 'latin' | 'latin-ext' | 'cyrillic' | 'greek' | 'arabic' | 'devanagari';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter state
|
* Combined filter state for font queries
|
||||||
*/
|
*/
|
||||||
export interface FontFilters {
|
export interface FontFilters {
|
||||||
|
/** Selected font providers */
|
||||||
providers: FontProvider[];
|
providers: FontProvider[];
|
||||||
|
/** Selected font categories */
|
||||||
categories: FontCategory[];
|
categories: FontCategory[];
|
||||||
|
/** Selected character subsets */
|
||||||
subsets: FontSubset[];
|
subsets: FontSubset[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CheckboxFilter = 'providers' | 'categories' | 'subsets';
|
/** Filter group identifier */
|
||||||
export type FilterType = CheckboxFilter | 'searchQuery';
|
export type FilterGroup = 'providers' | 'categories' | 'subsets';
|
||||||
|
|
||||||
|
/** Filter type including search query */
|
||||||
|
export type FilterType = FilterGroup | 'searchQuery';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard font weights
|
* Numeric font weights (100-900)
|
||||||
*/
|
*/
|
||||||
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Italic variant format: e.g., "100italic", "400italic", "700italic"
|
* Italic variant with weight: "100italic", "400italic", "700italic", etc.
|
||||||
*/
|
*/
|
||||||
export type FontWeightItalic = `${FontWeight}italic`;
|
export type FontWeightItalic = `${FontWeight}italic`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All possible font variants
|
* All possible font variant identifiers
|
||||||
|
*
|
||||||
|
* Includes:
|
||||||
* - Numeric weights: "400", "700", etc.
|
* - Numeric weights: "400", "700", etc.
|
||||||
* - Italic variants: "400italic", "700italic", etc.
|
* - Italic variants: "400italic", "700italic", etc.
|
||||||
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
* - Legacy names: "regular", "italic", "bold", "bolditalic"
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export interface FontMetadata {
|
|||||||
lastModified?: string;
|
lastModified?: string;
|
||||||
/** Popularity rank (if available from provider) */
|
/** Popularity rank (if available from provider) */
|
||||||
popularity?: number;
|
popularity?: number;
|
||||||
|
/**
|
||||||
|
* Normalized popularity score (0-100)
|
||||||
|
*
|
||||||
|
* Normalized across all fonts for consistent ranking
|
||||||
|
* Higher values indicate more popular fonts
|
||||||
|
*/
|
||||||
|
popularityScore?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +86,13 @@ export interface UnifiedFont {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Font provider (google | fontshare) */
|
/** Font provider (google | fontshare) */
|
||||||
provider: FontProvider;
|
provider: FontProvider;
|
||||||
|
/**
|
||||||
|
* Provider badge display name
|
||||||
|
*
|
||||||
|
* Human-readable provider name for UI display
|
||||||
|
* e.g., "Google Fonts" or "Fontshare"
|
||||||
|
*/
|
||||||
|
providerBadge?: string;
|
||||||
/** Font category classification */
|
/** Font category classification */
|
||||||
category: FontCategory;
|
category: FontCategory;
|
||||||
/** Supported character subsets */
|
/** Supported character subsets */
|
||||||
|
|||||||
@@ -16,19 +16,20 @@ import {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Applied font
|
* Font to apply
|
||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Font weight
|
||||||
|
* @default 400
|
||||||
*/
|
*/
|
||||||
weight?: number;
|
weight?: number;
|
||||||
/**
|
/**
|
||||||
* Additional classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Children
|
* Content snippet
|
||||||
*/
|
*/
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,7 @@ const status = $derived(
|
|||||||
appliedFontsManager.getFontStatus(
|
appliedFontsManager.getFontStatus(
|
||||||
font.id,
|
font.id,
|
||||||
weight,
|
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>
|
|
||||||
@@ -28,11 +28,11 @@ interface Props extends
|
|||||||
>
|
>
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Callback for when visible items change
|
* Visible items callback
|
||||||
*/
|
*/
|
||||||
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||||
/**
|
/**
|
||||||
* Weight of the font
|
* Font weight
|
||||||
*/
|
*/
|
||||||
weight: number;
|
weight: number;
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +69,7 @@ function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-register fonts with the manager
|
// Auto-register fonts with the manager
|
||||||
appliedFontsManager.touch(configs);
|
appliedFontsManager.touch(configs);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
import FontApplicator from './FontApplicator/FontApplicator.svelte';
|
||||||
import FontListItem from './FontListItem/FontListItem.svelte';
|
|
||||||
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
FontListItem,
|
|
||||||
FontVirtualList,
|
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
|
Component: FontSampler
|
||||||
Displays a sample text with a given font in a contenteditable element.
|
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">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
@@ -9,11 +10,14 @@ import {
|
|||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { controlManager } from '$features/SetupFont';
|
import { controlManager } from '$features/SetupFont';
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
|
Divider,
|
||||||
Footnote,
|
Footnote,
|
||||||
// IconButton,
|
Stat,
|
||||||
|
StatGroup,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
// import XIcon from '@lucide/svelte/icons/x';
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -21,68 +25,124 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
/**
|
/**
|
||||||
* Text to display
|
* Sample text
|
||||||
*/
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
/**
|
||||||
* Index of the font sampler
|
* Position index
|
||||||
|
* @default 0
|
||||||
*/
|
*/
|
||||||
index?: number;
|
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 fontWeight = $derived(controlManager.weight);
|
||||||
const fontSize = $derived(controlManager.renderedSize);
|
const fontSize = $derived(controlManager.renderedSize);
|
||||||
const lineHeight = $derived(controlManager.height);
|
const lineHeight = $derived(controlManager.height);
|
||||||
const letterSpacing = $derived(controlManager.spacing);
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
in:fly={{ y: 20, duration: 400, delay: index * 50 }}
|
||||||
class="
|
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
|
flex flex-col
|
||||||
bg-background-80
|
min-h-60
|
||||||
border border-border-muted
|
rounded-none
|
||||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
|
||||||
relative overflow-hidden
|
|
||||||
"
|
"
|
||||||
style:font-weight={fontWeight}
|
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">
|
<!-- ── Header bar ─────────────────────────────────────────────────── -->
|
||||||
<div class="flex items-center gap-2 sm:gap-2.5">
|
<div
|
||||||
<Footnote>
|
class="
|
||||||
typeface_{String(index).padStart(3, '0')}
|
flex items-center justify-between
|
||||||
</Footnote>
|
px-4 sm:px-5 md:px-6 py-3 sm:py-4
|
||||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
border-b border-black/5 dark:border-white/10
|
||||||
<div class="font-bold text-foreground">
|
bg-paper dark:bg-dark-card
|
||||||
{font.name}
|
"
|
||||||
</div>
|
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
{#snippet icon({ className })}
|
<!-- Left: index · name · type badge · provider badge -->
|
||||||
<XIcon class={className} />
|
<div class="flex items-center gap-2 sm:gap-4 min-w-0 shrink-0">
|
||||||
{/snippet}
|
<span class="font-mono text-[0.625rem] tracking-widest text-neutral-400 uppercase leading-none shrink-0">
|
||||||
</IconButton>
|
{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}
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="p-4 sm:p-5 md:p-8 relative z-10">
|
<!-- 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
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{#each stats as stat}
|
||||||
|
<Stat label={stat.label} value={stat.value} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 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}>
|
<FontApplicator {font} weight={fontWeight}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
bind:text
|
bind:text
|
||||||
{...restProps}
|
|
||||||
{fontSize}
|
{fontSize}
|
||||||
{lineHeight}
|
{lineHeight}
|
||||||
{letterSpacing}
|
{letterSpacing}
|
||||||
@@ -90,21 +150,27 @@ const letterSpacing = $derived(controlManager.spacing);
|
|||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</div>
|
</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">
|
<!-- ── Mobile stats footer (md:hidden — header stats take over above) -->
|
||||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
|
<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">
|
||||||
SZ:{fontSize}PX
|
{#each stats as stat, i}
|
||||||
</Footnote>
|
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider {i === 0 ? 'ml-auto' : ''}">
|
||||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
{stat.label}:{stat.value}
|
||||||
<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>
|
</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>
|
||||||
</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,
|
mapManagerToParams,
|
||||||
} from './lib';
|
} from './lib';
|
||||||
|
|
||||||
export {
|
|
||||||
FONT_CATEGORIES,
|
|
||||||
FONT_PROVIDERS,
|
|
||||||
FONT_SUBSETS,
|
|
||||||
} from './model/const/const';
|
|
||||||
|
|
||||||
export { filterManager } from './model/state/manager.svelte';
|
export { filterManager } from './model/state/manager.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
SORT_MAP,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
type SortApiValue,
|
||||||
|
type SortOption,
|
||||||
|
sortStore,
|
||||||
|
} from './model/store/sortStore.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
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 { createFilter } from '$shared/lib';
|
||||||
import { createDebouncedState } from '$shared/lib/helpers';
|
import { createDebouncedState } from '$shared/lib/helpers';
|
||||||
import type { FilterConfig } from '../../model';
|
import type {
|
||||||
|
FilterConfig,
|
||||||
|
FilterGroupConfig,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a filter manager instance.
|
* Creates a filter manager instance
|
||||||
* - Uses debounce to update search query for better performance.
|
|
||||||
* - Manages filter instances for each group.
|
|
||||||
*
|
*
|
||||||
* @param config - Configuration for the filter manager.
|
* Manages multiple filter groups with debounced search. Each group
|
||||||
* @returns - An instance of the filter manager.
|
* 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>) {
|
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
||||||
const search = createDebouncedState(config.queryValue ?? '');
|
const search = createDebouncedState(config.queryValue ?? '');
|
||||||
@@ -28,37 +54,68 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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() {
|
get queryValue() {
|
||||||
return search.immediate;
|
return search.immediate;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Setter for queryValue
|
/**
|
||||||
|
* Set the search query value
|
||||||
|
*/
|
||||||
set queryValue(value) {
|
set queryValue(value) {
|
||||||
search.immediate = 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() {
|
get debouncedQueryValue() {
|
||||||
return search.debounced;
|
return search.debounced;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Direct array reference (reactive)
|
/**
|
||||||
|
* All filter groups (reactive)
|
||||||
|
*/
|
||||||
get groups() {
|
get groups() {
|
||||||
return groups;
|
return groups;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Derived values
|
/**
|
||||||
|
* Whether any filter has an active selection
|
||||||
|
*/
|
||||||
get hasAnySelection() {
|
get hasAnySelection() {
|
||||||
return hasAnySelection;
|
return hasAnySelection;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Global action
|
/**
|
||||||
|
* Deselect all filters across all groups
|
||||||
|
*/
|
||||||
deselectAllGlobal: () => {
|
deselectAllGlobal: () => {
|
||||||
groups.forEach(group => group.instance.deselectAll());
|
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) => {
|
getGroup: (id: string) => {
|
||||||
return groups.find(g => g.id === id);
|
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.
|
* Maps filter manager to proxy API parameters.
|
||||||
*
|
*
|
||||||
* Transforms UI filter state into proxy API query parameters.
|
* Updated to support multiple filter values (arrays)
|
||||||
* Handles conversion from filter groups to API-specific parameters.
|
|
||||||
*
|
*
|
||||||
* @param manager - Filter manager instance with reactive state
|
* @param manager - Filter manager instance with reactive state
|
||||||
* @returns - Partial proxy API parameters ready for API call
|
* @returns - Partial proxy API parameters ready for API call
|
||||||
@@ -15,13 +14,18 @@ import type { FilterManager } from '../filterManager/filterManager.svelte';
|
|||||||
* // Example filter manager state:
|
* // Example filter manager state:
|
||||||
* // {
|
* // {
|
||||||
* // queryValue: 'roboto',
|
* // queryValue: 'roboto',
|
||||||
* // providers: ['google'],
|
* // providers: ['google', 'fontshare'],
|
||||||
* // categories: ['sans-serif'],
|
* // categories: ['sans-serif', 'serif'],
|
||||||
* // subsets: ['latin']
|
* // subsets: ['latin']
|
||||||
* // }
|
* // }
|
||||||
*
|
*
|
||||||
* const params = mapManagerToParams(manager);
|
* 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> {
|
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
||||||
@@ -33,22 +37,17 @@ export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsPa
|
|||||||
// Search query (debounced)
|
// Search query (debounced)
|
||||||
q: manager.debouncedQueryValue || undefined,
|
q: manager.debouncedQueryValue || undefined,
|
||||||
|
|
||||||
// Provider filter (single value - proxy API doesn't support array)
|
// NEW: Support arrays - send all selected values
|
||||||
// Use first provider if multiple selected, or undefined if none/all selected
|
providers: providers && providers.length > 0
|
||||||
provider: providers && providers.length === 1
|
? providers as string[]
|
||||||
? (providers[0] as 'google' | 'fontshare')
|
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
// Category filter (single value - proxy API doesn't support array)
|
categories: categories && categories.length > 0
|
||||||
// Use first category if multiple selected, or undefined if none/all selected
|
? categories as string[]
|
||||||
category: categories && categories.length === 1
|
|
||||||
? (categories[0] as ProxyFontsParams['category'])
|
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
// Subset filter (single value - proxy API doesn't support array)
|
subsets: subsets && subsets.length > 0
|
||||||
// Use first subset if multiple selected, or undefined if none/all selected
|
? subsets as string[]
|
||||||
subset: subsets && subsets.length === 1
|
|
||||||
? (subsets[0] as ProxyFontsParams['subset'])
|
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,13 @@ export type {
|
|||||||
FilterGroupConfig,
|
FilterGroupConfig,
|
||||||
} from './types/filter';
|
} from './types/filter';
|
||||||
|
|
||||||
|
export { filtersStore } from './state/filters.svelte';
|
||||||
export { filterManager } from './state/manager.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 { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
|
||||||
import {
|
import { filtersStore } from './filters.svelte';
|
||||||
FONT_CATEGORIES,
|
|
||||||
FONT_PROVIDERS,
|
|
||||||
FONT_SUBSETS,
|
|
||||||
} from '../const/const';
|
|
||||||
|
|
||||||
const initialConfig = {
|
export const filterManager = createFilterManager({
|
||||||
queryValue: '',
|
queryValue: '',
|
||||||
groups: [
|
groups: [],
|
||||||
{
|
});
|
||||||
id: 'providers',
|
|
||||||
label: 'Font provider',
|
|
||||||
properties: FONT_PROVIDERS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'subsets',
|
|
||||||
label: 'Font subset',
|
|
||||||
properties: FONT_SUBSETS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'categories',
|
|
||||||
label: 'Font category',
|
|
||||||
properties: FONT_CATEGORIES,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filterManager = createFilterManager(initialConfig);
|
/**
|
||||||
|
* 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
|
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">
|
<script lang="ts">
|
||||||
import { CheckboxFilter } from '$shared/ui';
|
import { FilterGroup } from '$shared/ui';
|
||||||
import { filterManager } from '../../model';
|
import { filterManager } from '../../model';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each filterManager.groups as group (group.id)}
|
{#each filterManager.groups as group (group.id)}
|
||||||
<CheckboxFilter
|
<FilterGroup
|
||||||
displayedLabel={group.label}
|
displayedLabel={group.label}
|
||||||
filter={group.instance}
|
filter={group.instance}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,46 +1,94 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: FiltersControl
|
Component: FilterControls
|
||||||
Renders a group of action buttons for filter operations.
|
Sort options + Reset_Filters button.
|
||||||
- Reset: Clears all active filters (outline variant for secondary action)
|
Sits below the filter list, separated by a top border.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$shared/shadcn/ui/button';
|
import { unifiedFontStore } from '$entities/Font';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import Rotate from '@lucide/svelte/icons/rotate-ccw';
|
import { Button } from '$shared/ui';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { Label } from '$shared/ui';
|
||||||
import { Tween } from 'svelte/motion';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import { filterManager } from '../../model';
|
import {
|
||||||
|
getContext,
|
||||||
|
untrack,
|
||||||
|
} from 'svelte';
|
||||||
|
import {
|
||||||
|
SORT_OPTIONS,
|
||||||
|
filterManager,
|
||||||
|
sortStore,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes
|
||||||
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { class: className }: Props = $props();
|
const {
|
||||||
|
class: className,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const transform = new Tween(
|
$effect(() => {
|
||||||
{ scale: 1, rotate: 0 },
|
const apiSort = sortStore.apiValue;
|
||||||
{ duration: 150, easing: cubicOut },
|
untrack(() => unifiedFontStore.setSort(apiSort));
|
||||||
);
|
|
||||||
|
|
||||||
function handleClick() {
|
|
||||||
filterManager.deselectAllGlobal();
|
|
||||||
|
|
||||||
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
|
|
||||||
transform.set({ scale: 1, rotate: 0 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
filterManager.deselectAllGlobal();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn('flex flex-row gap-2', className)}
|
class={cn(
|
||||||
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
|
'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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="group flex flex-1 cursor-pointer gap-1"
|
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||||
onclick={handleClick}
|
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]',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
|
{option}
|
||||||
Reset
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset_Filters -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{#snippet icon()}
|
||||||
|
<RefreshCwIcon class="size-3 transition-transform duration-300 group-hover:rotate-180" />
|
||||||
|
{/snippet}
|
||||||
|
Reset_Filters
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 {
|
import {
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
@@ -17,14 +29,37 @@ import {
|
|||||||
|
|
||||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A control with its instance
|
||||||
|
*/
|
||||||
export interface Control extends ControlOnlyFields<ControlId> {
|
export interface Control extends ControlOnlyFields<ControlId> {
|
||||||
instance: TypographyControl;
|
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 {
|
export class TypographyControlManager {
|
||||||
|
/** Map of controls keyed by ID */
|
||||||
#controls = new SvelteMap<string, Control>();
|
#controls = new SvelteMap<string, Control>();
|
||||||
|
/** Responsive multiplier for font size display */
|
||||||
#multiplier = $state(1);
|
#multiplier = $state(1);
|
||||||
|
/** Persistent storage for settings */
|
||||||
#storage: PersistentStore<TypographySettings>;
|
#storage: PersistentStore<TypographySettings>;
|
||||||
|
/** Base font size (user preference, unscaled) */
|
||||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||||
|
|
||||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
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 {
|
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||||
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
||||||
if (id === 'font_weight') return saved.fontWeight;
|
if (id === 'font_weight') return saved.fontWeight;
|
||||||
@@ -93,11 +131,17 @@ export class TypographyControlManager {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Getters / Setters ---
|
/** Current multiplier for responsive scaling */
|
||||||
|
|
||||||
get multiplier() {
|
get multiplier() {
|
||||||
return this.#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) {
|
set multiplier(value: number) {
|
||||||
if (this.#multiplier === value) return;
|
if (this.#multiplier === value) return;
|
||||||
this.#multiplier = value;
|
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() {
|
get renderedSize() {
|
||||||
return this.#baseSize * this.#multiplier;
|
return this.#baseSize * this.#multiplier;
|
||||||
}
|
}
|
||||||
@@ -118,6 +165,7 @@ export class TypographyControlManager {
|
|||||||
get baseSize() {
|
get baseSize() {
|
||||||
return this.#baseSize;
|
return this.#baseSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
set baseSize(val: number) {
|
set baseSize(val: number) {
|
||||||
this.#baseSize = val;
|
this.#baseSize = val;
|
||||||
const ctrl = this.#controls.get('font_size')?.instance;
|
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;
|
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all controls to default values
|
||||||
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
this.#storage.clear();
|
this.#storage.clear();
|
||||||
const defaults = this.#storage.value;
|
const defaults = this.#storage.value;
|
||||||
@@ -185,21 +236,11 @@ export class TypographyControlManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage schema for typography settings
|
* Creates a typography control manager
|
||||||
*/
|
|
||||||
export interface TypographySettings {
|
|
||||||
fontSize: number;
|
|
||||||
fontWeight: number;
|
|
||||||
lineHeight: number;
|
|
||||||
letterSpacing: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a typography control manager that handles a collection of typography controls.
|
|
||||||
*
|
*
|
||||||
* @param configs - Array of control configurations.
|
* @param configs - Array of control configurations
|
||||||
* @param storageId - Persistent storage identifier.
|
* @param storageId - Persistent storage identifier
|
||||||
* @returns - Typography control manager instance.
|
* @returns Typography control manager instance
|
||||||
*/
|
*/
|
||||||
export function createTypographyControlManager(
|
export function createTypographyControlManager(
|
||||||
configs: ControlModel<ControlId>[],
|
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',
|
increaseLabel: 'Increase Font Size',
|
||||||
decreaseLabel: 'Decrease Font Size',
|
decreaseLabel: 'Decrease Font Size',
|
||||||
controlLabel: 'Font Size',
|
controlLabel: 'Size',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'font_weight',
|
id: 'font_weight',
|
||||||
@@ -54,7 +54,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
|
|
||||||
increaseLabel: 'Increase Font Weight',
|
increaseLabel: 'Increase Font Weight',
|
||||||
decreaseLabel: 'Decrease Font Weight',
|
decreaseLabel: 'Decrease Font Weight',
|
||||||
controlLabel: 'Font Weight',
|
controlLabel: 'Weight',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'line_height',
|
id: 'line_height',
|
||||||
@@ -65,7 +65,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
|
|
||||||
increaseLabel: 'Increase Line Height',
|
increaseLabel: 'Increase Line Height',
|
||||||
decreaseLabel: 'Decrease Line Height',
|
decreaseLabel: 'Decrease Line Height',
|
||||||
controlLabel: 'Line Height',
|
controlLabel: 'Leading',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'letter_spacing',
|
id: 'letter_spacing',
|
||||||
@@ -76,7 +76,7 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
|
|
||||||
increaseLabel: 'Increase Letter Spacing',
|
increaseLabel: 'Increase Letter Spacing',
|
||||||
decreaseLabel: 'Decrease 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-white dark:bg-[#1e1e1e] 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-[#f3f0e9] dark:bg-[#1e1e1e]',
|
||||||
|
'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-[#ff3b30]" />
|
||||||
|
<span
|
||||||
|
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-[#1a1a1a] dark:text-[#e5e5e5]"
|
||||||
|
>
|
||||||
|
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-[#f3f0e9]/95 dark:bg-[#121212]/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-[#1a1a1a] dark:text-[#e5e5e5] shrink-0">
|
||||||
|
<Settings2Icon
|
||||||
|
size={14}
|
||||||
|
class="text-[#ff3b30]"
|
||||||
|
/>
|
||||||
|
<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">
|
<script lang="ts">
|
||||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import { ComparisonView } from '$widgets/ComparisonView';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { FontSearchSection } from '$widgets/FontSearch';
|
||||||
import {
|
import { SampleListSection } from '$widgets/SampleList';
|
||||||
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 { cubicIn } from 'svelte/easing';
|
import { cubicIn } from 'svelte/easing';
|
||||||
import { fade } from 'svelte/transition';
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Font List -->
|
|
||||||
<div
|
<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 }}
|
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||||
>
|
>
|
||||||
<Section
|
<section class="w-auto">
|
||||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
|
<ComparisonView />
|
||||||
onTitleStatusChange={handleTitleStatusChanged}
|
</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">
|
||||||
{#snippet icon({ className })}
|
<FontSearchSection />
|
||||||
<CodeIcon class={className} />
|
<SampleListSection index={1} />
|
||||||
{/snippet}
|
</main>
|
||||||
{#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>
|
</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>
|
|
||||||
</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>
|
|
||||||
|
|||||||
282
src/shared/api/api.test.ts
Normal file
282
src/shared/api/api.test.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Tests for API client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
api,
|
||||||
|
} from './api';
|
||||||
|
|
||||||
|
describe('api', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET requests', () => {
|
||||||
|
test('should return data and status on successful request', async () => {
|
||||||
|
const mockData = { id: 1, name: 'Test' };
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockData,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.get<{ id: number; name: string }>('/api/test');
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockData);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST requests', () => {
|
||||||
|
test('should send JSON body and return response', async () => {
|
||||||
|
const requestBody = { name: 'Alice', email: 'alice@example.com' };
|
||||||
|
const mockResponse = { id: 1, ...requestBody };
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 201,
|
||||||
|
json: async () => mockResponse,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.post<{ id: number; name: string; email: string }>(
|
||||||
|
'/api/users',
|
||||||
|
requestBody,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockResponse);
|
||||||
|
expect(result.status).toBe(201);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/users',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle POST with undefined body', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await api.post('/api/users', undefined);
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/users',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT requests', () => {
|
||||||
|
test('should send JSON body and return response', async () => {
|
||||||
|
const requestBody = { id: 1, name: 'Updated' };
|
||||||
|
const mockResponse = { ...requestBody };
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockResponse,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.put<{ id: number; name: string }>('/api/users/1', requestBody);
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockResponse);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/users/1',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE requests', () => {
|
||||||
|
test('should return data and status on successful deletion', async () => {
|
||||||
|
const mockData = { message: 'Deleted successfully' };
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => mockData,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.delete<{ message: string }>('/api/users/1');
|
||||||
|
|
||||||
|
expect(result.data).toEqual(mockData);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/users/1',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
test('should throw ApiError on non-OK response', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
json: async () => ({ error: 'Resource not found' }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(api.get('/api/not-found')).rejects.toThrow(ApiError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include status code in ApiError', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: 'Internal Server Error',
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.get('/api/error');
|
||||||
|
expect.fail('Should have thrown ApiError');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).status).toBe(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include message in ApiError', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
statusText: 'Forbidden',
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.get('/api/forbidden');
|
||||||
|
expect.fail('Should have thrown ApiError');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).message).toBe('Request failed: Forbidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include response object in ApiError', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
statusText: 'Unauthorized',
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response;
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.get('/api/unauthorized');
|
||||||
|
expect.fail('Should have thrown ApiError');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).response).toBe(mockResponse);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have correct error name', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.get('/api/bad-request');
|
||||||
|
expect.fail('Should have thrown ApiError');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
expect((error as ApiError).name).toBe('ApiError');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headers', () => {
|
||||||
|
test('should accept custom headers (replaces defaults)', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await api.get('/api/test', {
|
||||||
|
headers: { 'X-Custom-Header': 'custom-value' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: {
|
||||||
|
'X-Custom-Header': 'custom-value',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow overriding default Content-Type', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await api.get('/api/test', {
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/test',
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty response handling', () => {
|
||||||
|
test('should handle empty JSON response', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 204,
|
||||||
|
json: async () => null,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await api.get<null>('/api/empty');
|
||||||
|
|
||||||
|
expect(result.data).toBeNull();
|
||||||
|
expect(result.status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* HTTP API client with error handling
|
||||||
|
*
|
||||||
|
* Provides a typed wrapper around fetch for JSON APIs.
|
||||||
|
* Automatically handles JSON serialization and error responses.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { api } from '$shared/api';
|
||||||
|
*
|
||||||
|
* // GET request
|
||||||
|
* const users = await api.get<User[]>('/api/users');
|
||||||
|
*
|
||||||
|
* // POST request
|
||||||
|
* const newUser = await api.post<User>('/api/users', { name: 'Alice' });
|
||||||
|
*
|
||||||
|
* // Error handling
|
||||||
|
* try {
|
||||||
|
* const data = await api.get('/api/data');
|
||||||
|
* } catch (error) {
|
||||||
|
* if (error instanceof ApiError) {
|
||||||
|
* console.error(error.status, error.message);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
import type { ApiResponse } from '$shared/types/common';
|
import type { ApiResponse } from '$shared/types/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for API failures
|
||||||
|
*
|
||||||
|
* Includes HTTP status code and the original Response object
|
||||||
|
* for debugging and error handling.
|
||||||
|
*/
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
|
/**
|
||||||
|
* Creates a new API error
|
||||||
|
* @param status - HTTP status code
|
||||||
|
* @param message - Error message
|
||||||
|
* @param response - Original fetch Response object
|
||||||
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
|
/** HTTP status code */
|
||||||
public status: number,
|
public status: number,
|
||||||
message: string,
|
message: string,
|
||||||
|
/** Original Response object for inspection */
|
||||||
public response?: Response,
|
public response?: Response,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -11,6 +52,16 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal request handler
|
||||||
|
*
|
||||||
|
* Performs fetch with JSON headers and throws ApiError on failure.
|
||||||
|
*
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param options - Fetch options (method, headers, body, etc.)
|
||||||
|
* @returns Response data and status code
|
||||||
|
* @throws ApiError when response is not OK
|
||||||
|
*/
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
options?: RequestInit,
|
options?: RequestInit,
|
||||||
@@ -39,9 +90,28 @@ async function request<T>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API client methods
|
||||||
|
*
|
||||||
|
* Provides typed methods for common HTTP verbs.
|
||||||
|
* All methods return ApiResponse with data and status.
|
||||||
|
*/
|
||||||
export const api = {
|
export const api = {
|
||||||
|
/**
|
||||||
|
* Performs a GET request
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
|
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a POST request with JSON body
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param body - Request body (will be JSON stringified)
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
post: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
post: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
@@ -49,6 +119,13 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a PUT request with JSON body
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param body - Request body (will be JSON stringified)
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
put: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
put: <T>(url: string, body?: unknown, options?: RequestInit) =>
|
||||||
request<T>(url, {
|
request<T>(url, {
|
||||||
...options,
|
...options,
|
||||||
@@ -56,5 +133,11 @@ export const api = {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a DELETE request
|
||||||
|
* @param url - Request URL
|
||||||
|
* @param options - Additional fetch options
|
||||||
|
* @returns Response data
|
||||||
|
*/
|
||||||
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import { QueryClient } from '@tanstack/query-core';
|
import { QueryClient } from '@tanstack/query-core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query client instance
|
* TanStack Query client instance
|
||||||
|
*
|
||||||
|
* Configured for optimal caching and refetching behavior.
|
||||||
|
* Used by all font stores for data fetching and caching.
|
||||||
|
*
|
||||||
|
* Cache behavior:
|
||||||
|
* - Data stays fresh for 5 minutes (staleTime)
|
||||||
|
* - Unused data is garbage collected after 10 minutes (gcTime)
|
||||||
|
* - No refetch on window focus (reduces unnecessary network requests)
|
||||||
|
* - 3 retries with exponential backoff on failure
|
||||||
*/
|
*/
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
/**
|
/** Data remains fresh for 5 minutes after fetch */
|
||||||
* Default staleTime: 5 minutes
|
|
||||||
*/
|
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
/**
|
/** Unused cache entries are removed after 10 minutes */
|
||||||
* Default gcTime: 10 minutes
|
|
||||||
*/
|
|
||||||
gcTime: 10 * 60 * 1000,
|
gcTime: 10 * 60 * 1000,
|
||||||
|
/** Don't refetch when window regains focus */
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
/** Refetch on mount if data is stale */
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
|
/** Retry failed requests up to 3 times */
|
||||||
retry: 3,
|
retry: 3,
|
||||||
/**
|
/**
|
||||||
* Exponential backoff
|
* Exponential backoff for retries
|
||||||
|
* 1s, 2s, 4s, 8s... capped at 30s
|
||||||
*/
|
*/
|
||||||
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +1,83 @@
|
|||||||
/**
|
/**
|
||||||
* Interface representing a line of text with its measured width.
|
* Character-by-character font comparison helper
|
||||||
|
*
|
||||||
|
* Creates utilities for comparing two fonts character by character.
|
||||||
|
* Used by the ComparisonView widget to render morphing text effects
|
||||||
|
* where characters transition between font A and font B based on
|
||||||
|
* slider position.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Responsive text measurement using canvas
|
||||||
|
* - Binary search for optimal line breaking
|
||||||
|
* - Character proximity calculation for morphing effects
|
||||||
|
* - Handles CSS transforms correctly (uses offsetWidth)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* import { createCharacterComparison } from '$shared/lib/helpers';
|
||||||
|
*
|
||||||
|
* const comparison = createCharacterComparison(
|
||||||
|
* () => text,
|
||||||
|
* () => fontA,
|
||||||
|
* () => fontB,
|
||||||
|
* () => weight,
|
||||||
|
* () => size
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* $: lines = comparison.lines;
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* <canvas bind:this={measureCanvas} hidden></canvas>
|
||||||
|
* <div bind:this={container}>
|
||||||
|
* {#each lines as line}
|
||||||
|
* <span>{line.text}</span>
|
||||||
|
* {/each}
|
||||||
|
* </div>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single line of text with its measured width
|
||||||
*/
|
*/
|
||||||
export interface LineData {
|
export interface LineData {
|
||||||
/**
|
/** The text content of the line */
|
||||||
* Line's text
|
|
||||||
*/
|
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
/** Maximum width between both fonts in pixels */
|
||||||
* It's width
|
|
||||||
*/
|
|
||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a helper for splitting text into lines and calculating character proximity.
|
* Creates a character comparison helper for morphing text effects
|
||||||
* This is used by the ComparisonSlider (TestTen) to render morphing text.
|
|
||||||
*
|
*
|
||||||
* @param text - The text to split and measure
|
* Measures text in both fonts to determine line breaks and calculates
|
||||||
* @param fontA - The first font definition
|
* character-level proximity for morphing animations.
|
||||||
* @param fontB - The second font definition
|
*
|
||||||
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
|
* @param text - Getter for the text to compare
|
||||||
|
* @param fontA - Getter for the first font (left/top side)
|
||||||
|
* @param fontB - Getter for the second font (right/bottom side)
|
||||||
|
* @param weight - Getter for the current font weight
|
||||||
|
* @param size - Getter for the controlled font size
|
||||||
|
* @returns Character comparison instance with lines and proximity calculations
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const comparison = createCharacterComparison(
|
||||||
|
* () => $sampleText,
|
||||||
|
* () => $selectedFontA,
|
||||||
|
* () => $selectedFontB,
|
||||||
|
* () => $fontWeight,
|
||||||
|
* () => $fontSize
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // Call when DOM is ready
|
||||||
|
* comparison.breakIntoLines(container, canvas);
|
||||||
|
*
|
||||||
|
* // Get character state for morphing
|
||||||
|
* const state = comparison.getCharState(5, sliderPosition, lineEl, container);
|
||||||
|
* // state.proximity: 0-1 value for opacity/interpolation
|
||||||
|
* // state.isPast: true if slider is past this character
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createCharacterComparison<
|
export function createCharacterComparison<
|
||||||
T extends { name: string; id: string } | undefined = undefined,
|
T extends { name: string; id: string } | undefined = undefined,
|
||||||
@@ -33,17 +91,22 @@ export function createCharacterComparison<
|
|||||||
let lines = $state<LineData[]>([]);
|
let lines = $state<LineData[]>([]);
|
||||||
let containerWidth = $state(0);
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a font is defined
|
||||||
|
*/
|
||||||
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
||||||
return font !== undefined;
|
return font !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Measures text width using a canvas context.
|
* Measures text width using canvas 2D context
|
||||||
|
*
|
||||||
* @param ctx - Canvas rendering context
|
* @param ctx - Canvas rendering context
|
||||||
* @param text - Text string to measure
|
* @param text - Text string to measure
|
||||||
* @param fontFamily - Font family name
|
|
||||||
* @param fontSize - Font size in pixels
|
* @param fontSize - Font size in pixels
|
||||||
* @param fontWeight - Font weight
|
* @param fontWeight - Font weight (100-900)
|
||||||
|
* @param fontFamily - Font family name (optional, returns 0 if missing)
|
||||||
|
* @returns Width of text in pixels
|
||||||
*/
|
*/
|
||||||
function measureText(
|
function measureText(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
@@ -58,8 +121,13 @@ export function createCharacterComparison<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the appropriate font size based on window width.
|
* Gets responsive font size based on viewport width
|
||||||
* Matches the Tailwind breakpoints used in the component.
|
*
|
||||||
|
* Matches Tailwind breakpoints used in the component:
|
||||||
|
* - < 640px: 64px
|
||||||
|
* - 640-767px: 80px
|
||||||
|
* - 768-1023px: 96px
|
||||||
|
* - >= 1024px: 112px
|
||||||
*/
|
*/
|
||||||
function getFontSize() {
|
function getFontSize() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -75,13 +143,14 @@ export function createCharacterComparison<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Breaks the text into lines based on the container width and measure canvas.
|
* Breaks text into lines based on container width
|
||||||
* Populates the `lines` state.
|
|
||||||
*
|
*
|
||||||
* @param container - The container element to measure width from
|
* Measures text in BOTH fonts and uses the wider width to prevent
|
||||||
* @param measureCanvas - The canvas element used for text measurement
|
* layout shifts. Uses binary search for efficient word breaking.
|
||||||
|
*
|
||||||
|
* @param container - Container element to measure width from
|
||||||
|
* @param measureCanvas - Hidden canvas element for text measurement
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function breakIntoLines(
|
function breakIntoLines(
|
||||||
container: HTMLElement | undefined,
|
container: HTMLElement | undefined,
|
||||||
measureCanvas: HTMLCanvasElement | undefined,
|
measureCanvas: HTMLCanvasElement | undefined,
|
||||||
@@ -90,13 +159,11 @@ export function createCharacterComparison<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
|
// Use offsetWidth to avoid CSS transform scaling issues
|
||||||
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking
|
// getBoundingClientRect() includes transform scale which breaks calculations
|
||||||
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
|
|
||||||
const width = container.offsetWidth;
|
const width = container.offsetWidth;
|
||||||
containerWidth = width;
|
containerWidth = width;
|
||||||
|
|
||||||
// Padding considerations - matches the container padding
|
|
||||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||||
const availableWidth = width - padding;
|
const availableWidth = width - padding;
|
||||||
const ctx = measureCanvas.getContext('2d');
|
const ctx = measureCanvas.getContext('2d');
|
||||||
@@ -106,17 +173,19 @@ export function createCharacterComparison<
|
|||||||
|
|
||||||
const controlledFontSize = size();
|
const controlledFontSize = size();
|
||||||
const fontSize = getFontSize();
|
const fontSize = getFontSize();
|
||||||
const currentWeight = weight(); // Get current weight
|
const currentWeight = weight();
|
||||||
const words = text().split(' ');
|
const words = text().split(' ');
|
||||||
const newLines: LineData[] = [];
|
const newLines: LineData[] = [];
|
||||||
let currentLineWords: string[] = [];
|
let currentLineWords: string[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a line to the output using the wider font's width
|
||||||
|
*/
|
||||||
function pushLine(words: string[]) {
|
function pushLine(words: string[]) {
|
||||||
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lineText = words.join(' ');
|
const lineText = words.join(' ');
|
||||||
// Measure both fonts at the CURRENT weight
|
|
||||||
const widthA = measureText(
|
const widthA = measureText(
|
||||||
ctx!,
|
ctx!,
|
||||||
lineText,
|
lineText,
|
||||||
@@ -139,7 +208,7 @@ export function createCharacterComparison<
|
|||||||
const testLine = currentLineWords.length > 0
|
const testLine = currentLineWords.length > 0
|
||||||
? currentLineWords.join(' ') + ' ' + word
|
? currentLineWords.join(' ') + ' ' + word
|
||||||
: word;
|
: word;
|
||||||
// Measure with both fonts and use the wider one to prevent layout shifts
|
// Measure with both fonts - use wider to prevent shifts
|
||||||
const widthA = measureText(
|
const widthA = measureText(
|
||||||
ctx,
|
ctx,
|
||||||
testLine,
|
testLine,
|
||||||
@@ -163,6 +232,7 @@ export function createCharacterComparison<
|
|||||||
currentLineWords = [];
|
currentLineWords = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if word alone fits
|
||||||
const wordWidthA = measureText(
|
const wordWidthA = measureText(
|
||||||
ctx,
|
ctx,
|
||||||
word,
|
word,
|
||||||
@@ -180,16 +250,16 @@ export function createCharacterComparison<
|
|||||||
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
|
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
|
||||||
|
|
||||||
if (wordAloneWidth <= availableWidth) {
|
if (wordAloneWidth <= availableWidth) {
|
||||||
// If word fits start new line with it
|
|
||||||
currentLineWords = [word];
|
currentLineWords = [word];
|
||||||
} else {
|
} else {
|
||||||
|
// Word doesn't fit - binary search to find break point
|
||||||
let remainingWord = word;
|
let remainingWord = word;
|
||||||
while (remainingWord.length > 0) {
|
while (remainingWord.length > 0) {
|
||||||
let low = 1;
|
let low = 1;
|
||||||
let high = remainingWord.length;
|
let high = remainingWord.length;
|
||||||
let bestBreak = 1;
|
let bestBreak = 1;
|
||||||
|
|
||||||
// Binary Search to find the maximum characters that fit
|
// Binary search for maximum characters that fit
|
||||||
while (low <= high) {
|
while (low <= high) {
|
||||||
const mid = Math.floor((low + high) / 2);
|
const mid = Math.floor((low + high) / 2);
|
||||||
const testFragment = remainingWord.slice(0, mid);
|
const testFragment = remainingWord.slice(0, mid);
|
||||||
@@ -236,13 +306,16 @@ export function createCharacterComparison<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* precise calculation of character state based on global slider position.
|
* Calculates character proximity to slider position
|
||||||
*
|
*
|
||||||
* @param charIndex - Index of the character in the line
|
* Used for morphing effects - returns how close a character is to
|
||||||
* @param sliderPos - Current slider position (0-100)
|
* the slider and whether it's on the "past" side.
|
||||||
* @param lineElement - The line element
|
*
|
||||||
* @param container - The container element
|
* @param charIndex - Index of character within its line
|
||||||
* @returns Object containing proximity (0-1) and isPast (boolean)
|
* @param sliderPos - Slider position (0-100, percent across container)
|
||||||
|
* @param lineElement - The line element containing the character
|
||||||
|
* @param container - The container element for position calculations
|
||||||
|
* @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider)
|
||||||
*/
|
*/
|
||||||
function getCharState(
|
function getCharState(
|
||||||
charIndex: number,
|
charIndex: number,
|
||||||
@@ -262,14 +335,15 @@ export function createCharacterComparison<
|
|||||||
return { proximity: 0, isPast: false };
|
return { proximity: 0, isPast: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the actual bounding box of the character
|
// Get character bounding box relative to container
|
||||||
const charRect = charElement.getBoundingClientRect();
|
const charRect = charElement.getBoundingClientRect();
|
||||||
const containerRect = container.getBoundingClientRect();
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
// Calculate character center relative to container
|
// Calculate character center as percentage of container width
|
||||||
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
|
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
|
||||||
const charGlobalPercent = (charCenter / containerWidth) * 100;
|
const charGlobalPercent = (charCenter / containerWidth) * 100;
|
||||||
|
|
||||||
|
// Calculate proximity (1.0 = at slider, 0.0 = 5% away)
|
||||||
const distance = Math.abs(sliderPos - charGlobalPercent);
|
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||||
const range = 5;
|
const range = 5;
|
||||||
const proximity = Math.max(0, 1 - distance / range);
|
const proximity = Math.max(0, 1 - distance / range);
|
||||||
@@ -279,15 +353,22 @@ export function createCharacterComparison<
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
/** Reactive array of broken lines */
|
||||||
get lines() {
|
get lines() {
|
||||||
return lines;
|
return lines;
|
||||||
},
|
},
|
||||||
|
/** Container width in pixels */
|
||||||
get containerWidth() {
|
get containerWidth() {
|
||||||
return containerWidth;
|
return containerWidth;
|
||||||
},
|
},
|
||||||
|
/** Break text into lines based on current container and fonts */
|
||||||
breakIntoLines,
|
breakIntoLines,
|
||||||
|
/** Get character state for morphing calculations */
|
||||||
getCharState,
|
getCharState,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representing a character comparison instance
|
||||||
|
*/
|
||||||
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
vi,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createCharacterComparison } from './createCharacterComparison.svelte';
|
||||||
|
|
||||||
|
type Font = { name: string; id: string };
|
||||||
|
|
||||||
|
const fontA: Font = { name: 'Roboto', id: 'roboto' };
|
||||||
|
const fontB: Font = { name: 'Open Sans', id: 'open-sans' };
|
||||||
|
|
||||||
|
function createMockCanvas(charWidth = 10): HTMLCanvasElement {
|
||||||
|
return {
|
||||||
|
getContext: () => ({
|
||||||
|
font: '',
|
||||||
|
measureText: (text: string) => ({ width: text.length * charWidth }),
|
||||||
|
}),
|
||||||
|
} as unknown as HTMLCanvasElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockContainer(offsetWidth = 500): HTMLElement {
|
||||||
|
return {
|
||||||
|
offsetWidth,
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
left: 0,
|
||||||
|
width: offsetWidth,
|
||||||
|
top: 0,
|
||||||
|
right: offsetWidth,
|
||||||
|
bottom: 0,
|
||||||
|
height: 0,
|
||||||
|
}),
|
||||||
|
} as unknown as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createCharacterComparison', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock window.innerWidth for getFontSize and padding calculations
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
value: { innerWidth: 1024 },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initial State', () => {
|
||||||
|
it('should initialize with empty lines and zero container width', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'test',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(comparison.lines).toEqual([]);
|
||||||
|
expect(comparison.containerWidth).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('breakIntoLines', () => {
|
||||||
|
it('should not break lines when container or canvas is undefined', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'Hello world',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(undefined, undefined);
|
||||||
|
expect(comparison.lines).toEqual([]);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(), undefined);
|
||||||
|
expect(comparison.lines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not break lines when fonts are undefined', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'Hello world',
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(), createMockCanvas());
|
||||||
|
expect(comparison.lines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should produce a single line when text fits within container', () => {
|
||||||
|
// charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404
|
||||||
|
// "Hello" = 5 chars * 10 = 50px, fits easily
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'Hello',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
||||||
|
|
||||||
|
expect(comparison.lines).toHaveLength(1);
|
||||||
|
expect(comparison.lines[0].text).toBe('Hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should break text into multiple lines when it overflows', () => {
|
||||||
|
// charWidth=10, container=200, padding=96, availableWidth=104
|
||||||
|
// "Hello world test" => "Hello" (50px), "Hello world" (110px > 104)
|
||||||
|
// So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'Hello world test',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10));
|
||||||
|
|
||||||
|
expect(comparison.lines.length).toBeGreaterThan(1);
|
||||||
|
// All original text should be preserved across lines
|
||||||
|
const reconstructed = comparison.lines.map(l => l.text).join(' ');
|
||||||
|
expect(reconstructed).toBe('Hello world test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update containerWidth after breaking lines', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'Hi',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10));
|
||||||
|
|
||||||
|
expect(comparison.containerWidth).toBe(750);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use smaller padding on narrow viewports', () => {
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
value: { innerWidth: 500 },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// container=150, padding=48 (innerWidth<640), availableWidth=102
|
||||||
|
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'ABCDEFGHIJ',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
|
||||||
|
|
||||||
|
expect(comparison.lines).toHaveLength(1);
|
||||||
|
expect(comparison.lines[0].text).toBe('ABCDEFGHIJ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should break a single long word using binary search', () => {
|
||||||
|
// container=150, padding=96, availableWidth=54
|
||||||
|
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word
|
||||||
|
// Binary search should split it
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'ABCDEFGHIJ',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
|
||||||
|
|
||||||
|
expect(comparison.lines.length).toBeGreaterThan(1);
|
||||||
|
const reconstructed = comparison.lines.map(l => l.text).join('');
|
||||||
|
expect(reconstructed).toBe('ABCDEFGHIJ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store max width between both fonts for each line', () => {
|
||||||
|
// Use a canvas where measureText returns text.length * charWidth
|
||||||
|
// Both fonts measure the same, so width = text.length * charWidth
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'Hi',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
||||||
|
|
||||||
|
expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCharState', () => {
|
||||||
|
it('should return zero proximity and isPast=false when containerWidth is 0', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'test',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = comparison.getCharState(0, 50, undefined, undefined);
|
||||||
|
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return zero proximity when charElement is not found', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'test',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
// First break lines to set containerWidth
|
||||||
|
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
||||||
|
|
||||||
|
const lineEl = { children: [] } as unknown as HTMLElement;
|
||||||
|
const container = createMockContainer(500);
|
||||||
|
const state = comparison.getCharState(0, 50, lineEl, container);
|
||||||
|
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate proximity based on distance from slider', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'test',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
||||||
|
|
||||||
|
// Character centered at 250px in a 500px container = 50%
|
||||||
|
const charEl = {
|
||||||
|
getBoundingClientRect: () => ({ left: 240, width: 20 }),
|
||||||
|
};
|
||||||
|
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
||||||
|
const container = createMockContainer(500);
|
||||||
|
|
||||||
|
// Slider at 50% => charCenter at 250px => charGlobalPercent = 50%
|
||||||
|
// distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1
|
||||||
|
const state = comparison.getCharState(0, 50, lineEl, container);
|
||||||
|
|
||||||
|
expect(state.proximity).toBe(1);
|
||||||
|
expect(state.isPast).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return isPast=true when slider is past the character', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'test',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
||||||
|
|
||||||
|
// Character centered at 100px => 20% of 500px
|
||||||
|
const charEl = {
|
||||||
|
getBoundingClientRect: () => ({ left: 90, width: 20 }),
|
||||||
|
};
|
||||||
|
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
||||||
|
const container = createMockContainer(500);
|
||||||
|
|
||||||
|
// Slider at 80% => past the character at 20%
|
||||||
|
const state = comparison.getCharState(0, 80, lineEl, container);
|
||||||
|
|
||||||
|
expect(state.isPast).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return zero proximity when character is far from slider', () => {
|
||||||
|
const comparison = createCharacterComparison(
|
||||||
|
() => 'test',
|
||||||
|
() => fontA,
|
||||||
|
() => fontB,
|
||||||
|
() => 400,
|
||||||
|
() => 48,
|
||||||
|
);
|
||||||
|
|
||||||
|
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
||||||
|
|
||||||
|
// Character at 10% of container, slider at 90% => distance = 80%, range = 5%
|
||||||
|
const charEl = {
|
||||||
|
getBoundingClientRect: () => ({ left: 45, width: 10 }),
|
||||||
|
};
|
||||||
|
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
||||||
|
const container = createMockContainer(500);
|
||||||
|
|
||||||
|
const state = comparison.getCharState(0, 90, lineEl, container);
|
||||||
|
|
||||||
|
expect(state.proximity).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,48 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Generic entity store using Svelte 5's reactive SvelteMap
|
||||||
|
*
|
||||||
|
* Provides O(1) lookups by ID and granular reactivity for entity collections.
|
||||||
|
* Ideal for managing collections of objects with unique identifiers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* interface User extends Entity {
|
||||||
|
* id: string;
|
||||||
|
* name: string;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const store = createEntityStore<User>([
|
||||||
|
* { id: '1', name: 'Alice' },
|
||||||
|
* { id: '2', name: 'Bob' }
|
||||||
|
* ]);
|
||||||
|
*
|
||||||
|
* // Access is reactive in Svelte components
|
||||||
|
* const allUsers = store.all;
|
||||||
|
* const alice = store.getById('1');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base entity interface requiring an ID field
|
||||||
|
*/
|
||||||
export interface Entity {
|
export interface Entity {
|
||||||
|
/** Unique identifier for the entity */
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Svelte 5 Entity Store
|
* Reactive entity store with O(1) lookups
|
||||||
* Uses SvelteMap for O(1) lookups and granular reactivity.
|
*
|
||||||
|
* Uses SvelteMap internally for reactive state that automatically
|
||||||
|
* triggers updates when entities are added, removed, or modified.
|
||||||
*/
|
*/
|
||||||
export class EntityStore<T extends Entity> {
|
export class EntityStore<T extends Entity> {
|
||||||
// SvelteMap is a reactive version of the native Map
|
/** Reactive map of entities keyed by ID */
|
||||||
#entities = new SvelteMap<string, T>();
|
#entities = new SvelteMap<string, T>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new entity store with optional initial data
|
||||||
|
* @param initialEntities - Initial entities to populate the store
|
||||||
|
*/
|
||||||
constructor(initialEntities: T[] = []) {
|
constructor(initialEntities: T[] = []) {
|
||||||
this.setAll(initialEntities);
|
this.setAll(initialEntities);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Selectors (Equivalent to Selectors) ---
|
/**
|
||||||
|
* Get all entities as an array
|
||||||
/** Get all entities as an array */
|
* @returns Array of all entities in the store
|
||||||
|
*/
|
||||||
get all() {
|
get all() {
|
||||||
return Array.from(this.#entities.values());
|
return Array.from(this.#entities.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select a single entity by ID */
|
/**
|
||||||
|
* Get a single entity by ID
|
||||||
|
* @param id - Entity ID to look up
|
||||||
|
* @returns The entity if found, undefined otherwise
|
||||||
|
*/
|
||||||
getById(id: string) {
|
getById(id: string) {
|
||||||
return this.#entities.get(id);
|
return this.#entities.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select multiple entities by IDs */
|
/**
|
||||||
|
* Get multiple entities by their IDs
|
||||||
|
* @param ids - Array of entity IDs to look up
|
||||||
|
* @returns Array of found entities (undefined IDs are filtered out)
|
||||||
|
*/
|
||||||
getByIds(ids: string[]) {
|
getByIds(ids: string[]) {
|
||||||
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
|
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Actions (CRUD) ---
|
/**
|
||||||
|
* Add a single entity to the store
|
||||||
|
* @param entity - Entity to add (updates if ID already exists)
|
||||||
|
*/
|
||||||
addOne(entity: T) {
|
addOne(entity: T) {
|
||||||
this.#entities.set(entity.id, entity);
|
this.#entities.set(entity.id, entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add multiple entities to the store
|
||||||
|
* @param entities - Array of entities to add
|
||||||
|
*/
|
||||||
addMany(entities: T[]) {
|
addMany(entities: T[]) {
|
||||||
entities.forEach(e => this.addOne(e));
|
entities.forEach(e => this.addOne(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing entity by merging changes
|
||||||
|
* @param id - ID of entity to update
|
||||||
|
* @param changes - Partial changes to merge into existing entity
|
||||||
|
*/
|
||||||
updateOne(id: string, changes: Partial<T>) {
|
updateOne(id: string, changes: Partial<T>) {
|
||||||
const entity = this.#entities.get(id);
|
const entity = this.#entities.get(id);
|
||||||
if (entity) {
|
if (entity) {
|
||||||
@@ -50,32 +104,61 @@ export class EntityStore<T extends Entity> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a single entity by ID
|
||||||
|
* @param id - ID of entity to remove
|
||||||
|
*/
|
||||||
removeOne(id: string) {
|
removeOne(id: string) {
|
||||||
this.#entities.delete(id);
|
this.#entities.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove multiple entities by their IDs
|
||||||
|
* @param ids - Array of entity IDs to remove
|
||||||
|
*/
|
||||||
removeMany(ids: string[]) {
|
removeMany(ids: string[]) {
|
||||||
ids.forEach(id => this.#entities.delete(id));
|
ids.forEach(id => this.#entities.delete(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all entities in the store
|
||||||
|
* Clears existing entities and adds new ones
|
||||||
|
* @param entities - New entities to populate the store with
|
||||||
|
*/
|
||||||
setAll(entities: T[]) {
|
setAll(entities: T[]) {
|
||||||
this.#entities.clear();
|
this.#entities.clear();
|
||||||
this.addMany(entities);
|
this.addMany(entities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an entity exists in the store
|
||||||
|
* @param id - Entity ID to check
|
||||||
|
* @returns true if entity exists, false otherwise
|
||||||
|
*/
|
||||||
has(id: string) {
|
has(id: string) {
|
||||||
return this.#entities.has(id);
|
return this.#entities.has(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all entities from the store
|
||||||
|
*/
|
||||||
clear() {
|
clear() {
|
||||||
this.#entities.clear();
|
this.#entities.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new EntityStore instance with the given initial entities.
|
* Creates a new entity store instance
|
||||||
* @param initialEntities The initial entities to populate the store with.
|
* @param initialEntities - Initial entities to populate the store with
|
||||||
* @returns - A new EntityStore instance.
|
* @returns A new EntityStore instance
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const store = createEntityStore([
|
||||||
|
* { id: '1', name: 'Item 1' },
|
||||||
|
* { id: '2', name: 'Item 2' }
|
||||||
|
* ]);
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
|
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
|
||||||
return new EntityStore<T>(initialEntities);
|
return new EntityStore<T>(initialEntities);
|
||||||
|
|||||||
@@ -1,35 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Filter state management for multi-select property filtering
|
||||||
|
*
|
||||||
|
* Creates reactive state for managing filterable properties with selection state.
|
||||||
|
* Commonly used for category filters, tag selection, and other multi-select UIs.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const filter = createFilter({
|
||||||
|
* properties: [
|
||||||
|
* { id: 'sans', name: 'Sans Serif', value: 'sans-serif', selected: false },
|
||||||
|
* { id: 'serif', name: 'Serif', value: 'serif', selected: false }
|
||||||
|
* ]
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Access state
|
||||||
|
* filter.selectedProperties; // Currently selected items
|
||||||
|
* filter.selectedCount; // Number of selected items
|
||||||
|
*
|
||||||
|
* // Modify state
|
||||||
|
* filter.toggleProperty('sans');
|
||||||
|
* filter.selectAll();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filterable property with selection state
|
||||||
|
*
|
||||||
|
* @template TValue - The type of the property value (typically string)
|
||||||
|
*/
|
||||||
export interface Property<TValue extends string> {
|
export interface Property<TValue extends string> {
|
||||||
/**
|
/** Unique identifier for the property */
|
||||||
* Property identifier
|
|
||||||
*/
|
|
||||||
id: string;
|
id: string;
|
||||||
/**
|
/** Human-readable display name */
|
||||||
* Property name
|
|
||||||
*/
|
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/** Underlying value for filtering logic */
|
||||||
* Property value
|
|
||||||
*/
|
|
||||||
value: TValue;
|
value: TValue;
|
||||||
/**
|
/** Whether the property is currently selected */
|
||||||
* Property selected state
|
|
||||||
*/
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterModel<TValue extends string> {
|
|
||||||
/**
|
/**
|
||||||
* Properties
|
* Initial state configuration for a filter
|
||||||
|
*
|
||||||
|
* @template TValue - The type of property values
|
||||||
*/
|
*/
|
||||||
|
export interface FilterModel<TValue extends string> {
|
||||||
|
/** Array of filterable properties */
|
||||||
properties: Property<TValue>[];
|
properties: Property<TValue>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a filter store.
|
* Creates a reactive filter store for managing multi-select state
|
||||||
* @param initialState - Initial state of filter store
|
*
|
||||||
|
* Provides methods for toggling, selecting, and deselecting properties
|
||||||
|
* along with derived state for selected items and counts.
|
||||||
|
*
|
||||||
|
* @param initialState - Initial configuration of properties and their selection state
|
||||||
|
* @returns Filter instance with reactive properties and methods
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Create category filter
|
||||||
|
* const categoryFilter = createFilter({
|
||||||
|
* properties: [
|
||||||
|
* { id: 'sans', name: 'Sans Serif', value: 'sans-serif' },
|
||||||
|
* { id: 'serif', name: 'Serif', value: 'serif' },
|
||||||
|
* { id: 'display', name: 'Display', value: 'display' }
|
||||||
|
* ]
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // In a Svelte component
|
||||||
|
* $: selected = categoryFilter.selectedProperties;
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) {
|
export function createFilter<TValue extends string>(initialState: FilterModel<TValue>) {
|
||||||
// We map the initial properties into a reactive state array
|
// Map initial properties to reactive state with defaulted selection
|
||||||
const properties = $state(
|
const properties = $state(
|
||||||
initialState.properties.map(p => ({
|
initialState.properties.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
@@ -41,41 +86,77 @@ export function createFilter<TValue extends string>(initialState: FilterModel<TV
|
|||||||
const findProp = (id: string) => properties.find(p => p.id === id);
|
const findProp = (id: string) => properties.find(p => p.id === id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* All properties with their current selection state
|
||||||
|
*/
|
||||||
get properties() {
|
get properties() {
|
||||||
return properties;
|
return properties;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only properties that are currently selected
|
||||||
|
*/
|
||||||
get selectedProperties() {
|
get selectedProperties() {
|
||||||
return properties.filter(p => p.selected);
|
return properties.filter(p => p.selected);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of currently selected properties
|
||||||
|
*/
|
||||||
get selectedCount() {
|
get selectedCount() {
|
||||||
return properties.filter(p => p.selected)?.length;
|
return properties.filter(p => p.selected)?.length;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the selection state of a property
|
||||||
|
* @param id - Property ID to toggle
|
||||||
|
*/
|
||||||
toggleProperty(id: string) {
|
toggleProperty(id: string) {
|
||||||
const property = findProp(id);
|
const property = findProp(id);
|
||||||
if (property) {
|
if (property) {
|
||||||
property.selected = !property.selected;
|
property.selected = !property.selected;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a property (idempotent - safe if already selected)
|
||||||
|
* @param id - Property ID to select
|
||||||
|
*/
|
||||||
selectProperty(id: string) {
|
selectProperty(id: string) {
|
||||||
const property = findProp(id);
|
const property = findProp(id);
|
||||||
if (property) {
|
if (property) {
|
||||||
property.selected = true;
|
property.selected = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deselect a property (idempotent - safe if already deselected)
|
||||||
|
* @param id - Property ID to deselect
|
||||||
|
*/
|
||||||
deselectProperty(id: string) {
|
deselectProperty(id: string) {
|
||||||
const property = findProp(id);
|
const property = findProp(id);
|
||||||
if (property) {
|
if (property) {
|
||||||
property.selected = false;
|
property.selected = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select all properties
|
||||||
|
*/
|
||||||
selectAll() {
|
selectAll() {
|
||||||
properties.forEach(property => property.selected = true);
|
properties.forEach(property => property.selected = true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deselect all properties
|
||||||
|
*/
|
||||||
deselectAll() {
|
deselectAll() {
|
||||||
properties.forEach(property => property.selected = false);
|
properties.forEach(property => property.selected = false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representing a filter instance
|
||||||
|
*/
|
||||||
export type Filter = ReturnType<typeof createFilter>;
|
export type Filter = ReturnType<typeof createFilter>;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
* Test Suite for createFilter Helper Function
|
* Test Suite for createFilter Helper Function
|
||||||
*
|
*
|
||||||
* This suite tests the Filter logic and state management.
|
* This suite tests the Filter logic and state management.
|
||||||
* Component rendering tests are in CheckboxFilter.svelte.test.ts
|
* Component rendering tests are in FilterGroup.svelte.test.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
describe('createFilter - Filter Logic', () => {
|
describe('createFilter - Filter Logic', () => {
|
||||||
|
|||||||
@@ -1,10 +1,66 @@
|
|||||||
/**
|
/**
|
||||||
* Reusable persistent storage utility using Svelte 5 runes
|
* Persistent localStorage-backed reactive state
|
||||||
*
|
*
|
||||||
* Automatically syncs state with localStorage.
|
* Creates reactive state that automatically syncs with localStorage.
|
||||||
|
* Values persist across browser sessions and are restored on page load.
|
||||||
|
*
|
||||||
|
* Handles edge cases:
|
||||||
|
* - SSR safety (no localStorage on server)
|
||||||
|
* - JSON parse errors (falls back to default)
|
||||||
|
* - Storage quota errors (logs warning, doesn't crash)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Store user preferences
|
||||||
|
* const preferences = createPersistentStore('user-prefs', {
|
||||||
|
* theme: 'dark',
|
||||||
|
* fontSize: 16,
|
||||||
|
* sidebarOpen: true
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Access reactive state
|
||||||
|
* $: currentTheme = preferences.value.theme;
|
||||||
|
*
|
||||||
|
* // Update (auto-saves to localStorage)
|
||||||
|
* preferences.value.theme = 'light';
|
||||||
|
*
|
||||||
|
* // Clear stored value
|
||||||
|
* preferences.clear();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive store backed by localStorage
|
||||||
|
*
|
||||||
|
* The value is loaded from localStorage on initialization and automatically
|
||||||
|
* saved whenever it changes. Uses Svelte 5's $effect for reactive sync.
|
||||||
|
*
|
||||||
|
* @param key - localStorage key for storing the value
|
||||||
|
* @param defaultValue - Default value if no stored value exists
|
||||||
|
* @returns Persistent store with getter/setter and clear method
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Simple value
|
||||||
|
* const counter = createPersistentStore('counter', 0);
|
||||||
|
* counter.value++;
|
||||||
|
*
|
||||||
|
* // Complex object
|
||||||
|
* interface Settings {
|
||||||
|
* theme: 'light' | 'dark';
|
||||||
|
* fontSize: number;
|
||||||
|
* }
|
||||||
|
* const settings = createPersistentStore<Settings>('app-settings', {
|
||||||
|
* theme: 'light',
|
||||||
|
* fontSize: 16
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||||
// Initialize from storage or default
|
/**
|
||||||
|
* Load value from localStorage or return default
|
||||||
|
* Safely handles missing keys, parse errors, and SSR
|
||||||
|
*/
|
||||||
const loadFromStorage = (): T => {
|
const loadFromStorage = (): T => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
@@ -21,6 +77,7 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
|||||||
let value = $state<T>(loadFromStorage());
|
let value = $state<T>(loadFromStorage());
|
||||||
|
|
||||||
// Sync to storage whenever value changes
|
// Sync to storage whenever value changes
|
||||||
|
// Wrapped in $effect.root to prevent memory leaks
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -29,18 +86,27 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Quota exceeded or privacy mode - log but don't crash
|
||||||
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
|
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* Current value (getter/setter)
|
||||||
|
* Changes automatically persist to localStorage
|
||||||
|
*/
|
||||||
get value() {
|
get value() {
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
set value(v: T) {
|
set value(v: T) {
|
||||||
value = v;
|
value = v;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove value from localStorage and reset to default
|
||||||
|
*/
|
||||||
clear() {
|
clear() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
@@ -50,4 +116,7 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representing a persistent store instance
|
||||||
|
*/
|
||||||
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;
|
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;
|
||||||
|
|||||||
@@ -1,61 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* 3D perspective animation state manager
|
||||||
|
*
|
||||||
|
* Manages smooth transitions between "front" (interactive) and "back" (background)
|
||||||
|
* visual states using Svelte springs. Used for creating depth-based UI effects
|
||||||
|
* like settings panels, modal transitions, and spatial navigation.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* import { createPerspectiveManager } from '$shared/lib/helpers';
|
||||||
|
*
|
||||||
|
* const perspective = createPerspectiveManager({
|
||||||
|
* depthStep: 100,
|
||||||
|
* scaleStep: 0.5,
|
||||||
|
* blurStep: 4
|
||||||
|
* });
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* <div
|
||||||
|
* style="transform: scale({perspective.isBack ? 0.5 : 1});
|
||||||
|
* filter: blur({perspective.isBack ? 4 : 0}px)"
|
||||||
|
* >
|
||||||
|
* <button on:click={perspective.toggle}>Toggle View</button>
|
||||||
|
* </div>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for perspective effects
|
||||||
|
*/
|
||||||
export interface PerspectiveConfig {
|
export interface PerspectiveConfig {
|
||||||
/**
|
/** Z-axis translation per level in pixels */
|
||||||
* How many px to move back per level
|
|
||||||
*/
|
|
||||||
depthStep?: number;
|
depthStep?: number;
|
||||||
/**
|
/** Scale reduction per level (0-1) */
|
||||||
* Scale reduction per level
|
|
||||||
*/
|
|
||||||
scaleStep?: number;
|
scaleStep?: number;
|
||||||
/**
|
/** Blur amount per level in pixels */
|
||||||
* Blur amount per level
|
|
||||||
*/
|
|
||||||
blurStep?: number;
|
blurStep?: number;
|
||||||
/**
|
/** Opacity reduction per level (0-1) */
|
||||||
* Opacity reduction per level
|
|
||||||
*/
|
|
||||||
opacityStep?: number;
|
opacityStep?: number;
|
||||||
/**
|
/** Parallax movement intensity per level */
|
||||||
* Parallax intensity per level
|
|
||||||
*/
|
|
||||||
parallaxIntensity?: number;
|
parallaxIntensity?: number;
|
||||||
/**
|
/** Horizontal offset - positive for right, negative for left */
|
||||||
* Horizontal offset for each plan (x-axis positioning)
|
|
||||||
* Positive = right, Negative = left
|
|
||||||
*/
|
|
||||||
horizontalOffset?: number;
|
horizontalOffset?: number;
|
||||||
/**
|
/** Layout mode: 'center' for centered, 'split' for side-by-side */
|
||||||
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
|
|
||||||
*/
|
|
||||||
layoutMode?: 'center' | 'split';
|
layoutMode?: 'center' | 'split';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages perspective state with a simple boolean flag.
|
* Manages perspective state with spring-based transitions
|
||||||
*
|
*
|
||||||
* Drastically simplified from the complex camera/index system.
|
* Simplified from a complex camera system to just track back/front state.
|
||||||
* Just manages whether content is in "back" or "front" state.
|
* The spring value animates between 0 (front) and 1 (back) for smooth
|
||||||
|
* visual transitions of scale, blur, opacity, and position.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```ts
|
||||||
* const perspective = createPerspectiveManager({
|
* const perspective = createPerspectiveManager({
|
||||||
* depthStep: 100,
|
* depthStep: 100,
|
||||||
* scaleStep: 0.5,
|
* scaleStep: 0.5,
|
||||||
* blurStep: 4,
|
* blurStep: 4
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* // Toggle back/front
|
* // Check state (reactive)
|
||||||
* perspective.toggle();
|
* console.log(perspective.isBack); // false
|
||||||
*
|
*
|
||||||
* // Check state
|
* // Toggle with animation
|
||||||
* const isBack = perspective.isBack; // reactive boolean
|
* perspective.toggle(); // Smoothly animates to back position
|
||||||
|
*
|
||||||
|
* // Direct control
|
||||||
|
* perspective.setBack(); // Go to back
|
||||||
|
* perspective.setFront(); // Go to front
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class PerspectiveManager {
|
export class PerspectiveManager {
|
||||||
/**
|
/**
|
||||||
* Spring for smooth back/front transitions
|
* Spring animation state
|
||||||
|
* Animates between 0 (front) and 1 (back) with configurable physics
|
||||||
*/
|
*/
|
||||||
spring = new Spring(0, {
|
spring = new Spring(0, {
|
||||||
stiffness: 0.2,
|
stiffness: 0.2,
|
||||||
@@ -63,20 +85,30 @@ export class PerspectiveManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive boolean: true when in back position (blurred, scaled down)
|
* Reactive state: true when in back position
|
||||||
|
*
|
||||||
|
* Content should appear blurred, scaled down, and less interactive
|
||||||
|
* when this is true. Derived from spring value > 0.5.
|
||||||
*/
|
*/
|
||||||
isBack = $derived(this.spring.current > 0.5);
|
isBack = $derived(this.spring.current > 0.5);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive boolean: true when in front position (fully visible, interactive)
|
* Reactive state: true when in front position
|
||||||
|
*
|
||||||
|
* Content should be fully visible, sharp, and interactive
|
||||||
|
* when this is true. Derived from spring value < 0.5.
|
||||||
*/
|
*/
|
||||||
isFront = $derived(this.spring.current < 0.5);
|
isFront = $derived(this.spring.current < 0.5);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration values for style computation
|
* Internal configuration with defaults applied
|
||||||
*/
|
*/
|
||||||
private config: Required<PerspectiveConfig>;
|
private config: Required<PerspectiveConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new perspective manager
|
||||||
|
* @param config - Configuration for visual effects
|
||||||
|
*/
|
||||||
constructor(config: PerspectiveConfig = {}) {
|
constructor(config: PerspectiveConfig = {}) {
|
||||||
this.config = {
|
this.config = {
|
||||||
depthStep: config.depthStep ?? 100,
|
depthStep: config.depthStep ?? 100,
|
||||||
@@ -90,8 +122,10 @@ export class PerspectiveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle between front (0) and back (1) positions.
|
* Toggle between front and back positions
|
||||||
* Smooth spring animation handles the transition.
|
*
|
||||||
|
* Uses spring animation for smooth transition. Toggles based on
|
||||||
|
* current state - if spring < 0.5 goes to 1, otherwise goes to 0.
|
||||||
*/
|
*/
|
||||||
toggle = () => {
|
toggle = () => {
|
||||||
const target = this.spring.current < 0.5 ? 1 : 0;
|
const target = this.spring.current < 0.5 ? 1 : 0;
|
||||||
@@ -99,31 +133,40 @@ export class PerspectiveManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force to back position
|
* Force to back position (blurred, scaled down)
|
||||||
*/
|
*/
|
||||||
setBack = () => {
|
setBack = () => {
|
||||||
this.spring.target = 1;
|
this.spring.target = 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force to front position
|
* Force to front position (fully visible, interactive)
|
||||||
*/
|
*/
|
||||||
setFront = () => {
|
setFront = () => {
|
||||||
this.spring.target = 0;
|
this.spring.target = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get configuration for style computation
|
* Get current configuration
|
||||||
* @internal
|
* @internal Used by components to compute styles
|
||||||
*/
|
*/
|
||||||
getConfig = () => this.config;
|
getConfig = () => this.config;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory function to create a PerspectiveManager instance.
|
* Factory function to create a perspective manager
|
||||||
*
|
*
|
||||||
* @param config - Configuration options
|
* @param config - Configuration options for visual effects
|
||||||
* @returns Configured PerspectiveManager instance
|
* @returns Configured PerspectiveManager instance
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const perspective = createPerspectiveManager({
|
||||||
|
* scaleStep: 0.6,
|
||||||
|
* blurStep: 8,
|
||||||
|
* layoutMode: 'split'
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
|
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
|
||||||
return new PerspectiveManager(config);
|
return new PerspectiveManager(config);
|
||||||
|
|||||||
@@ -1,66 +1,96 @@
|
|||||||
// $shared/lib/createResponsiveManager.svelte.ts
|
/**
|
||||||
|
* Responsive breakpoint tracking using Svelte 5 runes
|
||||||
|
*
|
||||||
|
* Provides reactive viewport dimensions and breakpoint detection that
|
||||||
|
* automatically updates on window resize. Includes touch device detection
|
||||||
|
* and orientation tracking.
|
||||||
|
*
|
||||||
|
* Default breakpoints match Tailwind CSS:
|
||||||
|
* - xs: < 640px (mobile)
|
||||||
|
* - sm: 640px (mobile)
|
||||||
|
* - md: 768px (tablet portrait)
|
||||||
|
* - lg: 1024px (tablet)
|
||||||
|
* - xl: 1280px (desktop)
|
||||||
|
* - 2xl: 1536px (desktop large)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <script lang="ts">
|
||||||
|
* import { responsiveManager } from '$shared/lib/helpers';
|
||||||
|
*
|
||||||
|
* // Singleton is auto-initialized
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* {#if responsiveManager.isMobile}
|
||||||
|
* <MobileNav />
|
||||||
|
* {:else}
|
||||||
|
* <DesktopNav />
|
||||||
|
* {/if}
|
||||||
|
*
|
||||||
|
* <p>Viewport: {responsiveManager.width}x{responsiveManager.height}</p>
|
||||||
|
* <p>Breakpoint: {responsiveManager.currentBreakpoint}</p>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Breakpoint definitions following common device sizes
|
* Breakpoint definitions for responsive design
|
||||||
* Customize these values to match your design system
|
*
|
||||||
|
* Values represent the minimum width (in pixels) for each breakpoint.
|
||||||
|
* Customize to match your design system's breakpoints.
|
||||||
*/
|
*/
|
||||||
export interface Breakpoints {
|
export interface Breakpoints {
|
||||||
/** Mobile devices (portrait phones) */
|
/** Mobile devices - default 640px */
|
||||||
mobile: number;
|
mobile: number;
|
||||||
/** Tablet portrait */
|
/** Tablet portrait - default 768px */
|
||||||
tabletPortrait: number;
|
tabletPortrait: number;
|
||||||
/** Tablet landscape */
|
/** Tablet landscape - default 1024px */
|
||||||
tablet: number;
|
tablet: number;
|
||||||
/** Desktop */
|
/** Desktop - default 1280px */
|
||||||
desktop: number;
|
desktop: number;
|
||||||
/** Large desktop */
|
/** Large desktop - default 1536px */
|
||||||
desktopLarge: number;
|
desktopLarge: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default breakpoints (matches common Tailwind-like breakpoints)
|
* Default breakpoint values (Tailwind CSS compatible)
|
||||||
*/
|
*/
|
||||||
const DEFAULT_BREAKPOINTS: Breakpoints = {
|
const DEFAULT_BREAKPOINTS: Breakpoints = {
|
||||||
mobile: 640, // sm
|
mobile: 640,
|
||||||
tabletPortrait: 768, // md
|
tabletPortrait: 768,
|
||||||
tablet: 1024, // lg
|
tablet: 1024,
|
||||||
desktop: 1280, // xl
|
desktop: 1280,
|
||||||
desktopLarge: 1536, // 2xl
|
desktopLarge: 1536,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Orientation type
|
* Device orientation type
|
||||||
*/
|
*/
|
||||||
export type Orientation = 'portrait' | 'landscape';
|
export type Orientation = 'portrait' | 'landscape';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
|
* Creates a responsive manager for tracking viewport state
|
||||||
*
|
*
|
||||||
* Provides reactive getters for:
|
* Tracks viewport dimensions, calculates breakpoint states, and detects
|
||||||
* - Current breakpoint detection (isMobile, isTablet, etc.)
|
* device capabilities (touch, orientation). Uses ResizeObserver for
|
||||||
* - Viewport dimensions (width, height)
|
* accurate tracking and falls back to window resize events.
|
||||||
* - Device orientation (portrait/landscape)
|
|
||||||
* - Custom breakpoint matching
|
|
||||||
*
|
*
|
||||||
* @param customBreakpoints - Optional custom breakpoint values
|
* @param customBreakpoints - Optional custom breakpoint values
|
||||||
* @returns Responsive manager instance with reactive properties
|
* @returns Responsive manager instance with reactive properties
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```svelte
|
* ```ts
|
||||||
* <script lang="ts">
|
* // Use defaults
|
||||||
* const responsive = createResponsiveManager();
|
* const responsive = createResponsiveManager();
|
||||||
* </script>
|
|
||||||
*
|
*
|
||||||
* {#if responsive.isMobile}
|
* // Custom breakpoints
|
||||||
* <MobileNav />
|
* const custom = createResponsiveManager({
|
||||||
* {:else if responsive.isTablet}
|
* mobile: 480,
|
||||||
* <TabletNav />
|
* desktop: 1024
|
||||||
* {:else}
|
* });
|
||||||
* <DesktopNav />
|
|
||||||
* {/if}
|
|
||||||
*
|
*
|
||||||
* <p>Width: {responsive.width}px</p>
|
* // In component
|
||||||
* <p>Orientation: {responsive.orientation}</p>
|
* $: isMobile = responsive.isMobile;
|
||||||
|
* $: cols = responsive.isDesktop ? 3 : 1;
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
|
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
|
||||||
@@ -69,7 +99,7 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
|||||||
...customBreakpoints,
|
...customBreakpoints,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reactive state
|
// Reactive viewport dimensions
|
||||||
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
|
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||||
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
|
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||||
|
|
||||||
@@ -90,12 +120,12 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
|||||||
const isMobileOrTablet = $derived(width < breakpoints.desktop);
|
const isMobileOrTablet = $derived(width < breakpoints.desktop);
|
||||||
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
|
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
|
||||||
|
|
||||||
// Orientation
|
// Orientation detection
|
||||||
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
|
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
|
||||||
const isPortrait = $derived(orientation === 'portrait');
|
const isPortrait = $derived(orientation === 'portrait');
|
||||||
const isLandscape = $derived(orientation === 'landscape');
|
const isLandscape = $derived(orientation === 'landscape');
|
||||||
|
|
||||||
// Touch device detection (best effort)
|
// Touch device detection (best effort heuristic)
|
||||||
const isTouchDevice = $derived(
|
const isTouchDevice = $derived(
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
|
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
|
||||||
@@ -103,7 +133,11 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize responsive tracking
|
* Initialize responsive tracking
|
||||||
* Call this in an $effect or component mount
|
*
|
||||||
|
* Sets up ResizeObserver on document.documentElement and falls back
|
||||||
|
* to window resize event listener. Returns cleanup function.
|
||||||
|
*
|
||||||
|
* @returns Cleanup function to remove listeners
|
||||||
*/
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
@@ -130,9 +164,17 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if current width matches a custom breakpoint
|
* Check if current viewport matches a custom breakpoint range
|
||||||
|
*
|
||||||
* @param min - Minimum width (inclusive)
|
* @param min - Minimum width (inclusive)
|
||||||
* @param max - Maximum width (exclusive)
|
* @param max - Optional maximum width (exclusive)
|
||||||
|
* @returns true if viewport width matches the range
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* responsive.matches(768, 1024); // true for tablet only
|
||||||
|
* responsive.matches(1280); // true for desktop and larger
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
function matches(min: number, max?: number): boolean {
|
function matches(min: number, max?: number): boolean {
|
||||||
if (max !== undefined) {
|
if (max !== undefined) {
|
||||||
@@ -142,24 +184,33 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current breakpoint name
|
* Current breakpoint name based on viewport width
|
||||||
*/
|
*/
|
||||||
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
|
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
|
||||||
(() => {
|
(() => {
|
||||||
if (isMobile) return 'mobile';
|
switch (true) {
|
||||||
if (isTabletPortrait) return 'tabletPortrait';
|
case isMobile:
|
||||||
if (isTablet) return 'tablet';
|
return 'mobile';
|
||||||
if (isDesktop) return 'desktop';
|
case isTabletPortrait:
|
||||||
if (isDesktopLarge) return 'desktopLarge';
|
return 'tabletPortrait';
|
||||||
return 'xs'; // Fallback for very small screens
|
case isTablet:
|
||||||
|
return 'tablet';
|
||||||
|
case isDesktop:
|
||||||
|
return 'desktop';
|
||||||
|
case isDesktopLarge:
|
||||||
|
return 'desktopLarge';
|
||||||
|
default:
|
||||||
|
return 'xs';
|
||||||
|
}
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Dimensions
|
/** Viewport width in pixels */
|
||||||
get width() {
|
get width() {
|
||||||
return width;
|
return width;
|
||||||
},
|
},
|
||||||
|
/** Viewport height in pixels */
|
||||||
get height() {
|
get height() {
|
||||||
return height;
|
return height;
|
||||||
},
|
},
|
||||||
@@ -219,6 +270,12 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton responsive manager instance
|
||||||
|
*
|
||||||
|
* Auto-initializes on the client side. Use this throughout the app
|
||||||
|
* rather than creating multiple instances.
|
||||||
|
*/
|
||||||
export const responsiveManager = createResponsiveManager();
|
export const responsiveManager = createResponsiveManager();
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Tests for createResponsiveManager
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { createResponsiveManager } from './createResponsiveManager.svelte';
|
||||||
|
|
||||||
|
describe('createResponsiveManager', () => {
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should create with default breakpoints', () => {
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
|
||||||
|
expect(manager.breakpoints).toEqual({
|
||||||
|
mobile: 640,
|
||||||
|
tabletPortrait: 768,
|
||||||
|
tablet: 1024,
|
||||||
|
desktop: 1280,
|
||||||
|
desktopLarge: 1536,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge custom breakpoints with defaults', () => {
|
||||||
|
const manager = createResponsiveManager({ mobile: 480, desktop: 1200 });
|
||||||
|
|
||||||
|
expect(manager.breakpoints.mobile).toBe(480);
|
||||||
|
expect(manager.breakpoints.desktop).toBe(1200);
|
||||||
|
expect(manager.breakpoints.tablet).toBe(1024); // default preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have initial width and height from window', () => {
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
|
||||||
|
// In test environment, window dimensions come from jsdom/mocks
|
||||||
|
expect(typeof manager.width).toBe('number');
|
||||||
|
expect(typeof manager.height).toBe('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matches', () => {
|
||||||
|
it('should return true when width is above min', () => {
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
|
||||||
|
// width is 0 in node env (no window), so matches(0) should be true
|
||||||
|
expect(manager.matches(0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when width is below min', () => {
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
|
||||||
|
// width is 0, so matches(100) should be false
|
||||||
|
expect(manager.matches(100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle range with max', () => {
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
|
||||||
|
// width is 0, so matches(0, 100) should be true (0 >= 0 && 0 < 100)
|
||||||
|
expect(manager.matches(0, 100)).toBe(true);
|
||||||
|
// matches(1, 100) should be false (0 >= 1 is false)
|
||||||
|
expect(manager.matches(1, 100)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('breakpoint states at width 0', () => {
|
||||||
|
it('should report isMobile when width is 0', () => {
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
|
||||||
|
expect(manager.isMobile).toBe(true);
|
||||||
|
expect(manager.isTabletPortrait).toBe(false);
|
||||||
|
expect(manager.isTablet).toBe(false);
|
||||||
|
expect(manager.isDesktop).toBe(false);
|
||||||
|
expect(manager.isDesktopLarge).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report correct convenience groupings', () => {
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
|
||||||
|
expect(manager.isMobileOrTablet).toBe(true);
|
||||||
|
expect(manager.isTabletOrDesktop).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('orientation', () => {
|
||||||
|
it('should detect portrait when height > width', () => {
|
||||||
|
// Default: width=0, height=0 => not portrait (0 > 0 is false)
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
|
||||||
|
expect(manager.orientation).toBe('landscape');
|
||||||
|
expect(manager.isLandscape).toBe(true);
|
||||||
|
expect(manager.isPortrait).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
it('should return undefined in non-browser environment', () => {
|
||||||
|
const manager = createResponsiveManager();
|
||||||
|
const cleanup = manager.init();
|
||||||
|
|
||||||
|
// In node test env, window is undefined so init returns early
|
||||||
|
expect(cleanup).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,46 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Numeric control with bounded values and step precision
|
||||||
|
*
|
||||||
|
* Creates a reactive control for numeric values that enforces min/max bounds
|
||||||
|
* and rounds to a specific step increment. Commonly used for typography controls
|
||||||
|
* like font size, line height, and letter spacing.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const fontSize = createTypographyControl({
|
||||||
|
* value: 16,
|
||||||
|
* min: 12,
|
||||||
|
* max: 72,
|
||||||
|
* step: 1
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Access current value
|
||||||
|
* fontSize.value; // 16
|
||||||
|
* fontSize.isAtMin; // false
|
||||||
|
*
|
||||||
|
* // Modify value (automatically clamped and rounded)
|
||||||
|
* fontSize.increase();
|
||||||
|
* fontSize.value = 100; // Will be clamped to max (72)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clampNumber,
|
clampNumber,
|
||||||
roundToStepPrecision,
|
roundToStepPrecision,
|
||||||
} from '$shared/lib/utils';
|
} from '$shared/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core numeric control configuration
|
||||||
|
* Defines the bounds and stepping behavior for a control
|
||||||
|
*/
|
||||||
export interface ControlDataModel {
|
export interface ControlDataModel {
|
||||||
/**
|
/** Current numeric value */
|
||||||
* Control value
|
|
||||||
*/
|
|
||||||
value: number;
|
value: number;
|
||||||
/**
|
/** Minimum allowed value (inclusive) */
|
||||||
* Minimal possible value
|
|
||||||
*/
|
|
||||||
min: number;
|
min: number;
|
||||||
/**
|
/** Maximum allowed value (inclusive) */
|
||||||
* Maximal possible value
|
|
||||||
*/
|
|
||||||
max: number;
|
max: number;
|
||||||
/**
|
/** Step size for increment/decrement operations */
|
||||||
* Step size for increase/decrease
|
|
||||||
*/
|
|
||||||
step: number;
|
step: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full control model including accessibility labels
|
||||||
|
*
|
||||||
|
* @template T - Type for the control identifier
|
||||||
|
*/
|
||||||
export interface ControlModel<T extends string = string> extends ControlDataModel {
|
export interface ControlModel<T extends string = string> extends ControlDataModel {
|
||||||
/**
|
/** Unique identifier for the control */
|
||||||
* Control identifier
|
|
||||||
*/
|
|
||||||
id: T;
|
id: T;
|
||||||
/**
|
/** ARIA label for the increase button */
|
||||||
* Area label for increase button
|
|
||||||
*/
|
|
||||||
increaseLabel?: string;
|
increaseLabel?: string;
|
||||||
/**
|
/** ARIA label for the decrease button */
|
||||||
* Area label for decrease button
|
|
||||||
*/
|
|
||||||
decreaseLabel?: string;
|
decreaseLabel?: string;
|
||||||
/**
|
/** ARIA label for the control area */
|
||||||
* Control area label
|
|
||||||
*/
|
|
||||||
controlLabel?: string;
|
controlLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reactive numeric control with bounds and stepping
|
||||||
|
*
|
||||||
|
* The control automatically:
|
||||||
|
* - Clamps values to the min/max range
|
||||||
|
* - Rounds values to the step precision
|
||||||
|
* - Tracks whether at min/max bounds
|
||||||
|
*
|
||||||
|
* @param initialState - Initial value, bounds, and step configuration
|
||||||
|
* @returns Typography control instance with reactive state and methods
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* // Font size control: 12-72px in 1px increments
|
||||||
|
* const fontSize = createTypographyControl({
|
||||||
|
* value: 16,
|
||||||
|
* min: 12,
|
||||||
|
* max: 72,
|
||||||
|
* step: 1
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Line height control: 1.0-2.0 in 0.1 increments
|
||||||
|
* const lineHeight = createTypographyControl({
|
||||||
|
* value: 1.5,
|
||||||
|
* min: 1.0,
|
||||||
|
* max: 2.0,
|
||||||
|
* step: 0.1
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Direct assignment (auto-clamped)
|
||||||
|
* fontSize.value = 100; // Becomes 72 (max)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export function createTypographyControl<T extends ControlDataModel>(
|
export function createTypographyControl<T extends ControlDataModel>(
|
||||||
initialState: T,
|
initialState: T,
|
||||||
) {
|
) {
|
||||||
@@ -49,12 +101,17 @@ export function createTypographyControl<T extends ControlDataModel>(
|
|||||||
let min = $state(initialState.min);
|
let min = $state(initialState.min);
|
||||||
let step = $state(initialState.step);
|
let step = $state(initialState.step);
|
||||||
|
|
||||||
|
// Derived state for boundary detection
|
||||||
const { isAtMax, isAtMin } = $derived({
|
const { isAtMax, isAtMin } = $derived({
|
||||||
isAtMax: value >= max,
|
isAtMax: value >= max,
|
||||||
isAtMin: value <= min,
|
isAtMin: value <= min,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* Current control value (getter/setter)
|
||||||
|
* Setting automatically clamps to bounds and rounds to step precision
|
||||||
|
*/
|
||||||
get value() {
|
get value() {
|
||||||
return value;
|
return value;
|
||||||
},
|
},
|
||||||
@@ -64,27 +121,45 @@ export function createTypographyControl<T extends ControlDataModel>(
|
|||||||
value = rounded;
|
value = rounded;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Maximum allowed value */
|
||||||
get max() {
|
get max() {
|
||||||
return max;
|
return max;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Minimum allowed value */
|
||||||
get min() {
|
get min() {
|
||||||
return min;
|
return min;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Step increment size */
|
||||||
get step() {
|
get step() {
|
||||||
return step;
|
return step;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Whether the value is at or exceeds the maximum */
|
||||||
get isAtMax() {
|
get isAtMax() {
|
||||||
return isAtMax;
|
return isAtMax;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Whether the value is at or below the minimum */
|
||||||
get isAtMin() {
|
get isAtMin() {
|
||||||
return isAtMin;
|
return isAtMin;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increase value by one step (clamped to max)
|
||||||
|
*/
|
||||||
increase() {
|
increase() {
|
||||||
value = roundToStepPrecision(
|
value = roundToStepPrecision(
|
||||||
clampNumber(value + step, min, max),
|
clampNumber(value + step, min, max),
|
||||||
step,
|
step,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrease value by one step (clamped to min)
|
||||||
|
*/
|
||||||
decrease() {
|
decrease() {
|
||||||
value = roundToStepPrecision(
|
value = roundToStepPrecision(
|
||||||
clampNumber(value - step, min, max),
|
clampNumber(value - step, min, max),
|
||||||
@@ -94,4 +169,7 @@ export function createTypographyControl<T extends ControlDataModel>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type representing a typography control instance
|
||||||
|
*/
|
||||||
export type TypographyControl = ReturnType<typeof createTypographyControl>;
|
export type TypographyControl = ReturnType<typeof createTypographyControl>;
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ export function createVirtualizer<T>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
containerHeight = node.offsetHeight;
|
containerHeight = node.clientHeight;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
scrollOffset = node.scrollTop;
|
scrollOffset = node.scrollTop;
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ function createMockContainer(height = 500, scrollTop = 0): any {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
Object.defineProperty(container, 'clientHeight', {
|
||||||
|
value: height,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
Object.defineProperty(container, 'scrollTop', {
|
Object.defineProperty(container, 'scrollTop', {
|
||||||
value: scrollTop,
|
value: scrollTop,
|
||||||
writable: true,
|
writable: true,
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Reactive helper factories using Svelte 5 runes
|
||||||
|
*
|
||||||
|
* Provides composable state management patterns for common UI needs:
|
||||||
|
* - Filter management with multi-selection
|
||||||
|
* - Typography controls with bounds and stepping
|
||||||
|
* - Virtual scrolling for large lists
|
||||||
|
* - Debounced state for search inputs
|
||||||
|
* - Entity stores with O(1) lookups
|
||||||
|
* - Character-by-character font comparison
|
||||||
|
* - Persistent localStorage-backed state
|
||||||
|
* - Responsive breakpoint tracking
|
||||||
|
* - 3D perspective animations
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { createFilter, createVirtualizer, createTypographyControl } from '$shared/lib/helpers';
|
||||||
|
*
|
||||||
|
* const filter = createFilter({ properties: [...] });
|
||||||
|
* const virtualizer = createVirtualizer(() => ({ count: 1000, estimateSize: () => 50 }));
|
||||||
|
* const control = createTypographyControl({ value: 16, min: 12, max: 72, step: 1 });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createFilter,
|
createFilter,
|
||||||
type Filter,
|
type Filter,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Shared library
|
||||||
|
*
|
||||||
|
* Reusable utilities, helpers, and providers for the application.
|
||||||
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type CharacterComparison,
|
type CharacterComparison,
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { setContext } from 'svelte';
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Content snippet
|
||||||
|
*/
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ import type {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* The Lucide icon component
|
* Lucide icon component
|
||||||
*/
|
*/
|
||||||
icon: Component;
|
icon: Component;
|
||||||
/**
|
/**
|
||||||
* CSS classes to apply to the icon
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
/**
|
/**
|
||||||
* Additional icon-specific attributes
|
* Additional attributes
|
||||||
*/
|
*/
|
||||||
attrs?: Record<string, unknown>;
|
attrs?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,17 +15,22 @@ import { setContext } from 'svelte';
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Content snippet
|
||||||
|
*/
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
/**
|
/**
|
||||||
* Initial viewport width for the responsive context (default: 1280)
|
* Initial viewport width
|
||||||
|
* @default 1280
|
||||||
*/
|
*/
|
||||||
initialWidth?: number;
|
initialWidth?: number;
|
||||||
/**
|
/**
|
||||||
* Initial viewport height for the responsive context (default: 720)
|
* Initial viewport height
|
||||||
|
* @default 720
|
||||||
*/
|
*/
|
||||||
initialHeight?: number;
|
initialHeight?: number;
|
||||||
/**
|
/**
|
||||||
* Tooltip provider options
|
* Tooltip delay duration
|
||||||
*/
|
*/
|
||||||
tooltipDelayDuration?: number;
|
tooltipDelayDuration?: number;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,56 +1,46 @@
|
|||||||
/**
|
/**
|
||||||
* Build query string from URL parameters
|
* Builds URL query strings from parameter objects
|
||||||
*
|
*
|
||||||
* Generic, type-safe function to build properly encoded query strings
|
* Creates properly encoded query strings from typed parameter objects.
|
||||||
* from URL parameters. Supports primitives, arrays, and optional values.
|
* Handles primitives, arrays, and omits null/undefined values.
|
||||||
*
|
|
||||||
* @param params - Object containing query parameters
|
|
||||||
* @returns Encoded query string (empty string if no parameters)
|
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
|
* buildQueryString({ category: 'serif', subsets: ['latin', 'latin-ext'] })
|
||||||
* // Returns: "category=serif&subsets=latin&subsets=latin-ext"
|
* // Returns: "?category=serif&subsets=latin%2Clatin-ext"
|
||||||
*
|
*
|
||||||
* buildQueryString({ limit: 50, page: 1 })
|
* buildQueryString({ limit: 50, page: 1 })
|
||||||
* // Returns: "limit=50&page=1"
|
* // Returns: "?limit=50&page=1"
|
||||||
*
|
*
|
||||||
* buildQueryString({})
|
* buildQueryString({})
|
||||||
* // Returns: ""
|
* // Returns: ""
|
||||||
*
|
*
|
||||||
* buildQueryString({ search: 'hello world', active: true })
|
* buildQueryString({ search: 'hello world', active: true })
|
||||||
* // Returns: "search=hello%20world&active=true"
|
* // Returns: "?search=hello%20world&active=true"
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query parameter value type
|
* Supported query parameter value types
|
||||||
* Supports primitives, arrays, and excludes null/undefined
|
|
||||||
*/
|
*/
|
||||||
export type QueryParamValue = string | number | boolean | string[] | number[];
|
export type QueryParamValue = string | number | boolean | string[] | number[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query parameters object
|
* Query parameters object with optional values
|
||||||
*/
|
*/
|
||||||
export type QueryParams = Record<string, QueryParamValue | undefined | null>;
|
export type QueryParams = Record<string, QueryParamValue | undefined | null>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build query string from URL parameters
|
* Builds a URL query string from a parameters object
|
||||||
*
|
*
|
||||||
* Handles:
|
* Handles:
|
||||||
* - Primitive values (string, number, boolean)
|
* - Primitive values (string, number, boolean) - converted to strings
|
||||||
* - Arrays (multiple values with same key)
|
* - Arrays - comma-separated values
|
||||||
* - Optional values (excludes undefined/null)
|
* - null/undefined - omitted from output
|
||||||
* - Proper URL encoding
|
* - Special characters - URL encoded
|
||||||
*
|
|
||||||
* Edge cases:
|
|
||||||
* - Empty object → empty string
|
|
||||||
* - No parameters → empty string
|
|
||||||
* - Nested objects → flattens to string representation
|
|
||||||
* - Special characters → proper encoding
|
|
||||||
*
|
*
|
||||||
* @param params - Object containing query parameters
|
* @param params - Object containing query parameters
|
||||||
* @returns Encoded query string (with "?" prefix if non-empty)
|
* @returns Encoded query string with "?" prefix, or empty string if no params
|
||||||
*/
|
*/
|
||||||
export function buildQueryString(params: QueryParams): string {
|
export function buildQueryString(params: QueryParams): string {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
@@ -61,12 +51,14 @@ export function buildQueryString(params: QueryParams): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle arrays (multiple values with same key)
|
// Handle arrays (comma-separated values)
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const item of value) {
|
const joined = value
|
||||||
if (item !== undefined && item !== null) {
|
.filter(item => item !== undefined && item !== null)
|
||||||
searchParams.append(key, String(item));
|
.map(String)
|
||||||
}
|
.join(',');
|
||||||
|
if (joined) {
|
||||||
|
searchParams.append(key, joined);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Handle primitives
|
// Handle primitives
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Clamp a number within a range.
|
* Clamps a number within a specified range
|
||||||
* @param value The number to clamp.
|
*
|
||||||
* @param min minimum value
|
* Ensures a value falls between minimum and maximum bounds.
|
||||||
* @param max maximum value
|
* Values below min return min, values above max return max.
|
||||||
* @returns The clamped number.
|
*
|
||||||
|
* @param value - The number to clamp
|
||||||
|
* @param min - Minimum allowed value (inclusive)
|
||||||
|
* @param max - Maximum allowed value (inclusive)
|
||||||
|
* @returns The clamped number
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* clampNumber(5, 0, 10); // 5
|
||||||
|
* clampNumber(-5, 0, 10); // 0
|
||||||
|
* clampNumber(15, 0, 10); // 10
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function clampNumber(value: number, min: number, max: number): number {
|
export function clampNumber(value: number, min: number, max: number): number {
|
||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
/**
|
|
||||||
* ============================================================================
|
|
||||||
* DEBOUNCE UTILITY
|
|
||||||
* ============================================================================
|
|
||||||
*
|
|
||||||
* Creates a debounced function that delays execution until after wait milliseconds
|
|
||||||
* have elapsed since the last time it was invoked.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const debouncedSearch = debounce((query: string) => {
|
|
||||||
* console.log('Searching for:', query);
|
|
||||||
* }, 300);
|
|
||||||
*
|
|
||||||
* debouncedSearch('hello');
|
|
||||||
* debouncedSearch('hello world'); // Only this will execute after 300ms
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a debounced version of a function
|
* Creates a debounced version of a function
|
||||||
*
|
*
|
||||||
|
* Delays function execution until after `wait` milliseconds have elapsed
|
||||||
|
* since the last invocation. Useful for rate-limiting expensive operations
|
||||||
|
* like API calls or expensive DOM updates.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const search = debounce((query: string) => {
|
||||||
|
* console.log('Searching for:', query);
|
||||||
|
* }, 300);
|
||||||
|
*
|
||||||
|
* search('a');
|
||||||
|
* search('ab');
|
||||||
|
* search('abc'); // Only this triggers the function after 300ms
|
||||||
|
* ```
|
||||||
|
*
|
||||||
* @param fn - The function to debounce
|
* @param fn - The function to debounce
|
||||||
* @param wait - The delay in milliseconds
|
* @param wait - The delay in milliseconds
|
||||||
* @returns A debounced function that will execute after the specified delay
|
* @returns A debounced function that executes after the delay
|
||||||
*/
|
*/
|
||||||
export function debounce<T extends (...args: any[]) => any>(
|
export function debounce<T extends (...args: any[]) => any>(
|
||||||
fn: T,
|
fn: T,
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Get the number of decimal places in a number
|
* Counts the number of decimal places in a number
|
||||||
*
|
*
|
||||||
* For example:
|
* Returns the length of the decimal portion of a number.
|
||||||
* - 1 -> 0
|
* Used to determine precision for rounding operations.
|
||||||
* - 0.1 -> 1
|
|
||||||
* - 0.01 -> 2
|
|
||||||
* - 0.05 -> 2
|
|
||||||
*
|
*
|
||||||
* @param step - The step number to analyze
|
* @param step - The number to analyze
|
||||||
* @returns The number of decimal places
|
* @returns The number of decimal places (0 for integers)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* getDecimalPlaces(1); // 0
|
||||||
|
* getDecimalPlaces(0.1); // 1
|
||||||
|
* getDecimalPlaces(0.01); // 2
|
||||||
|
* getDecimalPlaces(0.005); // 3
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function getDecimalPlaces(step: number): number {
|
export function getDecimalPlaces(step: number): number {
|
||||||
const str = step.toString();
|
const str = step.toString();
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Shared utility functions
|
* Shared utility functions
|
||||||
|
*
|
||||||
|
* Provides common utilities for:
|
||||||
|
* - Number manipulation (clamping, precision, decimal places)
|
||||||
|
* - Function execution control (debounce, throttle)
|
||||||
|
* - Array operations (split by predicate)
|
||||||
|
* - URL handling (query string building)
|
||||||
|
* - DOM interactions (smooth scrolling)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { getDecimalPlaces } from '$shared/lib/utils';
|
import { getDecimalPlaces } from '$shared/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Round a value to the precision of the given step
|
* Rounds a value to match the precision of a given step
|
||||||
*
|
*
|
||||||
* This fixes floating-point precision errors that occur with decimal steps.
|
* Fixes floating-point precision errors that occur with decimal arithmetic.
|
||||||
* For example, with step=0.05, adding it repeatedly can produce values like
|
* For example, repeatedly adding 0.05 can produce 1.3499999999999999
|
||||||
* 1.3499999999999999 instead of 1.35.
|
* instead of 1.35 due to IEEE 754 floating-point representation.
|
||||||
*
|
*
|
||||||
* We use toFixed() to round to the appropriate decimal places instead of
|
* Uses toFixed() instead of Math.round() for correct decimal rounding.
|
||||||
* Math.round(value / step) * step, which doesn't always work correctly
|
|
||||||
* due to floating-point arithmetic errors.
|
|
||||||
*
|
*
|
||||||
* @param value - The value to round
|
* @param value - The value to round
|
||||||
* @param step - The step to round to (defaults to 1)
|
* @param step - The step size to match precision of (default: 1)
|
||||||
* @returns The rounded value
|
* @returns The rounded value
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* roundToStepPrecision(1.3499999999999999, 0.05); // 1.35
|
||||||
|
* roundToStepPrecision(1.2345, 0.01); // 1.23
|
||||||
|
* roundToStepPrecision(1.2345, 0.1); // 1.2
|
||||||
|
* roundToStepPrecision(1.5, 1); // 2
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function roundToStepPrecision(value: number, step: number = 1): number {
|
export function roundToStepPrecision(value: number, step: number = 1): number {
|
||||||
if (step <= 0) {
|
if (step <= 0) {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Smoothly scrolls to the target element when an anchor element is clicked.
|
* Svelte action for smooth anchor scrolling
|
||||||
* @param node - The anchor element to listen for clicks on.
|
*
|
||||||
|
* Intercepts anchor link clicks to smoothly scroll to the target element
|
||||||
|
* instead of jumping instantly. Updates URL hash without causing scroll.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```svelte
|
||||||
|
* <a href="#section" use:smoothScroll>Go to Section</a>
|
||||||
|
* <div id="section">Section Content</div>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param node - The anchor element to attach to
|
||||||
|
* @returns Action object with destroy method
|
||||||
*/
|
*/
|
||||||
export function smoothScroll(node: HTMLAnchorElement) {
|
export function smoothScroll(node: HTMLAnchorElement) {
|
||||||
const handleClick = (event: MouseEvent) => {
|
const handleClick = (event: MouseEvent) => {
|
||||||
@@ -17,7 +28,7 @@ export function smoothScroll(node: HTMLAnchorElement) {
|
|||||||
block: 'start',
|
block: 'start',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update URL hash without jumping
|
// Update URL hash without triggering scroll
|
||||||
history.pushState(null, '', hash);
|
history.pushState(null, '', hash);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Splits an array into two arrays based on a callback function.
|
* Splits an array into two groups based on a predicate
|
||||||
* @param array The array to split.
|
*
|
||||||
* @param callback The callback function to determine which array to push each item to.
|
* Partitions an array into pass/fail groups using a callback function.
|
||||||
* @returns - An array containing two arrays, the first array contains items that passed the callback, the second array contains items that failed the callback.
|
* More efficient than calling filter() twice.
|
||||||
|
*
|
||||||
|
* @param array - The array to split
|
||||||
|
* @param callback - Predicate function (true = first array, false = second)
|
||||||
|
* @returns Tuple of [passing items, failing items]
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const numbers = [1, 2, 3, 4, 5, 6];
|
||||||
|
* const [even, odd] = splitArray(numbers, n => n % 2 === 0);
|
||||||
|
* // even: [2, 4, 6]
|
||||||
|
* // odd: [1, 3, 5]
|
||||||
|
*
|
||||||
|
* const users = [
|
||||||
|
* { name: 'Alice', active: true },
|
||||||
|
* { name: 'Bob', active: false }
|
||||||
|
* ];
|
||||||
|
* const [active, inactive] = splitArray(users, u => u.active);
|
||||||
|
* ```
|
||||||
*/
|
*/
|
||||||
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
|
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
|
||||||
return array.reduce<[T[], T[]]>(
|
return array.reduce<[T[], T[]]>(
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Throttle function execution to a maximum frequency.
|
* Throttles a function to limit execution frequency
|
||||||
*
|
*
|
||||||
* @param fn Function to throttle.
|
* Ensures a function executes at most once per `wait` milliseconds.
|
||||||
* @param wait Maximum time between function calls.
|
* Unlike debounce, throttled functions execute on the leading edge
|
||||||
* @returns Throttled function.
|
* and trailing edge if called repeatedly.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const logScroll = throttle(() => {
|
||||||
|
* console.log('Scroll position:', window.scrollY);
|
||||||
|
* }, 100);
|
||||||
|
*
|
||||||
|
* window.addEventListener('scroll', logScroll);
|
||||||
|
* // Will log at most once every 100ms
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param fn - Function to throttle
|
||||||
|
* @param wait - Minimum time between executions in milliseconds
|
||||||
|
* @returns Throttled function
|
||||||
*/
|
*/
|
||||||
export function throttle<T extends (...args: any[]) => any>(
|
export function throttle<T extends (...args: any[]) => any>(
|
||||||
fn: T,
|
fn: T,
|
||||||
@@ -20,7 +34,7 @@ export function throttle<T extends (...args: any[]) => any>(
|
|||||||
lastCall = now;
|
lastCall = now;
|
||||||
fn(...args);
|
fn(...args);
|
||||||
} else {
|
} else {
|
||||||
// Schedule for end of wait period
|
// Schedule for end of wait period (trailing edge)
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
lastCall = Date.now();
|
lastCall = Date.now();
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
import {
|
|
||||||
type VariantProps,
|
|
||||||
tv,
|
|
||||||
} from 'tailwind-variants';
|
|
||||||
|
|
||||||
export const badgeVariants = tv({
|
|
||||||
base:
|
|
||||||
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
|
|
||||||
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
|
|
||||||
destructive:
|
|
||||||
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
|
|
||||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
type WithElementRef,
|
|
||||||
cn,
|
|
||||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
|
||||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
href,
|
|
||||||
class: className,
|
|
||||||
variant = 'default',
|
|
||||||
children,
|
|
||||||
...restProps
|
|
||||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
|
||||||
variant?: BadgeVariant;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:element
|
|
||||||
this={href ? 'a' : 'span'}
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="badge"
|
|
||||||
{href}
|
|
||||||
class={cn(badgeVariants({ variant }), className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</svelte:element>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { default as Badge } from './badge.svelte';
|
|
||||||
export {
|
|
||||||
type BadgeVariant,
|
|
||||||
badgeVariants,
|
|
||||||
} from './badge.svelte';
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Separator } from '$shared/shadcn/ui/separator/index.js';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
|
||||||
import type { ComponentProps } from 'svelte';
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
orientation = 'vertical',
|
|
||||||
...restProps
|
|
||||||
}: ComponentProps<typeof Separator> = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Separator
|
|
||||||
bind:ref
|
|
||||||
data-slot="button-group-separator"
|
|
||||||
{orientation}
|
|
||||||
class={cn('bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto', className)}
|
|
||||||
{...restProps}
|
|
||||||
/>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
type WithElementRef,
|
|
||||||
cn,
|
|
||||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
child,
|
|
||||||
...restProps
|
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
||||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const mergedProps = $derived({
|
|
||||||
...restProps,
|
|
||||||
class: cn(
|
|
||||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if child}
|
|
||||||
{@render child({ props: mergedProps })}
|
|
||||||
{:else}
|
|
||||||
<div bind:this={ref} {...mergedProps}>
|
|
||||||
{@render mergedProps.children?.()}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
import {
|
|
||||||
type VariantProps,
|
|
||||||
tv,
|
|
||||||
} from 'tailwind-variants';
|
|
||||||
|
|
||||||
export const buttonGroupVariants = tv({
|
|
||||||
base:
|
|
||||||
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-e-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
|
||||||
variants: {
|
|
||||||
orientation: {
|
|
||||||
horizontal:
|
|
||||||
'[&>*:not(:first-child)]:rounded-s-none [&>*:not(:first-child)]:border-s-0 [&>*:not(:last-child)]:rounded-e-none',
|
|
||||||
vertical:
|
|
||||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
orientation: 'horizontal',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>['orientation'];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
type WithElementRef,
|
|
||||||
cn,
|
|
||||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
class: className,
|
|
||||||
children,
|
|
||||||
orientation = 'horizontal',
|
|
||||||
...restProps
|
|
||||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
||||||
orientation?: ButtonGroupOrientation;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={ref}
|
|
||||||
role="group"
|
|
||||||
data-slot="button-group"
|
|
||||||
data-orientation={orientation}
|
|
||||||
class={cn(buttonGroupVariants({ orientation }), className)}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import Separator from './button-group-separator.svelte';
|
|
||||||
import Text from './button-group-text.svelte';
|
|
||||||
import Root from './button-group.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
Root,
|
|
||||||
//
|
|
||||||
Root as ButtonGroup,
|
|
||||||
Separator,
|
|
||||||
Separator as ButtonGroupSeparator,
|
|
||||||
Text,
|
|
||||||
Text as ButtonGroupText,
|
|
||||||
};
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
import {
|
|
||||||
type WithElementRef,
|
|
||||||
cn,
|
|
||||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
|
||||||
import type {
|
|
||||||
HTMLAnchorAttributes,
|
|
||||||
HTMLButtonAttributes,
|
|
||||||
} from 'svelte/elements';
|
|
||||||
import {
|
|
||||||
type VariantProps,
|
|
||||||
tv,
|
|
||||||
} from 'tailwind-variants';
|
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
|
||||||
base:
|
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
|
|
||||||
destructive:
|
|
||||||
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs',
|
|
||||||
outline:
|
|
||||||
'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
|
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs',
|
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
||||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
|
||||||
icon: 'size-9',
|
|
||||||
'icon-sm': 'size-8',
|
|
||||||
'icon-lg': 'size-10',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'default',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
|
||||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
|
||||||
|
|
||||||
export type ButtonProps =
|
|
||||||
& WithElementRef<HTMLButtonAttributes>
|
|
||||||
& WithElementRef<HTMLAnchorAttributes>
|
|
||||||
& {
|
|
||||||
variant?: ButtonVariant;
|
|
||||||
size?: ButtonSize;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
let {
|
|
||||||
class: className,
|
|
||||||
variant = 'default',
|
|
||||||
size = 'default',
|
|
||||||
ref = $bindable(null),
|
|
||||||
href = undefined,
|
|
||||||
type = 'button',
|
|
||||||
disabled,
|
|
||||||
children,
|
|
||||||
...restProps
|
|
||||||
}: ButtonProps = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if href}
|
|
||||||
<a
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="button"
|
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
|
||||||
href={disabled ? undefined : href}
|
|
||||||
aria-disabled={disabled}
|
|
||||||
role={disabled ? 'link' : undefined}
|
|
||||||
tabindex={disabled ? -1 : undefined}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
bind:this={ref}
|
|
||||||
data-slot="button"
|
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
|
||||||
{type}
|
|
||||||
{disabled}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import Root, {
|
|
||||||
type ButtonProps,
|
|
||||||
type ButtonSize,
|
|
||||||
type ButtonVariant,
|
|
||||||
buttonVariants,
|
|
||||||
} from './button.svelte';
|
|
||||||
|
|
||||||
export {
|
|
||||||
type ButtonProps,
|
|
||||||
type ButtonProps as Props,
|
|
||||||
type ButtonSize,
|
|
||||||
type ButtonVariant,
|
|
||||||
buttonVariants,
|
|
||||||
Root,
|
|
||||||
//
|
|
||||||
Root as Button,
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
type WithoutChildrenOrChild,
|
|
||||||
cn,
|
|
||||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
|
||||||
import CheckIcon from '@lucide/svelte/icons/check';
|
|
||||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
|
||||||
import { Checkbox as CheckboxPrimitive } from 'bits-ui';
|
|
||||||
|
|
||||||
let {
|
|
||||||
ref = $bindable(null),
|
|
||||||
checked = $bindable(false),
|
|
||||||
indeterminate = $bindable(false),
|
|
||||||
class: className,
|
|
||||||
...restProps
|
|
||||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
bind:ref
|
|
||||||
data-slot="checkbox"
|
|
||||||
class={cn(
|
|
||||||
'border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
bind:checked
|
|
||||||
bind:indeterminate
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{#snippet children({ checked, indeterminate })}
|
|
||||||
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
|
||||||
{#if checked}
|
|
||||||
<CheckIcon class="size-3.5" />
|
|
||||||
{:else if indeterminate}
|
|
||||||
<MinusIcon class="size-3.5" />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user