feat(ComparisonView): add redesigned font comparison widget

This commit is contained in:
Ilia Mashkov
2026-03-02 22:18:05 +03:00
parent 6cd325ce38
commit ba186d00a1
14 changed files with 1884 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
<!--
Component: FontList
Renders font list for A/B comparison with animated selection
-->
<script lang="ts">
import {
FontApplicator,
FontVirtualList,
type UnifiedFont,
} from '$entities/Font';
import {
Button,
Label,
} from '$shared/ui';
import DotIcon from '@lucide/svelte/icons/dot';
import { cubicOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
import { comparisonStore } from '../../model';
const side = $derived(comparisonStore.side);
const typography = $derived(comparisonStore.typography);
let prevIndexA: number | null = null;
let prevIndexB: number | null = null;
let selectedIndexA: number | null = null;
let selectedIndexB: number | null = null;
let pendingDirection: 1 | -1 = 1;
const [send, receive] = crossfade({
duration: 300,
easing: cubicOut,
fallback(node) {
// Read pendingDirection synchronously — no reactive timing issues
const fromY = pendingDirection * (node.closest('[data-font-list]')?.clientHeight ?? 300);
return {
duration: 300,
easing: cubicOut,
css: t => `transform: translateY(${fromY * (1 - t)}px);`,
};
},
});
function handleSelect(font: UnifiedFont, index: number) {
if (side === 'A') {
if (prevIndexA !== null) {
pendingDirection = index > prevIndexA ? -1 : 1;
}
prevIndexA = index;
selectedIndexA = index;
comparisonStore.fontA = font;
} else if (side === 'B') {
if (prevIndexB !== null) {
pendingDirection = index > prevIndexB ? -1 : 1;
}
prevIndexB = index;
selectedIndexB = index;
comparisonStore.fontB = font;
}
}
// When side switches, compute direction from relative positions of A vs B
$effect(() => {
const _ = side; // track side
if (selectedIndexA !== null && selectedIndexB !== null) {
// Switching TO B means dot moves toward B's position relative to A
pendingDirection = side === 'B'
? (selectedIndexB > selectedIndexA ? 1 : -1)
: (selectedIndexA > selectedIndexB ? 1 : -1);
}
});
</script>
<div class="flex-1 min-h-0 h-full">
<div class="py-2 pl-4 relative flex flex-col min-h-0 h-full">
<div class="px-2 py-4 mr-4 sticky border-b border-black/5 dark:border-white/10 mb-2">
<Label class="font-primary text-neutral-400" bold variant="default" size="sm" uppercase>
Typeface Selection
</Label>
</div>
<FontVirtualList
data-font-list
weight={typography.weight}
itemHeight={45}
class="bg-transparent min-h-0 h-full scroll-stable pr-4"
>
{#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)}
<Button
variant="tertiary"
{active}
onclick={() => handleSelect(font, index)}
class="w-full px-3 md:px-4 py-2.5 md:py-3 justify-between text-left text-sm flex"
iconPosition="right"
>
<FontApplicator {font} weight={typography.weight}>{font.name}</FontApplicator>
{#snippet icon()}
{#if active}
<div
in:receive={{ key: 'active-dot' }}
out:send={{ key: 'active-dot' }}
>
<DotIcon class="size-8 stroke-brand" />
</div>
{:else if isSelectedA || isSelectedB}
<div
in:receive={{ key: 'inactive-dot' }}
out:send={{ key: 'inactive-dot' }}
>
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
</div>
{/if}
{/snippet}
</Button>
{/snippet}
</FontVirtualList>
</div>
</div>