Merge pull request 'feature/loading' (#21) from feature/loading into main
All checks were successful
Workflow / build (push) Successful in 52s

Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
2026-02-06 09:20:07 +00:00
17 changed files with 355 additions and 86 deletions

View File

@@ -4,8 +4,12 @@
- Handles font registration with the manager - Handles font registration with the manager
--> -->
<script lang="ts" generics="T extends UnifiedFont"> <script lang="ts" generics="T extends UnifiedFont">
import { VirtualList } from '$shared/ui'; import {
Skeleton,
VirtualList,
} from '$shared/ui';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib'; import { getFontUrl } from '../../lib';
import type { FontConfigRequest } from '../../model'; import type { FontConfigRequest } from '../../model';
import { import {
@@ -13,13 +17,42 @@ import {
appliedFontsManager, appliedFontsManager,
} from '../../model'; } from '../../model';
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> { interface Props extends
Omit<
ComponentProps<typeof VirtualList<T>>,
'onVisibleItemsChange'
>
{
/**
* Callback for when visible items change
*/
onVisibleItemsChange?: (items: T[]) => void; onVisibleItemsChange?: (items: T[]) => void;
/**
* Callback for when near bottom is reached
*/
onNearBottom?: (lastVisibleIndex: number) => void; onNearBottom?: (lastVisibleIndex: number) => void;
/**
* Weight of the font
*/
/**
* Weight of the font
*/
weight: number; weight: number;
/**
* Whether the list is in a loading state
*/
isLoading?: boolean;
} }
let { items, children, onVisibleItemsChange, onNearBottom, weight, ...rest }: Props = $props(); let {
items,
children,
onVisibleItemsChange,
onNearBottom,
weight,
isLoading = false,
...rest
}: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) { function handleInternalVisibleChange(visibleItems: T[]) {
const configs: FontConfigRequest[] = []; const configs: FontConfigRequest[] = [];
@@ -50,13 +83,31 @@ function handleNearBottom(lastVisibleIndex: number) {
} }
</script> </script>
<VirtualList {#key isLoading}
<div class="relative w-full h-full" transition:fade={{ duration: 300 }}>
{#if isLoading}
<div class="flex flex-col gap-4 p-4">
{#each Array(5) as _, i}
<div class="flex flex-col gap-2 p-4 border rounded-xl border-gray-200/50 bg-white/40">
<div class="flex items-center justify-between mb-4">
<Skeleton class="h-8 w-1/3" />
<Skeleton class="h-8 w-8 rounded-full" />
</div>
<Skeleton class="h-32 w-full" />
</div>
{/each}
</div>
{:else}
<VirtualList
{items} {items}
{...rest} {...rest}
onVisibleItemsChange={handleInternalVisibleChange} onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom} onNearBottom={handleNearBottom}
> >
{#snippet children(scope)} {#snippet children(scope)}
{@render children(scope)} {@render children(scope)}
{/snippet} {/snippet}
</VirtualList> </VirtualList>
{/if}
</div>
{/key}

View File

@@ -11,9 +11,9 @@ import {
import { controlManager } from '$features/SetupFont'; import { controlManager } from '$features/SetupFont';
import { import {
ContentEditable, ContentEditable,
IconButton, // IconButton,
} from '$shared/ui'; } from '$shared/ui';
import XIcon from '@lucide/svelte/icons/x'; // import XIcon from '@lucide/svelte/icons/x';
interface Props { interface Props {
/** /**
@@ -75,6 +75,7 @@ function removeSample() {
</span> </span>
</div> </div>
<!--
<IconButton <IconButton
onclick={removeSample} onclick={removeSample}
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer" class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
@@ -83,6 +84,7 @@ function removeSample() {
<XIcon class={className} /> <XIcon class={className} />
{/snippet} {/snippet}
</IconButton> </IconButton>
-->
</div> </div>
<div class="p-8 relative z-10"> <div class="p-8 relative z-10">

View File

@@ -10,8 +10,8 @@ import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/Comp
import { FontSearch } from '$widgets/FontSearch'; import { FontSearch } from '$widgets/FontSearch';
import { SampleList } from '$widgets/SampleList'; import { SampleList } from '$widgets/SampleList';
import CodeIcon from '@lucide/svelte/icons/code'; import CodeIcon from '@lucide/svelte/icons/code';
import EyeIcon from '@lucide/svelte/icons/eye';
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle'; import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
import ScanEyeIcon from '@lucide/svelte/icons/scan-eye';
import ScanSearchIcon from '@lucide/svelte/icons/search'; import ScanSearchIcon from '@lucide/svelte/icons/search';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -59,7 +59,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}> <Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
{#snippet icon({ className })} {#snippet icon({ className })}
<ScanEyeIcon class={className} /> <EyeIcon class={className} />
{/snippet} {/snippet}
{#snippet title({ className })} {#snippet title({ className })}
<h1 class={className}> <h1 class={className}>

View File

@@ -0,0 +1 @@
export { default as Spinner } from './spinner.svelte';

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
import Loader2Icon from '@lucide/svelte/icons/loader-2';
import type { ComponentProps } from 'svelte';
let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
</script>
<Loader2Icon
role="status"
aria-label="Loading"
class={cn('size-4 animate-spin', className)}
{...restProps}
/>

View File

@@ -0,0 +1,33 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Loader from './Loader.svelte';
const { Story } = defineMeta({
title: 'Shared/Loader',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Spinner with optional message',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
message: {
control: 'text',
description: 'Optional message to display',
defaultValue: 'analyzing_data',
},
size: {
control: 'number',
description: 'Size of the spinner',
defaultValue: 20,
},
},
});
</script>
<Story name="Default">
<Loader />
</Story>

View File

@@ -0,0 +1,77 @@
<!--
Component: Loader
Displays a loading spinner with an optional message.
-->
<script lang="ts">
import { fade } from 'svelte/transition';
interface Props {
/**
* Icon size (in pixels)
* @default 20
*/
size?: number;
/**
* Additional classes for container
*/
class?: string;
/**
* Message text
* @default analyzing_data
*/
message?: string;
}
let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $props();
</script>
<div
class="absolute inset-x-0 inset-y-0 flex items-center justify-center gap-4 {className}"
in:fade={{ duration: 300 }}
out:fade={{ duration: 300 }}
>
<div style:width="{size}px" style:height="{size}px">
<svg class="stroke-gray-900 stroke-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(12, 12)">
<!-- Four corner brackets rotating -->
<g>
<path
d="M -8 -8 L -4 -8 M -8 -8 L -8 -4"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
/>
<path
d="M 8 -8 L 4 -8 M 8 -8 L 8 -4"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
/>
<path
d="M -8 8 L -4 8 M -8 8 L -8 4"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
/>
<path d="M 8 8 L 4 8 M 8 8 L 8 4" stroke="currentColor" stroke-width="1" stroke-linecap="round" />
<animateTransform
attributeName="transform"
type="rotate"
from="0"
to="360"
dur="3s"
repeatCount="indefinite"
/>
</g>
</g>
</svg>
</div>
<!-- Divider -->
<div class="w-px h-3 bg-gray-400/50"></div>
<!-- Message -->
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600 font-medium">
{message}
</span>
</div>

View File

@@ -0,0 +1,41 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Skeleton from './Skeleton.svelte';
const { Story } = defineMeta({
title: 'Shared/Skeleton',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Skeleton component for loading states. Displays a shimmer animation when `animate` prop is true.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
animate: {
control: 'boolean',
description: 'Whether to show the shimmer animation',
},
},
});
</script>
<Story
name="Default"
args={{
animate: true,
}}
>
<div class="flex flex-col gap-4 p-4 w-full">
<div class="flex flex-col gap-2 p-4 border rounded-xl border-gray-200/50 bg-white/40">
<div class="flex items-center justify-between mb-4">
<Skeleton class="h-8 w-1/3" />
<Skeleton class="h-8 w-8 rounded-full" />
</div>
<Skeleton class="h-32 w-full" />
</div>
</div>
</Story>

View File

@@ -0,0 +1,27 @@
<!--
Component: Skeleton
Generic loading placeholder with shimmer animation.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { HTMLAttributes } from 'svelte/elements';
interface Props extends HTMLAttributes<HTMLDivElement> {
/**
* Whether to show the shimmer animation
*/
animate?: boolean;
}
let { class: className, animate = true, ...rest }: Props = $props();
</script>
<div
class={cn(
'rounded-md bg-gray-100/50 backdrop-blur-sm',
animate && 'animate-pulse',
className,
)}
{...rest}
>
</div>

View File

@@ -101,13 +101,25 @@ interface Props {
* @template T - The type of items in the list * @template T - The type of items in the list
*/ */
children: Snippet< children: Snippet<
[{ item: T; index: number; isFullyVisible: boolean; isPartiallyVisible: boolean; proximity: number }] [
{
item: T;
index: number;
isFullyVisible: boolean;
isPartiallyVisible: boolean;
proximity: number;
},
]
>; >;
/** /**
* Whether to use the window as the scroll container. * Whether to use the window as the scroll container.
* @default false * @default false
*/ */
useWindowScroll?: boolean; useWindowScroll?: boolean;
/**
* Flag to show loading state
*/
isLoading?: boolean;
} }
let { let {
@@ -120,6 +132,7 @@ let {
onNearBottom, onNearBottom,
children, children,
useWindowScroll = false, useWindowScroll = false,
isLoading = false,
}: Props = $props(); }: Props = $props();
// Reference to the ScrollArea viewport element for attaching the virtualizer // Reference to the ScrollArea viewport element for attaching the virtualizer

View File

@@ -1,27 +1,12 @@
/** export { default as CheckboxFilter } from './CheckboxFilter/CheckboxFilter.svelte';
* Shared UI components exports export { default as ComboControl } from './ComboControl/ComboControl.svelte';
* // ComboControlV2 might vary, assuming pattern holds or I'll fix later if build fails
* Exports all shared UI components and their types export { default as ComboControlV2 } from './ComboControlV2/ComboControlV2.svelte';
*/ export { default as ContentEditable } from './ContentEditable/ContentEditable.svelte';
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte'; export { default as IconButton } from './IconButton/IconButton.svelte';
import ComboControl from './ComboControl/ComboControl.svelte'; export { default as Loader } from './Loader/Loader.svelte';
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte'; export { default as SearchBar } from './SearchBar/SearchBar.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte'; export { default as Section } from './Section/Section.svelte';
import ExpandableWrapper from './ExpandableWrapper/ExpandableWrapper.svelte'; export { default as Skeleton } from './Skeleton/Skeleton.svelte';
import IconButton from './IconButton/IconButton.svelte'; export { default as VirtualList } from './VirtualList/VirtualList.svelte';
import SearchBar from './SearchBar/SearchBar.svelte';
import Section from './Section/Section.svelte';
import VirtualList from './VirtualList/VirtualList.svelte';
export {
CheckboxFilter,
ComboControl,
ComboControlV2,
ContentEditable,
ExpandableWrapper,
IconButton,
SearchBar,
Section,
VirtualList,
};

View File

@@ -136,6 +136,10 @@ class ComparisonStore {
return !!this.#fontA && !!this.#fontB; return !!this.#fontA && !!this.#fontB;
} }
get isLoading() {
return this.#isRestoring;
}
/** /**
* Public initializer (optional, as constructor starts it) * Public initializer (optional, as constructor starts it)
* Kept for compatibility if manual re-init is needed * Kept for compatibility if manual re-init is needed

View File

@@ -15,8 +15,10 @@ import {
createTypographyControl, createTypographyControl,
} from '$shared/lib'; } from '$shared/lib';
import type { LineData } from '$shared/lib'; import type { LineData } from '$shared/lib';
import { Loader } from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model'; import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import CharacterSlot from './components/CharacterSlot.svelte'; import CharacterSlot from './components/CharacterSlot.svelte';
import ControlsWrapper from './components/ControlsWrapper.svelte'; import ControlsWrapper from './components/ControlsWrapper.svelte';
import Labels from './components/Labels.svelte'; import Labels from './components/Labels.svelte';
@@ -26,6 +28,8 @@ import SliderLine from './components/SliderLine.svelte';
const fontA = $derived(comparisonStore.fontA); const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB); const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
let container: HTMLElement | undefined = $state(); let container: HTMLElement | undefined = $state();
let controlsWrapperElement = $state<HTMLDivElement | null>(null); let controlsWrapperElement = $state<HTMLDivElement | null>(null);
let measureCanvas: HTMLCanvasElement | undefined = $state(); let measureCanvas: HTMLCanvasElement | undefined = $state();
@@ -164,11 +168,10 @@ $effect(() => {
</div> </div>
{/snippet} {/snippet}
{#if fontA && fontB} <!-- Hidden canvas used for text measurement by the helper -->
<!-- Hidden canvas used for text measurement by the helper --> <canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div class="relative"> <div class="relative">
<div <div
bind:this={container} bind:this={container}
role="slider" role="slider"
@@ -189,6 +192,9 @@ $effect(() => {
" "
> >
<!-- Text Rendering Container --> <!-- Text Rendering Container -->
{#if isLoading}
<Loader size={24} />
{:else}
<div <div
class=" class="
relative flex flex-col items-center gap-4 relative flex flex-col items-center gap-4
@@ -197,6 +203,8 @@ $effect(() => {
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)] drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
" "
style:perspective="1000px" style:perspective="1000px"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300 }}
> >
{#each charComparison.lines as line, lineIndex} {#each charComparison.lines as line, lineIndex}
<div <div
@@ -212,8 +220,10 @@ $effect(() => {
</div> </div>
<SliderLine {sliderPos} {isDragging} /> <SliderLine {sliderPos} {isDragging} />
{/if}
</div> </div>
{#if fontA && fontB && !isLoading}
<Labels {fontA} {fontB} {sliderPos} weight={weightControl.value} /> <Labels {fontA} {fontB} {sliderPos} weight={weightControl.value} />
<!-- Since there're slider controls inside we put them outside the main one --> <!-- Since there're slider controls inside we put them outside the main one -->
<ControlsWrapper <ControlsWrapper
@@ -226,5 +236,5 @@ $effect(() => {
{sizeControl} {sizeControl}
{heightControl} {heightControl}
/> />
</div> {/if}
{/if} </div>

View File

@@ -12,6 +12,7 @@ import { ComboControlV2 } from '$shared/ui';
import { ExpandableWrapper } from '$shared/ui'; import { ExpandableWrapper } from '$shared/ui';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up'; import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
interface Props { interface Props {
/** /**
@@ -121,6 +122,8 @@ $effect(() => {
translateX({xSpring.current}px) translateX({xSpring.current}px)
rotateZ({rotateSpring.current}deg) rotateZ({rotateSpring.current}deg)
" "
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300, delay: 300 }}
> >
<ExpandableWrapper <ExpandableWrapper
bind:element={wrapper} bind:element={wrapper}

View File

@@ -17,6 +17,7 @@ import {
} from '$shared/shadcn/ui/select'; } from '$shared/shadcn/ui/select';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { comparisonStore } from '$widgets/ComparisonSlider/model'; import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { fade } from 'svelte/transition';
interface Props<T> { interface Props<T> {
/** /**
@@ -60,6 +61,8 @@ function selectFontB(font: UnifiedFont) {
<div <div
class="z-50 pointer-events-auto" class="z-50 pointer-events-auto"
onpointerdown={(e => e.stopPropagation())} onpointerdown={(e => e.stopPropagation())}
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300, delay: 300 }}
> >
<SelectRoot type="single" disabled={!fontList.length}> <SelectRoot type="single" disabled={!fontList.length}>
<SelectTrigger <SelectTrigger

View File

@@ -120,7 +120,7 @@ function toggleFilters() {
</div> </div>
<div class="mt-4 pt-4 border-t border-gray-300/40"> <div class="mt-4 pt-4 border-t border-gray-300/40">
<FilterControls class="ml-auto" /> <FilterControls class="m-auto w-fit" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -50,11 +50,9 @@ const displayRange = $derived.by(() => {
const loadedCount = Math.min(offset + limit, total); const loadedCount = Math.min(offset + limit, total);
return `Showing ${loadedCount} of ${total} fonts`; return `Showing ${loadedCount} of ${total} fonts`;
}); });
</script>
{#if unifiedFontStore.isFetching || unifiedFontStore.isLoading} const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading);
<span class="ml-2 text-xs text-muted-foreground/70">(Loading...)</span> </script>
{/if}
<FontVirtualList <FontVirtualList
items={unifiedFontStore.fonts} items={unifiedFontStore.fonts}
@@ -63,8 +61,15 @@ const displayRange = $derived.by(() => {
itemHeight={280} itemHeight={280}
useWindowScroll={true} useWindowScroll={true}
weight={controlManager.weight} weight={controlManager.weight}
{isLoading}
> >
{#snippet children({ item: font, isFullyVisible, isPartiallyVisible, proximity, index })} {#snippet children({
item: font,
isFullyVisible,
isPartiallyVisible,
proximity,
index,
})}
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}> <FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}>
<FontSampler {font} bind:text {index} /> <FontSampler {font} bind:text {index} />
</FontListItem> </FontListItem>