Compare commits

...

51 Commits

Author SHA1 Message Date
Ilia Mashkov 84ac886c33 chore: fix TS alias resolution and SVG mocking for test setup 2026-04-22 09:45:51 +03:00
Ilia Mashkov a60dbcfa51 test: track missing component test configuration 2026-04-22 09:42:59 +03:00
Ilia Mashkov 8fc8a7ee6f test: fix component tests by adding localStorage mock and resolving store interference 2026-04-22 09:42:00 +03:00
Ilia Mashkov cbc978df6d chore(ci): add unit and component tests to lefthook and gitea workflow 2026-04-22 09:09:21 +03:00
Ilia Mashkov 6664beec25 feat(FontList): unified skeleton — rows stay skeletal until font file loaded 2026-04-22 09:02:32 +03:00
Ilia Mashkov a801903fd3 feat(FontList): use getSkeletonWidth utility for skeleton row widths 2026-04-22 09:02:32 +03:00
Ilia Mashkov ecdb1e016d feat(FontApplicator): add skeleton snippet prop to replace blur loading state 2026-04-22 09:02:32 +03:00
Ilia Mashkov 092b58e651 feat(FontVirtualList): suppress font loading during jump scroll catch-up 2026-04-22 09:02:32 +03:00
Ilia Mashkov d6914f8179 feat(FontStore): add fetchAllPagesTo for parallel batch page loading 2026-04-22 09:01:45 +03:00
Ilia Mashkov b831861662 feat(VirtualList): add onJump callback for scroll-beyond-loaded detection 2026-04-22 09:01:45 +03:00
Ilia Mashkov 67fc9dee72 fix(FontList): address the bug with selected font transition animations 2026-04-20 13:36:05 +03:00
Ilia Mashkov a73bd75947 refactor(ComparisonView): unify pretext font string generation with a utility function 2026-04-20 11:13:54 +03:00
Ilia Mashkov 836b83f75d style: apply new dprint rules to CharacterComparisonEngine 2026-04-20 11:06:54 +03:00
Ilia Mashkov 07e4a0b9d9 chore: forbid one-line and braceless cycles in dprint config 2026-04-20 11:06:45 +03:00
Ilia Mashkov 141126530d fix(ComparisonView): fix character morphing thresholds and add tracking support 2026-04-20 10:52:28 +03:00
Ilia Mashkov f9f96e2797 fix(ComparisonView): add correct line-height calculation 2026-04-20 10:51:41 +03:00
Ilia Mashkov 3e11821814 feat: add meta description 2026-04-19 19:15:46 +03:00
Ilia Mashkov ee3f773ca5 chore: replace section with main tag 2026-04-19 19:15:03 +03:00
Ilia Mashkov 2a51f031cc chore: add missing aria labels 2026-04-19 19:14:49 +03:00
Ilia Mashkov b792dde7cb fix(FontList): overwrite css rule 2026-04-19 19:14:15 +03:00
Ilia Mashkov 66dcffa448 chore(storybook): replace viewport with defaultViewport 2026-04-18 11:04:10 +03:00
Ilia Mashkov cca00fccaa chore(storybook): remove mobile stories and initialWidth prop from stories. The mobile view available throught viewport selector in the header 2026-04-18 11:03:43 +03:00
Ilia Mashkov af05443763 chore(storybook): purge unused Providers props 2026-04-18 11:02:34 +03:00
Ilia Mashkov 99d92d487f feat(storybook): replace width with maxWidth for StoryStage 2026-04-18 11:01:36 +03:00
Ilia Mashkov 4a907619cc chore(storybook): purge custom viewports from storybook preview 2026-04-18 11:00:32 +03:00
Ilia Mashkov 6c69d7a5b3 test(ComparisonView): cover parts of the widget with tests 2026-04-18 01:19:01 +03:00
Ilia Mashkov 993812de0a test(GetFonts): add tests for Filters component behavior 2026-04-18 01:18:02 +03:00
Ilia Mashkov 67c16530af test(ChangeAppTheme): cover theme switcher component with tests 2026-04-18 01:17:25 +03:00
Ilia Mashkov fbbb439023 test(Breadcrumb): add test for BreadcrumbHeader component 2026-04-18 01:16:45 +03:00
Ilia Mashkov c2046770ef test(SampleList): add test coverage for LayoutSwitch component 2026-04-18 01:16:09 +03:00
Ilia Mashkov adfba38063 test: exclude lucide from dependency optimization 2026-04-18 01:15:25 +03:00
Ilia Mashkov dfb304d436 test: remove legacy tests and add new ones 2026-04-17 22:16:44 +03:00
Ilia Mashkov f55043a1e7 test(Badge): cover Baddge with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov 409dd1b229 test(Divider): cover Divider with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov 9fbce095b2 test(Footnote): cover Footnote with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov 171627e0ea test(Input): cover Input with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov d07fb1a3af test(Label): cover Label with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov 6f84644ecb test(Loader): cover Loader with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov 5ab5cda611 test(SearchBar): cover SearchBar with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov 7975d9aeee test(Skeleton): cover Skeleton with tests 2026-04-17 20:26:46 +03:00
Ilia Mashkov 2ba5fc0e3e test(Slider): cover Slider with tests 2026-04-17 20:24:09 +03:00
Ilia Mashkov 1947d7731e test(Stat): cover Stat with tests 2026-04-17 20:09:59 +03:00
Ilia Mashkov 38bfc4ba4b test(TechTech): cover TextTech with tests 2026-04-17 20:09:41 +03:00
Ilia Mashkov 6cf3047b74 test(Button): cover Button with tests 2026-04-17 19:20:13 +03:00
Ilia Mashkov 81363156d7 feat: set up vitest browser config for svelte components tests 2026-04-17 18:52:37 +03:00
Ilia Mashkov bb65f1c8d6 feat: add missing storybook files and type template arguments properly 2026-04-17 18:01:24 +03:00
Ilia Mashkov 5eb9584797 feat(TypographyMenu): add bindable "open" prop to close popover from outside 2026-04-17 16:30:41 +03:00
Ilia Mashkov bb5c3667b4 feat(SliderArea): utilize responsive breakpoints for TypographyMenu positioning 2026-04-17 14:39:25 +03:00
Ilia Mashkov 3711616a91 feat(TypograpyMenu): change custom button for existed Button component 2026-04-17 14:31:57 +03:00
Ilia Mashkov 6905c54040 chore: edit comments 2026-04-17 14:30:30 +03:00
Ilia Mashkov 1e8e22e2eb fix: edit tailwind variable name 2026-04-17 13:56:43 +03:00
96 changed files with 3232 additions and 1796 deletions
+6
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+4
View File
@@ -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
+5 -5
View File
@@ -7,7 +7,7 @@
/* Base font size */ /* Base font size */
--font-size: 16px; --font-size: 16px;
/* GLYPHDIFF Swiss Design System */ /* GLYPHDIFF Design System */
/* Primary Colors */ /* Primary Colors */
--swiss-beige: #f3f0e9; --swiss-beige: #f3f0e9;
--swiss-red: #ff3b30; --swiss-red: #ff3b30;
@@ -206,10 +206,10 @@
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; --font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */ /* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
--font-size-5xs: 0.4375rem; --text-5xs: 0.4375rem;
--font-size-4xs: 0.5rem; --text-4xs: 0.5rem;
--font-size-3xs: 0.5625rem; --text-3xs: 0.5625rem;
--font-size-2xs: 0.625rem; --text-2xs: 0.625rem;
/* Monospace label tracking — used in Loader and Footnote */ /* Monospace label tracking — used in Loader and Footnote */
--tracking-wider-mono: 0.2em; --tracking-wider-mono: 0.2em;
} }
+4
View File
@@ -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,65 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
const { Story } = defineMeta({
title: 'Entities/BreadcrumbHeader',
component: BreadcrumbHeader,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Fixed header that slides in when the user scrolls past tracked page sections. Reads `scrollBreadcrumbsStore.scrolledPastItems` — renders nothing when the list is empty. Requires the `responsive` context provided by `Providers`.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {},
});
</script>
<script>
import Providers from '$shared/lib/storybook/Providers.svelte';
import BreadcrumbHeaderSeeded from './BreadcrumbHeaderSeeded.svelte';
</script>
<Story
name="With Breadcrumbs"
parameters={{
docs: {
description: {
story:
'Three sections are registered with the breadcrumb store. The story scrolls the iframe so the IntersectionObserver marks them as scrolled-past, revealing the fixed header.',
},
},
}}
>
{#snippet template()}
<Providers>
<BreadcrumbHeaderSeeded />
</Providers>
{/snippet}
</Story>
<Story
name="Empty"
parameters={{
docs: {
description: {
story:
'No sections registered — BreadcrumbHeader renders nothing. This is the initial state before the user scrolls past any tracked section.',
},
},
}}
>
{#snippet template()}
<Providers>
<div style="padding: 2rem; color: #888; font-size: 0.875rem;">
BreadcrumbHeader renders nothing when scrolledPastItems is empty.
</div>
<BreadcrumbHeader />
</Providers>
{/snippet}
</Story>
@@ -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();
});
});
@@ -0,0 +1,49 @@
<script>
import { onMount } from 'svelte';
import { scrollBreadcrumbsStore } from '../../model';
import BreadcrumbHeader from './BreadcrumbHeader.svelte';
const sections = [
{ index: 100, title: 'Introduction' },
{ index: 101, title: 'Typography' },
{ index: 102, title: 'Spacing' },
];
/** @type {HTMLDivElement} */
let container;
onMount(() => {
for (const section of sections) {
const el = /** @type {HTMLElement} */ (container.querySelector(`[data-story-index="${section.index}"]`));
scrollBreadcrumbsStore.add({ index: section.index, title: section.title, element: el }, 96);
}
/*
* Scroll past the sections so IntersectionObserver marks them as
* scrolled-past, making scrolledPastItems non-empty and the header visible.
*/
setTimeout(() => {
window.scrollTo({ top: 2000, behavior: 'instant' });
}, 100);
return () => {
for (const { index } of sections) {
scrollBreadcrumbsStore.remove(index);
}
window.scrollTo({ top: 0, behavior: 'instant' });
};
});
</script>
<div bind:this={container} style="height: 2400px; padding-top: 900px;">
{#each sections as section}
<div
data-story-index={section.index}
style="height: 400px; padding: 2rem; background: #f5f5f5; margin-bottom: 1rem;"
>
{section.title} — scroll up to see the breadcrumb header
</div>
{/each}
</div>
<BreadcrumbHeader />
@@ -0,0 +1,109 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import NavigationWrapper from './NavigationWrapper.svelte';
const { Story } = defineMeta({
title: 'Entities/NavigationWrapper',
component: NavigationWrapper,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Thin wrapper that registers an HTML section with `scrollBreadcrumbsStore` via a Svelte use-directive action. Has no visual output of its own — renders `{@render content(registerBreadcrumb)}` where `registerBreadcrumb` is the action to attach with `use:`. On destroy the section is automatically removed from the store.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
index: {
control: { type: 'number', min: 0 },
description: 'Unique index used for ordering in the breadcrumb trail',
},
title: {
control: 'text',
description: 'Display title shown in the breadcrumb header',
},
offset: {
control: { type: 'number', min: 0 },
description: 'Scroll offset in pixels to account for sticky headers',
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story
name="Single Section"
args={{ index: 0, title: 'Introduction', offset: 96 }}
parameters={{
docs: {
description: {
story:
'A single section registered with NavigationWrapper. The `content` snippet receives the `register` action and applies it via `use:register`.',
},
},
}}
>
{#snippet template(args: ComponentProps<typeof NavigationWrapper>)}
<NavigationWrapper {...args}>
{#snippet content(register)}
<section use:register style="padding: 2rem; background: #f5f5f5; min-height: 200px;">
<p style="font-size: 0.875rem; color: #555;">
Section registered as <strong>{args.title}</strong> at index {args.index}. Scroll past this
section to see it appear in the breadcrumb header.
</p>
</section>
{/snippet}
</NavigationWrapper>
{/snippet}
</Story>
<Story
name="Multiple Sections"
parameters={{
docs: {
description: {
story:
'Three sequential sections each wrapped in NavigationWrapper with distinct indices and titles. Demonstrates how the breadcrumb trail builds as the user scrolls.',
},
},
}}
>
{#snippet template()}
<div style="display: flex; flex-direction: column; gap: 0;">
<NavigationWrapper index={0} title="Introduction" offset={96}>
{#snippet content(register)}
<section use:register style="padding: 2rem; background: #f5f5f5; min-height: 300px;">
<h2 style="margin: 0 0 0.5rem;">Introduction</h2>
<p style="font-size: 0.875rem; color: #555;">
Registered as section 0. Scroll down to build the breadcrumb trail.
</p>
</section>
{/snippet}
</NavigationWrapper>
<NavigationWrapper index={1} title="Typography" offset={96}>
{#snippet content(register)}
<section use:register style="padding: 2rem; background: #ebebeb; min-height: 300px;">
<h2 style="margin: 0 0 0.5rem;">Typography</h2>
<p style="font-size: 0.875rem; color: #555;">Registered as section 1.</p>
</section>
{/snippet}
</NavigationWrapper>
<NavigationWrapper index={2} title="Spacing" offset={96}>
{#snippet content(register)}
<section use:register style="padding: 2rem; background: #e0e0e0; min-height: 300px;">
<h2 style="margin: 0 0 0.5rem;">Spacing</h2>
<p style="font-size: 0.875rem; color: #555;">Registered as section 2.</p>
</section>
{/snippet}
</NavigationWrapper>
</div>
{/snippet}
</Story>
+6
View File
@@ -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> = {};
@@ -0,0 +1,91 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import FontApplicator from './FontApplicator.svelte';
const { Story } = defineMeta({
title: 'Entities/FontApplicator',
component: FontApplicator,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Loads a font and applies it to children. Shows blur/scale loading state until font is ready, then reveals with a smooth transition.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
weight: { control: 'number' },
},
});
</script>
<script lang="ts">
import { mockUnifiedFont } from '$entities/Font/lib/mocks';
import type { ComponentProps } from 'svelte';
const fontUnknown = mockUnifiedFont({ id: 'nonexistent-font-xk92z', name: 'Nonexistent Font Xk92z' });
const fontArial = mockUnifiedFont({ id: 'arial', name: 'Arial' });
const fontArialBold = mockUnifiedFont({ id: 'arial-bold', name: 'Arial' });
</script>
<Story
name="Loading State"
parameters={{
docs: {
description: {
story:
'Font that has never been loaded by appliedFontsManager. The component renders in its pending state: blurred, scaled down, and semi-transparent.',
},
},
}}
args={{ font: fontUnknown, weight: 400 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
</FontApplicator>
{/snippet}
</Story>
<Story
name="Loaded State"
parameters={{
docs: {
description: {
story:
'Uses Arial, a system font available in all browsers. Because appliedFontsManager has not loaded it via FontFace, the manager status may remain pending — meaning the blur/scale state may still show. In a real app the manager would load the font and transition to the revealed state.',
},
},
}}
args={{ font: fontArial, weight: 400 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
</FontApplicator>
{/snippet}
</Story>
<Story
name="Custom Weight"
parameters={{
docs: {
description: {
story:
'Demonstrates passing a custom weight (700). The weight is forwarded to appliedFontsManager for font resolution; visually identical to the loaded state story until the manager confirms the font.',
},
},
}}
args={{ font: fontArialBold, weight: 700 }}
>
{#snippet template(args: ComponentProps<typeof FontApplicator>)}
<FontApplicator {...args}>
<p class="text-xl">The quick brown fox jumps over the lazy dog</p>
</FontApplicator>
{/snippet}
</Story>
@@ -1,14 +1,11 @@
<!-- <!--
Component: FontApplicator Component: FontApplicator
Loads fonts from fontshare with link tag Applies a font to its children once the font file is loaded.
- Loads font only if it's not already applied Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
- Reacts to font load status to show/hide content
- Adds smooth transition when font appears
--> -->
<script lang="ts"> <script lang="ts">
import clsx from 'clsx'; import clsx from 'clsx';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { prefersReducedMotion } from 'svelte/motion';
import { import {
DEFAULT_FONT_WEIGHT, DEFAULT_FONT_WEIGHT,
type UnifiedFont, type UnifiedFont,
@@ -33,6 +30,11 @@ interface Props {
* Content snippet * Content snippet
*/ */
children?: Snippet; children?: Snippet;
/**
* Shown while the font file is loading.
* When omitted, children render in system font until ready.
*/
skeleton?: Snippet;
} }
let { let {
@@ -40,6 +42,7 @@ let {
weight = DEFAULT_FONT_WEIGHT, weight = DEFAULT_FONT_WEIGHT,
className, className,
children, children,
skeleton,
}: Props = $props(); }: Props = $props();
const status = $derived( const status = $derived(
@@ -50,30 +53,16 @@ const status = $derived(
), ),
); );
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
const shouldReveal = $derived(status === 'loaded' || status === 'error'); const shouldReveal = $derived(status === 'loaded' || status === 'error');
const transitionClasses = $derived(
prefersReducedMotion.current
? 'transition-none' // Disable CSS transitions if motion is reduced
: 'transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
);
</script> </script>
{#if !shouldReveal && skeleton}
{@render skeleton()}
{:else}
<div <div
style:font-family={shouldReveal style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
? `'${font.name}'` class={clsx(className)}
: 'system-ui, -apple-system, sans-serif'}
class={clsx(
transitionClasses,
// If reduced motion is on, we skip the transform/blur entirely
!shouldReveal
&& !prefersReducedMotion.current
&& 'opacity-50 scale-[0.95] blur-sm',
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
shouldReveal && 'opacity-100 scale-100 blur-0',
className,
)}
> >
{@render children?.()} {@render children?.()}
</div> </div>
{/if}
@@ -0,0 +1,114 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import FontVirtualList from './FontVirtualList.svelte';
const { Story } = defineMeta({
title: 'Entities/FontVirtualList',
component: FontVirtualList,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Virtualized font list backed by the `fontStore` singleton. Handles font loading registration (pin/touch) for visible items and triggers infinite scroll pagination via `fontStore.nextPage()`. Because the component reads directly from the `fontStore` singleton, stories render against a live (but empty/loading) store — no font data will appear unless the API is reachable from the Storybook host.',
},
story: { inline: false },
},
layout: 'padded',
},
argTypes: {
weight: { control: 'number', description: 'Font weight applied to visible fonts' },
itemHeight: { control: 'number', description: 'Height of each list item in pixels' },
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story
name="Loading Skeleton"
parameters={{
docs: {
description: {
story:
'Skeleton state shown while `fontStore.fonts` is empty and `fontStore.isLoading` is true. In a real session the skeleton fades out once the first page loads.',
},
},
}}
args={{ weight: 400, itemHeight: 72 }}
>
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
<div class="h-[400px] w-full">
<FontVirtualList {...args}>
{#snippet skeleton()}
<div class="flex flex-col gap-2 p-4">
{#each Array(6) as _}
<div class="h-16 animate-pulse rounded bg-neutral-200"></div>
{/each}
</div>
{/snippet}
{#snippet children({ item })}
<div class="border-b border-neutral-100 p-3">{item.name}</div>
{/snippet}
</FontVirtualList>
</div>
{/snippet}
</Story>
<Story
name="Empty State"
parameters={{
docs: {
description: {
story:
'No `skeleton` snippet provided. When `fontStore.fonts` is empty the underlying VirtualList renders its empty state directly.',
},
},
}}
args={{ weight: 400, itemHeight: 72 }}
>
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
<div class="h-[400px] w-full">
<FontVirtualList {...args}>
{#snippet children({ item })}
<div class="border-b border-neutral-100 p-3">{item.name}</div>
{/snippet}
</FontVirtualList>
</div>
{/snippet}
</Story>
<Story
name="With Item Renderer"
parameters={{
docs: {
description: {
story:
'Demonstrates how to configure a `children` snippet for item rendering. The list will be empty because `fontStore` is not populated in Storybook, but the template shows the expected slot shape: `{ item: UnifiedFont }`.',
},
},
}}
args={{ weight: 400, itemHeight: 80 }}
>
{#snippet template(args: ComponentProps<typeof FontVirtualList>)}
<div class="h-[400px] w-full">
<FontVirtualList {...args}>
{#snippet skeleton()}
<div class="flex flex-col gap-2 p-4">
{#each Array(6) as _}
<div class="h-16 animate-pulse rounded bg-neutral-200"></div>
{/each}
</div>
{/snippet}
{#snippet children({ item })}
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3">
<span class="text-sm font-medium">{item.name}</span>
<span class="text-xs text-neutral-400">{item.category}</span>
</div>
{/snippet}
</FontVirtualList>
</div>
{/snippet}
</Story>
@@ -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');
});
});
});
@@ -35,6 +35,7 @@ const { Story } = defineMeta({
<script lang="ts"> <script lang="ts">
import type { UnifiedFont } from '$entities/Font'; import type { UnifiedFont } from '$entities/Font';
import type { ComponentProps } from 'svelte';
// Mock fonts for testing // Mock fonts for testing
const mockArial: UnifiedFont = { const mockArial: UnifiedFont = {
@@ -88,7 +89,7 @@ const mockGeorgia: UnifiedFont = {
index: 0, index: 0,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers> <Providers>
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<FontSampler {...args} /> <FontSampler {...args} />
@@ -105,7 +106,7 @@ const mockGeorgia: UnifiedFont = {
index: 1, index: 1,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof FontSampler>)}
<Providers> <Providers>
<div class="max-w-2xl mx-auto"> <div class="max-w-2xl mx-auto">
<FontSampler {...args} /> <FontSampler {...args} />
+1
View File
@@ -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,26 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Filters from './Filters.svelte';
const { Story } = defineMeta({
title: 'Features/Filters',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Renders the full list of filter groups managed by filterManager. Each group maps to a collapsible FilterGroup with checkboxes. No props — reads directly from the filterManager singleton.',
},
story: { inline: false },
},
layout: 'padded',
},
argTypes: {},
});
</script>
<Story name="Default">
{#snippet template()}
<Filters />
{/snippet}
</Story>
@@ -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();
});
});
});
@@ -0,0 +1,39 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import FilterControls from './FilterControls.svelte';
const { Story } = defineMeta({
title: 'Features/FilterControls',
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Sort options and Reset_Filters button rendered below the filter list. Reads sort state from sortStore and dispatches resets via filterManager. Requires responsive context — wrap with Providers.',
},
story: { inline: false },
},
layout: 'padded',
},
argTypes: {},
});
</script>
<Story name="Default">
{#snippet template()}
<Providers>
<FilterControls />
</Providers>
{/snippet}
</Story>
<Story name="Mobile layout">
{#snippet template()}
<Providers>
<div style="width: 375px;">
<FilterControls />
</div>
</Providers>
{/snippet}
</Story>
@@ -0,0 +1,45 @@
<script module>
import Providers from '$shared/lib/storybook/Providers.svelte';
import { defineMeta } from '@storybook/addon-svelte-csf';
import TypographyMenu from './TypographyMenu.svelte';
const { Story } = defineMeta({
title: 'Features/TypographyMenu',
component: TypographyMenu,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Floating typography controls. Mobile/tablet: settings button that opens a popover. Desktop: inline bar with combo controls.',
},
story: { inline: false },
},
layout: 'centered',
storyStage: { maxWidth: 'max-w-xl' },
},
argTypes: {
hidden: { control: 'boolean' },
},
});
</script>
<Story name="Desktop">
{#snippet template()}
<Providers>
<div class="relative h-20 flex items-end justify-center p-4">
<TypographyMenu />
</div>
</Providers>
{/snippet}
</Story>
<Story name="Hidden">
{#snippet template()}
<Providers>
<div class="relative h-20 flex items-end justify-center p-4">
<TypographyMenu hidden={true} />
</div>
</Providers>
{/snippet}
</Story>
@@ -1,7 +1,6 @@
<!-- <!--
Component: TypographyMenu Component: TypographyMenu
Floating controls bar for typography settings. Floating controls bar for typography settings.
Warm surface, sharp corners, Settings icon header, dividers between units.
Mobile: popover with slider controls anchored to settings button. Mobile: popover with slider controls anchored to settings button.
Desktop: inline bar with combo controls. Desktop: inline bar with combo controls.
--> -->
@@ -13,6 +12,7 @@ import {
} from '$entities/Font'; } from '$entities/Font';
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { import {
Button,
ComboControl, ComboControl,
ControlGroup, ControlGroup,
Slider, Slider,
@@ -36,14 +36,17 @@ interface Props {
* @default false * @default false
*/ */
hidden?: boolean; hidden?: boolean;
/**
* Bindable popover open state
* @default false
*/
open?: boolean;
} }
const { class: className, hidden = false }: Props = $props(); let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
let isOpen = $state(false);
/** /**
* Sets the common font size multiplier based on the current responsive state. * Sets the common font size multiplier based on the current responsive state.
*/ */
@@ -68,32 +71,22 @@ $effect(() => {
</script> </script>
{#if !hidden} {#if !hidden}
{#if responsive.isMobile} {#if responsive.isMobileOrTablet}
<Popover.Root bind:open={isOpen}> <Popover.Root bind:open>
<Popover.Trigger> <Popover.Trigger>
{#snippet child({ props })} {#snippet child({ props })}
<button <Button class={className} variant="primary" {...props}>
{...props} {#snippet icon()}
class={clsx(
'inline-flex items-center justify-center',
'size-8 p-0',
'border border-transparent rounded-none',
'transition-colors duration-150',
'hover:bg-white/50 dark:hover:bg-white/5',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
isOpen && 'bg-paper dark:bg-dark-card border-subtle shadow-sm',
className,
)}
>
<Settings2Icon class="size-4" /> <Settings2Icon class="size-4" />
</button> {/snippet}
</Button>
{/snippet} {/snippet}
</Popover.Trigger> </Popover.Trigger>
<Popover.Portal> <Popover.Portal>
<Popover.Content <Popover.Content
side="top" side="top"
align="start" align="end"
sideOffset={8} sideOffset={8}
class={clsx( class={clsx(
'z-50 w-72', 'z-50 w-72',
+2 -2
View File
@@ -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>
@@ -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 (0100) of `containerWidth`. * @param sliderPos Current slider position as a percentage (0100) 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;
@@ -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');
}
}); });
}); });
+1 -25
View File
@@ -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`;
}
+1
View File
@@ -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';
+80
View File
@@ -0,0 +1,80 @@
import {
render,
screen,
} from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import Badge from './Badge.svelte';
function textSnippet(text: string) {
return createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));
}
describe('Badge', () => {
describe('Rendering', () => {
it('renders text content', () => {
render(Badge, { children: textSnippet('v1.0') });
expect(screen.getByText('v1.0')).toBeInTheDocument();
});
it('renders as a span element', () => {
const { container } = render(Badge, { children: textSnippet('test') });
expect(container.querySelector('span')).toBeInTheDocument();
});
it('renders nothing extra when no children', () => {
const { container } = render(Badge);
const span = container.querySelector('span');
expect(span).toBeInTheDocument();
expect(span?.textContent?.trim()).toBe('');
});
});
describe('Dot', () => {
it('renders status dot when dot=true', () => {
const { container } = render(Badge, {
children: textSnippet('live'),
dot: true,
});
const dots = container.querySelectorAll('span > span');
expect(dots.length).toBeGreaterThan(0);
});
it('does not render dot by default', () => {
const { container } = render(Badge, { children: textSnippet('live') });
const innerSpans = container.querySelectorAll('span > span');
expect(innerSpans).toHaveLength(1); // only the children span
});
});
describe('Variants', () => {
it.each(['default', 'accent', 'success', 'warning', 'info'] as const)(
'renders %s variant without error',
variant => {
render(Badge, { children: textSnippet('label'), variant });
expect(screen.getByText('label')).toBeInTheDocument();
},
);
});
describe('Sizes', () => {
it.each(['xs', 'sm', 'md', 'lg'] as const)('renders %s size without error', size => {
render(Badge, { children: textSnippet('label'), size });
expect(screen.getByText('label')).toBeInTheDocument();
});
});
describe('Nowrap', () => {
it('applies nowrap when nowrap=true', () => {
const { container } = render(Badge, {
children: textSnippet('no wrap text'),
nowrap: true,
});
expect(container.querySelector('span')).toHaveClass('text-nowrap');
});
it('does not apply nowrap by default', () => {
const { container } = render(Badge, { children: textSnippet('wrappable') });
expect(container.querySelector('span')).not.toHaveClass('text-nowrap');
});
});
});
+7 -6
View File
@@ -45,6 +45,7 @@ const { Story } = defineMeta({
<script lang="ts"> <script lang="ts">
import XIcon from '@lucide/svelte/icons/x'; import XIcon from '@lucide/svelte/icons/x';
import type { ComponentProps } from 'svelte';
import ButtonGroup from './ButtonGroup.svelte'; import ButtonGroup from './ButtonGroup.svelte';
</script> </script>
@@ -52,7 +53,7 @@ import ButtonGroup from './ButtonGroup.svelte';
name="Default/Basic" name="Default/Basic"
parameters={{ docs: { description: { story: 'Standard text button at all sizes' } } }} parameters={{ docs: { description: { story: 'Standard text button at all sizes' } } }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Button>)}
<ButtonGroup> <ButtonGroup>
<Button {...args} size="xs">xs</Button> <Button {...args} size="xs">xs</Button>
<Button {...args} size="sm">sm</Button> <Button {...args} size="sm">sm</Button>
@@ -67,7 +68,7 @@ import ButtonGroup from './ButtonGroup.svelte';
name="Default/With Icon" name="Default/With Icon"
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }} args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Button>)}
<Button {...args}> <Button {...args}>
{#snippet icon()} {#snippet icon()}
<XIcon /> <XIcon />
@@ -81,7 +82,7 @@ import ButtonGroup from './ButtonGroup.svelte';
name="Primary" name="Primary"
args={{ variant: 'primary', size: 'md', iconPosition: 'left', active: false, animate: true }} args={{ variant: 'primary', size: 'md', iconPosition: 'left', active: false, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Button>)}
<Button {...args}>Primary</Button> <Button {...args}>Primary</Button>
{/snippet} {/snippet}
</Story> </Story>
@@ -90,7 +91,7 @@ import ButtonGroup from './ButtonGroup.svelte';
name="Secondary" name="Secondary"
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }} args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Button>)}
<Button {...args}>Secondary</Button> <Button {...args}>Secondary</Button>
{/snippet} {/snippet}
</Story> </Story>
@@ -99,7 +100,7 @@ import ButtonGroup from './ButtonGroup.svelte';
name="Icon" name="Icon"
args={{ variant: 'icon', size: 'md', iconPosition: 'left', active: false, animate: true }} args={{ variant: 'icon', size: 'md', iconPosition: 'left', active: false, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Button>)}
<Button {...args}> <Button {...args}>
{#snippet icon()} {#snippet icon()}
<XIcon /> <XIcon />
@@ -112,7 +113,7 @@ import ButtonGroup from './ButtonGroup.svelte';
name="Ghost" name="Ghost"
args={{ variant: 'ghost', size: 'md', iconPosition: 'left', active: false, animate: true }} args={{ variant: 'ghost', size: 'md', iconPosition: 'left', active: false, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Button>)}
<Button {...args}> <Button {...args}>
Ghost Ghost
</Button> </Button>
@@ -0,0 +1,89 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import Button from './Button.svelte';
/**
* Create a plain text snippet for passing as children/icon prop
*/
function textSnippet(text: string) {
return createRawSnippet(() => ({
render: () => `<span>${text}</span>`,
}));
}
describe('Button', () => {
describe('Rendering', () => {
it('renders with text label', () => {
render(Button, { children: textSnippet('Click me') });
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('has type="button" by default', () => {
render(Button, { children: textSnippet('Submit') });
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
});
it('renders with custom type', () => {
render(Button, { children: textSnippet('Submit'), type: 'submit' });
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});
it('renders icon-only when icon snippet is provided without children', () => {
render(Button, { icon: textSnippet('×') });
const btn = screen.getByRole('button');
expect(btn).toBeInTheDocument();
});
});
describe('Disabled state', () => {
it('is not disabled by default', () => {
render(Button, { children: textSnippet('Click') });
expect(screen.getByRole('button')).not.toBeDisabled();
});
it('is disabled when disabled prop is true', () => {
render(Button, { children: textSnippet('Click'), disabled: true });
expect(screen.getByRole('button')).toBeDisabled();
});
});
describe('Click interaction', () => {
it('calls onclick when clicked', async () => {
const handler = vi.fn();
render(Button, { children: textSnippet('Click'), onclick: handler });
await fireEvent.click(screen.getByRole('button'));
expect(handler).toHaveBeenCalledOnce();
});
it('calls onclick multiple times on repeated clicks', async () => {
const handler = vi.fn();
render(Button, { children: textSnippet('Click'), onclick: handler });
const btn = screen.getByRole('button');
await fireEvent.click(btn);
await fireEvent.click(btn);
await fireEvent.click(btn);
expect(handler).toHaveBeenCalledTimes(3);
});
});
describe('Variants', () => {
it.each(['primary', 'secondary', 'outline', 'ghost', 'icon', 'tertiary'] as const)(
'renders %s variant without error',
variant => {
render(Button, { children: textSnippet('Click'), variant });
expect(screen.getByRole('button')).toBeInTheDocument();
},
);
});
describe('Sizes', () => {
it.each(['xs', 'sm', 'md', 'lg', 'xl'] as const)('renders %s size without error', size => {
render(Button, { children: textSnippet('Click'), size });
expect(screen.getByRole('button')).toBeInTheDocument();
});
});
});
@@ -27,10 +27,11 @@ const { Story } = defineMeta({
<script lang="ts"> <script lang="ts">
import { Button } from '$shared/ui/Button'; import { Button } from '$shared/ui/Button';
import type { ComponentProps } from 'svelte';
</script> </script>
<Story name="Default"> <Story name="Default">
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ButtonGroup>)}
<ButtonGroup {...args}> <ButtonGroup {...args}>
<Button variant="tertiary">Option 1</Button> <Button variant="tertiary">Option 1</Button>
<Button variant="tertiary">Option 2</Button> <Button variant="tertiary">Option 2</Button>
@@ -40,7 +41,7 @@ import { Button } from '$shared/ui/Button';
</Story> </Story>
<Story name="Horizontal"> <Story name="Horizontal">
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ButtonGroup>)}
<ButtonGroup {...args}> <ButtonGroup {...args}>
<Button variant="tertiary">Day</Button> <Button variant="tertiary">Day</Button>
<Button variant="tertiary" active>Week</Button> <Button variant="tertiary" active>Week</Button>
@@ -51,7 +52,7 @@ import { Button } from '$shared/ui/Button';
</Story> </Story>
<Story name="Vertical"> <Story name="Vertical">
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ButtonGroup>)}
<ButtonGroup {...args} class="flex-col"> <ButtonGroup {...args} class="flex-col">
<Button variant="tertiary">Top</Button> <Button variant="tertiary">Top</Button>
<Button variant="tertiary" active>Middle</Button> <Button variant="tertiary" active>Middle</Button>
@@ -61,7 +62,7 @@ import { Button } from '$shared/ui/Button';
</Story> </Story>
<Story name="With Icons"> <Story name="With Icons">
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ButtonGroup>)}
<ButtonGroup {...args}> <ButtonGroup {...args}>
<Button variant="tertiary">Grid</Button> <Button variant="tertiary">Grid</Button>
<Button variant="tertiary" active>List</Button> <Button variant="tertiary" active>List</Button>
@@ -78,7 +79,7 @@ import { Button } from '$shared/ui/Button';
}, },
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ButtonGroup>)}
<div class="p-8 bg-background text-foreground"> <div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3> <h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<ButtonGroup {...args}> <ButtonGroup {...args}>
+2 -2
View File
@@ -1,6 +1,6 @@
<!-- <!--
Component: SwissButtonGroup Component: ButtonGroup
Wraps SwissButtons in a warm-surface pill with a 1px gap and subtle border. Wraps buttons in a warm-surface pill with a small gap and subtle border.
Use for segmented controls, view toggles, or any mutually exclusive button set. Use for segmented controls, view toggles, or any mutually exclusive button set.
--> -->
<script lang="ts"> <script lang="ts">
@@ -43,13 +43,14 @@ const { Story } = defineMeta({
import MoonIcon from '@lucide/svelte/icons/moon'; import MoonIcon from '@lucide/svelte/icons/moon';
import SearchIcon from '@lucide/svelte/icons/search'; import SearchIcon from '@lucide/svelte/icons/search';
import TrashIcon from '@lucide/svelte/icons/trash-2'; import TrashIcon from '@lucide/svelte/icons/trash-2';
import type { ComponentProps } from 'svelte';
</script> </script>
<Story <Story
name="Default" name="Default"
args={{ variant: 'icon', size: 'md', active: false, animate: true }} args={{ variant: 'icon', size: 'md', active: false, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof IconButton>)}
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<IconButton {...args}> <IconButton {...args}>
{#snippet icon()} {#snippet icon()}
@@ -74,7 +75,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2';
name="Variants" name="Variants"
args={{ size: 'md', active: false, animate: true }} args={{ size: 'md', active: false, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof IconButton>)}
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<IconButton {...args} variant="icon"> <IconButton {...args} variant="icon">
{#snippet icon()} {#snippet icon()}
@@ -99,7 +100,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2';
name="Active State" name="Active State"
args={{ size: 'md', animate: true }} args={{ size: 'md', animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof IconButton>)}
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<IconButton {...args} active={false} variant="icon"> <IconButton {...args} active={false} variant="icon">
{#snippet icon()} {#snippet icon()}
@@ -123,7 +124,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2';
}, },
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof IconButton>)}
<div class="p-8 bg-background text-foreground"> <div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3> <h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -44,6 +44,7 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import type { ComponentProps } from 'svelte';
let selected = $state(false); let selected = $state(false);
</script> </script>
@@ -51,7 +52,7 @@ let selected = $state(false);
name="Default" name="Default"
args={{ variant: 'tertiary', size: 'md', selected: false, animate: true }} args={{ variant: 'tertiary', size: 'md', selected: false, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ToggleButton>)}
<ToggleButton {...args}>Toggle Me</ToggleButton> <ToggleButton {...args}>Toggle Me</ToggleButton>
{/snippet} {/snippet}
</Story> </Story>
@@ -60,7 +61,7 @@ let selected = $state(false);
name="Selected/Unselected" name="Selected/Unselected"
args={{ variant: 'tertiary', size: 'md', animate: true }} args={{ variant: 'tertiary', size: 'md', animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ToggleButton>)}
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<ToggleButton {...args} selected={false}> <ToggleButton {...args} selected={false}>
Unselected Unselected
@@ -76,7 +77,7 @@ let selected = $state(false);
name="Variants" name="Variants"
args={{ size: 'md', selected: true, animate: true }} args={{ size: 'md', selected: true, animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ToggleButton>)}
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<ToggleButton {...args} variant="primary"> <ToggleButton {...args} variant="primary">
Primary Primary
@@ -101,9 +102,15 @@ let selected = $state(false);
name="Interactive" name="Interactive"
args={{ variant: 'tertiary', size: 'md', animate: true }} args={{ variant: 'tertiary', size: 'md', animate: true }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ToggleButton>)}
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<ToggleButton {...args} selected={selected} onclick={() => selected = !selected}> <ToggleButton
{...args}
selected={selected}
onclick={() => {
selected = !selected;
}}
>
Click to toggle Click to toggle
</ToggleButton> </ToggleButton>
<span class="text-sm text-muted-foreground">Currently: {selected ? 'selected' : 'unselected'}</span> <span class="text-sm text-muted-foreground">Currently: {selected ? 'selected' : 'unselected'}</span>
@@ -119,7 +126,7 @@ let selected = $state(false);
}, },
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ToggleButton>)}
<div class="p-8 bg-background text-foreground"> <div class="p-8 bg-background text-foreground">
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3> <h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
@@ -30,6 +30,7 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import type { ComponentProps } from 'svelte';
const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 }); const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
</script> </script>
@@ -40,7 +41,7 @@ const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, v
label: 'Size', label: 'Size',
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ComboControl>)}
<ComboControl {...args} /> <ComboControl {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -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);
}); });
}); });
}); });
@@ -37,6 +37,8 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import type { ComponentProps } from 'svelte';
let value = $state('Here we can type and edit the content. Try it!'); let value = $state('Here we can type and edit the content. Try it!');
let smallValue = $state('Small font size for compact text.'); let smallValue = $state('Small font size for compact text.');
let largeValue = $state('Large font size for emphasis.'); let largeValue = $state('Large font size for emphasis.');
@@ -55,7 +57,7 @@ let longValue = $state(
letterSpacing: 0, letterSpacing: 0,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ContentEditable>)}
<ContentEditable {...args} /> <ContentEditable {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -69,7 +71,7 @@ let longValue = $state(
letterSpacing: 0, letterSpacing: 0,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ContentEditable>)}
<ContentEditable {...args} /> <ContentEditable {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -83,7 +85,7 @@ let longValue = $state(
letterSpacing: 0, letterSpacing: 0,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ContentEditable>)}
<ContentEditable {...args} /> <ContentEditable {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -97,7 +99,7 @@ let longValue = $state(
letterSpacing: 0.3, letterSpacing: 0.3,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ContentEditable>)}
<ContentEditable {...args} /> <ContentEditable {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -111,7 +113,7 @@ let longValue = $state(
letterSpacing: 0, letterSpacing: 0,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof ContentEditable>)}
<ContentEditable {...args} /> <ContentEditable {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -0,0 +1,53 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ControlGroup from './ControlGroup.svelte';
const { Story } = defineMeta({
title: 'Shared/ControlGroup',
component: ControlGroup,
tags: ['autodocs'],
parameters: {
docs: {
description: { component: 'Labelled section for grouping related sidebar controls, with a bottom border.' },
story: { inline: false },
},
layout: 'padded',
},
argTypes: {
label: {
control: 'text',
description: 'Uppercase label shown above the control content',
},
class: {
control: 'text',
description: 'Additional CSS classes',
},
},
});
</script>
<Story name="Default/Basic">
{#snippet template()}
<div class="w-64">
<ControlGroup label="Font Size">
<div class="text-sm text-neutral-500">Control content here</div>
</ControlGroup>
</div>
{/snippet}
</Story>
<Story name="With form control">
{#snippet template()}
<div class="w-64">
<ControlGroup label="Weight">
<div class="flex gap-2">
<button class="px-3 py-1 text-xs border border-neutral-300 rounded">100</button>
<button class="px-3 py-1 text-xs border border-neutral-300 rounded">400</button>
<button class="px-3 py-1 text-xs border border-neutral-300 rounded bg-neutral-900 text-white">
700
</button>
</div>
</ControlGroup>
</div>
{/snippet}
</Story>
@@ -0,0 +1,21 @@
import { render } from '@testing-library/svelte';
import Divider from './Divider.svelte';
describe('Divider', () => {
it('renders horizontal by default', () => {
const { container } = render(Divider);
const el = container.querySelector('div');
expect(el).toHaveClass('w-full', 'h-px');
});
it('renders vertical when orientation="vertical"', () => {
const { container } = render(Divider, { orientation: 'vertical' });
const el = container.querySelector('div');
expect(el).toHaveClass('w-px', 'h-full');
});
it('passes additional class', () => {
const { container } = render(Divider, { class: 'my-custom' });
expect(container.querySelector('div')).toHaveClass('my-custom');
});
});
@@ -29,6 +29,7 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import type { ComponentProps } from 'svelte';
const defaultFilter = createFilter({ const defaultFilter = createFilter({
properties: [{ properties: [{
id: 'cats', id: 'cats',
@@ -64,14 +65,20 @@ const selectedFilter = createFilter({
}); });
</script> </script>
<Story name="Default"> <Story
{#snippet template(args)} name="Default"
<FilterGroup filter={defaultFilter} displayedLabel="Zoo" {...args} /> args={{ filter: defaultFilter, displayedLabel: 'Zoo' }}
>
{#snippet template(args: ComponentProps<typeof FilterGroup>)}
<FilterGroup {...args} />
{/snippet} {/snippet}
</Story> </Story>
<Story name="Selected"> <Story
{#snippet template(args)} name="Selected"
<FilterGroup filter={selectedFilter} displayedLabel="Shopping list" {...args} /> args={{ filter: selectedFilter, displayedLabel: 'Shopping list' }}
>
{#snippet template(args: ComponentProps<typeof FilterGroup>)}
<FilterGroup {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -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);
});
}); });
}); });
}); });
@@ -16,8 +16,12 @@ const { Story } = defineMeta({
}); });
</script> </script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story name="Default"> <Story name="Default">
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Footnote>)}
<Footnote {...args}> <Footnote {...args}>
Footnote Footnote
</Footnote> </Footnote>
@@ -25,7 +29,7 @@ const { Story } = defineMeta({
</Story> </Story>
<Story name="With custom render"> <Story name="With custom render">
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Footnote>)}
<Footnote {...args}> <Footnote {...args}>
{#snippet render({ class: className })} {#snippet render({ class: className })}
<span class={className}>Footnote</span> <span class={className}>Footnote</span>
@@ -0,0 +1,33 @@
import {
render,
screen,
} from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import Footnote from './Footnote.svelte';
function textSnippet(text: string) {
return createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));
}
describe('Footnote', () => {
it('renders children content in a span', () => {
render(Footnote, { children: textSnippet('* measured at 20°C') });
expect(screen.getByText('* measured at 20°C')).toBeInTheDocument();
});
it('renders nothing when no children or render', () => {
const { container } = render(Footnote);
expect(container.firstElementChild).toBeNull();
});
it('passes class to the render snippet', () => {
const renderSnippet = createRawSnippet<[{ class: string }]>(getParams => ({
render: () => {
const cls = getParams().class;
return `<span class="${cls}">note</span>`;
},
}));
const { container } = render(Footnote, { render: renderSnippet });
expect(container.querySelector('span')?.className).toContain('font-mono');
});
});
+18 -17
View File
@@ -61,42 +61,43 @@ const { Story } = defineMeta({
<script lang="ts"> <script lang="ts">
import SearchIcon from '@lucide/svelte/icons/search'; import SearchIcon from '@lucide/svelte/icons/search';
import ClearIcon from '@lucide/svelte/icons/x'; import ClearIcon from '@lucide/svelte/icons/x';
import type { ComponentProps } from 'svelte';
let value = $state(''); let value = $state('');
const placeholder = 'Enter text'; const placeholder = 'Enter text';
</script> </script>
<!-- Default Story (Left Aligned) --> <!-- Default Story (Left Aligned) -->
<Story name="Default" args={{ placeholder, value }}> <Story name="Default" args={{ placeholder, value }}>
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Input>)}
<Input {...args} /> <Input {...args} />
{/snippet} {/snippet}
</Story> </Story>
<Story name="All sizes" args={{ value }}> <Story name="All sizes" args={{ value }}>
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Input>)}
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<Input size="sm" placeholder="Size sm" {...args} /> <Input {...args} size="sm" placeholder="Size sm" />
<Input size="md" placeholder="Size md" {...args} /> <Input {...args} size="md" placeholder="Size md" />
<Input size="lg" placeholder="Size lg" {...args} /> <Input {...args} size="lg" placeholder="Size lg" />
<Input size="xl" placeholder="Size xl" {...args} /> <Input {...args} size="xl" placeholder="Size xl" />
</div> </div>
{/snippet} {/snippet}
</Story> </Story>
<Story name="Underlined" args={{ placeholder, value }}> <Story name="Underlined" args={{ placeholder, value, variant: 'underline' }}>
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Input>)}
<Input variant="underline" {...args} /> <Input {...args} />
{/snippet} {/snippet}
</Story> </Story>
<Story name="Filled" args={{ placeholder, value }}> <Story name="Filled" args={{ placeholder, value, variant: 'filled' }}>
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Input>)}
<Input variant="filled" {...args} /> <Input {...args} />
{/snippet} {/snippet}
</Story> </Story>
<Story name="With icon on the right" args={{ placeholder, value }}> <Story name="With icon on the right" args={{ placeholder, value }}>
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Input>)}
<Input {...args}> <Input {...args}>
{#snippet rightIcon()} {#snippet rightIcon()}
<SearchIcon /> <SearchIcon />
@@ -106,7 +107,7 @@ const placeholder = 'Enter text';
</Story> </Story>
<Story name="With icon on the left" args={{ placeholder, value }}> <Story name="With icon on the left" args={{ placeholder, value }}>
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Input>)}
<Input {...args}> <Input {...args}>
{#snippet leftIcon()} {#snippet leftIcon()}
<SearchIcon /> <SearchIcon />
@@ -115,9 +116,9 @@ const placeholder = 'Enter text';
{/snippet} {/snippet}
</Story> </Story>
<Story name="With clear button" args={{ placeholder, value }}> <Story name="With clear button" args={{ placeholder, value, showClearButton: true }}>
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Input>)}
<Input showClearButton {...args}> <Input {...args}>
{#snippet rightIcon()} {#snippet rightIcon()}
<ClearIcon /> <ClearIcon />
{/snippet} {/snippet}
+108
View File
@@ -0,0 +1,108 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import Input from './Input.svelte';
describe('Input', () => {
describe('Rendering', () => {
it('renders an input element', () => {
render(Input);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders placeholder text', () => {
render(Input, { placeholder: 'Search fonts…' });
expect(screen.getByPlaceholderText('Search fonts…')).toBeInTheDocument();
});
it('renders helper text when provided', () => {
render(Input, { helperText: 'Enter a font name' });
expect(screen.getByText('Enter a font name')).toBeInTheDocument();
});
it('does not render helper text by default', () => {
render(Input);
const input = screen.getByRole('textbox');
expect(input.closest('div')?.parentElement?.querySelector('span')).toBeNull();
});
});
describe('Value', () => {
it('renders with initial value', () => {
render(Input, { value: 'Roboto' });
expect(screen.getByDisplayValue('Roboto')).toBeInTheDocument();
});
it('updates displayed value on user input', async () => {
render(Input);
const input = screen.getByRole('textbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'Inter' } });
expect(input.value).toBe('Inter');
});
});
describe('Disabled state', () => {
it('is not disabled by default', () => {
render(Input);
expect(screen.getByRole('textbox')).not.toBeDisabled();
});
it('is disabled when disabled prop is true', () => {
render(Input, { disabled: true });
expect(screen.getByRole('textbox')).toBeDisabled();
});
});
describe('Error state', () => {
it('renders helper text with error styling when error=true', () => {
const { container } = render(Input, { error: true, helperText: 'Invalid value' });
const helperSpan = container.querySelector('span');
expect(helperSpan).toBeInTheDocument();
expect(helperSpan).toHaveClass('text-brand');
});
});
describe('Clear button', () => {
it('does not show clear button by default', () => {
render(Input, { value: 'Roboto' });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
it('shows clear button when showClearButton=true and value is set and onclear is provided', () => {
render(Input, {
value: 'Roboto',
showClearButton: true,
onclear: vi.fn(),
});
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('calls onclear when clear button is clicked', async () => {
const onclear = vi.fn();
render(Input, { value: 'Roboto', showClearButton: true, onclear });
await fireEvent.click(screen.getByRole('button'));
expect(onclear).toHaveBeenCalledOnce();
});
it('hides clear button when value is empty', () => {
render(Input, { value: '', showClearButton: true, onclear: vi.fn() });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});
describe('Variants', () => {
it.each(['default', 'underline', 'filled'] as const)('renders %s variant without error', variant => {
render(Input, { variant });
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
});
describe('Sizes', () => {
it.each(['sm', 'md', 'lg', 'xl'] as const)('renders %s size without error', size => {
render(Input, { size });
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
});
});
+13 -12
View File
@@ -47,13 +47,14 @@ const { Story } = defineMeta({
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle'; import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
import CheckIcon from '@lucide/svelte/icons/check'; import CheckIcon from '@lucide/svelte/icons/check';
import CircleIcon from '@lucide/svelte/icons/circle'; import CircleIcon from '@lucide/svelte/icons/circle';
import type { ComponentProps } from 'svelte';
</script> </script>
<Story <Story
name="Default/Basic" name="Default/Basic"
parameters={{ docs: { description: { story: 'Standard label with default styling' } } }} parameters={{ docs: { description: { story: 'Standard label with default styling' } } }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Default Label</Label> <Label {...args}>Default Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -72,7 +73,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Default variant" name="Default variant"
args={{ variant: 'default', size: 'sm' }} args={{ variant: 'default', size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Default Label</Label> <Label {...args}>Default Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -81,7 +82,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Accent variant" name="Accent variant"
args={{ variant: 'accent', size: 'sm' }} args={{ variant: 'accent', size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Accent Label</Label> <Label {...args}>Accent Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -90,7 +91,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Muted variant" name="Muted variant"
args={{ variant: 'muted', size: 'sm' }} args={{ variant: 'muted', size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Muted Label</Label> <Label {...args}>Muted Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -99,7 +100,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Success variant" name="Success variant"
args={{ variant: 'success', size: 'sm' }} args={{ variant: 'success', size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Success Label</Label> <Label {...args}>Success Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -108,7 +109,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Warning variant" name="Warning variant"
args={{ variant: 'warning', size: 'sm' }} args={{ variant: 'warning', size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Warning Label</Label> <Label {...args}>Warning Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -117,7 +118,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Error variant" name="Error variant"
args={{ variant: 'error', size: 'sm' }} args={{ variant: 'error', size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Error Label</Label> <Label {...args}>Error Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -139,7 +140,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Uppercase" name="Uppercase"
args={{ uppercase: true, size: 'sm' }} args={{ uppercase: true, size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Uppercase Label</Label> <Label {...args}>Uppercase Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -148,7 +149,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Lowercase" name="Lowercase"
args={{ uppercase: false, size: 'sm' }} args={{ uppercase: false, size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>lowercase label</Label> <Label {...args}>lowercase label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -157,7 +158,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="Bold" name="Bold"
args={{ bold: true, size: 'sm' }} args={{ bold: true, size: 'sm' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}>Bold Label</Label> <Label {...args}>Bold Label</Label>
{/snippet} {/snippet}
</Story> </Story>
@@ -166,7 +167,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="With icon (left)" name="With icon (left)"
args={{ variant: 'default', size: 'sm', iconPosition: 'left' }} args={{ variant: 'default', size: 'sm', iconPosition: 'left' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}> <Label {...args}>
{#snippet icon()} {#snippet icon()}
<CircleIcon size={10} /> <CircleIcon size={10} />
@@ -180,7 +181,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
name="With icon (right)" name="With icon (right)"
args={{ variant: 'default', size: 'sm', iconPosition: 'right' }} args={{ variant: 'default', size: 'sm', iconPosition: 'right' }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Label>)}
<Label {...args}> <Label {...args}>
Label with icon Label with icon
{#snippet icon()} {#snippet icon()}
+69
View File
@@ -0,0 +1,69 @@
import {
render,
screen,
} from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import Label from './Label.svelte';
function textSnippet(text: string) {
return createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));
}
describe('Label', () => {
it('renders text content', () => {
render(Label, { children: textSnippet('Category') });
expect(screen.getByText('Category')).toBeInTheDocument();
});
it('renders as a span element', () => {
const { container } = render(Label, { children: textSnippet('test') });
expect(container.querySelector('span')).toBeInTheDocument();
});
it('uppercases text by default', () => {
const { container } = render(Label, { children: textSnippet('hello') });
expect(container.querySelector('span')).toHaveClass('uppercase');
});
it('removes uppercase when uppercase=false', () => {
const { container } = render(Label, { children: textSnippet('hello'), uppercase: false });
expect(container.querySelector('span')).not.toHaveClass('uppercase');
});
it('applies bold when bold=true', () => {
const { container } = render(Label, { children: textSnippet('hello'), bold: true });
expect(container.querySelector('span')).toHaveClass('font-bold');
});
it('renders icon on the left by default', () => {
const { container } = render(Label, {
children: textSnippet('label'),
icon: textSnippet('★'),
});
const spans = container.querySelectorAll('span > span');
expect(spans[0]?.textContent).toContain('★');
});
it('renders icon on the right when iconPosition="right"', () => {
const { container } = render(Label, {
children: textSnippet('label'),
icon: textSnippet('★'),
iconPosition: 'right',
});
const spans = container.querySelectorAll('span > span');
expect(spans[spans.length - 1]?.textContent).toContain('★');
});
it.each(['default', 'accent', 'muted', 'success', 'warning', 'error'] as const)(
'renders %s variant without error',
variant => {
render(Label, { children: textSnippet('test'), variant });
expect(screen.getAllByText('test')[0]).toBeInTheDocument();
},
);
it.each(['xs', 'sm', 'md', 'lg'] as const)('renders %s size without error', size => {
render(Label, { children: textSnippet('test'), size });
expect(screen.getAllByText('test')[0]).toBeInTheDocument();
});
});
+5 -1
View File
@@ -28,8 +28,12 @@ const { Story } = defineMeta({
}); });
</script> </script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story name="Default"> <Story name="Default">
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Loader>)}
<Loader {...args} /> <Loader {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -0,0 +1,28 @@
import {
render,
screen,
} from '@testing-library/svelte';
import Loader from './Loader.svelte';
describe('Loader', () => {
it('renders the default message', () => {
render(Loader);
expect(screen.getByText('analyzing_data')).toBeInTheDocument();
});
it('renders a custom message', () => {
render(Loader, { message: 'loading_fonts' });
expect(screen.getByText('loading_fonts')).toBeInTheDocument();
});
it('renders the SVG spinner', () => {
const { container } = render(Loader);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('renders the divider', () => {
const { container } = render(Loader);
const divider = container.querySelector('.w-px.h-3');
expect(divider).toBeInTheDocument();
});
});
+5 -1
View File
@@ -16,8 +16,12 @@ const { Story } = defineMeta({
}); });
</script> </script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story name="Default"> <Story name="Default">
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Logo>)}
<Logo {...args} /> <Logo {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -0,0 +1,91 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import PerspectivePlan from './PerspectivePlan.svelte';
const { Story } = defineMeta({
title: 'Shared/PerspectivePlan',
component: PerspectivePlan,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Wrapper applying perspective 3D transform based on a PerspectiveManager spring. Used for front/back stacking in comparison views.',
},
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
region: {
control: 'select',
options: ['full', 'left', 'right'],
},
regionWidth: {
control: 'number',
},
},
});
</script>
<script>
import { createPerspectiveManager } from '$shared/lib';
const frontManager = createPerspectiveManager({ depthStep: 100, scaleStep: 0.5, blurStep: 4 });
const backManager = createPerspectiveManager({ depthStep: 100, scaleStep: 0.5, blurStep: 4 });
backManager.setBack();
const leftManager = createPerspectiveManager({ depthStep: 100, scaleStep: 0.5, blurStep: 4 });
</script>
<Story name="Front State">
{#snippet template()}
<div style="width: 300px; height: 200px; perspective: 800px; position: relative;">
<PerspectivePlan manager={frontManager}>
{#snippet children({ className })}
<div
class={className}
style="width: 300px; height: 200px; background: #1e1e2e; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #cdd6f4; font-family: sans-serif;"
>
Front — fully visible
</div>
{/snippet}
</PerspectivePlan>
</div>
{/snippet}
</Story>
<Story name="Back State">
{#snippet template()}
<div style="width: 300px; height: 200px; perspective: 800px; position: relative;">
<PerspectivePlan manager={backManager}>
{#snippet children({ className })}
<div
class={className}
style="width: 300px; height: 200px; background: #1e1e2e; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #cdd6f4; font-family: sans-serif;"
>
Back — blurred and scaled down
</div>
{/snippet}
</PerspectivePlan>
</div>
{/snippet}
</Story>
<Story name="Left Region">
{#snippet template()}
<div style="width: 300px; height: 200px; perspective: 800px; position: relative;">
<PerspectivePlan manager={leftManager} region="left" regionWidth={50}>
{#snippet children({ className })}
<div
class={className}
style="width: 100%; height: 100%; background: #313244; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #cdd6f4; font-family: sans-serif;"
>
Left half
</div>
{/snippet}
</PerspectivePlan>
</div>
{/snippet}
</Story>
@@ -28,6 +28,8 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import type { ComponentProps } from 'svelte';
let defaultSearchValue = $state(''); let defaultSearchValue = $state('');
</script> </script>
@@ -36,9 +38,10 @@ let defaultSearchValue = $state('');
args={{ args={{
value: defaultSearchValue, value: defaultSearchValue,
placeholder: 'Type here...', placeholder: 'Type here...',
variant: 'filled',
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof SearchBar>)}
<SearchBar variant="filled" {...args} /> <SearchBar {...args} />
{/snippet} {/snippet}
</Story> </Story>
@@ -0,0 +1,45 @@
import {
fireEvent,
render,
screen,
} from '@testing-library/svelte';
import SearchBar from './SearchBar.svelte';
describe('SearchBar', () => {
it('renders an input element', () => {
render(SearchBar);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders placeholder text', () => {
render(SearchBar, { placeholder: 'Search…' });
expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument();
});
it('renders with initial value', () => {
render(SearchBar, { value: 'Roboto' });
expect(screen.getByDisplayValue('Roboto')).toBeInTheDocument();
});
it('renders search icon', () => {
const { container } = render(SearchBar);
expect(container.querySelector('svg')).toBeInTheDocument();
});
it('updates value on user input', async () => {
render(SearchBar);
const input = screen.getByRole('textbox') as HTMLInputElement;
await fireEvent.input(input, { target: { value: 'Inter' } });
expect(input.value).toBe('Inter');
});
it('is not disabled by default', () => {
render(SearchBar);
expect(screen.getByRole('textbox')).not.toBeDisabled();
});
it('is disabled when disabled prop is true', () => {
render(SearchBar, { disabled: true });
expect(screen.getByRole('textbox')).toBeDisabled();
});
});
@@ -0,0 +1,45 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import SectionSeparator from './SectionSeparator.svelte';
const { Story } = defineMeta({
title: 'Shared/SectionSeparator',
component: SectionSeparator,
tags: ['autodocs'],
parameters: {
docs: {
description: { component: 'Full-width horizontal rule for separating page sections.' },
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
class: {
control: 'text',
description: 'Additional CSS classes to merge onto the element',
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story name="Default/Basic" args={{}}>
{#snippet template(args: ComponentProps<typeof SectionSeparator>)}
<div class="w-96">
<SectionSeparator {...args} />
</div>
{/snippet}
</Story>
<Story name="Custom class" args={{ class: 'my-4' }}>
{#snippet template(args: ComponentProps<typeof SectionSeparator>)}
<div class="w-96">
<p class="text-sm text-neutral-500">Above</p>
<SectionSeparator {...args} />
<p class="text-sm text-neutral-500">Below</p>
</div>
{/snippet}
</Story>
@@ -0,0 +1,45 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import SectionTitle from './SectionTitle.svelte';
const { Story } = defineMeta({
title: 'Shared/SectionTitle',
component: SectionTitle,
tags: ['autodocs'],
parameters: {
docs: {
description: { component: 'Large responsive heading for named page sections.' },
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
text: {
control: 'text',
description: 'Heading text; renders nothing when omitted',
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story name="Default/Basic" args={{ text: 'Browse Fonts' }}>
{#snippet template(args: ComponentProps<typeof SectionTitle>)}
<SectionTitle {...args} />
{/snippet}
</Story>
<Story name="Long title" args={{ text: 'Explore Typefaces From Around the World' }}>
{#snippet template(args: ComponentProps<typeof SectionTitle>)}
<SectionTitle {...args} />
{/snippet}
</Story>
<Story name="Empty" args={{}}>
{#snippet template(args: ComponentProps<typeof SectionTitle>)}
<SectionTitle {...args} />
{/snippet}
</Story>
@@ -0,0 +1,102 @@
<script module>
import { Providers } from '$shared/lib/storybook';
import { defineMeta } from '@storybook/addon-svelte-csf';
import SidebarContainer from './SidebarContainer.svelte';
const { Story } = defineMeta({
title: 'Shared/SidebarContainer',
component: SidebarContainer,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Responsive sidebar container. On desktop it collapses to zero width with a CSS transition. On mobile it renders as a slide-in drawer over a backdrop overlay. The `sidebar` prop is a snippet that receives `{ onClose }` so the sidebar content can dismiss itself.',
},
story: { inline: false },
},
layout: 'fullscreen',
},
argTypes: {
isOpen: {
control: 'boolean',
description: 'Whether the sidebar is open (bindable)',
},
class: {
control: 'text',
description: 'Additional CSS classes applied to the desktop wrapper element',
},
},
});
</script>
<Story name="Desktop Open">
{#snippet template()}
<Providers>
<div class="h-64 flex relative overflow-hidden">
<SidebarContainer isOpen={true}>
{#snippet sidebar({ onClose })}
<div class="w-80 h-full bg-white p-4 border-r border-neutral-200 flex flex-col gap-3">
<button
onclick={onClose}
class="self-start text-sm text-neutral-500 hover:text-neutral-900"
>
Close
</button>
<p class="text-sm text-neutral-700">Sidebar Content</p>
</div>
{/snippet}
</SidebarContainer>
<div class="flex-1 p-4 bg-neutral-50 text-sm text-neutral-500">Main content</div>
</div>
</Providers>
{/snippet}
</Story>
<Story name="Desktop Closed">
{#snippet template()}
<Providers>
<div class="h-64 flex relative overflow-hidden">
<SidebarContainer isOpen={false}>
{#snippet sidebar({ onClose })}
<div class="w-80 h-full bg-white p-4 border-r border-neutral-200 flex flex-col gap-3">
<button
onclick={onClose}
class="self-start text-sm text-neutral-500 hover:text-neutral-900"
>
Close
</button>
<p class="text-sm text-neutral-700">Sidebar Content</p>
</div>
{/snippet}
</SidebarContainer>
<div class="flex-1 p-4 bg-neutral-50 text-sm text-neutral-500">
Main content — sidebar is collapsed to zero width
</div>
</div>
</Providers>
{/snippet}
</Story>
<Story name="Mobile Open">
{#snippet template()}
<Providers>
<div class="h-64 relative overflow-hidden">
<SidebarContainer isOpen={true}>
{#snippet sidebar({ onClose })}
<div class="w-80 h-full bg-white p-4 flex flex-col gap-3">
<button
onclick={onClose}
class="self-start text-sm text-neutral-500 hover:text-neutral-900"
>
Close
</button>
<p class="text-sm text-neutral-700">Sidebar Content</p>
</div>
{/snippet}
</SidebarContainer>
<div class="p-4 bg-neutral-50 text-sm text-neutral-500">Main content</div>
</div>
</Providers>
{/snippet}
</Story>
@@ -23,13 +23,17 @@ const { Story } = defineMeta({
}); });
</script> </script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story <Story
name="Default" name="Default"
args={{ args={{
animate: true, animate: true,
}} }}
> >
{#snippet template(args)} {#snippet template(args: ComponentProps<typeof Skeleton>)}
<div class="flex flex-col gap-4 p-4 w-full"> <div class="flex flex-col gap-4 p-4 w-full">
<div class="flex flex-col gap-2 p-4 border rounded-xl border-border-subtle bg-background-40"> <div class="flex flex-col gap-2 p-4 border rounded-xl border-border-subtle bg-background-40">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -0,0 +1,24 @@
import { render } from '@testing-library/svelte';
import Skeleton from './Skeleton.svelte';
describe('Skeleton', () => {
it('renders a div element', () => {
const { container } = render(Skeleton);
expect(container.querySelector('div')).toBeInTheDocument();
});
it('animates by default', () => {
const { container } = render(Skeleton);
expect(container.querySelector('div')).toHaveClass('animate-pulse');
});
it('disables animation when animate=false', () => {
const { container } = render(Skeleton, { animate: false });
expect(container.querySelector('div')).not.toHaveClass('animate-pulse');
});
it('passes additional class', () => {
const { container } = render(Skeleton, { class: 'w-24 h-4' });
expect(container.querySelector('div')).toHaveClass('w-24', 'h-4');
});
});
+56 -10
View File
@@ -37,13 +37,23 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import type { ComponentProps } from 'svelte';
let value = $state(50); let value = $state(50);
let valueLow = $state(25); let valueLow = $state(25);
let valueHigh = $state(75); let valueHigh = $state(75);
</script> </script>
<Story name="Horizontal" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}> <Story
{#snippet template(args)} name="Horizontal"
args={{
orientation: 'horizontal',
min: 0,
max: 100,
step: 1,
value,
}}
>
{#snippet template(args: ComponentProps<typeof Slider>)}
<div class="p-8"> <div class="p-8">
<Slider {...args} /> <Slider {...args} />
<p class="mt-4 text-sm text-muted-foreground">Value: {args.value}</p> <p class="mt-4 text-sm text-muted-foreground">Value: {args.value}</p>
@@ -54,8 +64,17 @@ let valueHigh = $state(75);
{/snippet} {/snippet}
</Story> </Story>
<Story name="Vertical" args={{ orientation: 'vertical', min: 0, max: 100, step: 1, value }}> <Story
{#snippet template(args)} name="Vertical"
args={{
orientation: 'vertical',
min: 0,
max: 100,
step: 1,
value,
}}
>
{#snippet template(args: ComponentProps<typeof Slider>)}
<div class="p-8 flex items-center gap-8 h-72"> <div class="p-8 flex items-center gap-8 h-72">
<Slider {...args} /> <Slider {...args} />
<div> <div>
@@ -66,8 +85,17 @@ let valueHigh = $state(75);
{/snippet} {/snippet}
</Story> </Story>
<Story name="With Label" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}> <Story
{#snippet template(args)} name="With Label"
args={{
orientation: 'horizontal',
min: 0,
max: 100,
step: 1,
value,
}}
>
{#snippet template(args: ComponentProps<typeof Slider>)}
<div class="p-8"> <div class="p-8">
<Slider {...args} /> <Slider {...args} />
<p class="mt-4 text-sm text-muted-foreground">Slider with inline label</p> <p class="mt-4 text-sm text-muted-foreground">Slider with inline label</p>
@@ -75,8 +103,17 @@ let valueHigh = $state(75);
{/snippet} {/snippet}
</Story> </Story>
<Story name="Interactive States" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value: 50 }}> <Story
{#snippet template(args)} name="Interactive States"
args={{
orientation: 'horizontal',
min: 0,
max: 100,
step: 1,
value: 50,
}}
>
{#snippet template(args: ComponentProps<typeof Slider>)}
<div class="p-8 space-y-8"> <div class="p-8 space-y-8">
<div> <div>
<p class="text-sm font-medium mb-2">Thumb: 45° rotated square</p> <p class="text-sm font-medium mb-2">Thumb: 45° rotated square</p>
@@ -103,8 +140,17 @@ let valueHigh = $state(75);
{/snippet} {/snippet}
</Story> </Story>
<Story name="Step Sizes" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}> <Story
{#snippet template(args)} name="Step Sizes"
args={{
orientation: 'horizontal',
min: 0,
max: 100,
step: 1,
value,
}}
>
{#snippet template(args: ComponentProps<typeof Slider>)}
<div class="p-8 space-y-6"> <div class="p-8 space-y-6">
<div> <div>
<p class="text-sm font-medium mb-2">Step: 1 (default)</p> <p class="text-sm font-medium mb-2">Step: 1 (default)</p>
@@ -0,0 +1,62 @@
import {
render,
screen,
} from '@testing-library/svelte';
import Slider from './Slider.svelte';
describe('Slider', () => {
describe('Rendering', () => {
it('renders a slider element', () => {
render(Slider);
expect(screen.getByRole('slider')).toBeInTheDocument();
});
it('displays formatted value', () => {
render(Slider, { value: 50 });
expect(screen.getByText('50')).toBeInTheDocument();
});
it('applies a custom formatter', () => {
const { container } = render(Slider, { value: 25, format: (v: number) => `${v}%` });
expect(container.textContent).toContain('25%');
});
});
describe('Props', () => {
it('respects min and max attributes', () => {
render(Slider, { min: 10, max: 90, value: 50 });
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuemin', '10');
expect(slider).toHaveAttribute('aria-valuemax', '90');
});
it('reflects value as aria-valuenow', () => {
render(Slider, { value: 42 });
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '42');
});
it('is disabled when disabled=true', () => {
render(Slider, { disabled: true });
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true');
});
it('is not disabled by default', () => {
render(Slider, { value: 0 });
expect(screen.getByRole('slider')).not.toHaveAttribute('aria-disabled', 'true');
});
});
describe('Orientations', () => {
it('renders horizontal by default', () => {
const { container } = render(Slider);
expect(screen.getByRole('slider')).toHaveAttribute('aria-orientation', 'horizontal');
expect(container.querySelector('.cursor-col-resize')).toBeInTheDocument();
});
it('renders vertical when orientation="vertical"', () => {
const { container } = render(Slider, { orientation: 'vertical' });
expect(screen.getByRole('slider')).toHaveAttribute('aria-orientation', 'vertical');
expect(container.querySelector('.cursor-row-resize')).toBeInTheDocument();
});
});
});
+28
View File
@@ -0,0 +1,28 @@
import {
render,
screen,
} from '@testing-library/svelte';
import Stat from './Stat.svelte';
describe('Stat', () => {
it('renders label and value', () => {
render(Stat, { label: 'weight', value: '400' });
expect(screen.getByText('weight:')).toBeInTheDocument();
expect(screen.getByText('400')).toBeInTheDocument();
});
it('renders numeric value', () => {
render(Stat, { label: 'size', value: 16 });
expect(screen.getByText('16')).toBeInTheDocument();
});
it('renders separator when separator=true', () => {
const { container } = render(Stat, { label: 'x', value: 'y', separator: true });
expect(container.querySelector('.w-px.h-2')).toBeInTheDocument();
});
it('does not render separator by default', () => {
const { container } = render(Stat, { label: 'x', value: 'y' });
expect(container.querySelector('.w-px.h-2')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,52 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import StatGroup from './StatGroup.svelte';
const { Story } = defineMeta({
title: 'Shared/StatGroup',
component: StatGroup,
tags: ['autodocs'],
parameters: {
docs: {
description: { component: 'Horizontal row of Stat items with automatic separators between them.' },
story: { inline: false },
},
layout: 'centered',
},
argTypes: {
stats: {
control: 'object',
description: 'Array of stat items with label, value, and optional variant',
},
class: {
control: 'text',
description: 'Additional CSS classes',
},
},
});
</script>
<script lang="ts">
import type { ComponentProps } from 'svelte';
</script>
<Story name="Default/Basic" args={{ stats: [{ label: 'Size', value: '16px' }, { label: 'Weight', value: 400 }] }}>
{#snippet template(args: ComponentProps<typeof StatGroup>)}
<StatGroup {...args} />
{/snippet}
</Story>
<Story
name="Three stats"
args={{ stats: [{ label: 'Size', value: '24px' }, { label: 'Weight', value: 700 }, { label: 'Line height', value: 1.5 }] }}
>
{#snippet template(args: ComponentProps<typeof StatGroup>)}
<StatGroup {...args} />
{/snippet}
</Story>
<Story name="Single stat" args={{ stats: [{ label: 'Style', value: 'Italic' }] }}>
{#snippet template(args: ComponentProps<typeof StatGroup>)}
<StatGroup {...args} />
{/snippet}
</Story>
@@ -0,0 +1,38 @@
import {
render,
screen,
} from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import TechText from './TechText.svelte';
function textSnippet(text: string) {
return createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));
}
describe('TechText', () => {
it('renders text content in a code element', () => {
render(TechText, { children: textSnippet('400px') });
expect(screen.getByText('400px')).toBeInTheDocument();
const { container } = render(TechText, { children: textSnippet('400px') });
expect(container.querySelector('code')).toBeInTheDocument();
});
it('renders nothing when no children', () => {
const { container } = render(TechText);
expect(container.querySelector('code')).toBeInTheDocument();
expect(container.querySelector('code')?.textContent?.trim()).toBe('');
});
it.each(['default', 'accent', 'muted', 'success', 'warning', 'error'] as const)(
'renders %s variant without error',
variant => {
render(TechText, { children: textSnippet('val'), variant });
expect(screen.getAllByText('val')[0]).toBeInTheDocument();
},
);
it.each(['xs', 'sm', 'md', 'lg'] as const)('renders %s size without error', size => {
render(TechText, { children: textSnippet('val'), size });
expect(screen.getAllByText('val')[0]).toBeInTheDocument();
});
});
@@ -37,6 +37,8 @@ const { Story } = defineMeta({
</script> </script>
<script lang="ts"> <script lang="ts">
import type { ComponentProps } from 'svelte';
const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`); const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`);
const mediumDataSet = Array.from( const mediumDataSet = Array.from(
{ length: 200 }, { length: 200 },
@@ -45,10 +47,13 @@ const mediumDataSet = Array.from(
const emptyDataSet: string[] = []; const emptyDataSet: string[] = [];
</script> </script>
<Story name="Small Dataset"> <Story
{#snippet template(args)} name="Small Dataset"
args={{ items: smallDataSet, itemHeight: 40 }}
>
{#snippet template(args: ComponentProps<typeof VirtualList>)}
<div class="h-[400px]"> <div class="h-[400px]">
<VirtualList items={smallDataSet} itemHeight={40} {...args}> <VirtualList {...args}>
{#snippet children({ item })} {#snippet children({ item })}
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div> <div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
{/snippet} {/snippet}
@@ -57,10 +62,13 @@ const emptyDataSet: string[] = [];
{/snippet} {/snippet}
</Story> </Story>
<Story name="Medium Dataset (200 items)"> <Story
{#snippet template(args)} name="Medium Dataset (200 items)"
args={{ items: mediumDataSet, itemHeight: 40 }}
>
{#snippet template(args: ComponentProps<typeof VirtualList>)}
<div class="h-[400px]"> <div class="h-[400px]">
<VirtualList items={mediumDataSet} itemHeight={40} {...args}> <VirtualList {...args}>
{#snippet children({ item })} {#snippet children({ item })}
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div> <div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
{/snippet} {/snippet}
@@ -69,9 +77,12 @@ const emptyDataSet: string[] = [];
{/snippet} {/snippet}
</Story> </Story>
<Story name="Empty Dataset"> <Story
{#snippet template(args)} name="Empty Dataset"
<VirtualList items={emptyDataSet} itemHeight={40} {...args}> args={{ items: emptyDataSet, itemHeight: 40 }}
>
{#snippet template(args: ComponentProps<typeof VirtualList>)}
<VirtualList {...args}>
{#snippet children({ item })} {#snippet children({ item })}
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div> <div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
{/snippet} {/snippet}
@@ -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()}
+2
View File
@@ -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';
@@ -52,6 +53,8 @@ const responsive = getContext<ResponsiveManager>('responsive');
const isMobile = $derived(responsive?.isMobile ?? false); const isMobile = $derived(responsive?.isMobile ?? false);
let isDragging = $state(false); let isDragging = $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();
@@ -76,6 +79,8 @@ function handleMove(e: PointerEvent) {
function startDragging(e: PointerEvent) { function startDragging(e: PointerEvent) {
e.preventDefault(); e.preventDefault();
// Close typography menu popover
isTypographyMenuOpen = false;
isDragging = true; isDragging = true;
handleMove(e); handleMove(e);
} }
@@ -124,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,
); );
} }
}); });
@@ -154,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,
); );
} }
}; };
@@ -222,7 +234,7 @@ const scaleClass = $derived(
class=" class="
relative w-full max-w-6xl h-full relative w-full max-w-6xl h-full
flex flex-col justify-center flex flex-col justify-center
select-none touch-none cursor-ew-resize select-none touch-none outline-none cursor-ew-resize
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24 py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
" "
in:fade={{ duration: 300, delay: 300 }} in:fade={{ duration: 300, delay: 300 }}
@@ -236,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}
@@ -253,8 +269,12 @@ const scaleClass = $derived(
</div> </div>
<TypographyMenu <TypographyMenu
bind:open={isTypographyMenuOpen}
class={clsx( class={clsx(
'absolute bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2 z-50', 'absolute z-50',
responsive.isMobileOrTablet
? 'bottom-4 right-4 -translate-1/2'
: 'bottom-5 left-1/2 right-[unset] -translate-x-1/2',
)} )}
/> />
</div> </div>
@@ -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">
+2
View File
@@ -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": [
+35
View File
@@ -0,0 +1,35 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { playwright } from '@vitest/browser-playwright';
import path from 'node:path';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [svelte()],
optimizeDeps: {
exclude: ['@lucide/svelte'],
},
test: {
browser: {
enabled: true,
provider: playwright({}),
instances: [{ browser: 'chromium' }],
},
include: ['src/**/*.svelte.test.ts'],
setupFiles: ['./vitest.setup.component.ts'],
globals: true,
},
resolve: {
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'),
},
},
});
+29
View File
@@ -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'),
},
},
});
+2
View File
@@ -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
+46
View File
@@ -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,
});