refactor(font): inject font-load status as a prop, decoupling UI from the store
FontApplicator and FontSampler no longer read fontLifecycleManager. They take a `status` prop (FontLoadStatus | undefined) supplied by the composing widget; FontList and SampleList resolve status once per visible row and pass it down. FSD+ dependency inversion: the entity/feature UI depends on a value, not the lifecycle store. Removes FontApplicator's value-import of the store (one step toward an inert ./ui barrel) and drops the duplicate getFontStatus read per row in FontList. FontSampler is now status-decoupled and trivially relocatable to entities/Font/ui.
This commit is contained in:
@@ -10,14 +10,14 @@ const { Story } = defineMeta({
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component:
|
component:
|
||||||
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
|
'Applies a font to its children based on the supplied load `status`. Renders the skeleton (or system font) until status is `loaded`/`error`, then reveals the font. The status is provided by the composing widget — the component does not read the lifecycle store itself.',
|
||||||
},
|
},
|
||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
layout: 'centered',
|
layout: 'centered',
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
weight: { control: 'number' },
|
status: { control: 'select', options: ['loading', 'loaded', 'error'] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -39,11 +39,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Font that has never been loaded by fontLifecycleManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
|
'Status is `loading`: the font file has not resolved yet, so children render in the skeleton (or system font) fallback rather than the target font.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
args={{ font: fontUnknown, weight: 400 }}
|
args={{ font: fontUnknown, status: 'loading' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
@@ -58,11 +58,11 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Uses Arial, a system font available in all browsers. Because fontLifecycleManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
|
'Status is `loaded`: the component reveals the font, applying it to its children (Arial here, available in all browsers).',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
args={{ font: fontArial, weight: 400 }}
|
args={{ font: fontArial, status: 'loaded' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
@@ -72,16 +72,16 @@ const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
|
|||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Custom Weight"
|
name="Error State"
|
||||||
parameters={{
|
parameters={{
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
story:
|
story:
|
||||||
'Demonstrates passing a custom weight (700). The weight is forwarded to fontLifecycleManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
|
'Status is `error`: the font failed to load. The component still reveals (it treats `error` like `loaded` for reveal purposes) so children are not stuck behind the skeleton — they fall back to the system font.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
args={{ font: fontArialBold, weight: 700 }}
|
args={{ font: fontArialBold, status: 'error' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
|
||||||
<FontApplicator {...args}>
|
<FontApplicator {...args}>
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import {
|
import type {
|
||||||
DEFAULT_FONT_WEIGHT,
|
FontLoadStatus,
|
||||||
type UnifiedFont,
|
UnifiedFont,
|
||||||
fontLifecycleManager,
|
} from '../../model/types';
|
||||||
} from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -18,10 +17,13 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Current load status for this font, supplied by the composing layer.
|
||||||
* @default 400
|
* Kept out of the component so it does not depend on (and import) the
|
||||||
|
* lifecycle store — the owning widget reads the manager and passes the
|
||||||
|
* resolved status down. `undefined` means the font is not tracked yet and
|
||||||
|
* is treated as not-yet-revealed (skeleton / system-font fallback).
|
||||||
*/
|
*/
|
||||||
weight?: number;
|
status: FontLoadStatus | undefined;
|
||||||
/**
|
/**
|
||||||
* CSS classes
|
* CSS classes
|
||||||
*/
|
*/
|
||||||
@@ -39,20 +41,12 @@ interface Props {
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
font,
|
font,
|
||||||
weight = DEFAULT_FONT_WEIGHT,
|
status,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
skeleton,
|
skeleton,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const status = $derived(
|
|
||||||
fontLifecycleManager.getFontStatus(
|
|
||||||
font.id,
|
|
||||||
weight,
|
|
||||||
font.features?.isVariable,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ const { Story } = defineMeta({
|
|||||||
control: 'object',
|
control: 'object',
|
||||||
description: 'Font information object',
|
description: 'Font information object',
|
||||||
},
|
},
|
||||||
|
status: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['loading', 'loaded', 'error'],
|
||||||
|
description: 'Font-load status, supplied by the composing widget and forwarded to FontApplicator',
|
||||||
|
},
|
||||||
text: {
|
text: {
|
||||||
control: 'text',
|
control: 'text',
|
||||||
description: 'Editable sample text (two-way bindable)',
|
description: 'Editable sample text (two-way bindable)',
|
||||||
@@ -85,6 +90,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
name="Default"
|
name="Default"
|
||||||
args={{
|
args={{
|
||||||
font: mockArial,
|
font: mockArial,
|
||||||
|
status: 'loaded',
|
||||||
text: 'The quick brown fox jumps over the lazy dog',
|
text: 'The quick brown fox jumps over the lazy dog',
|
||||||
index: 0,
|
index: 0,
|
||||||
}}
|
}}
|
||||||
@@ -101,6 +107,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
name="Long Text"
|
name="Long Text"
|
||||||
args={{
|
args={{
|
||||||
font: mockGeorgia,
|
font: mockGeorgia,
|
||||||
|
status: 'loaded',
|
||||||
text:
|
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.',
|
'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,
|
index: 1,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
|
type FontLoadStatus,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||||
@@ -23,6 +24,12 @@ interface Props {
|
|||||||
* Font info
|
* Font info
|
||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
font: UnifiedFont;
|
||||||
|
/**
|
||||||
|
* Current font-load status, supplied by the composing widget so this
|
||||||
|
* component (and FontApplicator) stay decoupled from the lifecycle store.
|
||||||
|
* `undefined` means not tracked yet (treated as not-yet-revealed).
|
||||||
|
*/
|
||||||
|
status: FontLoadStatus | undefined;
|
||||||
/**
|
/**
|
||||||
* Sample text
|
* Sample text
|
||||||
*/
|
*/
|
||||||
@@ -34,7 +41,7 @@ interface Props {
|
|||||||
index?: number;
|
index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { font, text = $bindable(), index = 0 }: Props = $props();
|
let { font, status, text = $bindable(), index = 0 }: Props = $props();
|
||||||
|
|
||||||
// Adjust the property name to match your UnifiedFont type
|
// Adjust the property name to match your UnifiedFont type
|
||||||
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
const fontType = $derived((font as any).type ?? (font as any).category ?? '');
|
||||||
@@ -132,7 +139,7 @@ const stats = $derived([
|
|||||||
|
|
||||||
<!-- ── Main content area ──────────────────────────────────────────── -->
|
<!-- ── 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">
|
<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={typographySettingsStore.weight}>
|
<FontApplicator {font} {status}>
|
||||||
<ContentEditable
|
<ContentEditable
|
||||||
bind:text
|
bind:text
|
||||||
fontSize={typographySettingsStore.renderedSize}
|
fontSize={typographySettingsStore.renderedSize}
|
||||||
|
|||||||
@@ -75,21 +75,6 @@ function handleSelect(font: UnifiedFont) {
|
|||||||
comparisonStore.fontB = font;
|
comparisonStore.fontB = font;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true once the font file is loaded (or errored) and safe to render.
|
|
||||||
* Called inside the template — Svelte 5 tracks the $state reads inside
|
|
||||||
* fontLifecycleManager.getFontStatus(), so each row re-renders reactively
|
|
||||||
* when its file arrives.
|
|
||||||
*/
|
|
||||||
function isFontReady(font: UnifiedFont): boolean {
|
|
||||||
const status = fontLifecycleManager.getFontStatus(
|
|
||||||
font.id,
|
|
||||||
DEFAULT_FONT_WEIGHT,
|
|
||||||
font.features?.isVariable,
|
|
||||||
);
|
|
||||||
return status === 'loaded' || status === 'error';
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 h-full">
|
<div class="flex-1 min-h-0 h-full">
|
||||||
@@ -129,8 +114,15 @@ function isFontReady(font: UnifiedFont): boolean {
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet children({ item: font, index })}
|
{#snippet children({ item: font, index })}
|
||||||
|
<!--
|
||||||
|
Read load status once per row. Svelte 5 tracks the $state reads inside
|
||||||
|
fontLifecycleManager.getFontStatus(), so the row re-renders reactively
|
||||||
|
when its file arrives — and the same value drives both the skeleton gate
|
||||||
|
and FontApplicator below.
|
||||||
|
-->
|
||||||
|
{@const status = fontLifecycleManager.getFontStatus(font.id, DEFAULT_FONT_WEIGHT, font.features?.isVariable)}
|
||||||
<div class="relative h-11 w-full">
|
<div class="relative h-11 w-full">
|
||||||
{#if !isFontReady(font)}
|
{#if status !== 'loaded' && status !== 'error'}
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent"
|
class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent"
|
||||||
transition:fade={{ duration: 300 }}
|
transition:fade={{ duration: 300 }}
|
||||||
@@ -155,7 +147,7 @@ function isFontReady(font: UnifiedFont): boolean {
|
|||||||
class="h-full"
|
class="h-full"
|
||||||
iconPosition="right"
|
iconPosition="right"
|
||||||
>
|
>
|
||||||
<FontApplicator {font}>
|
<FontApplicator {font} {status}>
|
||||||
{font.name}
|
{font.name}
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,13 @@ const fontRowHeight = $derived.by(() =>
|
|||||||
{skeleton}
|
{skeleton}
|
||||||
>
|
>
|
||||||
{#snippet children({ item: font, index })}
|
{#snippet children({ item: font, index })}
|
||||||
<FontSampler bind:text {font} {index} />
|
<!--
|
||||||
|
Resolve load status here (the widget owns the lifecycle store) and
|
||||||
|
pass it down — FontSampler and FontApplicator stay store-decoupled.
|
||||||
|
getFontStatus reads a $state SvelteMap, so the row stays reactive.
|
||||||
|
-->
|
||||||
|
{@const status = fontLifecycleManager.getFontStatus(font.id, typographySettingsStore.weight, font.features?.isVariable)}
|
||||||
|
<FontSampler bind:text {font} {index} {status} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FontVirtualList>
|
</FontVirtualList>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user