Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84ac886c33 | |||
| a60dbcfa51 | |||
| 8fc8a7ee6f | |||
| cbc978df6d | |||
| 6664beec25 | |||
| a801903fd3 | |||
| ecdb1e016d | |||
| 092b58e651 | |||
| d6914f8179 | |||
| b831861662 | |||
| 67fc9dee72 |
@@ -43,6 +43,12 @@ jobs:
|
|||||||
- name: Type Check
|
- name: Type Check
|
||||||
run: yarn check
|
run: yarn check
|
||||||
|
|
||||||
|
- name: Run Unit Tests
|
||||||
|
run: yarn test:unit
|
||||||
|
|
||||||
|
- name: Run Component Tests
|
||||||
|
run: yarn test:component
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: build # Only runs if tests/lint pass
|
needs: build # Only runs if tests/lint pass
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pre-commit:
|
|||||||
pre-push:
|
pre-push:
|
||||||
parallel: true
|
parallel: true
|
||||||
commands:
|
commands:
|
||||||
|
test-unit:
|
||||||
|
run: yarn test:unit
|
||||||
|
test-component:
|
||||||
|
run: yarn test:component
|
||||||
type-check:
|
type-check:
|
||||||
run: yarn tsc --noEmit
|
run: yarn tsc --noEmit
|
||||||
|
|
||||||
|
|||||||
@@ -86,3 +86,9 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
|||||||
export const MULTIPLIER_S = 0.5;
|
export const MULTIPLIER_S = 0.5;
|
||||||
export const MULTIPLIER_M = 0.75;
|
export const MULTIPLIER_M = 0.75;
|
||||||
export const MULTIPLIER_L = 1;
|
export const MULTIPLIER_L = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index value for items not yet loaded in a virtualized list.
|
||||||
|
* Treated as being at the very bottom of the infinite scroll.
|
||||||
|
*/
|
||||||
|
export const VIRTUAL_INDEX_NOT_LOADED = Infinity;
|
||||||
|
|||||||
@@ -561,4 +561,67 @@ describe('FontStore', () => {
|
|||||||
store.destroy();
|
store.destroy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchAllPagesTo', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetch.mockReset();
|
||||||
|
queryClient.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches all missing pages in parallel up to targetIndex', async () => {
|
||||||
|
// First page already loaded (offset 0, limit 10, total 50)
|
||||||
|
const firstFonts = generateMockFonts(10);
|
||||||
|
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(10);
|
||||||
|
|
||||||
|
// Mock remaining pages
|
||||||
|
for (let offset = 10; offset < 50; offset += 10) {
|
||||||
|
fetch.mockResolvedValueOnce(
|
||||||
|
makeResponse(generateMockFonts(10), { total: 50, limit: 10, offset }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.fetchAllPagesTo(40);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
expect(store.fonts).toHaveLength(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips pages that fail and still merges successful ones', async () => {
|
||||||
|
const firstFonts = generateMockFonts(10);
|
||||||
|
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 30, limit: 10, offset: 0 }));
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
// offset=10 fails, offset=20 succeeds
|
||||||
|
fetch.mockRejectedValueOnce(new Error('network error'));
|
||||||
|
fetch.mockResolvedValueOnce(
|
||||||
|
makeResponse(generateMockFonts(10), { total: 30, limit: 10, offset: 20 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.fetchAllPagesTo(25);
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
// Page at offset=20 merged, page at offset=10 missing — 20 total
|
||||||
|
expect(store.fonts).toHaveLength(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when target is within already-loaded data', async () => {
|
||||||
|
const firstFonts = generateMockFonts(10);
|
||||||
|
fetch.mockResolvedValueOnce(makeResponse(firstFonts, { total: 50, limit: 10, offset: 0 }));
|
||||||
|
const store = makeStore();
|
||||||
|
await store.refetch();
|
||||||
|
flushSync();
|
||||||
|
|
||||||
|
const callsBefore = fetch.mock.calls.length;
|
||||||
|
await store.fetchAllPagesTo(5);
|
||||||
|
|
||||||
|
expect(fetch.mock.calls.length).toBe(callsBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -242,6 +242,80 @@ export class FontStore {
|
|||||||
async nextPage(): Promise<void> {
|
async nextPage(): Promise<void> {
|
||||||
await this.#observer.fetchNextPage();
|
await this.#observer.fetchNextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#isCatchingUp = false;
|
||||||
|
#inFlightOffsets = new Set<number>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all pages between the current loaded count and targetIndex in parallel.
|
||||||
|
* Pages are merged into the cache as they arrive (sorted by offset).
|
||||||
|
* Failed pages are silently skipped — normal scroll will re-fetch them on demand.
|
||||||
|
*/
|
||||||
|
async fetchAllPagesTo(targetIndex: number): Promise<void> {
|
||||||
|
if (this.#isCatchingUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSize = typeof this.#params.limit === 'number' ? this.#params.limit : 50;
|
||||||
|
const key = this.buildQueryKey(this.#params);
|
||||||
|
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
|
||||||
|
|
||||||
|
// Collect offsets for all missing and not-in-flight pages
|
||||||
|
const missingOffsets: number[] = [];
|
||||||
|
for (let offset = 0; offset <= targetIndex; offset += pageSize) {
|
||||||
|
if (!loadedOffsets.has(offset) && !this.#inFlightOffsets.has(offset)) {
|
||||||
|
missingOffsets.push(offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingOffsets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#isCatchingUp = true;
|
||||||
|
|
||||||
|
// Sorted merge buffer — flush in offset order as pages arrive
|
||||||
|
const buffer = new Map<number, ProxyFontsResponse>();
|
||||||
|
const failed = new Set<number>();
|
||||||
|
let nextFlushOffset = (existing.pageParams.at(-1)?.offset ?? -pageSize) + pageSize;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
while (buffer.has(nextFlushOffset) || failed.has(nextFlushOffset)) {
|
||||||
|
if (buffer.has(nextFlushOffset)) {
|
||||||
|
this.#appendPageToCache(buffer.get(nextFlushOffset)!);
|
||||||
|
buffer.delete(nextFlushOffset);
|
||||||
|
}
|
||||||
|
failed.delete(nextFlushOffset);
|
||||||
|
nextFlushOffset += pageSize;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(
|
||||||
|
missingOffsets.map(async offset => {
|
||||||
|
this.#inFlightOffsets.add(offset);
|
||||||
|
try {
|
||||||
|
const page = await this.fetchPage({ ...this.#params, offset });
|
||||||
|
buffer.set(offset, page);
|
||||||
|
} catch {
|
||||||
|
failed.add(offset);
|
||||||
|
} finally {
|
||||||
|
this.#inFlightOffsets.delete(offset);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.#isCatchingUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backward pagination (no-op: infinite scroll accumulates forward only)
|
* Backward pagination (no-op: infinite scroll accumulates forward only)
|
||||||
*/
|
*/
|
||||||
@@ -289,6 +363,34 @@ export class FontStore {
|
|||||||
return this.fonts.filter(f => f.category === 'monospace');
|
return this.fonts.filter(f => f.category === 'monospace');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a single page into the InfiniteQuery cache in offset order.
|
||||||
|
* Called by fetchAllPagesTo as each parallel fetch resolves.
|
||||||
|
*/
|
||||||
|
#appendPageToCache(page: ProxyFontsResponse): void {
|
||||||
|
const key = this.buildQueryKey(this.#params);
|
||||||
|
const existing = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key);
|
||||||
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against duplicates
|
||||||
|
const loadedOffsets = new Set(existing.pageParams.map(p => p.offset));
|
||||||
|
if (loadedOffsets.has(page.offset)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPages = [...existing.pages, page].sort((a, b) => a.offset - b.offset);
|
||||||
|
const allParams = [...existing.pageParams, { offset: page.offset }].sort(
|
||||||
|
(a, b) => a.offset - b.offset,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.#qc.setQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(key, {
|
||||||
|
pages: allPages,
|
||||||
|
pageParams: allParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
private buildQueryKey(params: FontStoreParams): readonly unknown[] {
|
||||||
const filtered: Record<string, any> = {};
|
const filtered: Record<string, any> = {};
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: FontApplicator
|
Component: FontApplicator
|
||||||
Loads fonts from fontshare with link tag
|
Applies a font to its children once the font file is loaded.
|
||||||
- Loads font only if it's not already applied
|
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
|
||||||
- Reacts to font load status to show/hide content
|
|
||||||
- Adds smooth transition when font appears
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { prefersReducedMotion } from 'svelte/motion';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
@@ -33,6 +30,11 @@ interface Props {
|
|||||||
* Content snippet
|
* Content snippet
|
||||||
*/
|
*/
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
/**
|
||||||
|
* Shown while the font file is loading.
|
||||||
|
* When omitted, children render in system font until ready.
|
||||||
|
*/
|
||||||
|
skeleton?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -40,6 +42,7 @@ let {
|
|||||||
weight = DEFAULT_FONT_WEIGHT,
|
weight = DEFAULT_FONT_WEIGHT,
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
skeleton,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const status = $derived(
|
const status = $derived(
|
||||||
@@ -50,30 +53,16 @@ const status = $derived(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
|
|
||||||
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
|
|
||||||
const transitionClasses = $derived(
|
|
||||||
prefersReducedMotion.current
|
|
||||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
|
||||||
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if !shouldReveal && skeleton}
|
||||||
|
{@render skeleton()}
|
||||||
|
{:else}
|
||||||
<div
|
<div
|
||||||
style:font-family={shouldReveal
|
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
||||||
? `'${font.name}'`
|
class={clsx(className)}
|
||||||
: 'system-ui, -apple-system, sans-serif'}
|
|
||||||
class={clsx(
|
|
||||||
transitionClasses,
|
|
||||||
// If reduced motion is on, we skip the transform/blur entirely
|
|
||||||
!shouldReveal
|
|
||||||
&& !prefersReducedMotion.current
|
|
||||||
&& 'opacity-50 scale-[0.95] blur-sm',
|
|
||||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
|
||||||
shouldReveal && 'opacity-100 scale-100 blur-0',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
- Handles font registration with the manager
|
- Handles font registration with the manager
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { debounce } from '$shared/lib/utils';
|
||||||
import {
|
import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
@@ -54,6 +55,10 @@ const isLoading = $derived(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let visibleFonts = $state<UnifiedFont[]>([]);
|
let visibleFonts = $state<UnifiedFont[]>([]);
|
||||||
|
let isCatchingUp = $state(false);
|
||||||
|
|
||||||
|
const showInitialSkeleton = $derived(!!skeleton && isLoading && fontStore.fonts.length === 0);
|
||||||
|
const showCatchupSkeleton = $derived(!!skeleton && isCatchingUp);
|
||||||
|
|
||||||
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||||
visibleFonts = items;
|
visibleFonts = items;
|
||||||
@@ -61,8 +66,32 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
|||||||
onVisibleItemsChange?.(items);
|
onVisibleItemsChange?.(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle jump scroll — batch-load all missing pages then re-enable font loading.
|
||||||
|
* Suppresses appliedFontsManager.touch() during catch-up to avoid loading
|
||||||
|
* font files for thousands of intermediate fonts.
|
||||||
|
*/
|
||||||
|
async function handleJump(targetIndex: number) {
|
||||||
|
if (isCatchingUp || !fontStore.pagination.hasMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isCatchingUp = true;
|
||||||
|
try {
|
||||||
|
await fontStore.fetchAllPagesTo(targetIndex);
|
||||||
|
} finally {
|
||||||
|
isCatchingUp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedTouch = debounce((configs: FontLoadRequestConfig[]) => {
|
||||||
|
appliedFontsManager.touch(configs);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
// Re-touch whenever visible set or weight changes — fixes weight-change gap
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
if (isCatchingUp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
||||||
const url = getFontUrl(item, weight);
|
const url = getFontUrl(item, weight);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -71,7 +100,7 @@ $effect(() => {
|
|||||||
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
||||||
});
|
});
|
||||||
if (configs.length > 0) {
|
if (configs.length > 0) {
|
||||||
appliedFontsManager.touch(configs);
|
debouncedTouch(configs);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,17 +142,19 @@ function loadMore() {
|
|||||||
function handleNearBottom(_lastVisibleIndex: number) {
|
function handleNearBottom(_lastVisibleIndex: number) {
|
||||||
const { hasMore } = fontStore.pagination;
|
const { hasMore } = fontStore.pagination;
|
||||||
|
|
||||||
// VirtualList already checks if we're near the bottom of loaded items
|
// VirtualList already checks if we're near the bottom of loaded items.
|
||||||
if (hasMore && !fontStore.isFetching) {
|
// Guard isCatchingUp: fetchAllPagesTo bypasses TQ so isFetching stays false
|
||||||
|
// during batch catch-up, which would otherwise let nextPage() race with it.
|
||||||
|
if (hasMore && !fontStore.isFetching && !isCatchingUp) {
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
{#if skeleton && isLoading && fontStore.fonts.length === 0}
|
{#if showInitialSkeleton && skeleton}
|
||||||
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||||
<div transition:fade={{ duration: 300 }}>
|
<div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}>
|
||||||
{@render skeleton()}
|
{@render skeleton()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -131,14 +162,20 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
<VirtualList
|
<VirtualList
|
||||||
items={fontStore.fonts}
|
items={fontStore.fonts}
|
||||||
total={fontStore.pagination.total}
|
total={fontStore.pagination.total}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading || isCatchingUp}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
|
onJump={handleJump}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{#snippet children(scope)}
|
{#snippet children(scope)}
|
||||||
{@render children(scope)}
|
{@render children(scope)}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</VirtualList>
|
</VirtualList>
|
||||||
|
{#if showCatchupSkeleton && skeleton}
|
||||||
|
<div class="absolute inset-0 overflow-hidden" transition:fade={{ duration: 150 }}>
|
||||||
|
{@render skeleton()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
mapManagerToParams,
|
mapManagerToParams,
|
||||||
} from './lib';
|
} from './lib';
|
||||||
|
|
||||||
|
export { filtersStore } from './model/state/filters.svelte';
|
||||||
export { filterManager } from './model/state/manager.svelte';
|
export { filterManager } from './model/state/manager.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
import { filterManager } from '$features/GetFonts';
|
import {
|
||||||
|
filterManager,
|
||||||
|
filtersStore,
|
||||||
|
} from '$features/GetFonts';
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
|
import { vi } from 'vitest';
|
||||||
import Filters from './Filters.svelte';
|
import Filters from './Filters.svelte';
|
||||||
|
|
||||||
describe('Filters', () => {
|
describe('Filters', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
|
||||||
filterManager.setGroups([]);
|
filterManager.setGroups([]);
|
||||||
|
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('renders nothing when filter groups are empty', () => {
|
it('renders nothing when filter groups are empty', () => {
|
||||||
const { container } = render(Filters);
|
const { container } = render(Filters);
|
||||||
expect(container.firstElementChild).toBeNull();
|
// It might render an empty container if the component has one, but we expect no children
|
||||||
|
expect(container.firstChild?.childNodes.length ?? 0).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a label for each filter group', () => {
|
it('renders a label for each filter group', () => {
|
||||||
filterManager.setGroups([
|
filterManager.setGroups([
|
||||||
{ id: 'cat', label: 'Category', properties: [] },
|
{ id: 'cat', label: 'Categories', properties: [] },
|
||||||
{ id: 'prov', label: 'Provider', properties: [] },
|
{ id: 'prov', label: 'Font Providers', properties: [] },
|
||||||
]);
|
]);
|
||||||
render(Filters);
|
render(Filters);
|
||||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
expect(screen.getByText('Categories')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Provider')).toBeInTheDocument();
|
expect(screen.getByText('Font Providers')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders filter properties within groups', () => {
|
it('renders filter properties within groups', () => {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Generates a consistent but varied width for skeleton placeholders.
|
||||||
|
* Uses a predefined sequence to ensure stability between renders.
|
||||||
|
*
|
||||||
|
* @param index - Index of the item in a list to pick a width from the sequence
|
||||||
|
* @param multiplier - Multiplier to apply to the base sequence values (default: 4)
|
||||||
|
* @returns CSS width value (e.g., "128px")
|
||||||
|
*/
|
||||||
|
export function getSkeletonWidth(index: number, multiplier = 4): string {
|
||||||
|
const sequence = [32, 48, 40, 56, 36, 44, 52, 38, 46, 42, 34, 50];
|
||||||
|
const base = sequence[index % sequence.length];
|
||||||
|
return `${base * multiplier}px`;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export {
|
|||||||
export { clampNumber } from './clampNumber/clampNumber';
|
export { clampNumber } from './clampNumber/clampNumber';
|
||||||
export { debounce } from './debounce/debounce';
|
export { debounce } from './debounce/debounce';
|
||||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||||
|
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
|
||||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||||
export { smoothScroll } from './smoothScroll/smoothScroll';
|
export { smoothScroll } from './smoothScroll/smoothScroll';
|
||||||
export { splitArray } from './splitArray/splitArray';
|
export { splitArray } from './splitArray/splitArray';
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe('ComboControl', () => {
|
|||||||
it('opens popover with vertical slider on trigger click', async () => {
|
it('opens popover with vertical slider on trigger click', async () => {
|
||||||
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
|
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
|
||||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||||
await fireEvent.click(screen.getByLabelText('Size control'));
|
await fireEvent.click(screen.getByText('Size control'));
|
||||||
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ interface Props extends
|
|||||||
* Near bottom callback
|
* Near bottom callback
|
||||||
*/
|
*/
|
||||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
onNearBottom?: (lastVisibleIndex: number) => void;
|
||||||
|
/**
|
||||||
|
* Fires when scroll position exceeds loaded content — user jumped beyond data
|
||||||
|
*/
|
||||||
|
onJump?: (targetIndex: number) => void;
|
||||||
/**
|
/**
|
||||||
* Item render snippet
|
* Item render snippet
|
||||||
*/
|
*/
|
||||||
@@ -95,6 +99,7 @@ let {
|
|||||||
class: className,
|
class: className,
|
||||||
onVisibleItemsChange,
|
onVisibleItemsChange,
|
||||||
onNearBottom,
|
onNearBottom,
|
||||||
|
onJump,
|
||||||
children,
|
children,
|
||||||
useWindowScroll = false,
|
useWindowScroll = false,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -170,6 +175,10 @@ const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
|||||||
onNearBottom?.(lastVisibleIndex);
|
onNearBottom?.(lastVisibleIndex);
|
||||||
}, 200); // 200ms throttle
|
}, 200); // 200ms throttle
|
||||||
|
|
||||||
|
const throttledOnJump = throttle((targetIndex: number) => {
|
||||||
|
onJump?.(targetIndex);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
// Calculate top/bottom padding for spacer elements
|
// Calculate top/bottom padding for spacer elements
|
||||||
// In CSS Grid, gap creates space BETWEEN elements.
|
// In CSS Grid, gap creates space BETWEEN elements.
|
||||||
// The top spacer should place the first row at its virtual offset.
|
// The top spacer should place the first row at its virtual offset.
|
||||||
@@ -227,6 +236,26 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Fire onJump when scroll is beyond the loaded content boundary.
|
||||||
|
// Target index estimates which item the user scrolled to.
|
||||||
|
if (!onJump || !virtualizer.containerHeight || virtualizer.scrollOffset <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAhead = virtualizer.scrollOffset > virtualizer.totalSize;
|
||||||
|
if (!isAhead) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimatedItemHeight = typeof itemHeight === 'number' ? itemHeight : 80;
|
||||||
|
// Include visible rows + overscan so the bottom of the viewport is fully covered
|
||||||
|
const topItemIndex = Math.floor(virtualizer.scrollOffset / estimatedItemHeight) * columns;
|
||||||
|
const visibleRows = Math.ceil(virtualizer.containerHeight / estimatedItemHeight);
|
||||||
|
const targetIndex = topItemIndex + (visibleRows + overscan) * columns;
|
||||||
|
throttledOnJump(targetIndex);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet content()}
|
{#snippet content()}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './utils/dotTransition';
|
||||||
export * from './utils/getPretextFontString';
|
export * from './utils/getPretextFontString';
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { VIRTUAL_INDEX_NOT_LOADED } from '$entities/Font';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import {
|
||||||
|
type CrossfadeParams,
|
||||||
|
type TransitionConfig,
|
||||||
|
crossfade,
|
||||||
|
} from 'svelte/transition';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom parameters for dot transitions in virtualized lists.
|
||||||
|
*/
|
||||||
|
export interface DotTransitionParams extends CrossfadeParams {
|
||||||
|
/**
|
||||||
|
* Unique key for crossfade pairing
|
||||||
|
*/
|
||||||
|
key: any;
|
||||||
|
/**
|
||||||
|
* Current index of the item in the list
|
||||||
|
*/
|
||||||
|
index: number;
|
||||||
|
/**
|
||||||
|
* Target index to move towards (e.g. counterpart side index)
|
||||||
|
*/
|
||||||
|
otherIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-safe helper to create dot transition parameters.
|
||||||
|
*/
|
||||||
|
export function getDotTransitionParams(
|
||||||
|
key: 'active-dot' | 'inactive-dot',
|
||||||
|
index: number,
|
||||||
|
otherIndex: number,
|
||||||
|
): DotTransitionParams {
|
||||||
|
return { key, index, otherIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for DotTransitionParams.
|
||||||
|
*/
|
||||||
|
function isDotTransitionParams(p: CrossfadeParams): p is DotTransitionParams {
|
||||||
|
return (
|
||||||
|
p !== null
|
||||||
|
&& typeof p === 'object'
|
||||||
|
&& 'index' in p
|
||||||
|
&& 'otherIndex' in p
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a crossfade transition pair optimized for virtualized font lists.
|
||||||
|
*
|
||||||
|
* It uses the 'index' and 'otherIndex' params to calculate the direction
|
||||||
|
* of the slide animation when a matching pair cannot be found in the DOM
|
||||||
|
* (e.g. because it was virtualized out).
|
||||||
|
*/
|
||||||
|
export function createDotCrossfade() {
|
||||||
|
return crossfade({
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
fallback(node: Element, params: CrossfadeParams, _intro: boolean): TransitionConfig {
|
||||||
|
if (!isDotTransitionParams(params)) {
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
css: t => `opacity: ${t};`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { index, otherIndex } = params;
|
||||||
|
|
||||||
|
// If the other target is unknown, just fade in place
|
||||||
|
if (otherIndex === undefined || otherIndex === -1) {
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
css: t => `opacity: ${t};`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = otherIndex - index;
|
||||||
|
const sign = diff > 0 ? 1 : (diff < 0 ? -1 : 0);
|
||||||
|
|
||||||
|
// Use container height for a full-height slide
|
||||||
|
const listEl = node.closest('[data-font-list]');
|
||||||
|
const h = listEl?.clientHeight ?? 300;
|
||||||
|
const fromY = sign * h;
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration: 300,
|
||||||
|
easing: cubicOut,
|
||||||
|
css: (t, u) => `
|
||||||
|
transform: translateY(${fromY * u}px);
|
||||||
|
opacity: ${t};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,65 +8,86 @@ import {
|
|||||||
FontApplicator,
|
FontApplicator,
|
||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
|
VIRTUAL_INDEX_NOT_LOADED,
|
||||||
|
appliedFontsManager,
|
||||||
|
fontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
|
import { getSkeletonWidth } from '$shared/lib/utils';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Label,
|
Label,
|
||||||
|
Skeleton,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import DotIcon from '@lucide/svelte/icons/dot';
|
import DotIcon from '@lucide/svelte/icons/dot';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { fade } from 'svelte/transition';
|
||||||
import { crossfade } from 'svelte/transition';
|
import {
|
||||||
import { comparisonStore } from '../../model';
|
createDotCrossfade,
|
||||||
|
getDotTransitionParams,
|
||||||
|
} from '../../lib';
|
||||||
|
import {
|
||||||
|
type Side,
|
||||||
|
comparisonStore,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
const side = $derived(comparisonStore.side);
|
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({
|
// Treat -1 (not loaded) as being at the very bottom of the infinite list
|
||||||
duration: 300,
|
function getVirtualIndex(fontId: string | undefined): number {
|
||||||
easing: cubicOut,
|
if (!fontId) {
|
||||||
fallback(node) {
|
return -1;
|
||||||
// Read pendingDirection synchronously — no reactive timing issues
|
}
|
||||||
const fromY = pendingDirection * (node.closest('[data-font-list]')?.clientHeight ?? 300);
|
const idx = fontStore.fonts.findIndex(f => f.id === fontId);
|
||||||
return {
|
if (idx === -1) {
|
||||||
duration: 300,
|
return VIRTUAL_INDEX_NOT_LOADED;
|
||||||
easing: cubicOut,
|
}
|
||||||
css: t => `transform: translateY(${fromY * (1 - t)}px);`,
|
return idx;
|
||||||
};
|
}
|
||||||
},
|
|
||||||
|
// Reactive indices of the currently selected fonts in the full list
|
||||||
|
const indexA = $derived(getVirtualIndex(comparisonStore.fontA?.id));
|
||||||
|
const indexB = $derived(getVirtualIndex(comparisonStore.fontB?.id));
|
||||||
|
|
||||||
|
// Track previous state for directional fallback transitions.
|
||||||
|
// We use plain variables here. In Svelte 5, updates to these in an $effect
|
||||||
|
// happen AFTER the render/DOM update, so transitions starting as a result
|
||||||
|
// of that update will see the "old" values of these variables.
|
||||||
|
let prevIndexA = indexA;
|
||||||
|
let prevIndexB = indexB;
|
||||||
|
let prevSide: Side = side;
|
||||||
|
|
||||||
|
const [send, receive] = createDotCrossfade();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// This effect runs after every change to indexA, indexB, or side.
|
||||||
|
// It captures the "current" values which will serve as "previous" values
|
||||||
|
// for the NEXT transition.
|
||||||
|
prevIndexA = indexA;
|
||||||
|
prevIndexB = indexB;
|
||||||
|
prevSide = side;
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSelect(font: UnifiedFont, index: number) {
|
function handleSelect(font: UnifiedFont) {
|
||||||
if (side === 'A') {
|
if (side === 'A') {
|
||||||
if (prevIndexA !== null) {
|
|
||||||
pendingDirection = index > prevIndexA ? -1 : 1;
|
|
||||||
}
|
|
||||||
prevIndexA = index;
|
|
||||||
selectedIndexA = index;
|
|
||||||
comparisonStore.fontA = font;
|
comparisonStore.fontA = font;
|
||||||
} else if (side === 'B') {
|
} else {
|
||||||
if (prevIndexB !== null) {
|
|
||||||
pendingDirection = index > prevIndexB ? -1 : 1;
|
|
||||||
}
|
|
||||||
prevIndexB = index;
|
|
||||||
selectedIndexB = index;
|
|
||||||
comparisonStore.fontB = font;
|
comparisonStore.fontB = font;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When side switches, compute direction from relative positions of A vs B
|
/**
|
||||||
$effect(() => {
|
* Returns true once the font file is loaded (or errored) and safe to render.
|
||||||
const _ = side; // track side
|
* Called inside the template — Svelte 5 tracks the $state reads inside
|
||||||
if (selectedIndexA !== null && selectedIndexB !== null) {
|
* appliedFontsManager.getFontStatus(), so each row re-renders reactively
|
||||||
// Switching TO B means dot moves toward B's position relative to A
|
* when its file arrives.
|
||||||
pendingDirection = side === 'B'
|
*/
|
||||||
? (selectedIndexB > selectedIndexA ? 1 : -1)
|
function isFontReady(font: UnifiedFont): boolean {
|
||||||
: (selectedIndexA > selectedIndexB ? 1 : -1);
|
const status = appliedFontsManager.getFontStatus(
|
||||||
|
font.id,
|
||||||
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
font.features?.isVariable,
|
||||||
|
);
|
||||||
|
return status === 'loaded' || status === 'error';
|
||||||
}
|
}
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex-1 min-h-0 h-full">
|
<div class="flex-1 min-h-0 h-full">
|
||||||
@@ -79,41 +100,93 @@ $effect(() => {
|
|||||||
<FontVirtualList
|
<FontVirtualList
|
||||||
data-font-list
|
data-font-list
|
||||||
weight={DEFAULT_FONT_WEIGHT}
|
weight={DEFAULT_FONT_WEIGHT}
|
||||||
itemHeight={45}
|
itemHeight={44}
|
||||||
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
|
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
|
||||||
>
|
>
|
||||||
|
{#snippet skeleton()}
|
||||||
|
<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>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet children({ item: font, index })}
|
{#snippet children({ item: font, index })}
|
||||||
|
<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 isSelectedA = font.id === comparisonStore.fontA?.id}
|
||||||
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
|
{@const isSelectedB = font.id === comparisonStore.fontB?.id}
|
||||||
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
|
{@const active = (side === 'A' && isSelectedA) || (side === 'B' && isSelectedB)}
|
||||||
|
|
||||||
|
<div transition:fade={{ duration: 300 }} class="h-full">
|
||||||
<Button
|
<Button
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
{active}
|
{active}
|
||||||
onclick={() => handleSelect(font, index)}
|
onclick={() => handleSelect(font)}
|
||||||
class="w-full px-3 md:px-4 py-2.5 md:py-3 flex !justify-between text-left text-sm"
|
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"
|
iconPosition="right"
|
||||||
>
|
>
|
||||||
<FontApplicator {font}>{font.name}</FontApplicator>
|
<FontApplicator {font}>
|
||||||
|
{font.name}
|
||||||
|
</FontApplicator>
|
||||||
|
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
{#if active}
|
{#if active}
|
||||||
<div
|
<div
|
||||||
in:receive={{ key: 'active-dot' }}
|
in:receive={getDotTransitionParams(
|
||||||
out:send={{ key: 'active-dot' }}
|
'active-dot',
|
||||||
|
index,
|
||||||
|
prevSide === 'A' ? prevIndexA : prevIndexB,
|
||||||
|
)}
|
||||||
|
out:send={getDotTransitionParams(
|
||||||
|
'active-dot',
|
||||||
|
index,
|
||||||
|
side === 'A' ? indexA : indexB,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<DotIcon class="size-8 stroke-brand" />
|
<DotIcon class="size-8 stroke-brand" />
|
||||||
</div>
|
</div>
|
||||||
{:else if isSelectedA || isSelectedB}
|
{:else if isSelectedA || isSelectedB}
|
||||||
|
{@const isA = isSelectedA}
|
||||||
<div
|
<div
|
||||||
in:receive={{ key: 'inactive-dot' }}
|
in:receive={getDotTransitionParams(
|
||||||
out:send={{ key: 'inactive-dot' }}
|
'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" />
|
<DotIcon class="size-8 stroke-neutral-300 dark:stroke-neutral-600" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FontVirtualList>
|
</FontVirtualList>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@
|
|||||||
"src/**/*.js",
|
"src/**/*.js",
|
||||||
"src/**/*.svelte",
|
"src/**/*.svelte",
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
|
"vitest.config*.ts",
|
||||||
|
"vitest.setup*.ts",
|
||||||
"vitest.types.d.ts"
|
"vitest.types.d.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.svelte.test.ts'],
|
||||||
|
exclude: ['node_modules', 'dist', 'e2e', '.storybook'],
|
||||||
|
restoreMocks: true,
|
||||||
|
setupFiles: ['./vitest.setup.component.ts', './vitest.setup.jsdom.ts'],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
conditions: ['browser'],
|
||||||
|
alias: {
|
||||||
|
$lib: path.resolve(__dirname, './src/lib'),
|
||||||
|
$app: path.resolve(__dirname, './src/app'),
|
||||||
|
$shared: path.resolve(__dirname, './src/shared'),
|
||||||
|
$entities: path.resolve(__dirname, './src/entities'),
|
||||||
|
$features: path.resolve(__dirname, './src/features'),
|
||||||
|
$routes: path.resolve(__dirname, './src/routes'),
|
||||||
|
$widgets: path.resolve(__dirname, './src/widgets'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||||
import { cleanup } from '@testing-library/svelte';
|
import { cleanup } from '@testing-library/svelte';
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +14,7 @@ expect.extend(matchers);
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
queryClient.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock window.matchMedia for components that use it
|
// Mock window.matchMedia for components that use it
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
// jsdom lacks ResizeObserver
|
||||||
|
global.ResizeObserver = class {
|
||||||
|
observe = vi.fn();
|
||||||
|
unobserve = vi.fn();
|
||||||
|
disconnect = vi.fn();
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
// jsdom lacks Web Animations API
|
||||||
|
Element.prototype.animate = vi.fn().mockReturnValue({
|
||||||
|
onfinish: null,
|
||||||
|
cancel: vi.fn(),
|
||||||
|
finish: vi.fn(),
|
||||||
|
pause: vi.fn(),
|
||||||
|
play: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// jsdom lacks SVG geometry methods
|
||||||
|
(SVGElement.prototype as any).getTotalLength = vi.fn(() => 0);
|
||||||
|
|
||||||
|
// Robust localStorage mock for jsdom environment
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
getItem: vi.fn((key: string) => store[key] || null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
store[key] = value.toString();
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
delete store[key];
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => {
|
||||||
|
store = {};
|
||||||
|
}),
|
||||||
|
key: vi.fn((index: number) => Object.keys(store)[index] || null),
|
||||||
|
get length() {
|
||||||
|
return Object.keys(store).length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: localStorageMock,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user