Refactor/reacrhitecture to fsd+ #49
+111
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import type { UnifiedFont } from '../../model/types';
|
||||||
|
import { createFontLoadRequestContfig } from './createFontLoadRequestContfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal UnifiedFont mock — override only the fields a case exercises.
|
||||||
|
*/
|
||||||
|
function createMockFont(overrides: Partial<UnifiedFont> = {}): UnifiedFont {
|
||||||
|
const baseFont: UnifiedFont = {
|
||||||
|
id: 'test-font',
|
||||||
|
name: 'Test Font',
|
||||||
|
provider: 'google',
|
||||||
|
category: 'sans-serif',
|
||||||
|
subsets: ['latin'],
|
||||||
|
variants: [],
|
||||||
|
styles: {},
|
||||||
|
metadata: {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
isVariable: false,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...baseFont, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createFontLoadRequestContfig', () => {
|
||||||
|
it('builds a single-element config when a URL resolves', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
id: 'roboto',
|
||||||
|
name: 'Roboto',
|
||||||
|
styles: { variants: { '400': 'https://example.com/roboto-400.woff2' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = createFontLoadRequestContfig(font, 400);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: 'roboto',
|
||||||
|
name: 'Roboto',
|
||||||
|
weight: 400,
|
||||||
|
url: 'https://example.com/roboto-400.woff2',
|
||||||
|
isVariable: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no URL resolves (flatMap drops the font)', () => {
|
||||||
|
const font = createMockFont({ styles: {} });
|
||||||
|
|
||||||
|
expect(createFontLoadRequestContfig(font, 400)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards isVariable from font features', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
features: { isVariable: true, tags: [] },
|
||||||
|
styles: { variants: { '700': 'https://example.com/inter-vf.woff2' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [config] = createFontLoadRequestContfig(font, 700);
|
||||||
|
|
||||||
|
expect(config.isVariable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets isVariable to undefined when features is absent', () => {
|
||||||
|
// features is non-optional on UnifiedFont, but upstream data can be partial —
|
||||||
|
// the optional chain must not throw, and isVariable stays undefined.
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: { variants: { '400': 'https://example.com/font.woff2' } },
|
||||||
|
});
|
||||||
|
// @ts-expect-error — deliberately drop the guaranteed field to exercise the optional chain
|
||||||
|
font.features = undefined;
|
||||||
|
|
||||||
|
const [config] = createFontLoadRequestContfig(font, 400);
|
||||||
|
|
||||||
|
expect(config.isVariable).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the resolved fallback URL, not just exact matches', () => {
|
||||||
|
// getFontUrl falls back to styles.regular when the exact weight is missing;
|
||||||
|
// the config must carry whatever URL actually resolved.
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: { regular: 'https://example.com/font-regular.woff2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [config] = createFontLoadRequestContfig(font, 900);
|
||||||
|
|
||||||
|
expect(config.url).toBe('https://example.com/font-regular.woff2');
|
||||||
|
expect(config.weight).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the requested weight even when the URL is a shared fallback', () => {
|
||||||
|
const font = createMockFont({
|
||||||
|
styles: { variants: { '400': 'https://example.com/shared.woff2' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createFontLoadRequestContfig(font, 700)[0].weight).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates the invalid-weight error from getFontUrl', () => {
|
||||||
|
const font = createMockFont();
|
||||||
|
|
||||||
|
expect(() => createFontLoadRequestContfig(font, 450)).toThrow('Invalid weight: 450');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type {
|
||||||
|
FontLoadRequestConfig,
|
||||||
|
UnifiedFont,
|
||||||
|
} from '../../model';
|
||||||
|
import { getFontUrl } from '../getFontUrl/getFontUrl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the font-lifecycle load request for a single font at a given weight.
|
||||||
|
*
|
||||||
|
* Returns a 0-or-1 element array rather than `FontLoadRequestConfig | undefined`
|
||||||
|
* so call sites can `flatMap` over a font list — resolve the URL and drop fonts
|
||||||
|
* that have none in a single pass, with no separate filter step. An empty array
|
||||||
|
* means the font has no loadable asset for this weight (or its fallbacks) and is
|
||||||
|
* silently skipped.
|
||||||
|
*
|
||||||
|
* `isVariable` is forwarded from the font's features so the lifecycle manager can
|
||||||
|
* dedupe variable fonts per ID (they load once regardless of weight) while still
|
||||||
|
* loading static fonts per weight.
|
||||||
|
*
|
||||||
|
* @param font - Unified font to load
|
||||||
|
* @param weight - Numeric weight (100-900)
|
||||||
|
* @returns Single-element config array, or `[]` when no URL resolves
|
||||||
|
* @throws Error when weight is outside the valid 100-900 range (propagated from `getFontUrl`)
|
||||||
|
*/
|
||||||
|
export function createFontLoadRequestContfig(font: UnifiedFont, weight: number): FontLoadRequestConfig[] {
|
||||||
|
const url = getFontUrl(font, weight);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ id: font.id, name: font.name, weight, url, isVariable: font.features?.isVariable }];
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
export * from './const/const';
|
export * from './const/const';
|
||||||
|
|
||||||
|
export { getFontCatalog } from './store';
|
||||||
|
|
||||||
export * from './store';
|
export * from './store';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@@ -483,8 +483,14 @@ export class FontCatalogStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createFontCatalogStore(params: FontStoreParams = {}): FontCatalogStore {
|
let _catalog: FontCatalogStore | undefined;
|
||||||
return new FontCatalogStore(params);
|
|
||||||
|
export function getFontCatalog(): FontCatalogStore {
|
||||||
|
return (_catalog ??= new FontCatalogStore({ limit: 50 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fontCatalogStore = new FontCatalogStore({ limit: 50 });
|
// test-only reset, so specs don't share a live observer
|
||||||
|
export function __resetFontCatalog() {
|
||||||
|
_catalog?.destroy();
|
||||||
|
_catalog = undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
export * from './fontLifecycleManager/fontLifecycleManager.svelte';
|
||||||
|
|
||||||
// Paginated catalog
|
// Paginated catalog
|
||||||
export {
|
export { getFontCatalog } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
createFontCatalogStore,
|
|
||||||
FontCatalogStore,
|
export type { FontCatalogStore } from './fontCatalogStore/fontCatalogStore.svelte';
|
||||||
fontCatalogStore,
|
|
||||||
} from './fontCatalogStore/fontCatalogStore.svelte';
|
|
||||||
|
|
||||||
// Batch fetch by IDs (detail-cache seeding)
|
// Batch fetch by IDs (detail-cache seeding)
|
||||||
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
|
export { FontsByIdsStore } from './fontsByIdsStore/fontsByIdsStore.svelte';
|
||||||
|
|||||||
@@ -5,21 +5,18 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { debounce } from '$shared/lib/utils';
|
import { debounce } from '$shared/lib/utils';
|
||||||
import {
|
import { VirtualList } from '$shared/ui';
|
||||||
Skeleton,
|
|
||||||
VirtualList,
|
|
||||||
} from '$shared/ui';
|
|
||||||
import type {
|
import type {
|
||||||
ComponentProps,
|
ComponentProps,
|
||||||
Snippet,
|
Snippet,
|
||||||
} from 'svelte';
|
} from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { getFontUrl } from '../../lib';
|
import { createFontLoadRequestContfig } from '../../lib/createFontLoadRequestContfig/createFontLoadRequestContfig';
|
||||||
import {
|
import {
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
fontCatalogStore,
|
|
||||||
fontLifecycleManager,
|
fontLifecycleManager,
|
||||||
|
getFontCatalog,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
@@ -55,17 +52,27 @@ let {
|
|||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const isLoading = $derived(
|
const fontCatalog = getFontCatalog();
|
||||||
fontCatalogStore.isFetching || fontCatalogStore.isLoading,
|
|
||||||
);
|
const isLoading = $derived<boolean>(fontCatalog?.isLoading);
|
||||||
|
const isFetching = $derived<boolean>(fontCatalog.isFetching);
|
||||||
|
const hasMore = $derived<boolean>(fontCatalog?.pagination?.hasMore);
|
||||||
|
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
|
||||||
|
const total = $derived<number>(fontCatalog?.pagination.total);
|
||||||
|
|
||||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||||
let isCatchingUp = $state(false);
|
let isCatchingUp = $state<boolean>(false);
|
||||||
|
|
||||||
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontCatalogStore.fonts.length === 0);
|
const showInitialSkeleton = $derived.by(() => (
|
||||||
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
!!skeleton && (isLoading || isFetching) && fontCatalog.fonts.length === 0
|
||||||
|
));
|
||||||
|
const showCatchupSkeleton = $derived.by(() => (
|
||||||
|
!!skeleton && isCatchingUp
|
||||||
|
));
|
||||||
// Settled query with no matches — empty state replaces the (otherwise blank) list.
|
// Settled query with no matches — empty state replaces the (otherwise blank) list.
|
||||||
const showEmpty = $derived(!!empty && !isLoading && !isCatchingUp && fontCatalogStore.fonts.length === 0);
|
const showEmpty = $derived.by(() => (
|
||||||
|
!!empty && !(isLoading || isFetching) && !isCatchingUp && fontCatalog.fonts.length === 0
|
||||||
|
));
|
||||||
|
|
||||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||||
visibleFonts = items;
|
visibleFonts = items;
|
||||||
@@ -79,12 +86,12 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
|||||||
* font files for thousands of intermediate fonts.
|
* font files for thousands of intermediate fonts.
|
||||||
*/
|
*/
|
||||||
async function handleJump(targetIndex: number) {
|
async function handleJump(targetIndex: number) {
|
||||||
if (isCatchingUp || !fontCatalogStore.pagination.hasMore) {
|
if (isCatchingUp || !hasMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isCatchingUp = true;
|
isCatchingUp = true;
|
||||||
try {
|
try {
|
||||||
await fontCatalogStore.fetchAllPagesTo(targetIndex);
|
await fontCatalog.fetchAllPagesTo(targetIndex);
|
||||||
} finally {
|
} finally {
|
||||||
isCatchingUp = false;
|
isCatchingUp = false;
|
||||||
}
|
}
|
||||||
@@ -105,13 +112,7 @@ $effect(() => {
|
|||||||
if (isCatchingUp) {
|
if (isCatchingUp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
const configs = visibleFonts.flatMap(item => createFontLoadRequestContfig(item, weight));
|
||||||
const url = getFontUrl(item, weight);
|
|
||||||
if (!url) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
|
||||||
});
|
|
||||||
if (configs.length > 0) {
|
if (configs.length > 0) {
|
||||||
debouncedTouch(configs);
|
debouncedTouch(configs);
|
||||||
}
|
}
|
||||||
@@ -137,13 +138,11 @@ $effect(() => {
|
|||||||
* Load more fonts by moving to the next page
|
* Load more fonts by moving to the next page
|
||||||
*/
|
*/
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
if (
|
if (!hasMore || isFetching) {
|
||||||
!fontCatalogStore.pagination.hasMore
|
|
||||||
|| fontCatalogStore.isFetching
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fontCatalogStore.nextPage();
|
|
||||||
|
fontCatalog.nextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,12 +152,10 @@ function loadMore() {
|
|||||||
* of the loaded items. Only fetches if there are more pages available.
|
* of the loaded items. Only fetches if there are more pages available.
|
||||||
*/
|
*/
|
||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
const { hasMore } = fontCatalogStore.pagination;
|
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items.
|
// VirtualList already checks if we're near the bottom of loaded items.
|
||||||
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||||
// during batch catch-up, which would otherwise let nextPage() race with it.
|
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||||
if (hasMore && !fontCatalogStore.isFetching && !isCatchingUp) {
|
if (hasMore && !isFetching && !isCatchingUp) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,9 +174,9 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<VirtualList
|
<VirtualList
|
||||||
items={fontCatalogStore.fonts}
|
items={fonts}
|
||||||
total={fontCatalogStore.pagination.total}
|
{total}
|
||||||
isLoading={isLoading || isCatchingUp}
|
isLoading={isLoading || isFetching || isCatchingUp}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
onJump={handleJump}
|
onJump={handleJump}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
createFontRowSizeResolver,
|
createFontRowSizeResolver,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
fontCatalogStore,
|
|
||||||
fontLifecycleManager,
|
fontLifecycleManager,
|
||||||
|
getFontCatalog,
|
||||||
} from '$entities/Font/model';
|
} from '$entities/Font/model';
|
||||||
import {
|
import {
|
||||||
TypographyMenu,
|
TypographyMenu,
|
||||||
@@ -22,6 +22,8 @@ import { throttle } from '$shared/lib/utils';
|
|||||||
import { Skeleton } from '$shared/ui';
|
import { Skeleton } from '$shared/ui';
|
||||||
import { layoutManager } from '../../model';
|
import { layoutManager } from '../../model';
|
||||||
|
|
||||||
|
const fontCatalog = getFontCatalog();
|
||||||
|
|
||||||
// FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte.
|
// FontSampler chrome heights — derived from Tailwind classes in FontSampler.svelte.
|
||||||
// Header: py-3 (12+12px padding) + ~32px content row ≈ 56px.
|
// Header: py-3 (12+12px padding) + ~32px content row ≈ 56px.
|
||||||
// Only the header is counted; the mobile footer (md:hidden) is excluded because
|
// Only the header is counted; the mobile footer (md:hidden) is excluded because
|
||||||
@@ -44,14 +46,16 @@ const SAMPLER_FALLBACK_HEIGHT = 220;
|
|||||||
*/
|
*/
|
||||||
const CHECK_POSITION_THROTTLE_MS = 100;
|
const CHECK_POSITION_THROTTLE_MS = 100;
|
||||||
|
|
||||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
const fonts = $derived(fontCatalog?.fonts);
|
||||||
|
|
||||||
|
let text = $state<string>('The quick brown fox jumps over the lazy dog...');
|
||||||
let wrapper = $state<HTMLDivElement | null>(null);
|
let wrapper = $state<HTMLDivElement | null>(null);
|
||||||
// Binds to the actual window height
|
// Binds to the actual window height
|
||||||
let innerHeight = $state(0);
|
let innerHeight = $state<number>(0);
|
||||||
// Is the component above the middle of the viewport?
|
// Is the component above the middle of the viewport?
|
||||||
let isAboveMiddle = $state(false);
|
let isAboveMiddle = $state<boolean>(false);
|
||||||
// Inner width of the wrapper div — updated by bind:clientWidth on mount and resize.
|
// Inner width of the wrapper div — updated by bind:clientWidth on mount and resize.
|
||||||
let containerWidth = $state(0);
|
let containerWidth = $state<number>(0);
|
||||||
|
|
||||||
const checkPosition = throttle(() => {
|
const checkPosition = throttle(() => {
|
||||||
if (!wrapper) {
|
if (!wrapper) {
|
||||||
@@ -69,7 +73,7 @@ const checkPosition = throttle(() => {
|
|||||||
// change triggers a full offsets recompute in createVirtualizer — no DOM snap.
|
// change triggers a full offsets recompute in createVirtualizer — no DOM snap.
|
||||||
const fontRowHeight = $derived.by(() =>
|
const fontRowHeight = $derived.by(() =>
|
||||||
createFontRowSizeResolver({
|
createFontRowSizeResolver({
|
||||||
getFonts: () => fontCatalogStore.fonts,
|
getFonts: () => fonts,
|
||||||
getWeight: () => typographySettingsStore.weight,
|
getWeight: () => typographySettingsStore.weight,
|
||||||
getPreviewText: () => text,
|
getPreviewText: () => text,
|
||||||
getContainerWidth: () => containerWidth,
|
getContainerWidth: () => containerWidth,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Wraps SampleList with a Section component
|
Wraps SampleList with a Section component
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fontCatalogStore } from '$entities/Font/model';
|
import { getFontCatalog } from '$entities/Font/model';
|
||||||
import { NavigationWrapper } from '$features/Breadcrumb';
|
import { NavigationWrapper } from '$features/Breadcrumb';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
@@ -26,6 +26,9 @@ interface Props {
|
|||||||
const { index }: Props = $props();
|
const { index }: Props = $props();
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
|
const fontCatalog = getFontCatalog();
|
||||||
|
const total = $derived<number>(fontCatalog?.pagination?.total);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<NavigationWrapper index={2} title="Samples">
|
<NavigationWrapper index={2} title="Samples">
|
||||||
@@ -36,7 +39,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
|||||||
id="sample_set"
|
id="sample_set"
|
||||||
title="Sample Set"
|
title="Sample Set"
|
||||||
headerTitle="visual_output"
|
headerTitle="visual_output"
|
||||||
headerSubtitle="items_total: {fontCatalogStore.pagination.total ?? 0}"
|
headerSubtitle="items_total: {total ?? 0}"
|
||||||
headerAction={registerAction}
|
headerAction={registerAction}
|
||||||
>
|
>
|
||||||
{#snippet headerContent()}
|
{#snippet headerContent()}
|
||||||
|
|||||||
Reference in New Issue
Block a user