Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84ac886c33 | |||
| a60dbcfa51 | |||
| 8fc8a7ee6f | |||
| cbc978df6d | |||
| 6664beec25 | |||
| a801903fd3 | |||
| ecdb1e016d | |||
| 092b58e651 | |||
| d6914f8179 | |||
| b831861662 | |||
| 67fc9dee72 | |||
| a73bd75947 | |||
| 836b83f75d | |||
| 07e4a0b9d9 | |||
| 141126530d | |||
| f9f96e2797 | |||
| 3e11821814 | |||
| ee3f773ca5 | |||
| 2a51f031cc | |||
| b792dde7cb | |||
| 66dcffa448 | |||
| cca00fccaa | |||
| af05443763 | |||
| 99d92d487f | |||
| 4a907619cc | |||
| 6c69d7a5b3 | |||
| 993812de0a | |||
| 67c16530af | |||
| fbbb439023 | |||
| c2046770ef | |||
| adfba38063 | |||
| dfb304d436 |
@@ -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
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
children: import('svelte').Snippet;
|
children: import('svelte').Snippet;
|
||||||
width?: string; // Optional width override
|
/**
|
||||||
|
* Tailwind max-width class applied to the card, or 'none' to remove width constraint.
|
||||||
|
* @default 'max-w-3xl'
|
||||||
|
*/
|
||||||
|
maxWidth?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { children, width = 'max-w-3xl' }: Props = $props();
|
let { children, maxWidth = 'max-w-3xl' }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
<div class="flex min-h-screen w-full items-center justify-center bg-background p-8">
|
||||||
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {width}">
|
<div class="w-full bg-card shadow-lg ring-1 ring-border rounded-xl p-12 {maxWidth !== 'none' ? maxWidth : ''}">
|
||||||
<div class="relative flex justify-center items-center text-foreground">
|
<div class="relative flex justify-center items-center text-foreground">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-63
@@ -5,68 +5,6 @@ import ThemeDecorator from './ThemeDecorator.svelte';
|
|||||||
import '../src/app/styles/app.css';
|
import '../src/app/styles/app.css';
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
globalTypes: {
|
|
||||||
viewport: {
|
|
||||||
description: 'Viewport size for responsive design',
|
|
||||||
defaultValue: 'widgetWide',
|
|
||||||
toolbar: {
|
|
||||||
icon: 'view',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
value: 'reset',
|
|
||||||
icon: 'refresh',
|
|
||||||
title: 'Reset viewport',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'mobile1',
|
|
||||||
icon: 'mobile',
|
|
||||||
title: 'iPhone 5/SE',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'mobile2',
|
|
||||||
icon: 'mobile',
|
|
||||||
title: 'iPhone 14 Pro Max',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'tablet',
|
|
||||||
icon: 'tablet',
|
|
||||||
title: 'iPad (Portrait)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'desktop',
|
|
||||||
icon: 'desktop',
|
|
||||||
title: 'Desktop (Small)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetMedium',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Medium',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetWide',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Wide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'widgetExtraWide',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Widget Extra Wide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'fullWidth',
|
|
||||||
icon: 'view',
|
|
||||||
title: 'Full Width',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'fullScreen',
|
|
||||||
icon: 'expand',
|
|
||||||
title: 'Full Screen',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dynamicTitle: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parameters: {
|
parameters: {
|
||||||
layout: 'padded',
|
layout: 'padded',
|
||||||
controls: {
|
controls: {
|
||||||
@@ -195,10 +133,11 @@ const preview: Preview = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// Wrap with StoryStage for presentation styling
|
// Wrap with StoryStage for presentation styling
|
||||||
story => ({
|
(story, context) => ({
|
||||||
Component: StoryStage,
|
Component: StoryStage,
|
||||||
props: {
|
props: {
|
||||||
children: story(),
|
children: story(),
|
||||||
|
maxWidth: context.parameters.storyStage?.maxWidth,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
+6
-1
@@ -33,10 +33,15 @@
|
|||||||
"exportDeclaration.forceMultiLine": "whenMultiple",
|
"exportDeclaration.forceMultiLine": "whenMultiple",
|
||||||
"exportDeclaration.forceSingleLine": false,
|
"exportDeclaration.forceSingleLine": false,
|
||||||
"ifStatement.useBraces": "always",
|
"ifStatement.useBraces": "always",
|
||||||
|
"ifStatement.singleBodyPosition": "nextLine",
|
||||||
"whileStatement.useBraces": "always",
|
"whileStatement.useBraces": "always",
|
||||||
|
"whileStatement.singleBodyPosition": "nextLine",
|
||||||
"forStatement.useBraces": "always",
|
"forStatement.useBraces": "always",
|
||||||
|
"forStatement.singleBodyPosition": "nextLine",
|
||||||
"forInStatement.useBraces": "always",
|
"forInStatement.useBraces": "always",
|
||||||
"forOfStatement.useBraces": "always"
|
"forInStatement.singleBodyPosition": "nextLine",
|
||||||
|
"forOfStatement.useBraces": "always",
|
||||||
|
"forOfStatement.singleBodyPosition": "nextLine"
|
||||||
},
|
},
|
||||||
"json": {
|
"json": {
|
||||||
"indentWidth": 2,
|
"indentWidth": 2,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ onDestroy(() => themeManager.destroy());
|
|||||||
/>
|
/>
|
||||||
</noscript>
|
</noscript>
|
||||||
<title>GlyphDiff | Typography & Typefaces</title>
|
<title>GlyphDiff | Typography & Typefaces</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Compare typefaces side by side. Adjust size, weight, leading, and tracking to find the perfect typographic pairing."
|
||||||
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<ResponsiveProvider>
|
<ResponsiveProvider>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { render } from '@testing-library/svelte';
|
||||||
|
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
|
||||||
|
|
||||||
|
const context = new Map([['responsive', { isMobile: false, isMobileOrTablet: false }]]);
|
||||||
|
|
||||||
|
describe('BreadcrumbHeader', () => {
|
||||||
|
it('renders nothing when no sections have been scrolled past', () => {
|
||||||
|
const { container } = render(BreadcrumbHeader, { context });
|
||||||
|
expect(container.firstElementChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
<div
|
{#if !shouldReveal && skeleton}
|
||||||
style:font-family={shouldReveal
|
{@render skeleton()}
|
||||||
? `'${font.name}'`
|
{:else}
|
||||||
: 'system-ui, -apple-system, sans-serif'}
|
<div
|
||||||
class={clsx(
|
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
||||||
transitionClasses,
|
class={clsx(className)}
|
||||||
// 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>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { themeManager } from '../../model';
|
||||||
|
import ThemeSwitch from './ThemeSwitch.svelte';
|
||||||
|
|
||||||
|
const context = new Map([['responsive', { isMobile: false }]]);
|
||||||
|
|
||||||
|
describe('ThemeSwitch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
themeManager.setTheme('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders an icon button', () => {
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has "Toggle theme" title', () => {
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
expect(screen.getByTitle('Toggle theme')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an SVG icon', () => {
|
||||||
|
const { container } = render(ThemeSwitch, { context });
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interaction', () => {
|
||||||
|
it('toggles theme from light to dark on click', async () => {
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
expect(themeManager.value).toBe('light');
|
||||||
|
await fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(themeManager.value).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles theme from dark to light on click', async () => {
|
||||||
|
themeManager.setTheme('dark');
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
await fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(themeManager.value).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('double click returns to original theme', async () => {
|
||||||
|
render(ThemeSwitch, { context });
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
expect(themeManager.value).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
filterManager,
|
||||||
|
filtersStore,
|
||||||
|
} from '$features/GetFonts';
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import Filters from './Filters.svelte';
|
||||||
|
|
||||||
|
describe('Filters', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear groups and mock filtersStore to be empty so the auto-sync effect doesn't overwrite us
|
||||||
|
filterManager.setGroups([]);
|
||||||
|
vi.spyOn(filtersStore, 'filters', 'get').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders nothing when filter groups are empty', () => {
|
||||||
|
const { container } = render(Filters);
|
||||||
|
// 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', () => {
|
||||||
|
filterManager.setGroups([
|
||||||
|
{ id: 'cat', label: 'Categories', properties: [] },
|
||||||
|
{ id: 'prov', label: 'Font Providers', properties: [] },
|
||||||
|
]);
|
||||||
|
render(Filters);
|
||||||
|
expect(screen.getByText('Categories')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Font Providers')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders filter properties within groups', () => {
|
||||||
|
filterManager.setGroups([
|
||||||
|
{
|
||||||
|
id: 'cat',
|
||||||
|
label: 'Category',
|
||||||
|
properties: [
|
||||||
|
{ id: 'serif', name: 'Serif', value: 'serif', selected: false },
|
||||||
|
{ id: 'sans', name: 'Sans-Serif', value: 'sans-serif', selected: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(Filters);
|
||||||
|
expect(screen.getByText('Serif')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sans-Serif')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple groups with their properties', () => {
|
||||||
|
filterManager.setGroups([
|
||||||
|
{
|
||||||
|
id: 'cat',
|
||||||
|
label: 'Category',
|
||||||
|
properties: [{ id: 'mono', name: 'Monospace', value: 'monospace', selected: false }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prov',
|
||||||
|
label: 'Provider',
|
||||||
|
properties: [{ id: 'google', name: 'Google', value: 'google', selected: false }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
render(Filters);
|
||||||
|
expect(screen.getByText('Monospace')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Google')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ const { Story } = defineMeta({
|
|||||||
|
|
||||||
<Story name="Mobile layout">
|
<Story name="Mobile layout">
|
||||||
{#snippet template()}
|
{#snippet template()}
|
||||||
<Providers initialWidth={375}>
|
<Providers>
|
||||||
<div style="width: 375px;">
|
<div style="width: 375px;">
|
||||||
<FilterControls />
|
<FilterControls />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const { Story } = defineMeta({
|
|||||||
story: { inline: false },
|
story: { inline: false },
|
||||||
},
|
},
|
||||||
layout: 'centered',
|
layout: 'centered',
|
||||||
|
storyStage: { maxWidth: 'max-w-xl' },
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
hidden: { control: 'boolean' },
|
hidden: { control: 'boolean' },
|
||||||
@@ -33,16 +34,6 @@ const { Story } = defineMeta({
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Mobile">
|
|
||||||
{#snippet template()}
|
|
||||||
<Providers initialWidth={375}>
|
|
||||||
<div class="relative h-20 flex items-end justify-end p-4">
|
|
||||||
<TypographyMenu />
|
|
||||||
</div>
|
|
||||||
</Providers>
|
|
||||||
{/snippet}
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
<Story name="Hidden">
|
<Story name="Hidden">
|
||||||
{#snippet template()}
|
{#snippet template()}
|
||||||
<Providers>
|
<Providers>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { fade } from 'svelte/transition';
|
|||||||
class="h-full flex flex-col gap-3 sm:gap-4"
|
class="h-full flex flex-col gap-3 sm:gap-4"
|
||||||
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||||
>
|
>
|
||||||
<section class="w-auto">
|
<main class="w-auto">
|
||||||
<ComparisonView />
|
<ComparisonView />
|
||||||
</section>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+89
-45
@@ -95,11 +95,13 @@ export class CharacterComparisonEngine {
|
|||||||
#lastText = '';
|
#lastText = '';
|
||||||
#lastFontA = '';
|
#lastFontA = '';
|
||||||
#lastFontB = '';
|
#lastFontB = '';
|
||||||
|
#lastSpacing = 0;
|
||||||
|
#lastSize = 0;
|
||||||
|
|
||||||
// Cached layout results
|
// Cached layout results
|
||||||
#lastWidth = -1;
|
#lastWidth = -1;
|
||||||
#lastLineHeight = -1;
|
#lastLineHeight = -1;
|
||||||
#lastResult: ComparisonResult | null = null;
|
#lastResult = $state<ComparisonResult | null>(null);
|
||||||
|
|
||||||
constructor(locale?: string) {
|
constructor(locale?: string) {
|
||||||
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||||
@@ -116,6 +118,8 @@ export class CharacterComparisonEngine {
|
|||||||
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
|
* @param fontB CSS font string for the second font: `"weight sizepx \"family\""`.
|
||||||
* @param width Available line width in pixels.
|
* @param width Available line width in pixels.
|
||||||
* @param lineHeight Line height in pixels (passed directly to pretext).
|
* @param lineHeight Line height in pixels (passed directly to pretext).
|
||||||
|
* @param spacing Letter spacing in em (from typography settings).
|
||||||
|
* @param size Current font size in pixels (used to convert spacing em to px).
|
||||||
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
|
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
|
||||||
*/
|
*/
|
||||||
layout(
|
layout(
|
||||||
@@ -124,12 +128,21 @@ export class CharacterComparisonEngine {
|
|||||||
fontB: string,
|
fontB: string,
|
||||||
width: number,
|
width: number,
|
||||||
lineHeight: number,
|
lineHeight: number,
|
||||||
|
spacing: number = 0,
|
||||||
|
size: number = 16,
|
||||||
): ComparisonResult {
|
): ComparisonResult {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return { lines: [], totalHeight: 0 };
|
return { lines: [], totalHeight: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB;
|
const spacingPx = spacing * size;
|
||||||
|
|
||||||
|
const isFontChange = text !== this.#lastText
|
||||||
|
|| fontA !== this.#lastFontA
|
||||||
|
|| fontB !== this.#lastFontB
|
||||||
|
|| spacing !== this.#lastSpacing
|
||||||
|
|| size !== this.#lastSize;
|
||||||
|
|
||||||
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
|
const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight;
|
||||||
|
|
||||||
if (!isFontChange && !isLayoutChange && this.#lastResult) {
|
if (!isFontChange && !isLayoutChange && this.#lastResult) {
|
||||||
@@ -140,11 +153,13 @@ export class CharacterComparisonEngine {
|
|||||||
if (isFontChange) {
|
if (isFontChange) {
|
||||||
this.#preparedA = prepareWithSegments(text, fontA);
|
this.#preparedA = prepareWithSegments(text, fontA);
|
||||||
this.#preparedB = prepareWithSegments(text, fontB);
|
this.#preparedB = prepareWithSegments(text, fontB);
|
||||||
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB);
|
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
|
||||||
|
|
||||||
this.#lastText = text;
|
this.#lastText = text;
|
||||||
this.#lastFontA = fontA;
|
this.#lastFontA = fontA;
|
||||||
this.#lastFontB = fontB;
|
this.#lastFontB = fontB;
|
||||||
|
this.#lastSpacing = spacing;
|
||||||
|
this.#lastSize = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
|
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
|
||||||
@@ -175,7 +190,6 @@ export class CharacterComparisonEngine {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop
|
|
||||||
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||||
|
|
||||||
const advA = intA.breakableFitAdvances[sIdx];
|
const advA = intA.breakableFitAdvances[sIdx];
|
||||||
@@ -186,8 +200,12 @@ export class CharacterComparisonEngine {
|
|||||||
|
|
||||||
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||||
const char = graphemes[gIdx];
|
const char = graphemes[gIdx];
|
||||||
const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
|
let wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
|
||||||
const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
|
let wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
|
||||||
|
|
||||||
|
// Apply letter spacing (tracking) to the width of each character
|
||||||
|
wA += spacingPx;
|
||||||
|
wB += spacingPx;
|
||||||
|
|
||||||
chars.push({
|
chars.push({
|
||||||
char,
|
char,
|
||||||
@@ -219,66 +237,92 @@ export class CharacterComparisonEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates character proximity and direction relative to a slider position.
|
* Calculates character states for an entire line in a single sequential pass.
|
||||||
*
|
*
|
||||||
* Uses the most recent `layout()` result — must be called after `layout()`.
|
* Walks characters left-to-right, accumulating the running x position using
|
||||||
* No DOM calls are made; all geometry is derived from cached layout data.
|
* each character's actual rendered width: `widthB` for already-morphed characters
|
||||||
|
* (isPast=true) and `widthA` for upcoming ones. This ensures thresholds stay
|
||||||
|
* aligned with the visual DOM layout even when the two fonts have different widths.
|
||||||
*
|
*
|
||||||
* @param lineIndex Zero-based index of the line within the last layout result.
|
* @param line A single laid-out line from the last layout result.
|
||||||
* @param charIndex Zero-based index of the character within that line's `chars` array.
|
|
||||||
* @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`.
|
* @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`.
|
||||||
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
|
* @param containerWidth Total container width in pixels.
|
||||||
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
|
* @returns Per-character `proximity` and `isPast` in the same order as `line.chars`.
|
||||||
* `isPast` (true when the slider has already passed the char center).
|
|
||||||
*/
|
*/
|
||||||
getCharState(
|
getLineCharStates(
|
||||||
lineIndex: number,
|
line: ComparisonLine,
|
||||||
charIndex: number,
|
|
||||||
sliderPos: number,
|
sliderPos: number,
|
||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
): { proximity: number; isPast: boolean } {
|
): Array<{ proximity: number; isPast: boolean }> {
|
||||||
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
|
if (!line) {
|
||||||
return { proximity: 0, isPast: false };
|
return [];
|
||||||
}
|
}
|
||||||
|
const chars = line.chars;
|
||||||
const line = this.#lastResult.lines[lineIndex];
|
const n = chars.length;
|
||||||
const char = line.chars[charIndex];
|
const sliderX = (sliderPos / 100) * containerWidth;
|
||||||
|
|
||||||
if (!char) {
|
|
||||||
return { proximity: 0, isPast: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center the comparison on the unified width
|
|
||||||
// In the UI, lines are centered. So we need to calculate the global X.
|
|
||||||
const lineXOffset = (containerWidth - line.width) / 2;
|
|
||||||
const charCenterX = lineXOffset + char.xA + (char.widthA / 2);
|
|
||||||
|
|
||||||
const charGlobalPercent = (charCenterX / containerWidth) * 100;
|
|
||||||
|
|
||||||
const distance = Math.abs(sliderPos - charGlobalPercent);
|
|
||||||
const range = 5;
|
const range = 5;
|
||||||
|
// Prefix sums of widthA (left chars will be past → use widthA).
|
||||||
|
// Suffix sums of widthB (right chars will not be past → use widthB).
|
||||||
|
// This lets us compute, for each char i, what the total line width and
|
||||||
|
// char center would be at the exact moment the slider crosses that char:
|
||||||
|
// left side (0..i-1) already past → font A widths
|
||||||
|
// right side (i+1..n-1) not yet past → font B widths
|
||||||
|
const prefA = new Float64Array(n + 1);
|
||||||
|
const sufB = new Float64Array(n + 1);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
prefA[i + 1] = prefA[i] + chars[i].widthA;
|
||||||
|
}
|
||||||
|
for (let i = n - 1; i >= 0; i--) {
|
||||||
|
sufB[i] = sufB[i + 1] + chars[i].widthB;
|
||||||
|
}
|
||||||
|
// Per-char threshold: slider x at which this char should toggle isPast.
|
||||||
|
const thresholds = new Float64Array(n);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1];
|
||||||
|
const xOffset = (containerWidth - totalWidth) / 2;
|
||||||
|
thresholds[i] = xOffset + prefA[i] + chars[i].widthA / 2;
|
||||||
|
}
|
||||||
|
// Determine isPast for each char at the current slider position.
|
||||||
|
const isPastArr = new Uint8Array(n);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
isPastArr[i] = sliderX > thresholds[i] ? 1 : 0;
|
||||||
|
}
|
||||||
|
// Compute visual positions based on actual rendered widths (font A if past, B if not).
|
||||||
|
const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0);
|
||||||
|
const xOffset = (containerWidth - totalRendered) / 2;
|
||||||
|
let currentX = xOffset;
|
||||||
|
return chars.map((char, i) => {
|
||||||
|
const isPast = isPastArr[i] === 1;
|
||||||
|
const charWidth = isPast ? char.widthA : char.widthB;
|
||||||
|
const visualCenter = currentX + charWidth / 2;
|
||||||
|
const charGlobalPercent = (visualCenter / containerWidth) * 100;
|
||||||
|
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||||
const proximity = Math.max(0, 1 - distance / range);
|
const proximity = Math.max(0, 1 - distance / range);
|
||||||
const isPast = sliderPos > charGlobalPercent;
|
currentX += charWidth;
|
||||||
|
|
||||||
return { proximity, isPast };
|
return { proximity, isPast };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal helper to merge two prepared texts into a "worst-case" unified version
|
* Internal helper to merge two prepared texts into a "worst-case" unified version
|
||||||
*/
|
*/
|
||||||
#createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments {
|
#createUnifiedPrepared(
|
||||||
|
a: PreparedTextWithSegments,
|
||||||
|
b: PreparedTextWithSegments,
|
||||||
|
spacingPx: number = 0,
|
||||||
|
): PreparedTextWithSegments {
|
||||||
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
||||||
const intA = a as any;
|
const intA = a as any;
|
||||||
const intB = b as any;
|
const intB = b as any;
|
||||||
|
|
||||||
const unified = { ...intA };
|
const unified = { ...intA };
|
||||||
|
|
||||||
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]));
|
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]) + spacingPx);
|
||||||
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
|
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
|
||||||
Math.max(w, intB.lineEndFitAdvances[i])
|
Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx
|
||||||
);
|
);
|
||||||
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
|
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
|
||||||
Math.max(w, intB.lineEndPaintAdvances[i])
|
Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx
|
||||||
);
|
);
|
||||||
|
|
||||||
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
|
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
|
||||||
@@ -287,13 +331,13 @@ export class CharacterComparisonEngine {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!advA) {
|
if (!advA) {
|
||||||
return advB;
|
return advB.map((w: number) => w + spacingPx);
|
||||||
}
|
}
|
||||||
if (!advB) {
|
if (!advB) {
|
||||||
return advA;
|
return advA.map((w: number) => w + spacingPx);
|
||||||
}
|
}
|
||||||
|
|
||||||
return advA.map((w: number, j: number) => Math.max(w, advB[j]));
|
return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx);
|
||||||
});
|
});
|
||||||
|
|
||||||
return unified;
|
return unified;
|
||||||
|
|||||||
+35
-39
@@ -109,56 +109,52 @@ describe('CharacterComparisonEngine', () => {
|
|||||||
expect(r2).not.toBe(r1);
|
expect(r2).not.toBe(r1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
|
it('getLineCharStates returns proximity 1 when slider is exactly over char center', () => {
|
||||||
// 'A' only: FontA width=10. Container=500px. Line centered.
|
|
||||||
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
|
|
||||||
// charCenterX = lineXOffset + xA + widthA/2.
|
|
||||||
// Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5
|
|
||||||
// charGlobalPercent = (252.5 / 500) * 100 = 50.5
|
|
||||||
// distance = |50.5 - 50.5| = 0 => proximity = 1
|
|
||||||
const containerWidth = 500;
|
const containerWidth = 500;
|
||||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
|
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
|
||||||
// Recalculate expected percent manually:
|
// Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2.
|
||||||
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
|
// When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2.
|
||||||
const lineXOffset = (containerWidth - lineWidth) / 2;
|
// So proximity=1 at exactly 50%.
|
||||||
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
|
const charPercent = 50;
|
||||||
const charPercent = (charCenterX / containerWidth) * 100;
|
|
||||||
|
|
||||||
const state = engine.getCharState(0, 0, charPercent, containerWidth);
|
const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth);
|
||||||
expect(state.proximity).toBe(1);
|
expect(states[0]?.proximity).toBe(1);
|
||||||
expect(state.isPast).toBe(false);
|
expect(states[0]?.isPast).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getCharState returns proximity 0 when slider is far from char', () => {
|
it('getLineCharStates returns proximity 0 when slider is far from char', () => {
|
||||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
|
const states = engine.getLineCharStates(result.lines[0], 0, 500);
|
||||||
const state = engine.getCharState(0, 0, 0, 500);
|
expect(states[0]?.proximity).toBe(0);
|
||||||
expect(state.proximity).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getCharState isPast is true when slider has passed char center', () => {
|
it('getLineCharStates isPast is true when slider has passed char center', () => {
|
||||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
const state = engine.getCharState(0, 0, 100, 500);
|
const states = engine.getLineCharStates(result.lines[0], 100, 500);
|
||||||
expect(state.isPast).toBe(true);
|
expect(states[0]?.isPast).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getCharState returns safe default for out-of-range lineIndex', () => {
|
it('getLineCharStates returns empty array for out-of-range lineIndex', () => {
|
||||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
const state = engine.getCharState(99, 0, 50, 500);
|
// Passing an undefined object because the index doesn't exist.
|
||||||
expect(state.proximity).toBe(0);
|
const states = engine.getLineCharStates(result.lines[99], 50, 500);
|
||||||
expect(state.isPast).toBe(false);
|
expect(states).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getCharState returns safe default for out-of-range charIndex', () => {
|
it('getLineCharStates returns empty array before layout() has been called', () => {
|
||||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
// Passing an undefined object because layout() hasn't been called.
|
||||||
const state = engine.getCharState(0, 99, 50, 500);
|
const states = engine.getLineCharStates(undefined as any, 50, 500);
|
||||||
expect(state.proximity).toBe(0);
|
expect(states).toEqual([]);
|
||||||
expect(state.isPast).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getCharState returns safe default before layout() has been called', () => {
|
it('getLineCharStates returns safe defaults for all chars', () => {
|
||||||
const state = engine.getCharState(0, 0, 50, 500);
|
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||||
expect(state.proximity).toBe(0);
|
const states = engine.getLineCharStates(result.lines[0], 50, 500);
|
||||||
expect(state.isPast).toBe(false);
|
expect(states.length).toBeGreaterThan(0);
|
||||||
|
for (const s of states) {
|
||||||
|
expect(s.proximity).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(s.proximity).toBeLessThanOrEqual(1);
|
||||||
|
expect(typeof s.isPast).toBe('boolean');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,33 +17,9 @@ interface Props {
|
|||||||
* Content snippet
|
* Content snippet
|
||||||
*/
|
*/
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
/**
|
|
||||||
* Initial viewport width
|
|
||||||
* @default 1280
|
|
||||||
*/
|
|
||||||
initialWidth?: number;
|
|
||||||
/**
|
|
||||||
* Initial viewport height
|
|
||||||
* @default 720
|
|
||||||
*/
|
|
||||||
initialHeight?: number;
|
|
||||||
/**
|
|
||||||
* Tooltip delay duration
|
|
||||||
*/
|
|
||||||
tooltipDelayDuration?: number;
|
|
||||||
/**
|
|
||||||
* Tooltip skip delay duration
|
|
||||||
*/
|
|
||||||
tooltipSkipDelayDuration?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { children }: Props = $props();
|
||||||
children,
|
|
||||||
initialWidth = 1280,
|
|
||||||
initialHeight = 720,
|
|
||||||
tooltipDelayDuration = 200,
|
|
||||||
tooltipSkipDelayDuration = 300,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Create a responsive manager with default breakpoints
|
// Create a responsive manager with default breakpoints
|
||||||
const responsiveManager = createResponsiveManager();
|
const responsiveManager = createResponsiveManager();
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
|||||||
? 'bg-paper dark:bg-dark-card shadow-sm border-subtle'
|
? 'bg-paper dark:bg-dark-card shadow-sm border-subtle'
|
||||||
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||||
)}
|
)}
|
||||||
aria-label={controlLabel}
|
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
|
||||||
>
|
>
|
||||||
<!-- Label row -->
|
<!-- Label row -->
|
||||||
{#if displayLabel}
|
{#if displayLabel}
|
||||||
|
|||||||
@@ -1,38 +1,3 @@
|
|||||||
/**
|
|
||||||
* Test Suite for ComboControl Component
|
|
||||||
*
|
|
||||||
* IMPORTANT: These tests require a proper browser environment to run.
|
|
||||||
*
|
|
||||||
* Svelte 5's $state() and $effect() runes do not work in jsdom (server-side simulation).
|
|
||||||
* The current vitest.config.component.ts uses 'environment: jsdom', which doesn't support Svelte 5 reactivity.
|
|
||||||
*
|
|
||||||
* To run these tests, you need to:
|
|
||||||
* 1. Update vitest to use browser-based testing with @vitest/browser-playwright
|
|
||||||
* 2. OR use Playwright E2E tests in e2e/ComboControl.e2e.test.ts
|
|
||||||
*
|
|
||||||
* To run E2E tests (recommended):
|
|
||||||
* ```bash
|
|
||||||
* yarn test:e2e ComboControl
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* This suite tests the actual Svelte component rendering, interactions, and behavior.
|
|
||||||
* Tests for the createTypographyControl helper function are in createTypographyControl.test.ts
|
|
||||||
*
|
|
||||||
* Test Coverage:
|
|
||||||
* 1. Component Rendering: Button labels, icons, and initial state
|
|
||||||
* 2. Button States: Disabled states based on isAtMin/isAtMax
|
|
||||||
* 3. Button Clicks: Increase/decrease button functionality
|
|
||||||
* 4. Popover Behavior: Opening/closing popover with slider and input
|
|
||||||
* 5. Slider Interaction: Dragging slider to update values
|
|
||||||
* 6. Input Field: Typing values directly
|
|
||||||
* 7. Accessibility: ARIA labels and keyboard navigation
|
|
||||||
* 8. Reactivity: Value updates propagating through the component
|
|
||||||
* 9. Edge Cases: Boundary conditions and special values
|
|
||||||
*
|
|
||||||
* Note: This file is intentionally left as-is with comprehensive @testing-library/svelte tests
|
|
||||||
* as a reference for when the browser environment is properly set up.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createTypographyControl } from '$shared/lib';
|
import { createTypographyControl } from '$shared/lib';
|
||||||
import {
|
import {
|
||||||
fireEvent,
|
fireEvent,
|
||||||
@@ -40,837 +5,131 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
import {
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
} from 'vitest';
|
|
||||||
import ComboControl from './ComboControl.svelte';
|
import ComboControl from './ComboControl.svelte';
|
||||||
|
|
||||||
describe('ComboControl Component', () => {
|
function makeControl(value: number, opts: { min?: number; max?: number; step?: number } = {}) {
|
||||||
/**
|
|
||||||
* Helper function to create a TypographyControl for testing
|
|
||||||
*/
|
|
||||||
function createTestControl(initialValue: number, options?: {
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
}) {
|
|
||||||
return createTypographyControl({
|
return createTypographyControl({
|
||||||
value: initialValue,
|
value,
|
||||||
min: options?.min ?? 0,
|
min: opts.min ?? 0,
|
||||||
max: options?.max ?? 100,
|
max: opts.max ?? 100,
|
||||||
step: options?.step ?? 1,
|
step: opts.step ?? 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('ComboControl', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('renders all three buttons (decrease, control, increase)', () => {
|
it('renders decrease and increase buttons', () => {
|
||||||
const control = createTestControl(50);
|
render(ComboControl, { control: makeControl(50) });
|
||||||
render(ComboControl, {
|
expect(screen.getByLabelText('Decrease')).toBeInTheDocument();
|
||||||
control,
|
expect(screen.getByLabelText('Increase')).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
const buttons = screen.getAllByRole('button');
|
|
||||||
expect(buttons).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays current value on control button', () => {
|
|
||||||
const control = createTestControl(42);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders the current integer value', () => {
|
||||||
|
render(ComboControl, { control: makeControl(42) });
|
||||||
expect(screen.getByText('42')).toBeInTheDocument();
|
expect(screen.getByText('42')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays decimal values on control button', () => {
|
it('formats decimal value to 1 decimal place when step >= 0.1', () => {
|
||||||
const control = createTestControl(12.5, { min: 0, max: 100, step: 0.5 });
|
render(ComboControl, { control: makeControl(1.5, { step: 0.1 }) });
|
||||||
render(ComboControl, {
|
expect(screen.getByText('1.5')).toBeInTheDocument();
|
||||||
control,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('12.5')).toBeInTheDocument();
|
it('formats decimal value to 2 decimal places when step < 0.1', () => {
|
||||||
|
render(ComboControl, { control: makeControl(1.55, { step: 0.01 }) });
|
||||||
|
expect(screen.getByText('1.55')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies custom ARIA labels to buttons', () => {
|
it('renders label when label prop is provided', () => {
|
||||||
const control = createTestControl(50);
|
render(ComboControl, { control: makeControl(16), label: 'Size' });
|
||||||
|
expect(screen.getByText('Size')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom aria-labels to buttons', () => {
|
||||||
render(ComboControl, {
|
render(ComboControl, {
|
||||||
control,
|
control: makeControl(50),
|
||||||
decreaseLabel: 'Decrease font size',
|
decreaseLabel: 'Decrease font size',
|
||||||
controlLabel: 'Font size control',
|
|
||||||
increaseLabel: 'Increase font size',
|
increaseLabel: 'Increase font size',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByLabelText('Decrease font size')).toBeInTheDocument();
|
expect(screen.getByLabelText('Decrease font size')).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Font size control')).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Increase font size')).toBeInTheDocument();
|
expect(screen.getByLabelText('Increase font size')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders decrease button with minus icon', () => {
|
|
||||||
const control = createTestControl(50);
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const decreaseBtn = screen.getAllByRole('button')[0];
|
describe('Disabled state', () => {
|
||||||
expect(decreaseBtn).toBeInTheDocument();
|
it('disables decrease button when at min', () => {
|
||||||
// Check for lucide icon SVG
|
render(ComboControl, { control: makeControl(0, { min: 0 }) });
|
||||||
const svg = container.querySelector('button svg');
|
expect(screen.getByLabelText('Decrease')).toBeDisabled();
|
||||||
expect(svg).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders increase button with plus icon', () => {
|
it('disables increase button when at max', () => {
|
||||||
const control = createTestControl(50);
|
render(ComboControl, { control: makeControl(100, { max: 100 }) });
|
||||||
const { container } = render(ComboControl, {
|
expect(screen.getByLabelText('Increase')).toBeDisabled();
|
||||||
control,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const increaseBtn = screen.getAllByRole('button')[2];
|
it('enables both buttons when value is within bounds', () => {
|
||||||
expect(increaseBtn).toBeInTheDocument();
|
render(ComboControl, { control: makeControl(50, { min: 0, max: 100 }) });
|
||||||
// Check for lucide icon SVG
|
expect(screen.getByLabelText('Decrease')).not.toBeDisabled();
|
||||||
const svgs = container.querySelectorAll('button svg');
|
expect(screen.getByLabelText('Increase')).not.toBeDisabled();
|
||||||
expect(svgs.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles zero value correctly', () => {
|
|
||||||
const control = createTestControl(0, { min: 0, max: 100 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles negative values correctly', () => {
|
|
||||||
const control = createTestControl(-5, { min: -10, max: 10 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('-5')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Button States', () => {
|
describe('Click interactions', () => {
|
||||||
it('disables decrease button when at min value', () => {
|
it('decreases value on decrease button click', async () => {
|
||||||
const control = createTestControl(0, { min: 0, max: 100 });
|
const control = makeControl(50, { step: 5 });
|
||||||
const { container } = render(ComboControl, {
|
render(ComboControl, { control });
|
||||||
control,
|
await fireEvent.click(screen.getByLabelText('Decrease'));
|
||||||
});
|
|
||||||
|
|
||||||
const buttons = container.querySelectorAll('button');
|
|
||||||
const decreaseBtn = buttons[0];
|
|
||||||
expect(decreaseBtn).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables increase button when at max value', () => {
|
|
||||||
const control = createTestControl(100, { min: 0, max: 100 });
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttons = container.querySelectorAll('button');
|
|
||||||
const increaseBtn = buttons[2];
|
|
||||||
expect(increaseBtn).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('both buttons enabled when within bounds', () => {
|
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttons = container.querySelectorAll('button');
|
|
||||||
expect(buttons[0]).not.toBeDisabled(); // decrease
|
|
||||||
expect(buttons[1]).not.toBeDisabled(); // control
|
|
||||||
expect(buttons[2]).not.toBeDisabled(); // increase
|
|
||||||
});
|
|
||||||
|
|
||||||
it('control button always enabled regardless of value', () => {
|
|
||||||
const control = createTestControl(0, { min: 0, max: 0 });
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttons = container.querySelectorAll('button');
|
|
||||||
const controlBtn = buttons[1];
|
|
||||||
expect(controlBtn).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Button Clicks', () => {
|
|
||||||
it('decrease button reduces value by step', async () => {
|
|
||||||
const control = createTestControl(50, { min: 0, max: 100, step: 5 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const decreaseBtn = screen.getAllByRole('button')[0];
|
|
||||||
await fireEvent.click(decreaseBtn);
|
|
||||||
|
|
||||||
expect(control.value).toBe(45);
|
expect(control.value).toBe(45);
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('45')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('increase button increases value by step', async () => {
|
it('increases value on increase button click', async () => {
|
||||||
const control = createTestControl(50, { min: 0, max: 100, step: 5 });
|
const control = makeControl(50, { step: 5 });
|
||||||
render(ComboControl, {
|
render(ComboControl, { control });
|
||||||
control,
|
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||||
});
|
|
||||||
|
|
||||||
const increaseBtn = screen.getAllByRole('button')[2];
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
|
|
||||||
expect(control.value).toBe(55);
|
expect(control.value).toBe(55);
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('55')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('value updates on control button after multiple clicks', async () => {
|
it('clamps decrease at min', async () => {
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
const control = makeControl(2, { min: 0, step: 5 });
|
||||||
render(ComboControl, {
|
render(ComboControl, { control });
|
||||||
control,
|
await fireEvent.click(screen.getByLabelText('Decrease'));
|
||||||
});
|
|
||||||
|
|
||||||
const buttons = screen.getAllByRole('button');
|
|
||||||
const decreaseBtn = buttons[0];
|
|
||||||
const increaseBtn = buttons[2];
|
|
||||||
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
expect(control.value).toBe(53);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('53')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await fireEvent.click(decreaseBtn);
|
|
||||||
expect(control.value).toBe(52);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('52')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decrease button does not go below min', async () => {
|
|
||||||
const control = createTestControl(1, { min: 0, max: 100, step: 5 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const decreaseBtn = screen.getAllByRole('button')[0];
|
|
||||||
await fireEvent.click(decreaseBtn);
|
|
||||||
|
|
||||||
expect(control.value).toBe(0);
|
expect(control.value).toBe(0);
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('increase button does not go above max', async () => {
|
it('clamps increase at max', async () => {
|
||||||
const control = createTestControl(99, { min: 0, max: 100, step: 5 });
|
const control = makeControl(98, { max: 100, step: 5 });
|
||||||
render(ComboControl, {
|
render(ComboControl, { control });
|
||||||
control,
|
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||||
});
|
|
||||||
|
|
||||||
const increaseBtn = screen.getAllByRole('button')[2];
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
|
|
||||||
expect(control.value).toBe(100);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('100')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('respects step precision on button clicks', async () => {
|
|
||||||
const control = createTestControl(5.5, { min: 0, max: 10, step: 0.25 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const increaseBtn = screen.getAllByRole('button')[2];
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
|
|
||||||
expect(control.value).toBeCloseTo(5.75);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('5.75')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Popover Behavior', () => {
|
|
||||||
it('popover content not visible initially', () => {
|
|
||||||
const control = createTestControl(50);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Popover content should not be visible initially
|
|
||||||
const popover = screen.queryByTestId('combo-control-popover');
|
|
||||||
expect(popover).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
const sliderInput = screen.queryByRole('slider');
|
|
||||||
expect(sliderInput).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
const numberInput = screen.queryByTestId('combo-control-input');
|
|
||||||
expect(numberInput).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clicking control button toggles popover', async () => {
|
|
||||||
const control = createTestControl(50);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
|
|
||||||
// Click to open popover
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
// Wait for popover to render (it's portaled to body)
|
|
||||||
await waitFor(() => {
|
|
||||||
const popover = screen.getByTestId('combo-control-popover');
|
|
||||||
expect(popover).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const slider = screen.queryByRole('slider');
|
|
||||||
expect(slider).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const numberInput = screen.queryByTestId('combo-control-input');
|
|
||||||
expect(numberInput).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('popover contains slider and input', async () => {
|
|
||||||
const control = createTestControl(50, { min: 10, max: 90, step: 5 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
// Verify both slider and input are present
|
|
||||||
const slider = await screen.findByRole('slider');
|
|
||||||
expect(slider).toBeInTheDocument();
|
|
||||||
|
|
||||||
const input = await screen.findByTestId('combo-control-input');
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Both should show current value
|
|
||||||
const inputElement = input as HTMLInputElement;
|
|
||||||
expect(inputElement.value).toBe('50');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('popover contains input field with current value', async () => {
|
|
||||||
const control = createTestControl(42);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
expect(input.value).toBe('42');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input field has min/max attributes', async () => {
|
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
await waitFor(async () => {
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
expect(input).toHaveAttribute('min', '0');
|
|
||||||
expect(input).toHaveAttribute('max', '100');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Slider Rendering', () => {
|
|
||||||
it('slider is present in popover', async () => {
|
|
||||||
const control = createTestControl(50);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
// Verify slider is present
|
|
||||||
const slider = await screen.findByRole('slider');
|
|
||||||
expect(slider).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('slider value syncs with control value', async () => {
|
|
||||||
const control = createTestControl(50);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
// Slider should be present and reflect initial value
|
|
||||||
const slider = await screen.findByRole('slider');
|
|
||||||
expect(slider).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Change value via input (which we know works)
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
await fireEvent.change(input, { target: { value: '75' } });
|
|
||||||
await fireEvent.blur(input);
|
|
||||||
|
|
||||||
// Slider should still be present (not re-rendered)
|
|
||||||
const sliderAfter = await screen.findByRole('slider');
|
|
||||||
expect(sliderAfter).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Input Field Interaction', () => {
|
|
||||||
it('typing valid number updates control value', async () => {
|
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Type new value
|
|
||||||
await fireEvent.change(input, { target: { value: '75' } });
|
|
||||||
await fireEvent.blur(input); // onchange fires on blur
|
|
||||||
|
|
||||||
// Wait for control value to update
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(control.value).toBe(75);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check that control button text updates
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('75')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input respects step precision', async () => {
|
|
||||||
const control = createTestControl(5, { min: 0, max: 10, step: 0.25 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Type value with more precision than step allows (0.25 has 2 decimal places)
|
|
||||||
await fireEvent.change(input, { target: { value: '5.23' } });
|
|
||||||
await fireEvent.blur(input);
|
|
||||||
|
|
||||||
// Should be rounded to step precision (2 decimal places)
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(control.value).toBeCloseTo(5.23, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input clamps to min', async () => {
|
|
||||||
const control = createTestControl(50, { min: 10, max: 100 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Type below min
|
|
||||||
await fireEvent.change(input, { target: { value: '5' } });
|
|
||||||
await fireEvent.blur(input);
|
|
||||||
|
|
||||||
// Should be clamped to min
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(control.value).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input clamps to max', async () => {
|
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Type above max
|
|
||||||
await fireEvent.change(input, { target: { value: '150' } });
|
|
||||||
await fireEvent.blur(input);
|
|
||||||
|
|
||||||
// Should be clamped to max
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(control.value).toBe(100);
|
expect(control.value).toBe(100);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid input (non-numeric)', async () => {
|
it('updates displayed value after click', async () => {
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
const control = makeControl(50);
|
||||||
render(ComboControl, {
|
render(ComboControl, { control });
|
||||||
control,
|
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||||
});
|
await waitFor(() => expect(screen.getByText('51')).toBeInTheDocument());
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
const originalValue = control.value;
|
|
||||||
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Type invalid value
|
|
||||||
await fireEvent.change(input, { target: { value: 'abc' } });
|
|
||||||
await fireEvent.blur(input);
|
|
||||||
|
|
||||||
// Value should not change for invalid input
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(control.value).toBe(originalValue);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles empty input gracefully', async () => {
|
describe('Popover', () => {
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
it('opens popover with vertical slider on trigger click', async () => {
|
||||||
render(ComboControl, {
|
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
|
||||||
control,
|
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||||
});
|
await fireEvent.click(screen.getByText('Size control'));
|
||||||
|
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
const originalValue = control.value;
|
|
||||||
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Clear input
|
|
||||||
await fireEvent.change(input, { target: { value: '' } });
|
|
||||||
await fireEvent.blur(input);
|
|
||||||
|
|
||||||
// Value should not change for empty input
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(control.value).toBe(originalValue);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Reactivity', () => {
|
|
||||||
it('external control value change updates control button text', async () => {
|
|
||||||
const control = createTestControl(50);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('50')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Change value externally
|
|
||||||
control.value = 75;
|
|
||||||
|
|
||||||
// Wait for UI to update
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('75')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('button states update when external value changes', async () => {
|
describe('Reduced mode', () => {
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
it('renders horizontal slider directly without popover trigger', () => {
|
||||||
const { container } = render(ComboControl, {
|
render(ComboControl, { control: makeControl(50), reduced: true });
|
||||||
control,
|
expect(screen.getByRole('slider')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('Decrease')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('Increase')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const buttons = container.querySelectorAll('button');
|
it('shows formatted value in reduced mode', () => {
|
||||||
|
const { container } = render(ComboControl, { control: makeControl(75), reduced: true });
|
||||||
// Both should be enabled
|
expect(container.textContent).toContain('75');
|
||||||
expect(buttons[0]).not.toBeDisabled();
|
|
||||||
expect(buttons[2]).not.toBeDisabled();
|
|
||||||
|
|
||||||
// Set to max
|
|
||||||
control.value = 100;
|
|
||||||
|
|
||||||
// Wait for button state to update
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(buttons[2]).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('input and slider sync when external value changes', async () => {
|
|
||||||
const control = createTestControl(50, { min: 0, max: 100 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
// Both should be present
|
|
||||||
const _slider = await screen.findByRole('slider');
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
|
|
||||||
// Input should show initial value
|
|
||||||
expect(input.value).toBe('50');
|
|
||||||
|
|
||||||
// Change value externally
|
|
||||||
control.value = 75;
|
|
||||||
|
|
||||||
// Wait for input to update
|
|
||||||
await waitFor(async () => {
|
|
||||||
const updatedInput = await screen.findByTestId(
|
|
||||||
'combo-control-input',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
expect(updatedInput.value).toBe('75');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Slider should still be present
|
|
||||||
const updatedSlider = await screen.findByRole('slider');
|
|
||||||
expect(updatedSlider).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decrease button becomes enabled when value increases externally', async () => {
|
|
||||||
const control = createTestControl(0, { min: 0, max: 100 });
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const decreaseBtn = container.querySelectorAll('button')[0];
|
|
||||||
|
|
||||||
// Initially disabled
|
|
||||||
expect(decreaseBtn).toBeDisabled();
|
|
||||||
|
|
||||||
// Increase value externally
|
|
||||||
control.value = 10;
|
|
||||||
|
|
||||||
// Wait for button to become enabled
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(decreaseBtn).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('increase button becomes enabled when value decreases externally', async () => {
|
|
||||||
const control = createTestControl(100, { min: 0, max: 100 });
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const increaseBtn = container.querySelectorAll('button')[2];
|
|
||||||
|
|
||||||
// Initially disabled
|
|
||||||
expect(increaseBtn).toBeDisabled();
|
|
||||||
|
|
||||||
// Decrease value externally
|
|
||||||
control.value = 90;
|
|
||||||
|
|
||||||
// Wait for button to become enabled
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(increaseBtn).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
|
||||||
it('handles equal min and max', () => {
|
|
||||||
const control = createTestControl(5, { min: 5, max: 5 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should render without errors
|
|
||||||
expect(screen.getByText('5')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Both decrease and increase should be disabled
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
const buttons = container.querySelectorAll('button');
|
|
||||||
expect(buttons[0]).toBeDisabled();
|
|
||||||
expect(buttons[2]).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles very small step values', () => {
|
|
||||||
const control = createTestControl(5, { min: 0, max: 10, step: 0.001 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('5')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles negative range with positive and negative values', async () => {
|
|
||||||
const control = createTestControl(-5, { min: -10, max: 10, step: 1 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('-5')).toBeInTheDocument();
|
|
||||||
|
|
||||||
const increaseBtn = screen.getAllByRole('button')[2];
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
|
|
||||||
expect(control.value).toBe(-4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles zero as min value', async () => {
|
|
||||||
const control = createTestControl(0, { min: 0, max: 10 });
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
|
||||||
|
|
||||||
const decreaseBtn = container.querySelectorAll('button')[0];
|
|
||||||
expect(decreaseBtn).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles large step value', async () => {
|
|
||||||
const control = createTestControl(5, { min: 0, max: 100, step: 50 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const increaseBtn = screen.getAllByRole('button')[2];
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
|
|
||||||
// Should jump by 50
|
|
||||||
expect(control.value).toBe(55);
|
|
||||||
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
expect(control.value).toBe(100); // Clamped to max
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
|
||||||
it('all buttons have aria-label when provided', () => {
|
|
||||||
const control = createTestControl(50);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
decreaseLabel: 'Decrease value',
|
|
||||||
controlLabel: 'Current value',
|
|
||||||
increaseLabel: 'Increase value',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByLabelText('Decrease value')).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Current value')).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Increase value')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('buttons are keyboard accessible', async () => {
|
|
||||||
const control = createTestControl(50);
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buttons = screen.getAllByRole('button');
|
|
||||||
expect(buttons).toHaveLength(3);
|
|
||||||
|
|
||||||
// All buttons should be focusable
|
|
||||||
buttons.forEach(btn => {
|
|
||||||
expect(btn).not.toHaveAttribute('disabled');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disabled buttons are properly marked', () => {
|
|
||||||
const control = createTestControl(0, { min: 0, max: 100 });
|
|
||||||
const { container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
const decreaseBtn = container.querySelectorAll('button')[0];
|
|
||||||
expect(decreaseBtn).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Integration Scenarios', () => {
|
|
||||||
it('typical font size control workflow', async () => {
|
|
||||||
const control = createTestControl(16, { min: 12, max: 72, step: 1 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
controlLabel: 'Font size',
|
|
||||||
decreaseLabel: 'Decrease font size',
|
|
||||||
increaseLabel: 'Increase font size',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial state
|
|
||||||
expect(screen.getByText('16')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Increase via button
|
|
||||||
const increaseBtn = screen.getByTestId('increase-button');
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
expect(control.value).toBe(17);
|
|
||||||
|
|
||||||
// Decrease via button
|
|
||||||
const decreaseBtn = screen.getByTestId('decrease-button');
|
|
||||||
await fireEvent.click(decreaseBtn);
|
|
||||||
expect(control.value).toBe(16);
|
|
||||||
|
|
||||||
// Open popover and use input
|
|
||||||
const controlBtn = screen.getByTestId('combo-control-value');
|
|
||||||
await fireEvent.click(controlBtn);
|
|
||||||
|
|
||||||
const input = await screen.findByTestId('combo-control-input') as HTMLInputElement;
|
|
||||||
await fireEvent.change(input, { target: { value: '24' } });
|
|
||||||
await fireEvent.blur(input);
|
|
||||||
|
|
||||||
expect(control.value).toBe(24);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('letter spacing control with decimal precision', async () => {
|
|
||||||
const control = createTestControl(0, { min: -0.1, max: 0.5, step: 0.01 });
|
|
||||||
const { container: _container } = render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('0')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Increase to positive value
|
|
||||||
const increaseBtn = screen.getAllByRole('button')[2];
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
await fireEvent.click(increaseBtn);
|
|
||||||
|
|
||||||
expect(control.value).toBeCloseTo(0.02);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('line height control with 0.1 step', async () => {
|
|
||||||
const control = createTestControl(1.5, { min: 0.8, max: 2.0, step: 0.1 });
|
|
||||||
render(ComboControl, {
|
|
||||||
control,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('1.5')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Decrease to 1.3
|
|
||||||
const decreaseBtn = screen.getAllByRole('button')[0];
|
|
||||||
await fireEvent.click(decreaseBtn);
|
|
||||||
await fireEvent.click(decreaseBtn);
|
|
||||||
|
|
||||||
expect(control.value).toBeCloseTo(1.3);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,573 +1,104 @@
|
|||||||
import {
|
import { createFilter } from '$shared/lib';
|
||||||
type Property,
|
|
||||||
createFilter,
|
|
||||||
} from '$shared/lib';
|
|
||||||
import {
|
import {
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
import {
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
} from 'vitest';
|
|
||||||
import FilterGroup from './FilterGroup.svelte';
|
import FilterGroup from './FilterGroup.svelte';
|
||||||
|
|
||||||
/**
|
function makeProperties(count: number, selectedIndices: number[] = []) {
|
||||||
* Test Suite for FilterGroup Component
|
|
||||||
*
|
|
||||||
* This suite tests the actual Svelte component rendering, interactions, and behavior
|
|
||||||
* using a real browser environment (Playwright) via @vitest/browser-playwright.
|
|
||||||
*
|
|
||||||
* Tests for the createFilter helper function are in createFilter.test.ts
|
|
||||||
*
|
|
||||||
* IMPORTANT: These tests use the browser environment because Svelte 5's $state,
|
|
||||||
* $derived, and onMount lifecycle require a browser environment. The bits-ui
|
|
||||||
* Checkbox component renders as <button type="button"> with role="checkbox",
|
|
||||||
* not as <input type="checkbox">.
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('FilterGroup Component', () => {
|
|
||||||
/**
|
|
||||||
* Helper function to create a filter for testing
|
|
||||||
*/
|
|
||||||
function createTestFilter<T extends string>(properties: Property<T>[]) {
|
|
||||||
return createFilter({ properties });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to create mock properties
|
|
||||||
*/
|
|
||||||
function createMockProperties(count: number, selectedIndices: number[] = []) {
|
|
||||||
return Array.from({ length: count }, (_, i) => ({
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
id: `prop-${i}`,
|
id: `prop-${i}`,
|
||||||
name: `Property ${i}`,
|
name: `Option ${i}`,
|
||||||
value: `Value ${i}`,
|
value: `val-${i}`,
|
||||||
selected: selectedIndices.includes(i),
|
selected: selectedIndices.includes(i),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('FilterGroup', () => {
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('displays the label', () => {
|
it('renders the group label', () => {
|
||||||
const filter = createTestFilter(createMockProperties(3));
|
const filter = createFilter({ properties: makeProperties(2) });
|
||||||
render(FilterGroup, {
|
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
displayedLabel: 'Test Label',
|
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||||
filter,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('Test Label')).toBeInTheDocument();
|
it('renders all properties as buttons', () => {
|
||||||
|
const filter = createFilter({ properties: makeProperties(3) });
|
||||||
|
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
|
expect(screen.getByText('Option 0')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Option 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Option 2')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders all properties as checkboxes with labels', () => {
|
it('renders no buttons for empty filter', () => {
|
||||||
const properties = createMockProperties(3);
|
const filter = createFilter({ properties: [] });
|
||||||
const filter = createTestFilter(properties);
|
render(FilterGroup, { displayedLabel: 'Empty', filter });
|
||||||
render(FilterGroup, {
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check that all property names are rendered
|
|
||||||
expect(screen.getByText('Property 0')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Property 1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Property 2')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows selected count badge when items are selected', () => {
|
|
||||||
const properties = createMockProperties(3, [0, 2]); // Select 2 items
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('2')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides badge when no items selected', () => {
|
describe('Selection state', () => {
|
||||||
const properties = createMockProperties(3);
|
it('selected property button has active styling', () => {
|
||||||
const filter = createTestFilter(properties);
|
const filter = createFilter({ properties: makeProperties(2, [0]) });
|
||||||
const { container } = render(FilterGroup, {
|
const { container } = render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
displayedLabel: 'Test',
|
const buttons = container.querySelectorAll<HTMLButtonElement>('button');
|
||||||
filter,
|
expect(buttons[0]).toHaveClass('shadow-sm');
|
||||||
|
expect(buttons[1]).not.toHaveClass('shadow-sm');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Badge should not be in the document
|
it('toggling a button updates property.selected', async () => {
|
||||||
const badges = container.querySelectorAll('[class*="badge"]');
|
const filter = createFilter({ properties: makeProperties(2) });
|
||||||
expect(badges).toHaveLength(0);
|
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
|
expect(filter.properties[0].selected).toBe(false);
|
||||||
|
await fireEvent.click(screen.getByText('Option 0'));
|
||||||
|
expect(filter.properties[0].selected).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders with no properties', () => {
|
it('clicking selected button deselects it', async () => {
|
||||||
const filter = createTestFilter([]);
|
const filter = createFilter({ properties: makeProperties(2, [0]) });
|
||||||
render(FilterGroup, {
|
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
displayedLabel: 'Empty Filter',
|
expect(filter.properties[0].selected).toBe(true);
|
||||||
filter,
|
await fireEvent.click(screen.getByText('Option 0'));
|
||||||
|
expect(filter.properties[0].selected).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('Empty Filter')).toBeInTheDocument();
|
it('multiple properties can be selected independently', async () => {
|
||||||
|
const filter = createFilter({ properties: makeProperties(3) });
|
||||||
|
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
|
await fireEvent.click(screen.getByText('Option 0'));
|
||||||
|
await fireEvent.click(screen.getByText('Option 2'));
|
||||||
|
expect(filter.properties[0].selected).toBe(true);
|
||||||
|
expect(filter.properties[1].selected).toBe(false);
|
||||||
|
expect(filter.properties[2].selected).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Checkbox Interactions', () => {
|
describe('Show more', () => {
|
||||||
it('checkboxes reflect initial selected state', async () => {
|
it('shows all properties when count <= 10', () => {
|
||||||
const properties = createMockProperties(3, [0, 2]);
|
const filter = createFilter({ properties: makeProperties(10) });
|
||||||
const filter = createTestFilter(properties);
|
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
render(FilterGroup, {
|
for (let i = 0; i < 10; i++) {
|
||||||
displayedLabel: 'Test',
|
expect(screen.getByText(`Option ${i}`)).toBeInTheDocument();
|
||||||
filter,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for component to render
|
|
||||||
// bits-ui Checkbox renders as <button type="button"> with role="checkbox"
|
|
||||||
const checkboxes = screen.getAllByRole('checkbox');
|
|
||||||
expect(checkboxes).toHaveLength(3);
|
|
||||||
|
|
||||||
// Check that the correct checkboxes are checked using aria-checked attribute
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
|
||||||
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
|
|
||||||
expect(checkboxes[2]).toHaveAttribute('data-state', 'checked');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clicking checkbox toggles property.selected state', async () => {
|
|
||||||
const properties = createMockProperties(3, [0]);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
|
|
||||||
// Initially, first checkbox is checked
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
|
||||||
expect(filter.selectedCount).toBe(1);
|
|
||||||
|
|
||||||
// Click to uncheck it
|
|
||||||
await fireEvent.click(checkboxes[0]);
|
|
||||||
|
|
||||||
// Now it should be unchecked
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
|
|
||||||
});
|
|
||||||
expect(filter.selectedCount).toBe(0);
|
|
||||||
|
|
||||||
// Click it again to re-check
|
|
||||||
await fireEvent.click(checkboxes[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
|
||||||
});
|
|
||||||
expect(filter.selectedCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('label styling changes based on selection state', async () => {
|
|
||||||
const properties = createMockProperties(2, [0]);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
|
|
||||||
// Find label elements - they are siblings of checkboxes
|
|
||||||
const labels = checkboxes.map(cb => cb.nextElementSibling);
|
|
||||||
|
|
||||||
// First label should have font-medium and text-foreground classes
|
|
||||||
expect(labels[0]).toHaveClass('font-medium', 'text-foreground');
|
|
||||||
|
|
||||||
// Second label should not have these classes
|
|
||||||
expect(labels[1]).not.toHaveClass('font-medium', 'text-foreground');
|
|
||||||
|
|
||||||
// Uncheck the first checkbox
|
|
||||||
await fireEvent.click(checkboxes[0]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Now first label should not have these classes
|
|
||||||
expect(labels[0]).not.toHaveClass('font-medium', 'text-foreground');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('multiple checkboxes can be toggled independently', async () => {
|
|
||||||
const properties = createMockProperties(3);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
|
|
||||||
// Check all three checkboxes
|
|
||||||
await fireEvent.click(checkboxes[0]);
|
|
||||||
await fireEvent.click(checkboxes[1]);
|
|
||||||
await fireEvent.click(checkboxes[2]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(filter.selectedCount).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Uncheck middle one
|
|
||||||
await fireEvent.click(checkboxes[1]);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(filter.selectedCount).toBe(2);
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
|
||||||
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
|
|
||||||
expect(checkboxes[2]).toHaveAttribute('data-state', 'checked');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Collapsible Behavior', () => {
|
|
||||||
it('is open by default', () => {
|
|
||||||
const filter = createTestFilter(createMockProperties(2));
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check that properties are visible (content is expanded)
|
|
||||||
expect(screen.getByText('Property 0')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Property 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clicking trigger toggles open/close state', async () => {
|
|
||||||
const filter = createTestFilter(createMockProperties(2));
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Content is initially visible
|
|
||||||
expect(screen.getByText('Property 0')).toBeVisible();
|
|
||||||
|
|
||||||
// Click the trigger (button) - use role and text to find it
|
|
||||||
const trigger = screen.getByRole('button', { name: /Test/ });
|
|
||||||
await fireEvent.click(trigger);
|
|
||||||
|
|
||||||
// Content should now be hidden
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('Property 0')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click again to open
|
|
||||||
await fireEvent.click(trigger);
|
|
||||||
|
|
||||||
// Content should be visible again
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Property 0')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chevron icon rotates based on open state', async () => {
|
|
||||||
const filter = createTestFilter(createMockProperties(2));
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trigger = screen.getByRole('button', { name: /Test/ });
|
|
||||||
const chevronContainer = trigger.querySelector('.lucide-chevron-down')
|
|
||||||
?.parentElement as HTMLElement;
|
|
||||||
|
|
||||||
// Initially open, transform should be rotate(0deg) or no rotation
|
|
||||||
expect(chevronContainer?.style.transform).toContain('0deg');
|
|
||||||
|
|
||||||
// Click to close
|
|
||||||
await fireEvent.click(trigger);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Now should be rotated -90deg
|
|
||||||
expect(chevronContainer?.style.transform).toContain('-90deg');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click to open again
|
|
||||||
await fireEvent.click(trigger);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
// Back to 0deg
|
|
||||||
expect(chevronContainer?.style.transform).toContain('0deg');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Count Display', () => {
|
|
||||||
it('badge shows correct count based on filter.selectedCount', async () => {
|
|
||||||
const properties = createMockProperties(5, [0, 2, 4]);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should show 3
|
|
||||||
expect(screen.getByText('3')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Click a checkbox to change selection
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
await fireEvent.click(checkboxes[1]);
|
|
||||||
|
|
||||||
// Should now show 4
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('4')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('badge visibility changes with hasSelection (selectedCount > 0)', async () => {
|
it('hides extra properties and shows show-more button when count > 10', () => {
|
||||||
const properties = createMockProperties(2, [0]);
|
const filter = createFilter({ properties: makeProperties(15) });
|
||||||
const filter = createTestFilter(properties);
|
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
render(FilterGroup, {
|
expect(screen.queryByText('Option 10')).not.toBeInTheDocument();
|
||||||
displayedLabel: 'Test',
|
expect(screen.getByRole('button', { name: '' })).toBeInTheDocument();
|
||||||
filter,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initially has 1 selection, badge should be visible
|
it('clicking show-more reveals all properties', async () => {
|
||||||
expect(screen.getByText('1')).toBeInTheDocument();
|
const filter = createFilter({ properties: makeProperties(12) });
|
||||||
|
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||||
// Uncheck the selected item
|
expect(screen.queryByText('Option 10')).not.toBeInTheDocument();
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
const showMoreBtn = screen.getAllByRole('button').at(-1)!;
|
||||||
await fireEvent.click(checkboxes[0]);
|
await fireEvent.click(showMoreBtn);
|
||||||
|
await waitFor(() => expect(screen.getByText('Option 10')).toBeInTheDocument());
|
||||||
// Now 0 selections, badge should be hidden
|
await waitFor(() => expect(screen.getByText('Option 11')).toBeInTheDocument());
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check it again
|
|
||||||
await fireEvent.click(checkboxes[0]);
|
|
||||||
|
|
||||||
// Badge should be visible again
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('badge shows count correctly when all items are selected', () => {
|
|
||||||
const properties = createMockProperties(5, [0, 1, 2, 3, 4]);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('5')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
|
||||||
it('provides proper ARIA labels on buttons', () => {
|
|
||||||
const filter = createTestFilter(createMockProperties(2));
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test Label',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The trigger button should be findable by its text
|
|
||||||
const trigger = screen.getByRole('button', { name: /Test Label/ });
|
|
||||||
expect(trigger).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('labels are properly associated with checkboxes', async () => {
|
|
||||||
const properties = createMockProperties(3);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
|
|
||||||
checkboxes.forEach((checkbox, index) => {
|
|
||||||
// Each checkbox should have an id
|
|
||||||
expect(checkbox).toHaveAttribute('id', `prop-${index}`);
|
|
||||||
|
|
||||||
// Find the label element (Label component wraps checkbox)
|
|
||||||
const labelElement = checkbox.closest('label');
|
|
||||||
expect(labelElement).toHaveAttribute('for', `prop-${index}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('checkboxes have proper role', async () => {
|
|
||||||
const filter = createTestFilter(createMockProperties(2));
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
checkboxes.forEach(checkbox => {
|
|
||||||
expect(checkbox).toHaveAttribute('role', 'checkbox');
|
|
||||||
expect(checkbox).toHaveAttribute('type', 'button');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('labels are clickable and toggle associated checkboxes', async () => {
|
|
||||||
const properties = createMockProperties(2);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
// Find the label text element (span inside label)
|
|
||||||
const firstLabelText = screen.getByText('Property 0');
|
|
||||||
|
|
||||||
// Initially unchecked
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
|
|
||||||
|
|
||||||
// Click the label text
|
|
||||||
await fireEvent.click(firstLabelText);
|
|
||||||
|
|
||||||
// Checkbox should now be checked
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click again
|
|
||||||
await fireEvent.click(firstLabelText);
|
|
||||||
|
|
||||||
// Should be unchecked again
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'unchecked');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
|
||||||
it('handles long property names', () => {
|
|
||||||
const properties: Property<string>[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'This is a very long property name that might wrap to multiple lines',
|
|
||||||
value: '1',
|
|
||||||
selected: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByText(
|
|
||||||
'This is a very long property name that might wrap to multiple lines',
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles special characters in property names', () => {
|
|
||||||
const properties: Property<string>[] = [
|
|
||||||
{ id: '1', name: 'Café & Restaurant', value: '1', selected: true },
|
|
||||||
{ id: '2', name: '100% Organic', value: '2', selected: false },
|
|
||||||
{ id: '3', name: '(Special) <Characters>', value: '3', selected: false },
|
|
||||||
];
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('Café & Restaurant')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('100% Organic')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('(Special) <Characters>')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles single property filter', () => {
|
|
||||||
const properties: Property<string>[] = [
|
|
||||||
{ id: '1', name: 'Only One', value: '1', selected: true },
|
|
||||||
];
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Single',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('Only One')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles very large number of properties', async () => {
|
|
||||||
const properties = createMockProperties(50, [0, 25, 49]);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Large List',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
expect(checkboxes).toHaveLength(50);
|
|
||||||
expect(screen.getByText('3')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates badge when filter is manipulated externally', async () => {
|
|
||||||
const properties = createMockProperties(3);
|
|
||||||
const filter = createTestFilter(properties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Test',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initially no badge (0 selections)
|
|
||||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// Externally select properties
|
|
||||||
filter.selectProperty('prop-0');
|
|
||||||
filter.selectProperty('prop-1');
|
|
||||||
|
|
||||||
// Badge should now show 2
|
|
||||||
// Note: This might not update immediately in the DOM due to Svelte reactivity
|
|
||||||
// In a real browser environment, this would update
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('2')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Component Integration', () => {
|
|
||||||
it('works correctly with real filter data', async () => {
|
|
||||||
const realProperties: Property<string>[] = [
|
|
||||||
{ id: 'sans-serif', name: 'Sans-serif', value: 'sans-serif', selected: true },
|
|
||||||
{ id: 'serif', name: 'Serif', value: 'serif', selected: false },
|
|
||||||
{ id: 'display', name: 'Display', value: 'display', selected: false },
|
|
||||||
{ id: 'handwriting', name: 'Handwriting', value: 'handwriting', selected: true },
|
|
||||||
{ id: 'monospace', name: 'Monospace', value: 'monospace', selected: false },
|
|
||||||
];
|
|
||||||
const filter = createTestFilter(realProperties);
|
|
||||||
render(FilterGroup, {
|
|
||||||
displayedLabel: 'Font Category',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check label
|
|
||||||
expect(screen.getByText('Font Category')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check count badge
|
|
||||||
expect(screen.getByText('2')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check property names
|
|
||||||
expect(screen.getByText('Sans-serif')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Serif')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Display')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Handwriting')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Monospace')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check initial checkbox states
|
|
||||||
const checkboxes = await screen.findAllByRole('checkbox');
|
|
||||||
expect(checkboxes[0]).toHaveAttribute('data-state', 'checked');
|
|
||||||
expect(checkboxes[1]).toHaveAttribute('data-state', 'unchecked');
|
|
||||||
expect(checkboxes[2]).toHaveAttribute('data-state', 'unchecked');
|
|
||||||
expect(checkboxes[3]).toHaveAttribute('data-state', 'checked');
|
|
||||||
expect(checkboxes[4]).toHaveAttribute('data-state', 'unchecked');
|
|
||||||
|
|
||||||
// Interact with checkboxes
|
|
||||||
await fireEvent.click(checkboxes[1]);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(filter.selectedCount).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const { Story } = defineMeta({
|
|||||||
|
|
||||||
<Story name="Mobile Open">
|
<Story name="Mobile Open">
|
||||||
{#snippet template()}
|
{#snippet template()}
|
||||||
<Providers initialWidth={375}>
|
<Providers>
|
||||||
<div class="h-64 relative overflow-hidden">
|
<div class="h-64 relative overflow-hidden">
|
||||||
<SidebarContainer isOpen={true}>
|
<SidebarContainer isOpen={true}>
|
||||||
{#snippet sidebar({ onClose })}
|
{#snippet sidebar({ onClose })}
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './utils/dotTransition';
|
||||||
|
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};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { getPretextFontString } from './getPretextFontString';
|
||||||
|
|
||||||
|
describe('getPretextFontString', () => {
|
||||||
|
it('correctly formats the font string for pretext', () => {
|
||||||
|
const weight = 400;
|
||||||
|
const sizePx = 16;
|
||||||
|
const fontName = 'Inter';
|
||||||
|
const expected = '400 16px "Inter"';
|
||||||
|
|
||||||
|
expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with different weight and size', () => {
|
||||||
|
const weight = 700;
|
||||||
|
const sizePx = 32;
|
||||||
|
const fontName = 'Roboto';
|
||||||
|
const expected = '700 32px "Roboto"';
|
||||||
|
|
||||||
|
expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles font names with spaces', () => {
|
||||||
|
const weight = 400;
|
||||||
|
const sizePx = 16;
|
||||||
|
const fontName = 'Open Sans';
|
||||||
|
const expected = '400 16px "Open Sans"';
|
||||||
|
|
||||||
|
expect(getPretextFontString(weight, sizePx, fontName)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Formats a font configuration into a string format required by @chenglou/pretext.
|
||||||
|
*
|
||||||
|
* @param weight - Numeric font weight (e.g., 400).
|
||||||
|
* @param sizePx - Font size in pixels.
|
||||||
|
* @param fontName - The font family name.
|
||||||
|
* @returns A formatted font string: `"weight sizepx \"fontName\""`.
|
||||||
|
*/
|
||||||
|
export function getPretextFontString(weight: number, sizePx: number, fontName: string): string {
|
||||||
|
return `${weight} ${sizePx}px "${fontName}"`;
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { typographySettingsStore } from '$features/SetupFont/model';
|
import { typographySettingsStore } from '$features/SetupFont/model';
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import { createPersistentStore } from '$shared/lib';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { getPretextFontString } from '../../lib';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage schema for comparison state
|
* Storage schema for comparison state
|
||||||
@@ -205,8 +206,8 @@ export class ComparisonStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontAString = `${weight} ${size}px "${fontAName}"`;
|
const fontAString = getPretextFontString(weight, size, fontAName);
|
||||||
const fontBString = `${weight} ${size}px "${fontBName}"`;
|
const fontBString = getPretextFontString(weight, size, fontBName);
|
||||||
|
|
||||||
// Check if already loaded to avoid UI flash
|
// Check if already loaded to avoid UI flash
|
||||||
const isALoaded = document.fonts.check(fontAString);
|
const isALoaded = document.fonts.check(fontAString);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ $effect(() => {
|
|||||||
<span
|
<span
|
||||||
class="char-wrap"
|
class="char-wrap"
|
||||||
style:font-size="{typography.renderedSize}px"
|
style:font-size="{typography.renderedSize}px"
|
||||||
|
style:margin-right="{typography.spacing}em"
|
||||||
style:will-change={proximity > 0 ? 'transform' : 'auto'}
|
style:will-change={proximity > 0 ? 'transform' : 'auto'}
|
||||||
>
|
>
|
||||||
{#each [0, 1] as s (s)}
|
{#each [0, 1] as s (s)}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ const { Story } = defineMeta({
|
|||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Default"
|
name="Default"
|
||||||
parameters={{ globals: { viewport: 'fullScreen' } }}
|
parameters={{ viewport: { defaultViewport: 'fullScreen' } }}
|
||||||
>
|
>
|
||||||
{#snippet template()}
|
{#snippet template()}
|
||||||
<Providers>
|
<Providers>
|
||||||
|
|||||||
@@ -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 justify-between text-left text-sm flex"
|
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>
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
|
|||||||
variant="underline"
|
variant="underline"
|
||||||
size="lg"
|
size="lg"
|
||||||
placeholder="The quick brown fox..."
|
placeholder="The quick brown fox..."
|
||||||
|
aria-label="Preview text"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import Header from './Header.svelte';
|
||||||
|
|
||||||
|
const context = new Map([['responsive', { isMobile: false }]]);
|
||||||
|
|
||||||
|
describe('Header', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders a header element', () => {
|
||||||
|
render(Header, { props: { isSidebarOpen: false, onSidebarToggle: () => {} }, context });
|
||||||
|
expect(document.querySelector('header')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Open Config" title when sidebar is closed', () => {
|
||||||
|
render(Header, { props: { isSidebarOpen: false, onSidebarToggle: () => {} }, context });
|
||||||
|
expect(screen.getByTitle('Open Config')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Close Config" title when sidebar is open', () => {
|
||||||
|
render(Header, { props: { isSidebarOpen: true, onSidebarToggle: () => {} }, context });
|
||||||
|
expect(screen.getByTitle('Close Config')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a text input for the sample text', () => {
|
||||||
|
render(Header, { props: { isSidebarOpen: false, onSidebarToggle: () => {} }, context });
|
||||||
|
expect(screen.getByPlaceholderText('The quick brown fox...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the theme toggle button', () => {
|
||||||
|
render(Header, { props: { isSidebarOpen: false, onSidebarToggle: () => {} }, context });
|
||||||
|
expect(screen.getByTitle('Toggle theme')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interaction', () => {
|
||||||
|
it('calls onSidebarToggle when toggle button is clicked', async () => {
|
||||||
|
const onSidebarToggle = vi.fn();
|
||||||
|
render(Header, { props: { isSidebarOpen: false, onSidebarToggle }, context });
|
||||||
|
await fireEvent.click(screen.getByTitle('Open Config'));
|
||||||
|
expect(onSidebarToggle).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,8 +33,8 @@ let { chars, character }: Props = $props();
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||||
style:height="{typography.height}em"
|
style:height="{typography.height * typography.renderedSize}px"
|
||||||
style:line-height="{typography.height}em"
|
style:line-height="{typography.height * typography.renderedSize}px"
|
||||||
>
|
>
|
||||||
{#each chars as c, index}
|
{#each chars as c, index}
|
||||||
{@render character?.({ char: c.char, index })}
|
{@render character?.({ char: c.char, index })}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { SearchBar } from '$shared/ui';
|
|||||||
id="font-search"
|
id="font-search"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="Typeface Search"
|
placeholder="Typeface Search"
|
||||||
|
aria-label="Search typefaces"
|
||||||
bind:value={filterManager.queryValue}
|
bind:value={filterManager.queryValue}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { filterManager } from '$features/GetFonts';
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import Search from './Search.svelte';
|
||||||
|
|
||||||
|
describe('Search', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
filterManager.queryValue = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders a text input', () => {
|
||||||
|
render(Search);
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has "Typeface Search" placeholder', () => {
|
||||||
|
render(Search);
|
||||||
|
expect(screen.getByPlaceholderText('Typeface Search')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Value binding', () => {
|
||||||
|
it('reflects filterManager.queryValue as initial value', () => {
|
||||||
|
filterManager.queryValue = 'Inter';
|
||||||
|
render(Search);
|
||||||
|
expect(screen.getByRole('textbox')).toHaveValue('Inter');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { createRawSnippet } from 'svelte';
|
||||||
|
import { comparisonStore } from '../../model';
|
||||||
|
import Sidebar from './Sidebar.svelte';
|
||||||
|
|
||||||
|
function textSnippet(text: string) {
|
||||||
|
return createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sidebar', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
comparisonStore.side = 'A';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders the "Configuration" title', () => {
|
||||||
|
render(Sidebar);
|
||||||
|
expect(screen.getByText('Configuration')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Left Font and Right Font toggle buttons', () => {
|
||||||
|
render(Sidebar);
|
||||||
|
expect(screen.getByText('Left Font')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Right Font')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders main snippet when provided', () => {
|
||||||
|
render(Sidebar, { props: { main: textSnippet('main-content') } });
|
||||||
|
expect(screen.getByText('main-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders controls snippet when provided', () => {
|
||||||
|
render(Sidebar, { props: { controls: textSnippet('controls-content') } });
|
||||||
|
expect(screen.getByText('controls-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing in main area when no main snippet', () => {
|
||||||
|
const { container } = render(Sidebar);
|
||||||
|
expect(container.querySelector('[data-main]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A/B toggle', () => {
|
||||||
|
it('Left Font button is active in side A mode', () => {
|
||||||
|
comparisonStore.side = 'A';
|
||||||
|
render(Sidebar);
|
||||||
|
const leftBtn = screen.getByText('Left Font').closest('button');
|
||||||
|
expect(leftBtn).toHaveClass('shadow-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Right Font button is not active in side A mode', () => {
|
||||||
|
comparisonStore.side = 'A';
|
||||||
|
render(Sidebar);
|
||||||
|
const rightBtn = screen.getByText('Right Font').closest('button');
|
||||||
|
expect(rightBtn).not.toHaveClass('shadow-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Right Font button is active in side B mode', () => {
|
||||||
|
comparisonStore.side = 'B';
|
||||||
|
render(Sidebar);
|
||||||
|
const rightBtn = screen.getByText('Right Font').closest('button');
|
||||||
|
expect(rightBtn).toHaveClass('shadow-sm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Right Font switches to side B', async () => {
|
||||||
|
render(Sidebar);
|
||||||
|
expect(comparisonStore.side).toBe('A');
|
||||||
|
await fireEvent.click(screen.getByText('Right Font'));
|
||||||
|
expect(comparisonStore.side).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Left Font switches back to side A from B', async () => {
|
||||||
|
comparisonStore.side = 'B';
|
||||||
|
render(Sidebar);
|
||||||
|
await fireEvent.click(screen.getByText('Left Font'));
|
||||||
|
expect(comparisonStore.side).toBe('A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UI updates reactively after side switch', async () => {
|
||||||
|
render(Sidebar);
|
||||||
|
await fireEvent.click(screen.getByText('Right Font'));
|
||||||
|
const rightBtn = screen.getByText('Right Font').closest('button');
|
||||||
|
await waitFor(() => expect(rightBtn).toHaveClass('shadow-sm'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import clsx from 'clsx';
|
|||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
import { getPretextFontString } from '../../lib';
|
||||||
import { comparisonStore } from '../../model';
|
import { comparisonStore } from '../../model';
|
||||||
import Character from '../Character/Character.svelte';
|
import Character from '../Character/Character.svelte';
|
||||||
import Line from '../Line/Line.svelte';
|
import Line from '../Line/Line.svelte';
|
||||||
@@ -53,6 +54,7 @@ const isMobile = $derived(responsive?.isMobile ?? false);
|
|||||||
|
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let isTypographyMenuOpen = $state(false);
|
let isTypographyMenuOpen = $state(false);
|
||||||
|
let containerWidth = $state(0);
|
||||||
|
|
||||||
// New high-performance layout engine
|
// New high-performance layout engine
|
||||||
const comparisonEngine = new CharacterComparisonEngine();
|
const comparisonEngine = new CharacterComparisonEngine();
|
||||||
@@ -127,24 +129,28 @@ $effect(() => {
|
|||||||
const _weight = typography.weight;
|
const _weight = typography.weight;
|
||||||
const _size = typography.renderedSize;
|
const _size = typography.renderedSize;
|
||||||
const _height = typography.height;
|
const _height = typography.height;
|
||||||
|
const _spacing = typography.spacing;
|
||||||
|
|
||||||
if (container && fontA && fontB) {
|
if (container && fontA && fontB) {
|
||||||
// PRETEXT API strings: "weight sizepx family"
|
// PRETEXT API strings: "weight sizepx family"
|
||||||
const fontAStr = `${_weight} ${_size}px "${fontA.name}"`;
|
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
|
||||||
const fontBStr = `${_weight} ${_size}px "${fontB.name}"`;
|
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
|
||||||
|
|
||||||
// Use offsetWidth to avoid transform scaling issues
|
// Use offsetWidth to avoid transform scaling issues
|
||||||
const width = container.offsetWidth;
|
const width = container.offsetWidth;
|
||||||
const padding = isMobile ? 48 : 96;
|
const padding = isMobile ? 48 : 96;
|
||||||
const availableWidth = width - padding;
|
const availableWidth = width - padding;
|
||||||
const lineHeight = _size * 1.2; // Approximate
|
const lineHeight = _size * _height;
|
||||||
|
|
||||||
|
containerWidth = width;
|
||||||
layoutResult = comparisonEngine.layout(
|
layoutResult = comparisonEngine.layout(
|
||||||
_text,
|
_text,
|
||||||
fontAStr,
|
fontAStr,
|
||||||
fontBStr,
|
fontBStr,
|
||||||
availableWidth,
|
availableWidth,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
|
_spacing,
|
||||||
|
_size,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -157,12 +163,15 @@ $effect(() => {
|
|||||||
if (container && fontA && fontB) {
|
if (container && fontA && fontB) {
|
||||||
const width = container.offsetWidth;
|
const width = container.offsetWidth;
|
||||||
const padding = isMobile ? 48 : 96;
|
const padding = isMobile ? 48 : 96;
|
||||||
|
containerWidth = width;
|
||||||
layoutResult = comparisonEngine.layout(
|
layoutResult = comparisonEngine.layout(
|
||||||
comparisonStore.text,
|
comparisonStore.text,
|
||||||
`${typography.weight} ${typography.renderedSize}px "${fontA.name}"`,
|
getPretextFontString(typography.weight, typography.renderedSize, fontA.name),
|
||||||
`${typography.weight} ${typography.renderedSize}px "${fontB.name}"`,
|
getPretextFontString(typography.weight, typography.renderedSize, fontB.name),
|
||||||
width - padding,
|
width - padding,
|
||||||
typography.renderedSize * 1.2,
|
typography.renderedSize * typography.height,
|
||||||
|
typography.spacing,
|
||||||
|
typography.renderedSize,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -239,11 +248,15 @@ const scaleClass = $derived(
|
|||||||
my-auto
|
my-auto
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{#each layoutResult.lines as line, lineIndex}
|
{#each layoutResult.lines as line}
|
||||||
|
{@const lineStates = comparisonEngine.getLineCharStates(line, sliderPos, containerWidth)}
|
||||||
<Line chars={line.chars}>
|
<Line chars={line.chars}>
|
||||||
{#snippet character({ char, index })}
|
{#snippet character({ char, index })}
|
||||||
{@const { proximity, isPast } = comparisonEngine.getCharState(lineIndex, index, sliderPos, container?.offsetWidth ?? 0)}
|
<Character
|
||||||
<Character {char} {proximity} {isPast} />
|
{char}
|
||||||
|
proximity={lineStates[index]?.proximity ?? 0}
|
||||||
|
isPast={lineStates[index]?.isPast ?? false}
|
||||||
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Line>
|
</Line>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { render } from '@testing-library/svelte';
|
||||||
|
import Thumb from './Thumb.svelte';
|
||||||
|
|
||||||
|
describe('Thumb', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders a container element', () => {
|
||||||
|
const { container } = render(Thumb, { sliderPos: 50, isDragging: false });
|
||||||
|
expect(container.firstElementChild).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies sliderPos as left CSS style', () => {
|
||||||
|
const { container } = render(Thumb, { sliderPos: 30, isDragging: false });
|
||||||
|
const el = container.firstElementChild as HTMLElement;
|
||||||
|
expect(el.style.left).toBe('30%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders two handle squares', () => {
|
||||||
|
const { container } = render(Thumb, { sliderPos: 50, isDragging: false });
|
||||||
|
const handles = container.querySelectorAll('.bg-brand.text-white');
|
||||||
|
expect(handles).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Dragging state', () => {
|
||||||
|
it('applies scale-110 to handles when dragging', () => {
|
||||||
|
const { container } = render(Thumb, { sliderPos: 50, isDragging: true });
|
||||||
|
const handles = container.querySelectorAll('.scale-110');
|
||||||
|
expect(handles.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies scale-100 to handles when not dragging', () => {
|
||||||
|
const { container } = render(Thumb, { sliderPos: 50, isDragging: false });
|
||||||
|
const handles = container.querySelectorAll('.scale-100');
|
||||||
|
expect(handles.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply scale-110 when not dragging', () => {
|
||||||
|
const { container } = render(Thumb, { sliderPos: 50, isDragging: false });
|
||||||
|
expect(container.querySelector('.scale-110')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,13 +98,13 @@ let showFiltersClosed = $state(false);
|
|||||||
let showFiltersOpen = $state(true);
|
let showFiltersOpen = $state(true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Default" parameters={{ globals: { viewport: 'widgetWide' } }}>
|
<Story name="Default" parameters={{ viewport: { defaultViewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-3xl">
|
<div class="w-full max-w-3xl">
|
||||||
<FontSearch bind:showFilters={showFiltersDefault} />
|
<FontSearch bind:showFilters={showFiltersDefault} />
|
||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Filters Open" parameters={{ globals: { viewport: 'widgetWide' } }}>
|
<Story name="Filters Open" parameters={{ viewport: { defaultViewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-3xl">
|
<div class="w-full max-w-3xl">
|
||||||
<FontSearch bind:showFilters={showFiltersOpen} />
|
<FontSearch bind:showFilters={showFiltersOpen} />
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8 text-center">
|
||||||
@@ -113,7 +113,7 @@ let showFiltersOpen = $state(true);
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Filters Closed" parameters={{ globals: { viewport: 'widgetWide' } }}>
|
<Story name="Filters Closed" parameters={{ viewport: { defaultViewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-3xl">
|
<div class="w-full max-w-3xl">
|
||||||
<FontSearch bind:showFilters={showFiltersClosed} />
|
<FontSearch bind:showFilters={showFiltersClosed} />
|
||||||
<div class="mt-8 text-center">
|
<div class="mt-8 text-center">
|
||||||
@@ -122,13 +122,13 @@ let showFiltersOpen = $state(true);
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Full Width" parameters={{ globals: { viewport: 'fullWidth' } }}>
|
<Story name="Full Width" parameters={{ viewport: { defaultViewport: 'fullWidth' } }}>
|
||||||
<div class="w-full px-8">
|
<div class="w-full px-8">
|
||||||
<FontSearch />
|
<FontSearch />
|
||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="In Context" tags={['!autodocs']} parameters={{ globals: { viewport: 'widgetWide' } }}>
|
<Story name="In Context" tags={['!autodocs']} parameters={{ viewport: { defaultViewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-3xl p-8 space-y-6">
|
<div class="w-full max-w-3xl p-8 space-y-6">
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h1 class="text-4xl font-bold mb-2">Font Browser</h1>
|
<h1 class="text-4xl font-bold mb-2">Font Browser</h1>
|
||||||
@@ -145,7 +145,7 @@ let showFiltersOpen = $state(true);
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With Filters Demo" parameters={{ globals: { viewport: 'widgetWide' } }}>
|
<Story name="With Filters Demo" parameters={{ viewport: { defaultViewport: 'widgetWide' } }}>
|
||||||
<div class="w-full max-w-3xl">
|
<div class="w-full max-w-3xl">
|
||||||
<div class="mb-4 p-4 bg-background-40 rounded-lg">
|
<div class="mb-4 p-4 bg-background-40 rounded-lg">
|
||||||
<p class="text-sm text-text-muted">
|
<p class="text-sm text-text-muted">
|
||||||
@@ -157,7 +157,7 @@ let showFiltersOpen = $state(true);
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Responsive Behavior" parameters={{ globals: { viewport: 'fullWidth' } }}>
|
<Story name="Responsive Behavior" parameters={{ viewport: { defaultViewport: 'fullWidth' } }}>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="mb-4 text-center">
|
<div class="mb-4 text-center">
|
||||||
<p class="text-text-muted text-sm">Resize browser to see responsive layout</p>
|
<p class="text-text-muted text-sm">Resize browser to see responsive layout</p>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ function toggleFilters() {
|
|||||||
id="font-search"
|
id="font-search"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placeholder="Typeface Search"
|
placeholder="Typeface Search"
|
||||||
|
aria-label="Search typefaces"
|
||||||
bind:value={filterManager.queryValue}
|
bind:value={filterManager.queryValue}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { layoutManager } from '../../model';
|
||||||
|
import LayoutSwitch from './LayoutSwitch.svelte';
|
||||||
|
|
||||||
|
describe('LayoutSwitch', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
layoutManager.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders two icon buttons', () => {
|
||||||
|
render(LayoutSwitch);
|
||||||
|
expect(screen.getAllByRole('button')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Active state', () => {
|
||||||
|
it('list button is active in list mode', () => {
|
||||||
|
render(LayoutSwitch);
|
||||||
|
const [listBtn] = screen.getAllByRole('button');
|
||||||
|
expect(listBtn).toHaveClass('text-brand');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grid button is not active in list mode', () => {
|
||||||
|
render(LayoutSwitch);
|
||||||
|
const [, gridBtn] = screen.getAllByRole('button');
|
||||||
|
expect(gridBtn).not.toHaveClass('text-brand');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grid button is active in grid mode', () => {
|
||||||
|
layoutManager.setMode('grid');
|
||||||
|
render(LayoutSwitch);
|
||||||
|
const [, gridBtn] = screen.getAllByRole('button');
|
||||||
|
expect(gridBtn).toHaveClass('text-brand');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list button is not active in grid mode', () => {
|
||||||
|
layoutManager.setMode('grid');
|
||||||
|
render(LayoutSwitch);
|
||||||
|
const [listBtn] = screen.getAllByRole('button');
|
||||||
|
expect(listBtn).not.toHaveClass('text-brand');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interaction', () => {
|
||||||
|
it('clicking second button switches to grid mode', async () => {
|
||||||
|
render(LayoutSwitch);
|
||||||
|
expect(layoutManager.mode).toBe('list');
|
||||||
|
await fireEvent.click(screen.getAllByRole('button')[1]);
|
||||||
|
expect(layoutManager.mode).toBe('grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking first button when in grid mode switches to list mode', async () => {
|
||||||
|
layoutManager.setMode('grid');
|
||||||
|
render(LayoutSwitch);
|
||||||
|
await fireEvent.click(screen.getAllByRole('button')[0]);
|
||||||
|
expect(layoutManager.mode).toBe('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('active button updates reactively after toggle', async () => {
|
||||||
|
render(LayoutSwitch);
|
||||||
|
const [listBtn, gridBtn] = screen.getAllByRole('button');
|
||||||
|
await fireEvent.click(gridBtn);
|
||||||
|
await waitFor(() => expect(gridBtn).toHaveClass('text-brand'));
|
||||||
|
await waitFor(() => expect(listBtn).not.toHaveClass('text-brand'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,7 +89,7 @@ const { Story } = defineMeta({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Default" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
<Story name="Default" parameters={{ viewport: { defaultViewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -101,13 +101,13 @@ const { Story } = defineMeta({
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Full Page" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
<Story name="Full Page" parameters={{ viewport: { defaultViewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<SampleList />
|
<SampleList />
|
||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With Typography Controls" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
<Story name="With Typography Controls" parameters={{ viewport: { defaultViewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -119,7 +119,7 @@ const { Story } = defineMeta({
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Custom Text" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
<Story name="Custom Text" parameters={{ viewport: { defaultViewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -131,7 +131,7 @@ const { Story } = defineMeta({
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Pagination Info" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
<Story name="Pagination Info" parameters={{ viewport: { defaultViewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -143,7 +143,7 @@ const { Story } = defineMeta({
|
|||||||
</div>
|
</div>
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Responsive Layout" parameters={{ globals: { viewport: 'fullScreen' } }}>
|
<Story name="Responsive Layout" parameters={{ viewport: { defaultViewport: 'fullScreen' } }}>
|
||||||
<div class="min-h-screen bg-background">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-6xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-6xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
|
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@lucide/svelte'],
|
||||||
|
},
|
||||||
|
|
||||||
test: {
|
test: {
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -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