Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84ac886c33 | |||
| a60dbcfa51 | |||
| 8fc8a7ee6f | |||
| cbc978df6d | |||
| 6664beec25 | |||
| a801903fd3 | |||
| ecdb1e016d | |||
| 092b58e651 | |||
| d6914f8179 | |||
| b831861662 | |||
| 67fc9dee72 | |||
| a73bd75947 | |||
| 836b83f75d | |||
| 07e4a0b9d9 | |||
| 141126530d | |||
| f9f96e2797 | |||
| 3e11821814 | |||
| ee3f773ca5 | |||
| 2a51f031cc | |||
| b792dde7cb | |||
| 66dcffa448 | |||
| cca00fccaa | |||
| af05443763 | |||
| 99d92d487f | |||
| 4a907619cc | |||
| 6c69d7a5b3 | |||
| 993812de0a | |||
| 67c16530af | |||
| fbbb439023 | |||
| c2046770ef | |||
| adfba38063 | |||
| dfb304d436 | |||
| f55043a1e7 | |||
| 409dd1b229 | |||
| 9fbce095b2 | |||
| 171627e0ea | |||
| d07fb1a3af | |||
| 6f84644ecb | |||
| 5ab5cda611 | |||
| 7975d9aeee | |||
| 2ba5fc0e3e | |||
| 1947d7731e | |||
| 38bfc4ba4b | |||
| 6cf3047b74 | |||
| 81363156d7 | |||
| bb65f1c8d6 | |||
| 5eb9584797 | |||
| bb5c3667b4 | |||
| 3711616a91 | |||
| 6905c54040 | |||
| 1e8e22e2eb | |||
| 8a93c7b545 | |||
| 0004b81e40 | |||
| fb1d2765d0 | |||
| 12e8bc0a89 |
@@ -41,7 +41,13 @@ jobs:
|
||||
run: yarn lint
|
||||
|
||||
- name: Type Check
|
||||
run: yarn check:shadcn-excluded
|
||||
run: yarn check
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: yarn test:unit
|
||||
|
||||
- name: Run Component Tests
|
||||
run: yarn test:component
|
||||
|
||||
publish:
|
||||
needs: build # Only runs if tests/lint pass
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
|
||||
This provides:
|
||||
- ResponsiveManager context for breakpoint tracking
|
||||
- TooltipProvider for shadcn Tooltip components
|
||||
- TooltipProvider for tooltip components
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createResponsiveManager } from '$shared/lib';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||
import { setContext } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -24,6 +23,4 @@ $effect(() => responsiveManager.init());
|
||||
setContext<ResponsiveManager>('responsive', responsiveManager);
|
||||
</script>
|
||||
|
||||
<TooltipProvider delayDuration={200} skipDelayDuration={300}>
|
||||
{@render children()}
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
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>
|
||||
|
||||
<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">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
+2
-63
@@ -5,68 +5,6 @@ import ThemeDecorator from './ThemeDecorator.svelte';
|
||||
import '../src/app/styles/app.css';
|
||||
|
||||
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: {
|
||||
layout: 'padded',
|
||||
controls: {
|
||||
@@ -195,10 +133,11 @@ const preview: Preview = {
|
||||
},
|
||||
}),
|
||||
// Wrap with StoryStage for presentation styling
|
||||
story => ({
|
||||
(story, context) => ({
|
||||
Component: StoryStage,
|
||||
props: {
|
||||
children: story(),
|
||||
maxWidth: context.parameters.storyStage?.maxWidth,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -8,14 +8,14 @@ A modern font exploration and comparison tool for browsing fonts from Google Fon
|
||||
- **Side-by-Side Comparison**: Compare up to 4 fonts simultaneously with customizable text, size, and typography settings
|
||||
- **Advanced Filtering**: Filter by category, provider, character subsets, and weight
|
||||
- **Virtual Scrolling**: Fast, smooth browsing of thousands of fonts
|
||||
- **Responsive UI**: Beautiful interface built with shadcn components and Tailwind CSS
|
||||
- **Responsive UI**: Beautiful interface built with Tailwind CSS
|
||||
- **Type-Safe**: Full TypeScript coverage with strict mode enabled
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Svelte 5 with reactive primitives (runes)
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **Components**: shadcn-svelte (via bits-ui)
|
||||
- **Components**: Bits UI primitives
|
||||
- **State Management**: TanStack Query for async data
|
||||
- **Architecture**: Feature-Sliced Design (FSD)
|
||||
- **Quality**: oxlint (linting), dprint (formatting), lefthook (git hooks)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$shared/shadcn/ui",
|
||||
"utils": "$shared/shadcn/utils/shadcn-utils",
|
||||
"ui": "$shared/shadcn/ui",
|
||||
"hooks": "$shared/shadcn/hooks",
|
||||
"lib": "$shared"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
+11
-1
@@ -31,7 +31,17 @@
|
||||
"importDeclaration.forceMultiLine": "whenMultiple",
|
||||
"importDeclaration.forceSingleLine": false,
|
||||
"exportDeclaration.forceMultiLine": "whenMultiple",
|
||||
"exportDeclaration.forceSingleLine": false
|
||||
"exportDeclaration.forceSingleLine": false,
|
||||
"ifStatement.useBraces": "always",
|
||||
"ifStatement.singleBodyPosition": "nextLine",
|
||||
"whileStatement.useBraces": "always",
|
||||
"whileStatement.singleBodyPosition": "nextLine",
|
||||
"forStatement.useBraces": "always",
|
||||
"forStatement.singleBodyPosition": "nextLine",
|
||||
"forInStatement.useBraces": "always",
|
||||
"forInStatement.singleBodyPosition": "nextLine",
|
||||
"forOfStatement.useBraces": "always",
|
||||
"forOfStatement.singleBodyPosition": "nextLine"
|
||||
},
|
||||
"json": {
|
||||
"indentWidth": 2,
|
||||
|
||||
+5
-1
@@ -13,11 +13,15 @@ pre-commit:
|
||||
pre-push:
|
||||
parallel: true
|
||||
commands:
|
||||
test-unit:
|
||||
run: yarn test:unit
|
||||
test-component:
|
||||
run: yarn test:component
|
||||
type-check:
|
||||
run: yarn tsc --noEmit
|
||||
|
||||
svelte-check:
|
||||
run: yarn check:shadcn-excluded --threshold warning
|
||||
run: yarn check --threshold warning
|
||||
|
||||
format-check:
|
||||
glob: "*.{ts,js,svelte,json,md}"
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"prepare": "svelte-check --tsconfig ./tsconfig.json || echo ''",
|
||||
"check": "svelte-check",
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"check:shadcn-excluded": "svelte-check --no-tsconfig --ignore \"src/shared/shadcn\"",
|
||||
"lint": "oxlint",
|
||||
"format": "dprint fmt",
|
||||
"format:check": "dprint check",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
/* Base font size */
|
||||
--font-size: 16px;
|
||||
|
||||
/* GLYPHDIFF Swiss Design System */
|
||||
/* GLYPHDIFF Design System */
|
||||
/* Primary Colors */
|
||||
--swiss-beige: #f3f0e9;
|
||||
--swiss-red: #ff3b30;
|
||||
@@ -206,10 +206,10 @@
|
||||
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
|
||||
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
|
||||
--font-size-5xs: 0.4375rem;
|
||||
--font-size-4xs: 0.5rem;
|
||||
--font-size-3xs: 0.5625rem;
|
||||
--font-size-2xs: 0.625rem;
|
||||
--text-5xs: 0.4375rem;
|
||||
--text-4xs: 0.5rem;
|
||||
--text-3xs: 0.5625rem;
|
||||
--text-2xs: 0.625rem;
|
||||
/* Monospace label tracking — used in Loader and Footnote */
|
||||
--tracking-wider-mono: 0.2em;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,10 @@
|
||||
*
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*/
|
||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import GD from '$shared/assets/GD.svg';
|
||||
import { ResponsiveProvider } from '$shared/lib';
|
||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type Snippet,
|
||||
onDestroy,
|
||||
@@ -75,21 +73,23 @@ onDestroy(() => themeManager.destroy());
|
||||
/>
|
||||
</noscript>
|
||||
<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>
|
||||
|
||||
<ResponsiveProvider>
|
||||
<div
|
||||
id="app-root"
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
|
||||
theme === 'dark' ? 'dark' : '',
|
||||
)}
|
||||
>
|
||||
<TooltipProvider>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</TooltipProvider>
|
||||
<footer></footer>
|
||||
</div>
|
||||
</ResponsiveProvider>
|
||||
|
||||
@@ -105,13 +105,17 @@ class ScrollBreadcrumbsStore {
|
||||
* (fires as soon as any part of element crosses viewport edge).
|
||||
*/
|
||||
#initObserver(): void {
|
||||
if (this.#observer) return;
|
||||
if (this.#observer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#observer = new IntersectionObserver(
|
||||
entries => {
|
||||
for (const entry of entries) {
|
||||
const item = this.#items.find(i => i.element === entry.target);
|
||||
if (!item) continue;
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isIntersecting && this.#isScrollingDown) {
|
||||
// Element exited viewport while scrolling DOWN - add to breadcrumbs
|
||||
@@ -199,7 +203,9 @@ class ScrollBreadcrumbsStore {
|
||||
* @param offset - Scroll offset in pixels (for sticky headers)
|
||||
*/
|
||||
add(item: BreadcrumbItem, offset = 0): void {
|
||||
if (this.#items.find(i => i.index === item.index)) return;
|
||||
if (this.#items.find(i => i.index === item.index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#scrollOffset = offset;
|
||||
this.#items.push(item);
|
||||
@@ -216,7 +222,9 @@ class ScrollBreadcrumbsStore {
|
||||
*/
|
||||
remove(index: number): void {
|
||||
const item = this.#items.find(i => i.index === index);
|
||||
if (!item) return;
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#observer?.unobserve(item.element);
|
||||
this.#items = this.#items.filter(i => i.index !== index);
|
||||
@@ -237,7 +245,9 @@ class ScrollBreadcrumbsStore {
|
||||
*/
|
||||
scrollTo(index: number, container: HTMLElement | Window = window): void {
|
||||
const item = this.#items.find(i => i.index === index);
|
||||
if (!item) return;
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = item.element.getBoundingClientRect();
|
||||
const scrollTop = container === window ? window.scrollY : (container as HTMLElement).scrollTop;
|
||||
|
||||
@@ -26,7 +26,9 @@ class MockIntersectionObserver implements IntersectionObserver {
|
||||
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
this.callbacks.push(callback);
|
||||
if (options?.rootMargin) this.rootMargin = options.rootMargin;
|
||||
if (options?.rootMargin) {
|
||||
this.rootMargin = options.rootMargin;
|
||||
}
|
||||
if (options?.threshold) {
|
||||
this.thresholds = Array.isArray(options.threshold) ? options.threshold : [options.threshold];
|
||||
}
|
||||
@@ -120,7 +122,9 @@ describe('ScrollBreadcrumbsStore', () => {
|
||||
(event: string, listener: EventListenerOrEventListenerObject) => {
|
||||
if (event === 'scroll') {
|
||||
const index = scrollListeners.indexOf(listener as () => void);
|
||||
if (index > -1) scrollListeners.splice(index, 1);
|
||||
if (index > -1) {
|
||||
scrollListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -197,7 +197,9 @@ export async function fetchProxyFontById(
|
||||
* @returns Promise resolving to an array of fonts
|
||||
*/
|
||||
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||
if (ids.length === 0) return [];
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryString = ids.join(',');
|
||||
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||
|
||||
@@ -529,8 +529,12 @@ export function createMockStore<T>(config: {
|
||||
* Returns semantic status string
|
||||
*/
|
||||
get status() {
|
||||
if (isLoading) return 'pending';
|
||||
if (isError) return 'error';
|
||||
if (isLoading) {
|
||||
return 'pending';
|
||||
}
|
||||
if (isError) {
|
||||
return 'error';
|
||||
}
|
||||
return 'success';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,12 +93,16 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
||||
return function resolveRowHeight(rowIndex: number): number {
|
||||
const fonts = options.getFonts();
|
||||
const font = fonts[rowIndex];
|
||||
if (!font) return options.fallbackHeight;
|
||||
if (!font) {
|
||||
return options.fallbackHeight;
|
||||
}
|
||||
|
||||
const containerWidth = options.getContainerWidth();
|
||||
const previewText = options.getPreviewText();
|
||||
|
||||
if (containerWidth <= 0 || !previewText) return options.fallbackHeight;
|
||||
if (containerWidth <= 0 || !previewText) {
|
||||
return options.fallbackHeight;
|
||||
}
|
||||
|
||||
const weight = options.getWeight();
|
||||
// generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts.
|
||||
@@ -107,7 +111,9 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
||||
// Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(),
|
||||
// which creates a Svelte 5 reactive dependency when called inside $derived.by.
|
||||
const status = options.getStatus(fontKey);
|
||||
if (status !== 'loaded') return options.fallbackHeight;
|
||||
if (status !== 'loaded') {
|
||||
return options.fallbackHeight;
|
||||
}
|
||||
|
||||
const fontSizePx = options.getFontSizePx();
|
||||
const lineHeightPx = options.getLineHeightPx();
|
||||
@@ -116,7 +122,9 @@ export function createFontRowSizeResolver(options: FontRowSizeResolverOptions):
|
||||
|
||||
const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx);
|
||||
const result = totalHeight + options.chromeHeight;
|
||||
|
||||
@@ -86,3 +86,9 @@ export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
export const MULTIPLIER_S = 0.5;
|
||||
export const MULTIPLIER_M = 0.75;
|
||||
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;
|
||||
|
||||
@@ -246,12 +246,16 @@ export class AppliedFontsManager {
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.ok) continue;
|
||||
if (result.ok) {
|
||||
continue;
|
||||
}
|
||||
const { key, config, reason } = result;
|
||||
const isAbort = reason instanceof FontFetchError
|
||||
&& reason.cause instanceof Error
|
||||
&& reason.cause.name === 'AbortError';
|
||||
if (isAbort) continue;
|
||||
if (isAbort) {
|
||||
continue;
|
||||
}
|
||||
if (reason instanceof FontFetchError) {
|
||||
console.error(`Font fetch failed: ${config.name}`, reason);
|
||||
}
|
||||
@@ -293,7 +297,9 @@ export class AppliedFontsManager {
|
||||
|
||||
// Remove FontFace from document to free memory
|
||||
const font = this.#loadedFonts.get(key);
|
||||
if (font) document.fonts.delete(font);
|
||||
if (font) {
|
||||
document.fonts.delete(font);
|
||||
}
|
||||
|
||||
// Evict from cache and cleanup URL mapping
|
||||
const url = this.#urlByKey.get(key);
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { UnifiedFont } from '../../model/types';
|
||||
* Standalone function to avoid 'this' issues during construction.
|
||||
*/
|
||||
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
||||
if (ids.length === 0) return [];
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let response: UnifiedFont[];
|
||||
try {
|
||||
|
||||
@@ -561,4 +561,67 @@ describe('FontStore', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,7 +166,9 @@ export class FontStore {
|
||||
const data = this.#qc.getQueryData<InfiniteData<ProxyFontsResponse, PageParam>>(
|
||||
this.buildQueryKey(this.#params),
|
||||
);
|
||||
if (!data) return undefined;
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
return data.pages.flatMap(p => p.fonts);
|
||||
}
|
||||
|
||||
@@ -240,6 +242,80 @@ export class FontStore {
|
||||
async nextPage(): Promise<void> {
|
||||
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)
|
||||
*/
|
||||
@@ -287,6 +363,34 @@ export class FontStore {
|
||||
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[] {
|
||||
const filtered: Record<string, any> = {};
|
||||
|
||||
@@ -336,9 +440,15 @@ export class FontStore {
|
||||
throw new FontNetworkError(cause);
|
||||
}
|
||||
|
||||
if (!response) throw new FontResponseError('response', response);
|
||||
if (!response.fonts) throw new FontResponseError('response.fonts', response.fonts);
|
||||
if (!Array.isArray(response.fonts)) throw new FontResponseError('response.fonts', response.fonts);
|
||||
if (!response) {
|
||||
throw new FontResponseError('response', response);
|
||||
}
|
||||
if (!response.fonts) {
|
||||
throw new FontResponseError('response.fonts', response.fonts);
|
||||
}
|
||||
if (!Array.isArray(response.fonts)) {
|
||||
throw new FontResponseError('response.fonts', response.fonts);
|
||||
}
|
||||
|
||||
return {
|
||||
fonts: response.fonts,
|
||||
|
||||
@@ -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
|
||||
Loads fonts from fontshare with link tag
|
||||
- Loads font only if it's not already applied
|
||||
- Reacts to font load status to show/hide content
|
||||
- Adds smooth transition when font appears
|
||||
Applies a font to its children once the font file is loaded.
|
||||
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
type UnifiedFont,
|
||||
@@ -33,6 +30,11 @@ interface Props {
|
||||
* Content snippet
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* Shown while the font file is loading.
|
||||
* When omitted, children render in system font until ready.
|
||||
*/
|
||||
skeleton?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -40,6 +42,7 @@ let {
|
||||
weight = DEFAULT_FONT_WEIGHT,
|
||||
className,
|
||||
children,
|
||||
skeleton,
|
||||
}: Props = $props();
|
||||
|
||||
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 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>
|
||||
|
||||
{#if !shouldReveal && skeleton}
|
||||
{@render skeleton()}
|
||||
{:else}
|
||||
<div
|
||||
style:font-family={shouldReveal
|
||||
? `'${font.name}'`
|
||||
: 'system-ui, -apple-system, sans-serif'}
|
||||
class={cn(
|
||||
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,
|
||||
)}
|
||||
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
||||
class={clsx(className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</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
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { debounce } from '$shared/lib/utils';
|
||||
import {
|
||||
Skeleton,
|
||||
VirtualList,
|
||||
@@ -54,6 +55,10 @@ const isLoading = $derived(
|
||||
);
|
||||
|
||||
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[]) {
|
||||
visibleFonts = items;
|
||||
@@ -61,15 +66,41 @@ function handleInternalVisibleChange(items: UnifiedFont[]) {
|
||||
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
|
||||
$effect(() => {
|
||||
if (isCatchingUp) {
|
||||
return;
|
||||
}
|
||||
const configs: FontLoadRequestConfig[] = visibleFonts.flatMap(item => {
|
||||
const url = getFontUrl(item, weight);
|
||||
if (!url) return [];
|
||||
if (!url) {
|
||||
return [];
|
||||
}
|
||||
return [{ id: item.id, name: item.name, weight, url, isVariable: item.features?.isVariable }];
|
||||
});
|
||||
if (configs.length > 0) {
|
||||
appliedFontsManager.touch(configs);
|
||||
debouncedTouch(configs);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -111,17 +142,19 @@ function loadMore() {
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = fontStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items
|
||||
if (hasMore && !fontStore.isFetching) {
|
||||
// VirtualList already checks if we're near the bottom of loaded items.
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div transition:fade={{ duration: 300 }}>
|
||||
<div class="overflow-hidden h-full" transition:fade={{ duration: 300 }}>
|
||||
{@render skeleton()}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -129,14 +162,20 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
||||
<VirtualList
|
||||
items={fontStore.fonts}
|
||||
total={fontStore.pagination.total}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isCatchingUp}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
onJump={handleJump}
|
||||
{...rest}
|
||||
>
|
||||
{#snippet children(scope)}
|
||||
{@render children(scope)}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{#if showCatchupSkeleton && skeleton}
|
||||
<div class="absolute inset-0 overflow-hidden" transition:fade={{ duration: 150 }}>
|
||||
{@render skeleton()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</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">
|
||||
import type { UnifiedFont } from '$entities/Font';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
// Mock fonts for testing
|
||||
const mockArial: UnifiedFont = {
|
||||
@@ -88,7 +89,7 @@ const mockGeorgia: UnifiedFont = {
|
||||
index: 0,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
<Providers>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<FontSampler {...args} />
|
||||
@@ -105,7 +106,7 @@ const mockGeorgia: UnifiedFont = {
|
||||
index: 1,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||
<Providers>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<FontSampler {...args} />
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
mapManagerToParams,
|
||||
} from './lib';
|
||||
|
||||
export { filtersStore } from './model/state/filters.svelte';
|
||||
export { filterManager } from './model/state/manager.svelte';
|
||||
|
||||
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>
|
||||
@@ -6,10 +6,10 @@
|
||||
<script lang="ts">
|
||||
import { fontStore } from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
@@ -45,7 +45,7 @@ function handleReset() {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
||||
'gap-1 md:gap-6',
|
||||
'pt-6 mt-6 md:pt-8 md:mt-8',
|
||||
@@ -77,7 +77,7 @@ function handleReset() {
|
||||
variant="ghost"
|
||||
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||
onclick={handleReset}
|
||||
class={cn('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||
iconPosition="left"
|
||||
>
|
||||
{#snippet icon()}
|
||||
|
||||
@@ -128,7 +128,9 @@ export class TypographySettingsManager {
|
||||
// This handles the "Multiplier" logic specifically for the Font Size Control
|
||||
$effect(() => {
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
if (!ctrl) return;
|
||||
if (!ctrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user moves the slider/clicks buttons in the UI:
|
||||
// We update the baseSize (User Intent)
|
||||
@@ -147,10 +149,18 @@ export class TypographySettingsManager {
|
||||
* Gets initial value for a control from storage or defaults
|
||||
*/
|
||||
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
||||
if (id === 'font_weight') return saved.fontWeight;
|
||||
if (id === 'line_height') return saved.lineHeight;
|
||||
if (id === 'letter_spacing') return saved.letterSpacing;
|
||||
if (id === 'font_size') {
|
||||
return saved.fontSize * this.#multiplier;
|
||||
}
|
||||
if (id === 'font_weight') {
|
||||
return saved.fontWeight;
|
||||
}
|
||||
if (id === 'line_height') {
|
||||
return saved.lineHeight;
|
||||
}
|
||||
if (id === 'letter_spacing') {
|
||||
return saved.letterSpacing;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -165,7 +175,9 @@ export class TypographySettingsManager {
|
||||
* Updates the multiplier and recalculates dependent control values
|
||||
*/
|
||||
set multiplier(value: number) {
|
||||
if (this.#multiplier === value) return;
|
||||
if (this.#multiplier === value) {
|
||||
return;
|
||||
}
|
||||
this.#multiplier = value;
|
||||
|
||||
// When multiplier changes, we must update the Font Size Control's display value
|
||||
@@ -192,7 +204,9 @@ export class TypographySettingsManager {
|
||||
set baseSize(val: number) {
|
||||
this.#baseSize = val;
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
if (ctrl) ctrl.value = val * this.#multiplier;
|
||||
if (ctrl) {
|
||||
ctrl.value = val * this.#multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,9 +282,15 @@ export class TypographySettingsManager {
|
||||
// Map storage key to control id
|
||||
const key = c.id.replace('_', '') as keyof TypographySettings;
|
||||
// Simplified for brevity, you'd map these properly:
|
||||
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight;
|
||||
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
|
||||
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
|
||||
if (c.id === 'font_weight') {
|
||||
c.instance.value = defaults.fontWeight;
|
||||
}
|
||||
if (c.id === 'line_height') {
|
||||
c.instance.value = defaults.lineHeight;
|
||||
}
|
||||
if (c.id === 'letter_spacing') {
|
||||
c.instance.value = defaults.letterSpacing;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
Desktop: inline bar with combo controls.
|
||||
-->
|
||||
@@ -12,8 +11,8 @@ import {
|
||||
MULTIPLIER_S,
|
||||
} from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Button,
|
||||
ComboControl,
|
||||
ControlGroup,
|
||||
Slider,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Popover } from 'bits-ui';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -36,19 +36,24 @@ interface Props {
|
||||
* @default false
|
||||
*/
|
||||
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');
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
/**
|
||||
* Sets the common font size multiplier based on the current responsive state.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!responsive) return;
|
||||
if (!responsive) {
|
||||
return;
|
||||
}
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
typographySettingsStore.multiplier = MULTIPLIER_S;
|
||||
@@ -66,34 +71,24 @@ $effect(() => {
|
||||
</script>
|
||||
|
||||
{#if !hidden}
|
||||
{#if responsive.isMobile}
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
{#if responsive.isMobileOrTablet}
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={cn(
|
||||
'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,
|
||||
)}
|
||||
>
|
||||
<Button class={className} variant="primary" {...props}>
|
||||
{#snippet icon()}
|
||||
<Settings2Icon class="size-4" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'z-50 w-72',
|
||||
'bg-surface dark:bg-dark-card',
|
||||
'border border-subtle',
|
||||
@@ -147,11 +142,11 @@ $effect(() => {
|
||||
</Popover.Root>
|
||||
{:else}
|
||||
<div
|
||||
class={cn('w-full md:w-auto', className)}
|
||||
class={clsx('w-full md:w-auto', className)}
|
||||
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
||||
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
||||
'border border-subtle',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { fade } from 'svelte/transition';
|
||||
class="h-full flex flex-col gap-3 sm:gap-4"
|
||||
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||
>
|
||||
<section class="w-auto">
|
||||
<main class="w-auto">
|
||||
<ComparisonView />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+99
-45
@@ -95,11 +95,13 @@ export class CharacterComparisonEngine {
|
||||
#lastText = '';
|
||||
#lastFontA = '';
|
||||
#lastFontB = '';
|
||||
#lastSpacing = 0;
|
||||
#lastSize = 0;
|
||||
|
||||
// Cached layout results
|
||||
#lastWidth = -1;
|
||||
#lastLineHeight = -1;
|
||||
#lastResult: ComparisonResult | null = null;
|
||||
#lastResult = $state<ComparisonResult | null>(null);
|
||||
|
||||
constructor(locale?: string) {
|
||||
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 width Available line width in pixels.
|
||||
* @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.
|
||||
*/
|
||||
layout(
|
||||
@@ -124,12 +128,21 @@ export class CharacterComparisonEngine {
|
||||
fontB: string,
|
||||
width: number,
|
||||
lineHeight: number,
|
||||
spacing: number = 0,
|
||||
size: number = 16,
|
||||
): ComparisonResult {
|
||||
if (!text) {
|
||||
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;
|
||||
|
||||
if (!isFontChange && !isLayoutChange && this.#lastResult) {
|
||||
@@ -140,11 +153,13 @@ export class CharacterComparisonEngine {
|
||||
if (isFontChange) {
|
||||
this.#preparedA = prepareWithSegments(text, fontA);
|
||||
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.#lastFontA = fontA;
|
||||
this.#lastFontB = fontB;
|
||||
this.#lastSpacing = spacing;
|
||||
this.#lastSize = size;
|
||||
}
|
||||
|
||||
if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) {
|
||||
@@ -171,9 +186,10 @@ export class CharacterComparisonEngine {
|
||||
|
||||
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||
const segmentText = this.#preparedA!.segments[sIdx];
|
||||
if (segmentText === undefined) continue;
|
||||
if (segmentText === undefined) {
|
||||
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 advA = intA.breakableFitAdvances[sIdx];
|
||||
@@ -184,8 +200,12 @@ export class CharacterComparisonEngine {
|
||||
|
||||
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||
const char = graphemes[gIdx];
|
||||
const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!;
|
||||
const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!;
|
||||
let wA = advA != null ? advA[gIdx]! : intA.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({
|
||||
char,
|
||||
@@ -217,73 +237,107 @@ 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()`.
|
||||
* No DOM calls are made; all geometry is derived from cached layout data.
|
||||
* Walks characters left-to-right, accumulating the running x position using
|
||||
* 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 charIndex Zero-based index of the character within that line's `chars` array.
|
||||
* @param line A single laid-out line from the last layout result.
|
||||
* @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`.
|
||||
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
|
||||
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
|
||||
* `isPast` (true when the slider has already passed the char center).
|
||||
* @param containerWidth Total container width in pixels.
|
||||
* @returns Per-character `proximity` and `isPast` in the same order as `line.chars`.
|
||||
*/
|
||||
getCharState(
|
||||
lineIndex: number,
|
||||
charIndex: number,
|
||||
getLineCharStates(
|
||||
line: ComparisonLine,
|
||||
sliderPos: number,
|
||||
containerWidth: number,
|
||||
): { proximity: number; isPast: boolean } {
|
||||
if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) {
|
||||
return { proximity: 0, isPast: false };
|
||||
): Array<{ proximity: number; isPast: boolean }> {
|
||||
if (!line) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const line = this.#lastResult.lines[lineIndex];
|
||||
const char = line.chars[charIndex];
|
||||
|
||||
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 chars = line.chars;
|
||||
const n = chars.length;
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
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 isPast = sliderPos > charGlobalPercent;
|
||||
|
||||
currentX += charWidth;
|
||||
return { proximity, isPast };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
const intA = a as any;
|
||||
const intB = b as any;
|
||||
|
||||
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) =>
|
||||
Math.max(w, intB.lineEndFitAdvances[i])
|
||||
Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx
|
||||
);
|
||||
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) => {
|
||||
const advB = intB.breakableFitAdvances[i];
|
||||
if (!advA && !advB) return null;
|
||||
if (!advA) return advB;
|
||||
if (!advB) return advA;
|
||||
if (!advA && !advB) {
|
||||
return null;
|
||||
}
|
||||
if (!advA) {
|
||||
return advB.map((w: number) => w + spacingPx);
|
||||
}
|
||||
if (!advB) {
|
||||
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;
|
||||
|
||||
+35
-39
@@ -109,56 +109,52 @@ describe('CharacterComparisonEngine', () => {
|
||||
expect(r2).not.toBe(r1);
|
||||
});
|
||||
|
||||
it('getCharState 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
|
||||
it('getLineCharStates returns proximity 1 when slider is exactly over char center', () => {
|
||||
const containerWidth = 500;
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
|
||||
// Recalculate expected percent manually:
|
||||
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
|
||||
const lineXOffset = (containerWidth - lineWidth) / 2;
|
||||
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
|
||||
const charPercent = (charCenterX / containerWidth) * 100;
|
||||
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
|
||||
// Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2.
|
||||
// When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2.
|
||||
// So proximity=1 at exactly 50%.
|
||||
const charPercent = 50;
|
||||
|
||||
const state = engine.getCharState(0, 0, charPercent, containerWidth);
|
||||
expect(state.proximity).toBe(1);
|
||||
expect(state.isPast).toBe(false);
|
||||
const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth);
|
||||
expect(states[0]?.proximity).toBe(1);
|
||||
expect(states[0]?.isPast).toBe(false);
|
||||
});
|
||||
|
||||
it('getCharState returns proximity 0 when slider is far from char', () => {
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
|
||||
const state = engine.getCharState(0, 0, 0, 500);
|
||||
expect(state.proximity).toBe(0);
|
||||
it('getLineCharStates returns proximity 0 when slider is far from char', () => {
|
||||
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const states = engine.getLineCharStates(result.lines[0], 0, 500);
|
||||
expect(states[0]?.proximity).toBe(0);
|
||||
});
|
||||
|
||||
it('getCharState isPast is true when slider has passed char center', () => {
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const state = engine.getCharState(0, 0, 100, 500);
|
||||
expect(state.isPast).toBe(true);
|
||||
it('getLineCharStates isPast is true when slider has passed char center', () => {
|
||||
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const states = engine.getLineCharStates(result.lines[0], 100, 500);
|
||||
expect(states[0]?.isPast).toBe(true);
|
||||
});
|
||||
|
||||
it('getCharState returns safe default for out-of-range lineIndex', () => {
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const state = engine.getCharState(99, 0, 50, 500);
|
||||
expect(state.proximity).toBe(0);
|
||||
expect(state.isPast).toBe(false);
|
||||
it('getLineCharStates returns empty array for out-of-range lineIndex', () => {
|
||||
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
// Passing an undefined object because the index doesn't exist.
|
||||
const states = engine.getLineCharStates(result.lines[99], 50, 500);
|
||||
expect(states).toEqual([]);
|
||||
});
|
||||
|
||||
it('getCharState returns safe default for out-of-range charIndex', () => {
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const state = engine.getCharState(0, 99, 50, 500);
|
||||
expect(state.proximity).toBe(0);
|
||||
expect(state.isPast).toBe(false);
|
||||
it('getLineCharStates returns empty array before layout() has been called', () => {
|
||||
// Passing an undefined object because layout() hasn't been called.
|
||||
const states = engine.getLineCharStates(undefined as any, 50, 500);
|
||||
expect(states).toEqual([]);
|
||||
});
|
||||
|
||||
it('getCharState returns safe default before layout() has been called', () => {
|
||||
const state = engine.getCharState(0, 0, 50, 500);
|
||||
expect(state.proximity).toBe(0);
|
||||
expect(state.isPast).toBe(false);
|
||||
it('getLineCharStates returns safe defaults for all chars', () => {
|
||||
const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const states = engine.getLineCharStates(result.lines[0], 50, 500);
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,7 +127,9 @@ export class TextLayoutEngine {
|
||||
// Both cursors are grapheme-level: start is inclusive, end is exclusive.
|
||||
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||
const segmentText = prepared.segments[sIdx];
|
||||
if (segmentText === undefined) continue;
|
||||
if (segmentText === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||
const advances = breakableFitAdvances[sIdx];
|
||||
|
||||
@@ -150,7 +150,9 @@ export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>
|
||||
* @returns Cleanup function to remove listeners
|
||||
*/
|
||||
function init() {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
width = window.innerWidth;
|
||||
|
||||
@@ -175,7 +175,9 @@ export function createVirtualizer<T>(
|
||||
const { count, data } = options;
|
||||
// Implicit dependency
|
||||
const v = _version;
|
||||
if (count === 0 || containerHeight === 0 || !data) return [];
|
||||
if (count === 0 || containerHeight === 0 || !data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const overscan = options.overscan ?? 5;
|
||||
|
||||
@@ -264,7 +266,9 @@ export function createVirtualizer<T>(
|
||||
containerHeight = window.innerHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafId !== null) return;
|
||||
if (rafId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
// Get current position of element relative to viewport
|
||||
@@ -323,7 +327,9 @@ export function createVirtualizer<T>(
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
if (entry) containerHeight = entry.contentRect.height;
|
||||
if (entry) {
|
||||
containerHeight = entry.contentRect.height;
|
||||
}
|
||||
});
|
||||
|
||||
node.addEventListener('scroll', handleScroll, { passive: true });
|
||||
@@ -423,7 +429,9 @@ export function createVirtualizer<T>(
|
||||
* ```
|
||||
*/
|
||||
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
|
||||
if (!elementRef || index < 0 || index >= options.count) return;
|
||||
if (!elementRef || index < 0 || index >= options.count) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemStart = offsets[index];
|
||||
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||
@@ -431,16 +439,24 @@ export function createVirtualizer<T>(
|
||||
const { useWindowScroll } = optionsGetter();
|
||||
|
||||
if (useWindowScroll) {
|
||||
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
|
||||
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
|
||||
if (align === 'center') {
|
||||
target = itemStart - window.innerHeight / 2 + itemSize / 2;
|
||||
}
|
||||
if (align === 'end') {
|
||||
target = itemStart - window.innerHeight + itemSize;
|
||||
}
|
||||
|
||||
// Add container offset to target to get absolute document position
|
||||
const absoluteTarget = target + elementOffsetTop;
|
||||
|
||||
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
|
||||
} else {
|
||||
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||
if (align === 'center') {
|
||||
target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||
}
|
||||
if (align === 'end') {
|
||||
target = itemStart - containerHeight + itemSize;
|
||||
}
|
||||
|
||||
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
correctly via the HTML element's class attribute.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import type {
|
||||
Component,
|
||||
Snippet,
|
||||
@@ -32,7 +32,7 @@ let { icon: Icon, class: className, attrs = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if Icon}
|
||||
{@const __iconClass__ = cn('size-4', className)}
|
||||
{@const __iconClass__ = clsx('size-4', className)}
|
||||
<!-- Render icon component dynamically with class prop -->
|
||||
<Icon
|
||||
class={__iconClass__}
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
|
||||
Provides:
|
||||
- responsive: ResponsiveManager context for breakpoint tracking
|
||||
- tooltip: Tooltip.Provider context for shadcn Tooltip components
|
||||
- Additional Radix UI providers can be added here as needed
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { createResponsiveManager } from '$shared/lib';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||
import { setContext } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
@@ -19,33 +17,9 @@ interface Props {
|
||||
* Content 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 {
|
||||
children,
|
||||
initialWidth = 1280,
|
||||
initialHeight = 720,
|
||||
tooltipDelayDuration = 200,
|
||||
tooltipSkipDelayDuration = 300,
|
||||
}: Props = $props();
|
||||
let { children }: Props = $props();
|
||||
|
||||
// Create a responsive manager with default breakpoints
|
||||
const responsiveManager = createResponsiveManager();
|
||||
@@ -60,10 +34,5 @@ setContext<ResponsiveManager>('responsive', responsiveManager);
|
||||
</script>
|
||||
|
||||
<div class="storybook-providers" style:width="100%" style:height="100%">
|
||||
<TooltipProvider
|
||||
delayDuration={tooltipDelayDuration}
|
||||
skipDelayDuration={tooltipSkipDelayDuration}
|
||||
>
|
||||
{@render children()}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Generates a consistent but varied width for skeleton placeholders.
|
||||
* Uses a predefined sequence to ensure stability between renders.
|
||||
*
|
||||
* @param index - Index of the item in a list to pick a width from the sequence
|
||||
* @param multiplier - Multiplier to apply to the base sequence values (default: 4)
|
||||
* @returns CSS width value (e.g., "128px")
|
||||
*/
|
||||
export function getSkeletonWidth(index: number, multiplier = 4): string {
|
||||
const sequence = [32, 48, 40, 56, 36, 44, 52, 38, 46, 42, 34, 50];
|
||||
const base = sequence[index % sequence.length];
|
||||
return `${base * multiplier}px`;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
export { clampNumber } from './clampNumber/clampNumber';
|
||||
export { debounce } from './debounce/debounce';
|
||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
|
||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||
export { smoothScroll } from './smoothScroll/smoothScroll';
|
||||
export { splitArray } from './splitArray/splitArray';
|
||||
|
||||
@@ -18,7 +18,9 @@ export function smoothScroll(node: HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
|
||||
const hash = node.getAttribute('href');
|
||||
if (!hash || hash === '#') return;
|
||||
if (!hash || hash === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = document.querySelector(hash);
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ export function throttle<T extends (...args: any[]) => any>(
|
||||
fn(...args);
|
||||
} else {
|
||||
// Schedule for end of wait period (trailing edge)
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
lastCall = Date.now();
|
||||
fn(...args);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
|
||||
const DEFAULT_MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export class IsMobile extends MediaQuery {
|
||||
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
|
||||
super(`max-width: ${breakpoint - 1}px`);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import Close from './popover-close.svelte';
|
||||
import Content from './popover-content.svelte';
|
||||
import Portal from './popover-portal.svelte';
|
||||
import Trigger from './popover-trigger.svelte';
|
||||
import Root from './popover.svelte';
|
||||
|
||||
export {
|
||||
Close,
|
||||
Close as PopoverClose,
|
||||
Content,
|
||||
Content as PopoverContent,
|
||||
Portal,
|
||||
Portal as PopoverPortal,
|
||||
Root,
|
||||
//
|
||||
Root as Popover,
|
||||
Trigger,
|
||||
Trigger as PopoverTrigger,
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||
@@ -1,34 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChildrenOrChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import PopoverPortal from './popover-portal.svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
align = 'center',
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPortal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPortal>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...restProps} />
|
||||
@@ -1,17 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="popover-trigger"
|
||||
class={cn('', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||
@@ -1,19 +0,0 @@
|
||||
import Content from './tooltip-content.svelte';
|
||||
import Portal from './tooltip-portal.svelte';
|
||||
import Provider from './tooltip-provider.svelte';
|
||||
import Trigger from './tooltip-trigger.svelte';
|
||||
import Root from './tooltip.svelte';
|
||||
|
||||
export {
|
||||
Content,
|
||||
Content as TooltipContent,
|
||||
Portal,
|
||||
Portal as TooltipPortal,
|
||||
Provider,
|
||||
Provider as TooltipProvider,
|
||||
Root,
|
||||
//
|
||||
Root as Tooltip,
|
||||
Trigger,
|
||||
Trigger as TooltipTrigger,
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { WithoutChildrenOrChild } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import TooltipPortal from './tooltip-portal.svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 0,
|
||||
side = 'top',
|
||||
children,
|
||||
arrowClasses,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: TooltipPrimitive.ContentProps & {
|
||||
arrowClasses?: string;
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof TooltipPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPortal {...portalProps}>
|
||||
<TooltipPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="tooltip-content"
|
||||
{sideOffset}
|
||||
{side}
|
||||
class={cn(
|
||||
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--bits-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<TooltipPrimitive.Arrow>
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
class={cn(
|
||||
'bg-primary z-50 size-2.5 rotate-45 rounded-[2px]',
|
||||
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
|
||||
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
|
||||
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
|
||||
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
|
||||
arrowClasses,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</TooltipPrimitive.Arrow>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPortal>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: TooltipPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Portal {...restProps} />
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: TooltipPrimitive.ProviderProps = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Provider {...restProps} />
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
|
||||
@@ -1,7 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps }: TooltipPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Root bind:open {...restProps} />
|
||||
@@ -1,40 +0,0 @@
|
||||
import {
|
||||
type ClassValue,
|
||||
clsx,
|
||||
} from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility function to merge Tailwind CSS classes
|
||||
* Combines clsx for conditional classes and tailwind-merge to handle conflicts
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Type utility to add a ref property to HTML element attributes
|
||||
* Used in shadcn-svelte components to support element references
|
||||
* @template T - The attributes type (e.g., HTMLAttributes<HTMLDivElement>)
|
||||
* @template E - The element type (e.g., HTMLDivElement)
|
||||
*/
|
||||
export type WithElementRef<T, E extends HTMLElement = HTMLElement> = T & {
|
||||
/**
|
||||
* Reference to the DOM element
|
||||
*/
|
||||
ref?: E | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type utility to remove 'children' and 'child' properties from a type
|
||||
* Used in shadcn-svelte components that use Snippets instead of children
|
||||
* @template T - The type to remove children from
|
||||
*/
|
||||
export type WithoutChildren<T> = Omit<T, 'children'>;
|
||||
|
||||
/**
|
||||
* Type utility to remove 'children' and 'child' properties from a type
|
||||
* Used in shadcn-svelte components that use Snippets instead of children
|
||||
* @template T - The type to remove children and child from
|
||||
*/
|
||||
export type WithoutChildrenOrChild<T> = Omit<T, 'children' | 'child'>;
|
||||
@@ -3,11 +3,11 @@
|
||||
Pill badge with border and optional status dot.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
type LabelSize,
|
||||
labelSizeConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -64,7 +64,7 @@ let {
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 border rounded-full',
|
||||
'font-mono uppercase tracking-wide',
|
||||
labelSizeConfig[size],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,7 @@ const { Story } = defineMeta({
|
||||
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import ButtonGroup from './ButtonGroup.svelte';
|
||||
</script>
|
||||
|
||||
@@ -52,7 +53,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
||||
name="Default/Basic"
|
||||
parameters={{ docs: { description: { story: 'Standard text button at all sizes' } } }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||
<ButtonGroup>
|
||||
<Button {...args} size="xs">xs</Button>
|
||||
<Button {...args} size="sm">sm</Button>
|
||||
@@ -67,7 +68,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
||||
name="Default/With Icon"
|
||||
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||
<Button {...args}>
|
||||
{#snippet icon()}
|
||||
<XIcon />
|
||||
@@ -81,7 +82,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
||||
name="Primary"
|
||||
args={{ variant: 'primary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||
<Button {...args}>Primary</Button>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -90,7 +91,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
||||
name="Secondary"
|
||||
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||
<Button {...args}>Secondary</Button>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -99,7 +100,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
||||
name="Icon"
|
||||
args={{ variant: 'icon', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||
<Button {...args}>
|
||||
{#snippet icon()}
|
||||
<XIcon />
|
||||
@@ -112,7 +113,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
||||
name="Ghost"
|
||||
args={{ variant: 'ghost', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||
<Button {...args}>
|
||||
Ghost
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
design-system button. Uppercase, zero border-radius, Space Grotesk.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
import type {
|
||||
@@ -71,7 +71,7 @@ let {
|
||||
const isIconOnly = $derived(!!icon && !children);
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: cn(
|
||||
primary: clsx(
|
||||
'bg-swiss-red text-white',
|
||||
'hover:bg-swiss-red/90',
|
||||
'active:bg-swiss-red/80',
|
||||
@@ -87,7 +87,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:cursor-not-allowed',
|
||||
'disabled:transform-none',
|
||||
),
|
||||
secondary: cn(
|
||||
secondary: clsx(
|
||||
'bg-surface dark:bg-paper',
|
||||
'text-swiss-black dark:text-neutral-200',
|
||||
'border border-black/10 dark:border-white/10',
|
||||
@@ -98,7 +98,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
outline: cn(
|
||||
outline: clsx(
|
||||
'bg-transparent',
|
||||
'text-swiss-black dark:text-neutral-200',
|
||||
'border border-black/20 dark:border-white/20',
|
||||
@@ -109,7 +109,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
ghost: cn(
|
||||
ghost: clsx(
|
||||
'bg-transparent',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
@@ -119,7 +119,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
icon: cn(
|
||||
icon: clsx(
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
@@ -130,8 +130,8 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
tertiary: cn(
|
||||
// Font override — must come after base in cn() to win via tailwind-merge
|
||||
tertiary: clsx(
|
||||
// Font override — must come after base in clsx() to win via tailwind-merge
|
||||
'font-secondary font-medium normal-case tracking-normal',
|
||||
// Inactive state
|
||||
'bg-transparent',
|
||||
@@ -175,7 +175,7 @@ const activeStyles: Partial<Record<ButtonVariant, string>> = {
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
|
||||
};
|
||||
|
||||
const classes = $derived(cn(
|
||||
const classes = $derived(clsx(
|
||||
// Base
|
||||
'inline-flex items-center justify-center',
|
||||
'font-primary font-bold tracking-tight uppercase',
|
||||
|
||||
@@ -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">
|
||||
import { Button } from '$shared/ui/Button';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||
<ButtonGroup {...args}>
|
||||
<Button variant="tertiary">Option 1</Button>
|
||||
<Button variant="tertiary">Option 2</Button>
|
||||
@@ -40,7 +41,7 @@ import { Button } from '$shared/ui/Button';
|
||||
</Story>
|
||||
|
||||
<Story name="Horizontal">
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||
<ButtonGroup {...args}>
|
||||
<Button variant="tertiary">Day</Button>
|
||||
<Button variant="tertiary" active>Week</Button>
|
||||
@@ -51,7 +52,7 @@ import { Button } from '$shared/ui/Button';
|
||||
</Story>
|
||||
|
||||
<Story name="Vertical">
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||
<ButtonGroup {...args} class="flex-col">
|
||||
<Button variant="tertiary">Top</Button>
|
||||
<Button variant="tertiary" active>Middle</Button>
|
||||
@@ -61,7 +62,7 @@ import { Button } from '$shared/ui/Button';
|
||||
</Story>
|
||||
|
||||
<Story name="With Icons">
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||
<ButtonGroup {...args}>
|
||||
<Button variant="tertiary">Grid</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">
|
||||
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
||||
<ButtonGroup {...args}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!--
|
||||
Component: SwissButtonGroup
|
||||
Wraps SwissButtons in a warm-surface pill with a 1px gap and subtle border.
|
||||
Component: ButtonGroup
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -23,7 +23,7 @@ let { children, class: className, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'flex items-center gap-1 p-1',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'border border-subtle',
|
||||
|
||||
@@ -43,13 +43,14 @@ const { Story } = defineMeta({
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
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">
|
||||
<IconButton {...args}>
|
||||
{#snippet icon()}
|
||||
@@ -74,7 +75,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||
name="Variants"
|
||||
args={{ size: 'md', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof IconButton>)}
|
||||
<div class="flex items-center gap-4">
|
||||
<IconButton {...args} variant="icon">
|
||||
{#snippet icon()}
|
||||
@@ -99,7 +100,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||
name="Active State"
|
||||
args={{ size: 'md', animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof IconButton>)}
|
||||
<div class="flex items-center gap-4">
|
||||
<IconButton {...args} active={false} variant="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">
|
||||
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
@@ -44,6 +44,7 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
let selected = $state(false);
|
||||
</script>
|
||||
|
||||
@@ -51,7 +52,7 @@ let selected = $state(false);
|
||||
name="Default"
|
||||
args={{ variant: 'tertiary', size: 'md', selected: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||
<ToggleButton {...args}>Toggle Me</ToggleButton>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -60,7 +61,7 @@ let selected = $state(false);
|
||||
name="Selected/Unselected"
|
||||
args={{ variant: 'tertiary', size: 'md', animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||
<div class="flex items-center gap-4">
|
||||
<ToggleButton {...args} selected={false}>
|
||||
Unselected
|
||||
@@ -76,7 +77,7 @@ let selected = $state(false);
|
||||
name="Variants"
|
||||
args={{ size: 'md', selected: true, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||
<div class="flex items-center gap-4">
|
||||
<ToggleButton {...args} variant="primary">
|
||||
Primary
|
||||
@@ -101,9 +102,15 @@ let selected = $state(false);
|
||||
name="Interactive"
|
||||
args={{ variant: 'tertiary', size: 'md', animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||
<div class="flex items-center gap-4">
|
||||
<ToggleButton {...args} selected={selected} onclick={() => selected = !selected}>
|
||||
<ToggleButton
|
||||
{...args}
|
||||
selected={selected}
|
||||
onclick={() => {
|
||||
selected = !selected;
|
||||
}}
|
||||
>
|
||||
Click to toggle
|
||||
</ToggleButton>
|
||||
<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">
|
||||
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
|
||||
@@ -30,6 +30,7 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
|
||||
</script>
|
||||
|
||||
@@ -40,7 +41,7 @@ const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, v
|
||||
label: 'Size',
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ComboControl>)}
|
||||
<ComboControl {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
@@ -5,16 +5,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import {
|
||||
Content as PopoverContent,
|
||||
Root as PopoverRoot,
|
||||
Trigger as PopoverTrigger,
|
||||
} from '$shared/shadcn/ui/popover';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Slider } from '$shared/ui';
|
||||
import { Button } from '$shared/ui/Button';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { Popover } from 'bits-ui';
|
||||
import clsx from 'clsx';
|
||||
import TechText from '../TechText/TechText.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -66,7 +62,9 @@ let open = $state(false);
|
||||
// Smart value formatting matching the Figma design
|
||||
const formattedValue = $derived(() => {
|
||||
const v = control.value;
|
||||
if (Number.isInteger(v)) return String(v);
|
||||
if (Number.isInteger(v)) {
|
||||
return String(v);
|
||||
}
|
||||
return control.step < 0.1 ? v.toFixed(2) : v.toFixed(1);
|
||||
});
|
||||
|
||||
@@ -80,7 +78,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
-->
|
||||
{#if reduced}
|
||||
<div
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'flex gap-4 items-end w-full',
|
||||
className,
|
||||
)}
|
||||
@@ -100,7 +98,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
<div class={cn('flex items-center px-1 relative', className)}>
|
||||
<div class={clsx('flex items-center px-1 relative', className)}>
|
||||
<!-- Decrease button -->
|
||||
<Button
|
||||
variant="icon"
|
||||
@@ -116,12 +114,12 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- Trigger -->
|
||||
<div class="relative mx-1">
|
||||
<PopoverRoot bind:open>
|
||||
<PopoverTrigger>
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'flex flex-col items-center justify-center w-14 py-1',
|
||||
'select-none rounded-none transition-all duration-150',
|
||||
'border border-transparent',
|
||||
@@ -130,7 +128,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
? 'bg-paper dark:bg-dark-card shadow-sm border-subtle'
|
||||
: 'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
||||
)}
|
||||
aria-label={controlLabel}
|
||||
aria-label={controlLabel ? `${controlLabel}: ${formattedValue()}` : undefined}
|
||||
>
|
||||
<!-- Label row -->
|
||||
{#if displayLabel}
|
||||
@@ -151,10 +149,10 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
</TechText>
|
||||
</button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
</Popover.Trigger>
|
||||
|
||||
<!-- Vertical slider popover -->
|
||||
<PopoverContent
|
||||
<Popover.Content
|
||||
class="w-auto py-4 px-3 h-64 flex items-center justify-center rounded-none border border-subtle shadow-sm bg-paper dark:bg-dark-card"
|
||||
align="center"
|
||||
side="top"
|
||||
@@ -167,8 +165,8 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
step={control.step}
|
||||
orientation="vertical"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
|
||||
<!-- Increase button -->
|
||||
|
||||
@@ -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 {
|
||||
fireEvent,
|
||||
@@ -40,837 +5,131 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/svelte';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import ComboControl from './ComboControl.svelte';
|
||||
|
||||
describe('ComboControl Component', () => {
|
||||
/**
|
||||
* Helper function to create a TypographyControl for testing
|
||||
*/
|
||||
function createTestControl(initialValue: number, options?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}) {
|
||||
function makeControl(value: number, opts: { min?: number; max?: number; step?: number } = {}) {
|
||||
return createTypographyControl({
|
||||
value: initialValue,
|
||||
min: options?.min ?? 0,
|
||||
max: options?.max ?? 100,
|
||||
step: options?.step ?? 1,
|
||||
value,
|
||||
min: opts.min ?? 0,
|
||||
max: opts.max ?? 100,
|
||||
step: opts.step ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
describe('ComboControl', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders all three buttons (decrease, control, increase)', () => {
|
||||
const control = createTestControl(50);
|
||||
render(ComboControl, {
|
||||
control,
|
||||
});
|
||||
|
||||
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 decrease and increase buttons', () => {
|
||||
render(ComboControl, { control: makeControl(50) });
|
||||
expect(screen.getByLabelText('Decrease')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Increase')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the current integer value', () => {
|
||||
render(ComboControl, { control: makeControl(42) });
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays decimal values on control button', () => {
|
||||
const control = createTestControl(12.5, { min: 0, max: 100, step: 0.5 });
|
||||
render(ComboControl, {
|
||||
control,
|
||||
it('formats decimal value to 1 decimal place when step >= 0.1', () => {
|
||||
render(ComboControl, { control: makeControl(1.5, { step: 0.1 }) });
|
||||
expect(screen.getByText('1.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const control = createTestControl(50);
|
||||
it('renders label when label prop is provided', () => {
|
||||
render(ComboControl, { control: makeControl(16), label: 'Size' });
|
||||
expect(screen.getByText('Size')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies custom aria-labels to buttons', () => {
|
||||
render(ComboControl, {
|
||||
control,
|
||||
control: makeControl(50),
|
||||
decreaseLabel: 'Decrease font size',
|
||||
controlLabel: 'Font size control',
|
||||
increaseLabel: 'Increase font size',
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Decrease font size')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Font size control')).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];
|
||||
expect(decreaseBtn).toBeInTheDocument();
|
||||
// Check for lucide icon SVG
|
||||
const svg = container.querySelector('button svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
describe('Disabled state', () => {
|
||||
it('disables decrease button when at min', () => {
|
||||
render(ComboControl, { control: makeControl(0, { min: 0 }) });
|
||||
expect(screen.getByLabelText('Decrease')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders increase button with plus icon', () => {
|
||||
const control = createTestControl(50);
|
||||
const { container } = render(ComboControl, {
|
||||
control,
|
||||
it('disables increase button when at max', () => {
|
||||
render(ComboControl, { control: makeControl(100, { max: 100 }) });
|
||||
expect(screen.getByLabelText('Increase')).toBeDisabled();
|
||||
});
|
||||
|
||||
const increaseBtn = screen.getAllByRole('button')[2];
|
||||
expect(increaseBtn).toBeInTheDocument();
|
||||
// Check for lucide icon SVG
|
||||
const svgs = container.querySelectorAll('button svg');
|
||||
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();
|
||||
it('enables both buttons when value is within bounds', () => {
|
||||
render(ComboControl, { control: makeControl(50, { min: 0, max: 100 }) });
|
||||
expect(screen.getByLabelText('Decrease')).not.toBeDisabled();
|
||||
expect(screen.getByLabelText('Increase')).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button States', () => {
|
||||
it('disables decrease button when at min value', () => {
|
||||
const control = createTestControl(0, { min: 0, max: 100 });
|
||||
const { container } = render(ComboControl, {
|
||||
control,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
describe('Click interactions', () => {
|
||||
it('decreases value on decrease button click', async () => {
|
||||
const control = makeControl(50, { step: 5 });
|
||||
render(ComboControl, { control });
|
||||
await fireEvent.click(screen.getByLabelText('Decrease'));
|
||||
expect(control.value).toBe(45);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('45')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('increase button increases value by step', async () => {
|
||||
const control = createTestControl(50, { min: 0, max: 100, step: 5 });
|
||||
render(ComboControl, {
|
||||
control,
|
||||
});
|
||||
|
||||
const increaseBtn = screen.getAllByRole('button')[2];
|
||||
await fireEvent.click(increaseBtn);
|
||||
|
||||
it('increases value on increase button click', async () => {
|
||||
const control = makeControl(50, { step: 5 });
|
||||
render(ComboControl, { control });
|
||||
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||
expect(control.value).toBe(55);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('55')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('value updates on control button after multiple clicks', async () => {
|
||||
const control = createTestControl(50, { min: 0, max: 100 });
|
||||
render(ComboControl, {
|
||||
control,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
it('clamps decrease at min', async () => {
|
||||
const control = makeControl(2, { min: 0, step: 5 });
|
||||
render(ComboControl, { control });
|
||||
await fireEvent.click(screen.getByLabelText('Decrease'));
|
||||
expect(control.value).toBe(0);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('increase button does not go above max', async () => {
|
||||
const control = createTestControl(99, { min: 0, max: 100, step: 5 });
|
||||
render(ComboControl, {
|
||||
control,
|
||||
});
|
||||
|
||||
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(() => {
|
||||
it('clamps increase at max', async () => {
|
||||
const control = makeControl(98, { max: 100, step: 5 });
|
||||
render(ComboControl, { control });
|
||||
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||
expect(control.value).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid input (non-numeric)', async () => {
|
||||
const control = createTestControl(50, { min: 0, max: 100 });
|
||||
render(ComboControl, {
|
||||
control,
|
||||
});
|
||||
|
||||
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('updates displayed value after click', async () => {
|
||||
const control = makeControl(50);
|
||||
render(ComboControl, { control });
|
||||
await fireEvent.click(screen.getByLabelText('Increase'));
|
||||
await waitFor(() => expect(screen.getByText('51')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty input gracefully', async () => {
|
||||
const control = createTestControl(50, { min: 0, max: 100 });
|
||||
render(ComboControl, {
|
||||
control,
|
||||
});
|
||||
|
||||
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();
|
||||
describe('Popover', () => {
|
||||
it('opens popover with vertical slider on trigger click', async () => {
|
||||
render(ComboControl, { control: makeControl(50), controlLabel: 'Size control' });
|
||||
expect(screen.queryByRole('slider')).not.toBeInTheDocument();
|
||||
await fireEvent.click(screen.getByText('Size control'));
|
||||
await waitFor(() => expect(screen.getByRole('slider')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
it('button states update when external value changes', async () => {
|
||||
const control = createTestControl(50, { min: 0, max: 100 });
|
||||
const { container } = render(ComboControl, {
|
||||
control,
|
||||
describe('Reduced mode', () => {
|
||||
it('renders horizontal slider directly without popover trigger', () => {
|
||||
render(ComboControl, { control: makeControl(50), reduced: true });
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Decrease')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Increase')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const buttons = container.querySelectorAll('button');
|
||||
|
||||
// Both should be enabled
|
||||
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);
|
||||
it('shows formatted value in reduced mode', () => {
|
||||
const { container } = render(ComboControl, { control: makeControl(75), reduced: true });
|
||||
expect(container.textContent).toContain('75');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,8 @@ const { Story } = defineMeta({
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let value = $state('Here we can type and edit the content. Try it!');
|
||||
let smallValue = $state('Small font size for compact text.');
|
||||
let largeValue = $state('Large font size for emphasis.');
|
||||
@@ -55,7 +57,7 @@ let longValue = $state(
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||
<ContentEditable {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -69,7 +71,7 @@ let longValue = $state(
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||
<ContentEditable {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -83,7 +85,7 @@ let longValue = $state(
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||
<ContentEditable {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -97,7 +99,7 @@ let longValue = $state(
|
||||
letterSpacing: 0.3,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||
<ContentEditable {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -111,7 +113,7 @@ let longValue = $state(
|
||||
letterSpacing: 0,
|
||||
}}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||
<ContentEditable {...args} />
|
||||
{/snippet}
|
||||
</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>
|
||||
@@ -3,7 +3,7 @@
|
||||
Labeled container for form controls
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -24,7 +24,7 @@ interface Props {
|
||||
const { label, children, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
|
||||
<div class={clsx('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
|
||||
<div class="flex justify-between items-center text-xs font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
1px separator line, horizontal or vertical.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ let {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'bg-black/10 dark:bg-white/10',
|
||||
orientation === 'horizontal' ? 'w-full h-px' : 'w-px h-full',
|
||||
className,
|
||||
|
||||
@@ -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 lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
const defaultFilter = createFilter({
|
||||
properties: [{
|
||||
id: 'cats',
|
||||
@@ -64,14 +65,20 @@ const selectedFilter = createFilter({
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
{#snippet template(args)}
|
||||
<FilterGroup filter={defaultFilter} displayedLabel="Zoo" {...args} />
|
||||
<Story
|
||||
name="Default"
|
||||
args={{ filter: defaultFilter, displayedLabel: 'Zoo' }}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FilterGroup>)}
|
||||
<FilterGroup {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Selected">
|
||||
{#snippet template(args)}
|
||||
<FilterGroup filter={selectedFilter} displayedLabel="Shopping list" {...args} />
|
||||
<Story
|
||||
name="Selected"
|
||||
args={{ filter: selectedFilter, displayedLabel: 'Shopping list' }}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof FilterGroup>)}
|
||||
<FilterGroup {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Filter } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||
import clsx from 'clsx';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
draw,
|
||||
@@ -68,7 +68,7 @@ $effect(() => {
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class={cn('flex flex-col', className)}>
|
||||
<div class={clsx('flex flex-col', className)}>
|
||||
<Label
|
||||
variant="default"
|
||||
size="sm"
|
||||
|
||||
@@ -1,573 +1,104 @@
|
||||
import {
|
||||
type Property,
|
||||
createFilter,
|
||||
} from '$shared/lib';
|
||||
import { createFilter } from '$shared/lib';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/svelte';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import FilterGroup from './FilterGroup.svelte';
|
||||
|
||||
/**
|
||||
* 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[] = []) {
|
||||
function makeProperties(count: number, selectedIndices: number[] = []) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `prop-${i}`,
|
||||
name: `Property ${i}`,
|
||||
value: `Value ${i}`,
|
||||
name: `Option ${i}`,
|
||||
value: `val-${i}`,
|
||||
selected: selectedIndices.includes(i),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('FilterGroup', () => {
|
||||
describe('Rendering', () => {
|
||||
it('displays the label', () => {
|
||||
const filter = createTestFilter(createMockProperties(3));
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test Label',
|
||||
filter,
|
||||
it('renders the group label', () => {
|
||||
const filter = createFilter({ properties: makeProperties(2) });
|
||||
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
it('renders no buttons for empty filter', () => {
|
||||
const filter = createFilter({ properties: [] });
|
||||
render(FilterGroup, { displayedLabel: 'Empty', filter });
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
const properties = createMockProperties(3);
|
||||
const filter = createTestFilter(properties);
|
||||
const { container } = render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
describe('Selection state', () => {
|
||||
it('selected property button has active styling', () => {
|
||||
const filter = createFilter({ properties: makeProperties(2, [0]) });
|
||||
const { container } = render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||
const buttons = container.querySelectorAll<HTMLButtonElement>('button');
|
||||
expect(buttons[0]).toHaveClass('shadow-sm');
|
||||
expect(buttons[1]).not.toHaveClass('shadow-sm');
|
||||
});
|
||||
|
||||
// Badge should not be in the document
|
||||
const badges = container.querySelectorAll('[class*="badge"]');
|
||||
expect(badges).toHaveLength(0);
|
||||
it('toggling a button updates property.selected', async () => {
|
||||
const filter = createFilter({ properties: makeProperties(2) });
|
||||
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', () => {
|
||||
const filter = createTestFilter([]);
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Empty Filter',
|
||||
filter,
|
||||
it('clicking selected button deselects it', async () => {
|
||||
const filter = createFilter({ properties: makeProperties(2, [0]) });
|
||||
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||
expect(filter.properties[0].selected).toBe(true);
|
||||
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', () => {
|
||||
it('checkboxes reflect initial selected state', async () => {
|
||||
const properties = createMockProperties(3, [0, 2]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
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();
|
||||
});
|
||||
describe('Show more', () => {
|
||||
it('shows all properties when count <= 10', () => {
|
||||
const filter = createFilter({ properties: makeProperties(10) });
|
||||
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(screen.getByText(`Option ${i}`)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('badge visibility changes with hasSelection (selectedCount > 0)', async () => {
|
||||
const properties = createMockProperties(2, [0]);
|
||||
const filter = createTestFilter(properties);
|
||||
render(FilterGroup, {
|
||||
displayedLabel: 'Test',
|
||||
filter,
|
||||
it('hides extra properties and shows show-more button when count > 10', () => {
|
||||
const filter = createFilter({ properties: makeProperties(15) });
|
||||
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||
expect(screen.queryByText('Option 10')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initially has 1 selection, badge should be visible
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
|
||||
// Uncheck the selected item
|
||||
const checkboxes = await screen.findAllByRole('checkbox');
|
||||
await fireEvent.click(checkboxes[0]);
|
||||
|
||||
// Now 0 selections, badge should be hidden
|
||||
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);
|
||||
});
|
||||
it('clicking show-more reveals all properties', async () => {
|
||||
const filter = createFilter({ properties: makeProperties(12) });
|
||||
render(FilterGroup, { displayedLabel: 'Category', filter });
|
||||
expect(screen.queryByText('Option 10')).not.toBeInTheDocument();
|
||||
const showMoreBtn = screen.getAllByRole('button').at(-1)!;
|
||||
await fireEvent.click(showMoreBtn);
|
||||
await waitFor(() => expect(screen.getByText('Option 10')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Option 11')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,8 +16,12 @@ const { Story } = defineMeta({
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Footnote>)}
|
||||
<Footnote {...args}>
|
||||
Footnote
|
||||
</Footnote>
|
||||
@@ -25,7 +29,7 @@ const { Story } = defineMeta({
|
||||
</Story>
|
||||
|
||||
<Story name="With custom render">
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Footnote>)}
|
||||
<Footnote {...args}>
|
||||
{#snippet render({ class: className })}
|
||||
<span class={className}>Footnote</span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Provides classes for styling footnotes
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -26,14 +26,14 @@ const { children, class: className, render }: Props = $props();
|
||||
|
||||
{#if render}
|
||||
{@render render({
|
||||
class: cn(
|
||||
class: clsx(
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
),
|
||||
})}
|
||||
{:else if children}
|
||||
<span
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -61,42 +61,43 @@ const { Story } = defineMeta({
|
||||
<script lang="ts">
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import ClearIcon from '@lucide/svelte/icons/x';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
let value = $state('');
|
||||
const placeholder = 'Enter text';
|
||||
</script>
|
||||
|
||||
<!-- Default Story (Left Aligned) -->
|
||||
<Story name="Default" args={{ placeholder, value }}>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||
<Input {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="All sizes" args={{ value }}>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||
<div class="flex flex-col gap-4">
|
||||
<Input size="sm" placeholder="Size sm" {...args} />
|
||||
<Input size="md" placeholder="Size md" {...args} />
|
||||
<Input size="lg" placeholder="Size lg" {...args} />
|
||||
<Input size="xl" placeholder="Size xl" {...args} />
|
||||
<Input {...args} size="sm" placeholder="Size sm" />
|
||||
<Input {...args} size="md" placeholder="Size md" />
|
||||
<Input {...args} size="lg" placeholder="Size lg" />
|
||||
<Input {...args} size="xl" placeholder="Size xl" />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Underlined" args={{ placeholder, value }}>
|
||||
{#snippet template(args)}
|
||||
<Input variant="underline" {...args} />
|
||||
<Story name="Underlined" args={{ placeholder, value, variant: 'underline' }}>
|
||||
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||
<Input {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Filled" args={{ placeholder, value }}>
|
||||
{#snippet template(args)}
|
||||
<Input variant="filled" {...args} />
|
||||
<Story name="Filled" args={{ placeholder, value, variant: 'filled' }}>
|
||||
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||
<Input {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="With icon on the right" args={{ placeholder, value }}>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||
<Input {...args}>
|
||||
{#snippet rightIcon()}
|
||||
<SearchIcon />
|
||||
@@ -106,7 +107,7 @@ const placeholder = 'Enter text';
|
||||
</Story>
|
||||
|
||||
<Story name="With icon on the left" args={{ placeholder, value }}>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||
<Input {...args}>
|
||||
{#snippet leftIcon()}
|
||||
<SearchIcon />
|
||||
@@ -115,9 +116,9 @@ const placeholder = 'Enter text';
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="With clear button" args={{ placeholder, value }}>
|
||||
{#snippet template(args)}
|
||||
<Input showClearButton {...args}>
|
||||
<Story name="With clear button" args={{ placeholder, value, showClearButton: true }}>
|
||||
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||
<Input {...args}>
|
||||
{#snippet rightIcon()}
|
||||
<ClearIcon />
|
||||
{/snippet}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
design-system input. Zero border-radius, Space Grotesk, precise states.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
@@ -90,7 +90,7 @@ const hasRightSlot = $derived(!!rightIcon || showClearButton);
|
||||
const cfg = $derived(inputSizeConfig[size]);
|
||||
const styles = $derived(inputVariantConfig[variant]);
|
||||
|
||||
const inputClasses = $derived(cn(
|
||||
const inputClasses = $derived(clsx(
|
||||
'font-primary rounded-none outline-none transition-all duration-200',
|
||||
'text-neutral-900 dark:text-neutral-100',
|
||||
'placeholder:text-neutral-400 dark:placeholder:text-neutral-600',
|
||||
@@ -107,8 +107,8 @@ const inputClasses = $derived(cn(
|
||||
));
|
||||
</script>
|
||||
|
||||
<div class={cn('flex flex-col gap-1', fullWidth && 'w-full')}>
|
||||
<div class={cn('relative group', fullWidth && 'w-full')}>
|
||||
<div class={clsx('flex flex-col gap-1', fullWidth && 'w-full')}>
|
||||
<div class={clsx('relative group', fullWidth && 'w-full')}>
|
||||
<!-- Left icon slot -->
|
||||
{#if leftIcon}
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-600 pointer-events-none z-10 flex items-center">
|
||||
@@ -147,7 +147,7 @@ const inputClasses = $derived(cn(
|
||||
<!-- Helper / error text -->
|
||||
{#if helperText}
|
||||
<span
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'text-2xs font-mono tracking-wide px-1',
|
||||
error ? 'text-brand ' : 'text-secondary',
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -47,13 +47,14 @@ const { Story } = defineMeta({
|
||||
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default/Basic"
|
||||
parameters={{ docs: { description: { story: 'Standard label with default styling' } } }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Default Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -72,7 +73,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Default variant"
|
||||
args={{ variant: 'default', size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Default Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -81,7 +82,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Accent variant"
|
||||
args={{ variant: 'accent', size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Accent Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -90,7 +91,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Muted variant"
|
||||
args={{ variant: 'muted', size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Muted Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -99,7 +100,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Success variant"
|
||||
args={{ variant: 'success', size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Success Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -108,7 +109,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Warning variant"
|
||||
args={{ variant: 'warning', size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Warning Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -117,7 +118,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Error variant"
|
||||
args={{ variant: 'error', size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Error Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -139,7 +140,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Uppercase"
|
||||
args={{ uppercase: true, size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Uppercase Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -148,7 +149,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Lowercase"
|
||||
args={{ uppercase: false, size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>lowercase label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -157,7 +158,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="Bold"
|
||||
args={{ bold: true, size: 'sm' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>Bold Label</Label>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -166,7 +167,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="With icon (left)"
|
||||
args={{ variant: 'default', size: 'sm', iconPosition: 'left' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>
|
||||
{#snippet icon()}
|
||||
<CircleIcon size={10} />
|
||||
@@ -180,7 +181,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
name="With icon (right)"
|
||||
args={{ variant: 'default', size: 'sm', iconPosition: 'right' }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||
<Label {...args}>
|
||||
Label with icon
|
||||
{#snippet icon()}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Inline monospace label. The base primitive for all micrographic text.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type LabelFont,
|
||||
@@ -72,7 +72,7 @@ let {
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
class={clsx(
|
||||
'font-mono tracking-widest leading-none',
|
||||
'inline-flex items-center gap-1.5',
|
||||
font === 'primary' && 'font-primary tracking-tight',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -28,8 +28,12 @@ const { Story } = defineMeta({
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Loader>)}
|
||||
<Loader {...args} />
|
||||
{/snippet}
|
||||
</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();
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,12 @@ const { Story } = defineMeta({
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
{#snippet template(args)}
|
||||
{#snippet template(args: ComponentProps<typeof Logo>)}
|
||||
<Logo {...args} />
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Project logo with apropriate styles
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Badge } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ const { class: className }: Props = $props();
|
||||
const title = 'GLYPHDIFF';
|
||||
</script>
|
||||
|
||||
<div class={cn('flex items-center gap-2 md:gap-3 select-none', className)}>
|
||||
<div class={clsx('flex items-center gap-2 md:gap-3 select-none', className)}>
|
||||
<h1 class="font-logo font-extrabold text-base md:text-xl tracking-tight text-swiss-black dark:text-neutral-200">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
@@ -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>
|
||||
@@ -5,7 +5,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { PerspectiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import clsx from 'clsx';
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -61,7 +61,9 @@ const style = $derived.by(() => {
|
||||
|
||||
// Calculate horizontal constraints based on region
|
||||
const regionStyleStr = $derived(() => {
|
||||
if (region === 'full') return '';
|
||||
if (region === 'full') {
|
||||
return '';
|
||||
}
|
||||
const side = region === 'left' ? 'left' : 'right';
|
||||
return `position: absolute; ${side}: 0; width: ${regionWidth}%; top: 0; bottom: 0;`;
|
||||
});
|
||||
@@ -71,7 +73,7 @@ const isVisible = $derived(manager.isFront);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('will-change-transform', className)}
|
||||
class={clsx('will-change-transform', className)}
|
||||
style:transform-style="preserve-3d"
|
||||
style:transform={style?.transform}
|
||||
style:filter={style?.filter}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user