feature/loading #21

Merged
ilia merged 12 commits from feature/loading into main 2026-02-06 09:20:09 +00:00
17 changed files with 355 additions and 86 deletions

View File

@@ -4,8 +4,12 @@
- Handles font registration with the manager
-->
<script lang="ts" generics="T extends UnifiedFont">
import { VirtualList } from '$shared/ui';
import {
Skeleton,
VirtualList,
} from '$shared/ui';
import type { ComponentProps } from 'svelte';
import { fade } from 'svelte/transition';
import { getFontUrl } from '../../lib';
import type { FontConfigRequest } from '../../model';
import {
@@ -13,13 +17,42 @@ import {
appliedFontsManager,
} 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;
/**
* Callback for when near bottom is reached
*/
onNearBottom?: (lastVisibleIndex: number) => void;
/**
* Weight of the font
*/
/**
* Weight of the font
*/
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[]) {
const configs: FontConfigRequest[] = [];
@@ -50,13 +83,31 @@ function handleNearBottom(lastVisibleIndex: number) {
}
</script>
<VirtualList
{items}
{...rest}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
>
{#snippet children(scope)}
{@render children(scope)}
{/snippet}
</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}
{...rest}
onVisibleItemsChange={handleInternalVisibleChange}
onNearBottom={handleNearBottom}
>
{#snippet children(scope)}
{@render children(scope)}
{/snippet}
</VirtualList>
{/if}
</div>
{/key}

View File

@@ -11,9 +11,9 @@ import {
import { controlManager } from '$features/SetupFont';
import {
ContentEditable,
IconButton,
// IconButton,
} from '$shared/ui';
import XIcon from '@lucide/svelte/icons/x';
// import XIcon from '@lucide/svelte/icons/x';
interface Props {
/**
@@ -75,14 +75,16 @@ function removeSample() {
</span>
</div>
<IconButton
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"
>
{#snippet icon({ className })}
<XIcon class={className} />
{/snippet}
</IconButton>
<!--
<IconButton
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"
>
{#snippet icon({ className })}
<XIcon class={className} />
{/snippet}
</IconButton>
-->
</div>
<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 { SampleList } from '$widgets/SampleList';
import CodeIcon from '@lucide/svelte/icons/code';
import EyeIcon from '@lucide/svelte/icons/eye';
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
import ScanEyeIcon from '@lucide/svelte/icons/scan-eye';
import ScanSearchIcon from '@lucide/svelte/icons/search';
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}>
{#snippet icon({ className })}
<ScanEyeIcon class={className} />
<EyeIcon class={className} />
{/snippet}
{#snippet title({ 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
*/
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.
* @default false
*/
useWindowScroll?: boolean;
/**
* Flag to show loading state
*/
isLoading?: boolean;
}
let {
@@ -120,6 +132,7 @@ let {
onNearBottom,
children,
useWindowScroll = false,
isLoading = false,
}: Props = $props();
// Reference to the ScrollArea viewport element for attaching the virtualizer

View File

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

View File

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

View File

@@ -15,8 +15,10 @@ import {
createTypographyControl,
} from '$shared/lib';
import type { LineData } from '$shared/lib';
import { Loader } from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import CharacterSlot from './components/CharacterSlot.svelte';
import ControlsWrapper from './components/ControlsWrapper.svelte';
import Labels from './components/Labels.svelte';
@@ -26,6 +28,8 @@ import SliderLine from './components/SliderLine.svelte';
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
let container: HTMLElement | undefined = $state();
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
let measureCanvas: HTMLCanvasElement | undefined = $state();
@@ -164,31 +168,33 @@ $effect(() => {
</div>
{/snippet}
{#if fontA && fontB}
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div class="relative">
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
group relative w-full py-16 px-24 sm:py-24 sm:px-24 overflow-hidden
rounded-[2.5rem]
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
backdrop-blur-lg bg-gradient-to-br from-gray-100/70 via-white/50 to-gray-100/60
border border-gray-300/40
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
before:absolute before:inset-0 before:rounded-[2.5rem] before:p-[1px]
before:bg-gradient-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm
"
>
<!-- Text Rendering Container -->
<div class="relative">
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
group relative w-full py-16 px-24 sm:py-24 sm:px-24 overflow-hidden
rounded-[2.5rem]
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
backdrop-blur-lg bg-gradient-to-br from-gray-100/70 via-white/50 to-gray-100/60
border border-gray-300/40
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
before:absolute before:inset-0 before:rounded-[2.5rem] before:p-[1px]
before:bg-gradient-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm
"
>
<!-- Text Rendering Container -->
{#if isLoading}
<Loader size={24} />
{:else}
<div
class="
relative flex flex-col items-center gap-4
@@ -197,6 +203,8 @@ $effect(() => {
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
"
style:perspective="1000px"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300 }}
>
{#each charComparison.lines as line, lineIndex}
<div
@@ -212,8 +220,10 @@ $effect(() => {
</div>
<SliderLine {sliderPos} {isDragging} />
</div>
{/if}
</div>
{#if fontA && fontB && !isLoading}
<Labels {fontA} {fontB} {sliderPos} weight={weightControl.value} />
<!-- Since there're slider controls inside we put them outside the main one -->
<ControlsWrapper
@@ -226,5 +236,5 @@ $effect(() => {
{sizeControl}
{heightControl}
/>
</div>
{/if}
{/if}
</div>

View File

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

View File

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

View File

@@ -120,7 +120,7 @@ function toggleFilters() {
</div>
<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>

View File

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