feat(FontList): unified skeleton — rows stay skeletal until font file loaded

This commit is contained in:
Ilia Mashkov
2026-04-21 12:58:46 +03:00
parent a801903fd3
commit 6664beec25
@@ -9,6 +9,7 @@ import {
FontVirtualList,
type UnifiedFont,
VIRTUAL_INDEX_NOT_LOADED,
appliedFontsManager,
fontStore,
} from '$entities/Font';
import { getSkeletonWidth } from '$shared/lib/utils';
@@ -18,6 +19,7 @@ import {
Skeleton,
} from '$shared/ui';
import DotIcon from '@lucide/svelte/icons/dot';
import { fade } from 'svelte/transition';
import {
createDotCrossfade,
getDotTransitionParams,
@@ -71,6 +73,21 @@ function handleSelect(font: UnifiedFont) {
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
* appliedFontsManager.getFontStatus(), so each row re-renders reactively
* when its file arrives.
*/
function isFontReady(font: UnifiedFont): boolean {
const status = appliedFontsManager.getFontStatus(
font.id,
DEFAULT_FONT_WEIGHT,
font.features?.isVariable,
);
return status === 'loaded' || status === 'error';
}
</script>
<div class="flex-1 min-h-0 h-full">
@@ -83,77 +100,93 @@ function handleSelect(font: UnifiedFont) {
<FontVirtualList
data-font-list
weight={DEFAULT_FONT_WEIGHT}
itemHeight={45}
itemHeight={44}
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
>
{#snippet skeleton()}
{#each { length: 12 } as _, i (i)}
<div class="w-full px-4 py-3 flex items-center justify-between border border-transparent bg-white/50 dark:bg-[#1e1e1e]/50">
<div class="flex-1 flex items-center gap-3">
<Skeleton
class="h-4 bg-neutral-200/70 dark:bg-neutral-800/70"
style="width: {getSkeletonWidth(i)}"
/>
<div class="py-2.5 md:py-3 px-7">
{#each { length: 50 } as _, index (index)}
<div class="w-full px-3 py-3 flex items-center justify-between">
<div class="flex-1 flex items-center gap-3">
<Skeleton
class="h-4 w-32 bg-neutral-200/70 dark:bg-neutral-800/70"
style="width: {getSkeletonWidth(index)}"
/>
</div>
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
</div>
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
</div>
{/each}
{/each}
</div>
{/snippet}
{#snippet children({ item: font, index })}
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
<div class="relative h-[44px] w-full">
{#if !isFontReady(font)}
<div
class="absolute inset-0 px-3 md:px-4 flex items-center justify-between border border-transparent"
transition:fade={{ duration: 300 }}
>
<Skeleton
class="h-4 bg-neutral-200/70 dark:bg-neutral-800/70"
style="width: {getSkeletonWidth(index)}"
/>
<Skeleton class="w-1.5 h-1.5 rounded-full bg-neutral-200/70 dark:bg-neutral-800/70" />
</div>
{:else}
{@const isSelectedA = font.id === comparisonStore.fontA?.id}
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
<Button
variant="tertiary"
{active}
onclick={() => handleSelect(font)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
iconPosition="right"
>
<FontApplicator {font}>
{#snippet skeleton()}
<Skeleton class="h-4 w-32 bg-neutral-200/70 dark:bg-neutral-800/70" />
{/snippet}
{font.name}
</FontApplicator>
<div transition:fade={{ duration: 300 }} class="h-full">
<Button
variant="tertiary"
{active}
onclick={() => handleSelect(font)}
class="w-full h-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
iconPosition="right"
>
<FontApplicator {font}>
{font.name}
</FontApplicator>
{#snippet icon()}
{#if active}
<div
in:receive={getDotTransitionParams(
'active-dot',
index,
prevSide === 'A' ? prevIndexA : prevIndexB,
)}
out:send={getDotTransitionParams(
'active-dot',
index,
side === 'A' ? indexA : indexB,
)}
>
<DotIcon class="size-8 stroke-brand" />
</div>
{:else if isSelectedA || isSelectedB}
{@const isA = isSelectedA}
<div
in:receive={getDotTransitionParams(
'inactive-dot',
index,
isA ? prevIndexB : prevIndexA,
)}
out:send={getDotTransitionParams(
'inactive-dot',
index,
isA ? indexB : indexA,
)}
>
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
</div>
{/if}
{/snippet}
</Button>
{#snippet icon()}
{#if active}
<div
in:receive={getDotTransitionParams(
'active-dot',
index,
prevSide === 'A' ? prevIndexA : prevIndexB,
)}
out:send={getDotTransitionParams(
'active-dot',
index,
side === 'A' ? indexA : indexB,
)}
>
<DotIcon class="size-8 stroke-brand" />
</div>
{:else if isSelectedA || isSelectedB}
{@const isA = isSelectedA}
<div
in:receive={getDotTransitionParams(
'inactive-dot',
index,
isA ? prevIndexB : prevIndexA,
)}
out:send={getDotTransitionParams(
'inactive-dot',
index,
isA ? indexB : indexA,
)}
>
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
</div>
{/if}
{/snippet}
</Button>
</div>
{/if}
</div>
{/snippet}
</FontVirtualList>
</div>