121 lines
4.2 KiB
Svelte
121 lines
4.2 KiB
Svelte
<!--
|
|
Component: FontList
|
|
Renders font list for A/B comparison with animated selection
|
|
-->
|
|
<script lang="ts">
|
|
import {
|
|
DEFAULT_FONT_WEIGHT,
|
|
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);
|
|
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={DEFAULT_FONT_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}>{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>
|