Compare commits
19 Commits
8a93c7b545
...
f55043a1e7
| Author | SHA1 | Date | |
|---|---|---|---|
| f55043a1e7 | |||
| 409dd1b229 | |||
| 9fbce095b2 | |||
| 171627e0ea | |||
| d07fb1a3af | |||
| 6f84644ecb | |||
| 5ab5cda611 | |||
| 7975d9aeee | |||
| 2ba5fc0e3e | |||
| 1947d7731e | |||
| 38bfc4ba4b | |||
| 6cf3047b74 | |||
| 81363156d7 | |||
| bb65f1c8d6 | |||
| 5eb9584797 | |||
| bb5c3667b4 | |||
| 3711616a91 | |||
| 6905c54040 | |||
| 1e8e22e2eb |
@@ -7,7 +7,7 @@
|
|||||||
/* Base font size */
|
/* Base font size */
|
||||||
--font-size: 16px;
|
--font-size: 16px;
|
||||||
|
|
||||||
/* GLYPHDIFF Swiss Design System */
|
/* GLYPHDIFF Design System */
|
||||||
/* Primary Colors */
|
/* Primary Colors */
|
||||||
--swiss-beige: #f3f0e9;
|
--swiss-beige: #f3f0e9;
|
||||||
--swiss-red: #ff3b30;
|
--swiss-red: #ff3b30;
|
||||||
@@ -206,10 +206,10 @@
|
|||||||
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
--font-secondary: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
|
||||||
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
|
/* Micro typography scale — extends Tailwind's text-xs (0.75rem) downward */
|
||||||
--font-size-5xs: 0.4375rem;
|
--text-5xs: 0.4375rem;
|
||||||
--font-size-4xs: 0.5rem;
|
--text-4xs: 0.5rem;
|
||||||
--font-size-3xs: 0.5625rem;
|
--text-3xs: 0.5625rem;
|
||||||
--font-size-2xs: 0.625rem;
|
--text-2xs: 0.625rem;
|
||||||
/* Monospace label tracking — used in Loader and Footnote */
|
/* Monospace label tracking — used in Loader and Footnote */
|
||||||
--tracking-wider-mono: 0.2em;
|
--tracking-wider-mono: 0.2em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -35,6 +35,7 @@ const { Story } = defineMeta({
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { UnifiedFont } from '$entities/Font';
|
import type { UnifiedFont } from '$entities/Font';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
// Mock fonts for testing
|
// Mock fonts for testing
|
||||||
const mockArial: UnifiedFont = {
|
const mockArial: UnifiedFont = {
|
||||||
@@ -88,7 +89,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
index: 0,
|
index: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
<Providers>
|
<Providers>
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<FontSampler {...args} />
|
<FontSampler {...args} />
|
||||||
@@ -105,7 +106,7 @@ const mockGeorgia: UnifiedFont = {
|
|||||||
index: 1,
|
index: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof FontSampler>)}
|
||||||
<Providers>
|
<Providers>
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<FontSampler {...args} />
|
<FontSampler {...args} />
|
||||||
|
|||||||
@@ -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,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 initialWidth={375}>
|
||||||
|
<div style="width: 375px;">
|
||||||
|
<FilterControls />
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<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',
|
||||||
|
},
|
||||||
|
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="Mobile">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers initialWidth={375}>
|
||||||
|
<div class="relative h-20 flex items-end justify-end p-4">
|
||||||
|
<TypographyMenu />
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Hidden">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<div class="relative h-20 flex items-end justify-center p-4">
|
||||||
|
<TypographyMenu hidden={true} />
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: TypographyMenu
|
Component: TypographyMenu
|
||||||
Floating controls bar for typography settings.
|
Floating controls bar for typography settings.
|
||||||
Warm surface, sharp corners, Settings icon header, dividers between units.
|
|
||||||
Mobile: popover with slider controls anchored to settings button.
|
Mobile: popover with slider controls anchored to settings button.
|
||||||
Desktop: inline bar with combo controls.
|
Desktop: inline bar with combo controls.
|
||||||
-->
|
-->
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
ControlGroup,
|
ControlGroup,
|
||||||
Slider,
|
Slider,
|
||||||
@@ -36,14 +36,17 @@ interface Props {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/**
|
||||||
|
* Bindable popover open state
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
open?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { class: className, hidden = false }: Props = $props();
|
let { class: className, hidden = false, open = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
let isOpen = $state(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the common font size multiplier based on the current responsive state.
|
* Sets the common font size multiplier based on the current responsive state.
|
||||||
*/
|
*/
|
||||||
@@ -68,32 +71,22 @@ $effect(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !hidden}
|
{#if !hidden}
|
||||||
{#if responsive.isMobile}
|
{#if responsive.isMobileOrTablet}
|
||||||
<Popover.Root bind:open={isOpen}>
|
<Popover.Root bind:open>
|
||||||
<Popover.Trigger>
|
<Popover.Trigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<button
|
<Button class={className} variant="primary" {...props}>
|
||||||
{...props}
|
{#snippet icon()}
|
||||||
class={clsx(
|
<Settings2Icon class="size-4" />
|
||||||
'inline-flex items-center justify-center',
|
{/snippet}
|
||||||
'size-8 p-0',
|
</Button>
|
||||||
'border border-transparent rounded-none',
|
|
||||||
'transition-colors duration-150',
|
|
||||||
'hover:bg-white/50 dark:hover:bg-white/5',
|
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
|
||||||
isOpen && 'bg-paper dark:bg-dark-card border-subtle shadow-sm',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Settings2Icon class="size-4" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|
||||||
<Popover.Portal>
|
<Popover.Portal>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
class={clsx(
|
class={clsx(
|
||||||
'z-50 w-72',
|
'z-50 w-72',
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
import ButtonGroup from './ButtonGroup.svelte';
|
import ButtonGroup from './ButtonGroup.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
|||||||
name="Default/Basic"
|
name="Default/Basic"
|
||||||
parameters={{ docs: { description: { story: 'Standard text button at all sizes' } } }}
|
parameters={{ docs: { description: { story: 'Standard text button at all sizes' } } }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<Button {...args} size="xs">xs</Button>
|
<Button {...args} size="xs">xs</Button>
|
||||||
<Button {...args} size="sm">sm</Button>
|
<Button {...args} size="sm">sm</Button>
|
||||||
@@ -67,7 +68,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
|||||||
name="Default/With Icon"
|
name="Default/With Icon"
|
||||||
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||||
<Button {...args}>
|
<Button {...args}>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<XIcon />
|
<XIcon />
|
||||||
@@ -81,7 +82,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
|||||||
name="Primary"
|
name="Primary"
|
||||||
args={{ variant: 'primary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
args={{ variant: 'primary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||||
<Button {...args}>Primary</Button>
|
<Button {...args}>Primary</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -90,7 +91,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
|||||||
name="Secondary"
|
name="Secondary"
|
||||||
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||||
<Button {...args}>Secondary</Button>
|
<Button {...args}>Secondary</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -99,7 +100,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
|||||||
name="Icon"
|
name="Icon"
|
||||||
args={{ variant: 'icon', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
args={{ variant: 'icon', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||||
<Button {...args}>
|
<Button {...args}>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<XIcon />
|
<XIcon />
|
||||||
@@ -112,7 +113,7 @@ import ButtonGroup from './ButtonGroup.svelte';
|
|||||||
name="Ghost"
|
name="Ghost"
|
||||||
args={{ variant: 'ghost', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
args={{ variant: 'ghost', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Button>)}
|
||||||
<Button {...args}>
|
<Button {...args}>
|
||||||
Ghost
|
Ghost
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { createRawSnippet } from 'svelte';
|
||||||
|
import Button from './Button.svelte';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a plain text snippet for passing as children/icon prop
|
||||||
|
*/
|
||||||
|
function textSnippet(text: string) {
|
||||||
|
return createRawSnippet(() => ({
|
||||||
|
render: () => `<span>${text}</span>`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders with text label', () => {
|
||||||
|
render(Button, { children: textSnippet('Click me') });
|
||||||
|
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has type="button" by default', () => {
|
||||||
|
render(Button, { children: textSnippet('Submit') });
|
||||||
|
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom type', () => {
|
||||||
|
render(Button, { children: textSnippet('Submit'), type: 'submit' });
|
||||||
|
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon-only when icon snippet is provided without children', () => {
|
||||||
|
render(Button, { icon: textSnippet('×') });
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Disabled state', () => {
|
||||||
|
it('is not disabled by default', () => {
|
||||||
|
render(Button, { children: textSnippet('Click') });
|
||||||
|
expect(screen.getByRole('button')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled when disabled prop is true', () => {
|
||||||
|
render(Button, { children: textSnippet('Click'), disabled: true });
|
||||||
|
expect(screen.getByRole('button')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Click interaction', () => {
|
||||||
|
it('calls onclick when clicked', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
render(Button, { children: textSnippet('Click'), onclick: handler });
|
||||||
|
await fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(handler).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onclick multiple times on repeated clicks', async () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
render(Button, { children: textSnippet('Click'), onclick: handler });
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
expect(handler).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Variants', () => {
|
||||||
|
it.each(['primary', 'secondary', 'outline', 'ghost', 'icon', 'tertiary'] as const)(
|
||||||
|
'renders %s variant without error',
|
||||||
|
variant => {
|
||||||
|
render(Button, { children: textSnippet('Click'), variant });
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sizes', () => {
|
||||||
|
it.each(['xs', 'sm', 'md', 'lg', 'xl'] as const)('renders %s size without error', size => {
|
||||||
|
render(Button, { children: textSnippet('Click'), size });
|
||||||
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,10 +27,11 @@ const { Story } = defineMeta({
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$shared/ui/Button';
|
import { Button } from '$shared/ui/Button';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Default">
|
<Story name="Default">
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||||
<ButtonGroup {...args}>
|
<ButtonGroup {...args}>
|
||||||
<Button variant="tertiary">Option 1</Button>
|
<Button variant="tertiary">Option 1</Button>
|
||||||
<Button variant="tertiary">Option 2</Button>
|
<Button variant="tertiary">Option 2</Button>
|
||||||
@@ -40,7 +41,7 @@ import { Button } from '$shared/ui/Button';
|
|||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Horizontal">
|
<Story name="Horizontal">
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||||
<ButtonGroup {...args}>
|
<ButtonGroup {...args}>
|
||||||
<Button variant="tertiary">Day</Button>
|
<Button variant="tertiary">Day</Button>
|
||||||
<Button variant="tertiary" active>Week</Button>
|
<Button variant="tertiary" active>Week</Button>
|
||||||
@@ -51,7 +52,7 @@ import { Button } from '$shared/ui/Button';
|
|||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Vertical">
|
<Story name="Vertical">
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||||
<ButtonGroup {...args} class="flex-col">
|
<ButtonGroup {...args} class="flex-col">
|
||||||
<Button variant="tertiary">Top</Button>
|
<Button variant="tertiary">Top</Button>
|
||||||
<Button variant="tertiary" active>Middle</Button>
|
<Button variant="tertiary" active>Middle</Button>
|
||||||
@@ -61,7 +62,7 @@ import { Button } from '$shared/ui/Button';
|
|||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With Icons">
|
<Story name="With Icons">
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||||
<ButtonGroup {...args}>
|
<ButtonGroup {...args}>
|
||||||
<Button variant="tertiary">Grid</Button>
|
<Button variant="tertiary">Grid</Button>
|
||||||
<Button variant="tertiary" active>List</Button>
|
<Button variant="tertiary" active>List</Button>
|
||||||
@@ -78,7 +79,7 @@ import { Button } from '$shared/ui/Button';
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ButtonGroup>)}
|
||||||
<div class="p-8 bg-background text-foreground">
|
<div class="p-8 bg-background text-foreground">
|
||||||
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
||||||
<ButtonGroup {...args}>
|
<ButtonGroup {...args}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: SwissButtonGroup
|
Component: ButtonGroup
|
||||||
Wraps SwissButtons in a warm-surface pill with a 1px gap and subtle border.
|
Wraps buttons in a warm-surface pill with a small gap and subtle border.
|
||||||
Use for segmented controls, view toggles, or any mutually exclusive button set.
|
Use for segmented controls, view toggles, or any mutually exclusive button set.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|||||||
@@ -43,13 +43,14 @@ const { Story } = defineMeta({
|
|||||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||||
import SearchIcon from '@lucide/svelte/icons/search';
|
import SearchIcon from '@lucide/svelte/icons/search';
|
||||||
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Default"
|
name="Default"
|
||||||
args={{ variant: 'icon', size: 'md', active: false, animate: true }}
|
args={{ variant: 'icon', size: 'md', active: false, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof IconButton>)}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<IconButton {...args}>
|
<IconButton {...args}>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
@@ -74,7 +75,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2';
|
|||||||
name="Variants"
|
name="Variants"
|
||||||
args={{ size: 'md', active: false, animate: true }}
|
args={{ size: 'md', active: false, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof IconButton>)}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<IconButton {...args} variant="icon">
|
<IconButton {...args} variant="icon">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
@@ -99,7 +100,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2';
|
|||||||
name="Active State"
|
name="Active State"
|
||||||
args={{ size: 'md', animate: true }}
|
args={{ size: 'md', animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof IconButton>)}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<IconButton {...args} active={false} variant="icon">
|
<IconButton {...args} active={false} variant="icon">
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
@@ -123,7 +124,7 @@ import TrashIcon from '@lucide/svelte/icons/trash-2';
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof IconButton>)}
|
||||||
<div class="p-8 bg-background text-foreground">
|
<div class="p-8 bg-background text-foreground">
|
||||||
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
let selected = $state(false);
|
let selected = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ let selected = $state(false);
|
|||||||
name="Default"
|
name="Default"
|
||||||
args={{ variant: 'tertiary', size: 'md', selected: false, animate: true }}
|
args={{ variant: 'tertiary', size: 'md', selected: false, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||||
<ToggleButton {...args}>Toggle Me</ToggleButton>
|
<ToggleButton {...args}>Toggle Me</ToggleButton>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -60,7 +61,7 @@ let selected = $state(false);
|
|||||||
name="Selected/Unselected"
|
name="Selected/Unselected"
|
||||||
args={{ variant: 'tertiary', size: 'md', animate: true }}
|
args={{ variant: 'tertiary', size: 'md', animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<ToggleButton {...args} selected={false}>
|
<ToggleButton {...args} selected={false}>
|
||||||
Unselected
|
Unselected
|
||||||
@@ -76,7 +77,7 @@ let selected = $state(false);
|
|||||||
name="Variants"
|
name="Variants"
|
||||||
args={{ size: 'md', selected: true, animate: true }}
|
args={{ size: 'md', selected: true, animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<ToggleButton {...args} variant="primary">
|
<ToggleButton {...args} variant="primary">
|
||||||
Primary
|
Primary
|
||||||
@@ -101,9 +102,15 @@ let selected = $state(false);
|
|||||||
name="Interactive"
|
name="Interactive"
|
||||||
args={{ variant: 'tertiary', size: 'md', animate: true }}
|
args={{ variant: 'tertiary', size: 'md', animate: true }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<ToggleButton {...args} selected={selected} onclick={() => selected = !selected}>
|
<ToggleButton
|
||||||
|
{...args}
|
||||||
|
selected={selected}
|
||||||
|
onclick={() => {
|
||||||
|
selected = !selected;
|
||||||
|
}}
|
||||||
|
>
|
||||||
Click to toggle
|
Click to toggle
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<span class="text-sm text-muted-foreground">Currently: {selected ? 'selected' : 'unselected'}</span>
|
<span class="text-sm text-muted-foreground">Currently: {selected ? 'selected' : 'unselected'}</span>
|
||||||
@@ -119,7 +126,7 @@ let selected = $state(false);
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ToggleButton>)}
|
||||||
<div class="p-8 bg-background text-foreground">
|
<div class="p-8 bg-background text-foreground">
|
||||||
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
<h3 class="mb-4 text-sm font-medium">Dark Mode</h3>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
|
const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ const horizontalControl = createTypographyControl({ min: 0, max: 100, step: 1, v
|
|||||||
label: 'Size',
|
label: 'Size',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ComboControl>)}
|
||||||
<ComboControl {...args} />
|
<ComboControl {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
let value = $state('Here we can type and edit the content. Try it!');
|
let value = $state('Here we can type and edit the content. Try it!');
|
||||||
let smallValue = $state('Small font size for compact text.');
|
let smallValue = $state('Small font size for compact text.');
|
||||||
let largeValue = $state('Large font size for emphasis.');
|
let largeValue = $state('Large font size for emphasis.');
|
||||||
@@ -55,7 +57,7 @@ let longValue = $state(
|
|||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||||
<ContentEditable {...args} />
|
<ContentEditable {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -69,7 +71,7 @@ let longValue = $state(
|
|||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||||
<ContentEditable {...args} />
|
<ContentEditable {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -83,7 +85,7 @@ let longValue = $state(
|
|||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||||
<ContentEditable {...args} />
|
<ContentEditable {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -97,7 +99,7 @@ let longValue = $state(
|
|||||||
letterSpacing: 0.3,
|
letterSpacing: 0.3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||||
<ContentEditable {...args} />
|
<ContentEditable {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -111,7 +113,7 @@ let longValue = $state(
|
|||||||
letterSpacing: 0,
|
letterSpacing: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof ContentEditable>)}
|
||||||
<ContentEditable {...args} />
|
<ContentEditable {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import ControlGroup from './ControlGroup.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/ControlGroup',
|
||||||
|
component: ControlGroup,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: { component: 'Labelled section for grouping related sidebar controls, with a bottom border.' },
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
label: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Uppercase label shown above the control content',
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Additional CSS classes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Default/Basic">
|
||||||
|
{#snippet template()}
|
||||||
|
<div class="w-64">
|
||||||
|
<ControlGroup label="Font Size">
|
||||||
|
<div class="text-sm text-neutral-500">Control content here</div>
|
||||||
|
</ControlGroup>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="With form control">
|
||||||
|
{#snippet template()}
|
||||||
|
<div class="w-64">
|
||||||
|
<ControlGroup label="Weight">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="px-3 py-1 text-xs border border-neutral-300 rounded">100</button>
|
||||||
|
<button class="px-3 py-1 text-xs border border-neutral-300 rounded">400</button>
|
||||||
|
<button class="px-3 py-1 text-xs border border-neutral-300 rounded bg-neutral-900 text-white">
|
||||||
|
700
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ControlGroup>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { render } from '@testing-library/svelte';
|
||||||
|
import Divider from './Divider.svelte';
|
||||||
|
|
||||||
|
describe('Divider', () => {
|
||||||
|
it('renders horizontal by default', () => {
|
||||||
|
const { container } = render(Divider);
|
||||||
|
const el = container.querySelector('div');
|
||||||
|
expect(el).toHaveClass('w-full', 'h-px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders vertical when orientation="vertical"', () => {
|
||||||
|
const { container } = render(Divider, { orientation: 'vertical' });
|
||||||
|
const el = container.querySelector('div');
|
||||||
|
expect(el).toHaveClass('w-px', 'h-full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes additional class', () => {
|
||||||
|
const { container } = render(Divider, { class: 'my-custom' });
|
||||||
|
expect(container.querySelector('div')).toHaveClass('my-custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
const defaultFilter = createFilter({
|
const defaultFilter = createFilter({
|
||||||
properties: [{
|
properties: [{
|
||||||
id: 'cats',
|
id: 'cats',
|
||||||
@@ -64,14 +65,20 @@ const selectedFilter = createFilter({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Default">
|
<Story
|
||||||
{#snippet template(args)}
|
name="Default"
|
||||||
<FilterGroup filter={defaultFilter} displayedLabel="Zoo" {...args} />
|
args={{ filter: defaultFilter, displayedLabel: 'Zoo' }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof FilterGroup>)}
|
||||||
|
<FilterGroup {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Selected">
|
<Story
|
||||||
{#snippet template(args)}
|
name="Selected"
|
||||||
<FilterGroup filter={selectedFilter} displayedLabel="Shopping list" {...args} />
|
args={{ filter: selectedFilter, displayedLabel: 'Shopping list' }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof FilterGroup>)}
|
||||||
|
<FilterGroup {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|||||||
@@ -16,8 +16,12 @@ const { Story } = defineMeta({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
<Story name="Default">
|
<Story name="Default">
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Footnote>)}
|
||||||
<Footnote {...args}>
|
<Footnote {...args}>
|
||||||
Footnote
|
Footnote
|
||||||
</Footnote>
|
</Footnote>
|
||||||
@@ -25,7 +29,7 @@ const { Story } = defineMeta({
|
|||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With custom render">
|
<Story name="With custom render">
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Footnote>)}
|
||||||
<Footnote {...args}>
|
<Footnote {...args}>
|
||||||
{#snippet render({ class: className })}
|
{#snippet render({ class: className })}
|
||||||
<span class={className}>Footnote</span>
|
<span class={className}>Footnote</span>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { createRawSnippet } from 'svelte';
|
||||||
|
import Footnote from './Footnote.svelte';
|
||||||
|
|
||||||
|
function textSnippet(text: string) {
|
||||||
|
return createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Footnote', () => {
|
||||||
|
it('renders children content in a span', () => {
|
||||||
|
render(Footnote, { children: textSnippet('* measured at 20°C') });
|
||||||
|
expect(screen.getByText('* measured at 20°C')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when no children or render', () => {
|
||||||
|
const { container } = render(Footnote);
|
||||||
|
expect(container.firstElementChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes class to the render snippet', () => {
|
||||||
|
const renderSnippet = createRawSnippet<[{ class: string }]>(getParams => ({
|
||||||
|
render: () => {
|
||||||
|
const cls = getParams().class;
|
||||||
|
return `<span class="${cls}">note</span>`;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const { container } = render(Footnote, { render: renderSnippet });
|
||||||
|
expect(container.querySelector('span')?.className).toContain('font-mono');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,42 +61,43 @@ const { Story } = defineMeta({
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SearchIcon from '@lucide/svelte/icons/search';
|
import SearchIcon from '@lucide/svelte/icons/search';
|
||||||
import ClearIcon from '@lucide/svelte/icons/x';
|
import ClearIcon from '@lucide/svelte/icons/x';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
let value = $state('');
|
let value = $state('');
|
||||||
const placeholder = 'Enter text';
|
const placeholder = 'Enter text';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Default Story (Left Aligned) -->
|
<!-- Default Story (Left Aligned) -->
|
||||||
<Story name="Default" args={{ placeholder, value }}>
|
<Story name="Default" args={{ placeholder, value }}>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||||
<Input {...args} />
|
<Input {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="All sizes" args={{ value }}>
|
<Story name="All sizes" args={{ value }}>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<Input size="sm" placeholder="Size sm" {...args} />
|
<Input {...args} size="sm" placeholder="Size sm" />
|
||||||
<Input size="md" placeholder="Size md" {...args} />
|
<Input {...args} size="md" placeholder="Size md" />
|
||||||
<Input size="lg" placeholder="Size lg" {...args} />
|
<Input {...args} size="lg" placeholder="Size lg" />
|
||||||
<Input size="xl" placeholder="Size xl" {...args} />
|
<Input {...args} size="xl" placeholder="Size xl" />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Underlined" args={{ placeholder, value }}>
|
<Story name="Underlined" args={{ placeholder, value, variant: 'underline' }}>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||||
<Input variant="underline" {...args} />
|
<Input {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Filled" args={{ placeholder, value }}>
|
<Story name="Filled" args={{ placeholder, value, variant: 'filled' }}>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||||
<Input variant="filled" {...args} />
|
<Input {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With icon on the right" args={{ placeholder, value }}>
|
<Story name="With icon on the right" args={{ placeholder, value }}>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||||
<Input {...args}>
|
<Input {...args}>
|
||||||
{#snippet rightIcon()}
|
{#snippet rightIcon()}
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
@@ -106,7 +107,7 @@ const placeholder = 'Enter text';
|
|||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With icon on the left" args={{ placeholder, value }}>
|
<Story name="With icon on the left" args={{ placeholder, value }}>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||||
<Input {...args}>
|
<Input {...args}>
|
||||||
{#snippet leftIcon()}
|
{#snippet leftIcon()}
|
||||||
<SearchIcon />
|
<SearchIcon />
|
||||||
@@ -115,9 +116,9 @@ const placeholder = 'Enter text';
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With clear button" args={{ placeholder, value }}>
|
<Story name="With clear button" args={{ placeholder, value, showClearButton: true }}>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Input>)}
|
||||||
<Input showClearButton {...args}>
|
<Input {...args}>
|
||||||
{#snippet rightIcon()}
|
{#snippet rightIcon()}
|
||||||
<ClearIcon />
|
<ClearIcon />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -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 AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
|
||||||
import CheckIcon from '@lucide/svelte/icons/check';
|
import CheckIcon from '@lucide/svelte/icons/check';
|
||||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Default/Basic"
|
name="Default/Basic"
|
||||||
parameters={{ docs: { description: { story: 'Standard label with default styling' } } }}
|
parameters={{ docs: { description: { story: 'Standard label with default styling' } } }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Default Label</Label>
|
<Label {...args}>Default Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -72,7 +73,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Default variant"
|
name="Default variant"
|
||||||
args={{ variant: 'default', size: 'sm' }}
|
args={{ variant: 'default', size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Default Label</Label>
|
<Label {...args}>Default Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -81,7 +82,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Accent variant"
|
name="Accent variant"
|
||||||
args={{ variant: 'accent', size: 'sm' }}
|
args={{ variant: 'accent', size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Accent Label</Label>
|
<Label {...args}>Accent Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -90,7 +91,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Muted variant"
|
name="Muted variant"
|
||||||
args={{ variant: 'muted', size: 'sm' }}
|
args={{ variant: 'muted', size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Muted Label</Label>
|
<Label {...args}>Muted Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -99,7 +100,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Success variant"
|
name="Success variant"
|
||||||
args={{ variant: 'success', size: 'sm' }}
|
args={{ variant: 'success', size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Success Label</Label>
|
<Label {...args}>Success Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -108,7 +109,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Warning variant"
|
name="Warning variant"
|
||||||
args={{ variant: 'warning', size: 'sm' }}
|
args={{ variant: 'warning', size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Warning Label</Label>
|
<Label {...args}>Warning Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -117,7 +118,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Error variant"
|
name="Error variant"
|
||||||
args={{ variant: 'error', size: 'sm' }}
|
args={{ variant: 'error', size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Error Label</Label>
|
<Label {...args}>Error Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -139,7 +140,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Uppercase"
|
name="Uppercase"
|
||||||
args={{ uppercase: true, size: 'sm' }}
|
args={{ uppercase: true, size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Uppercase Label</Label>
|
<Label {...args}>Uppercase Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -148,7 +149,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Lowercase"
|
name="Lowercase"
|
||||||
args={{ uppercase: false, size: 'sm' }}
|
args={{ uppercase: false, size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>lowercase label</Label>
|
<Label {...args}>lowercase label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -157,7 +158,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="Bold"
|
name="Bold"
|
||||||
args={{ bold: true, size: 'sm' }}
|
args={{ bold: true, size: 'sm' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>Bold Label</Label>
|
<Label {...args}>Bold Label</Label>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
@@ -166,7 +167,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="With icon (left)"
|
name="With icon (left)"
|
||||||
args={{ variant: 'default', size: 'sm', iconPosition: 'left' }}
|
args={{ variant: 'default', size: 'sm', iconPosition: 'left' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>
|
<Label {...args}>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<CircleIcon size={10} />
|
<CircleIcon size={10} />
|
||||||
@@ -180,7 +181,7 @@ import CircleIcon from '@lucide/svelte/icons/circle';
|
|||||||
name="With icon (right)"
|
name="With icon (right)"
|
||||||
args={{ variant: 'default', size: 'sm', iconPosition: 'right' }}
|
args={{ variant: 'default', size: 'sm', iconPosition: 'right' }}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Label>)}
|
||||||
<Label {...args}>
|
<Label {...args}>
|
||||||
Label with icon
|
Label with icon
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
<Story name="Default">
|
<Story name="Default">
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Loader>)}
|
||||||
<Loader {...args} />
|
<Loader {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import Loader from './Loader.svelte';
|
||||||
|
|
||||||
|
describe('Loader', () => {
|
||||||
|
it('renders the default message', () => {
|
||||||
|
render(Loader);
|
||||||
|
expect(screen.getByText('analyzing_data')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a custom message', () => {
|
||||||
|
render(Loader, { message: 'loading_fonts' });
|
||||||
|
expect(screen.getByText('loading_fonts')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the SVG spinner', () => {
|
||||||
|
const { container } = render(Loader);
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the divider', () => {
|
||||||
|
const { container } = render(Loader);
|
||||||
|
const divider = container.querySelector('.w-px.h-3');
|
||||||
|
expect(divider).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,8 +16,12 @@ const { Story } = defineMeta({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
<Story name="Default">
|
<Story name="Default">
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Logo>)}
|
||||||
<Logo {...args} />
|
<Logo {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import PerspectivePlan from './PerspectivePlan.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/PerspectivePlan',
|
||||||
|
component: PerspectivePlan,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Wrapper applying perspective 3D transform based on a PerspectiveManager spring. Used for front/back stacking in comparison views.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
region: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['full', 'left', 'right'],
|
||||||
|
},
|
||||||
|
regionWidth: {
|
||||||
|
control: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createPerspectiveManager } from '$shared/lib';
|
||||||
|
|
||||||
|
const frontManager = createPerspectiveManager({ depthStep: 100, scaleStep: 0.5, blurStep: 4 });
|
||||||
|
|
||||||
|
const backManager = createPerspectiveManager({ depthStep: 100, scaleStep: 0.5, blurStep: 4 });
|
||||||
|
backManager.setBack();
|
||||||
|
|
||||||
|
const leftManager = createPerspectiveManager({ depthStep: 100, scaleStep: 0.5, blurStep: 4 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Front State">
|
||||||
|
{#snippet template()}
|
||||||
|
<div style="width: 300px; height: 200px; perspective: 800px; position: relative;">
|
||||||
|
<PerspectivePlan manager={frontManager}>
|
||||||
|
{#snippet children({ className })}
|
||||||
|
<div
|
||||||
|
class={className}
|
||||||
|
style="width: 300px; height: 200px; background: #1e1e2e; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #cdd6f4; font-family: sans-serif;"
|
||||||
|
>
|
||||||
|
Front — fully visible
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PerspectivePlan>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Back State">
|
||||||
|
{#snippet template()}
|
||||||
|
<div style="width: 300px; height: 200px; perspective: 800px; position: relative;">
|
||||||
|
<PerspectivePlan manager={backManager}>
|
||||||
|
{#snippet children({ className })}
|
||||||
|
<div
|
||||||
|
class={className}
|
||||||
|
style="width: 300px; height: 200px; background: #1e1e2e; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #cdd6f4; font-family: sans-serif;"
|
||||||
|
>
|
||||||
|
Back — blurred and scaled down
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PerspectivePlan>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Left Region">
|
||||||
|
{#snippet template()}
|
||||||
|
<div style="width: 300px; height: 200px; perspective: 800px; position: relative;">
|
||||||
|
<PerspectivePlan manager={leftManager} region="left" regionWidth={50}>
|
||||||
|
{#snippet children({ className })}
|
||||||
|
<div
|
||||||
|
class={className}
|
||||||
|
style="width: 100%; height: 100%; background: #313244; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #cdd6f4; font-family: sans-serif;"
|
||||||
|
>
|
||||||
|
Left half
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PerspectivePlan>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -28,6 +28,8 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
let defaultSearchValue = $state('');
|
let defaultSearchValue = $state('');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -36,9 +38,10 @@ let defaultSearchValue = $state('');
|
|||||||
args={{
|
args={{
|
||||||
value: defaultSearchValue,
|
value: defaultSearchValue,
|
||||||
placeholder: 'Type here...',
|
placeholder: 'Type here...',
|
||||||
|
variant: 'filled',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof SearchBar>)}
|
||||||
<SearchBar variant="filled" {...args} />
|
<SearchBar {...args} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import SearchBar from './SearchBar.svelte';
|
||||||
|
|
||||||
|
describe('SearchBar', () => {
|
||||||
|
it('renders an input element', () => {
|
||||||
|
render(SearchBar);
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders placeholder text', () => {
|
||||||
|
render(SearchBar, { placeholder: 'Search…' });
|
||||||
|
expect(screen.getByPlaceholderText('Search…')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with initial value', () => {
|
||||||
|
render(SearchBar, { value: 'Roboto' });
|
||||||
|
expect(screen.getByDisplayValue('Roboto')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders search icon', () => {
|
||||||
|
const { container } = render(SearchBar);
|
||||||
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates value on user input', async () => {
|
||||||
|
render(SearchBar);
|
||||||
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||||
|
await fireEvent.input(input, { target: { value: 'Inter' } });
|
||||||
|
expect(input.value).toBe('Inter');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not disabled by default', () => {
|
||||||
|
render(SearchBar);
|
||||||
|
expect(screen.getByRole('textbox')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled when disabled prop is true', () => {
|
||||||
|
render(SearchBar, { disabled: true });
|
||||||
|
expect(screen.getByRole('textbox')).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import SectionSeparator from './SectionSeparator.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/SectionSeparator',
|
||||||
|
component: SectionSeparator,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: { component: 'Full-width horizontal rule for separating page sections.' },
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
class: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Additional CSS classes to merge onto the element',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Default/Basic" args={{}}>
|
||||||
|
{#snippet template(args: ComponentProps<typeof SectionSeparator>)}
|
||||||
|
<div class="w-96">
|
||||||
|
<SectionSeparator {...args} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Custom class" args={{ class: 'my-4' }}>
|
||||||
|
{#snippet template(args: ComponentProps<typeof SectionSeparator>)}
|
||||||
|
<div class="w-96">
|
||||||
|
<p class="text-sm text-neutral-500">Above</p>
|
||||||
|
<SectionSeparator {...args} />
|
||||||
|
<p class="text-sm text-neutral-500">Below</p>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script module>
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import SectionTitle from './SectionTitle.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/SectionTitle',
|
||||||
|
component: SectionTitle,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: { component: 'Large responsive heading for named page sections.' },
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
text: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Heading text; renders nothing when omitted',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Default/Basic" args={{ text: 'Browse Fonts' }}>
|
||||||
|
{#snippet template(args: ComponentProps<typeof SectionTitle>)}
|
||||||
|
<SectionTitle {...args} />
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Long title" args={{ text: 'Explore Typefaces From Around the World' }}>
|
||||||
|
{#snippet template(args: ComponentProps<typeof SectionTitle>)}
|
||||||
|
<SectionTitle {...args} />
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Empty" args={{}}>
|
||||||
|
{#snippet template(args: ComponentProps<typeof SectionTitle>)}
|
||||||
|
<SectionTitle {...args} />
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script module>
|
||||||
|
import { Providers } from '$shared/lib/storybook';
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import SidebarContainer from './SidebarContainer.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/SidebarContainer',
|
||||||
|
component: SidebarContainer,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component:
|
||||||
|
'Responsive sidebar container. On desktop it collapses to zero width with a CSS transition. On mobile it renders as a slide-in drawer over a backdrop overlay. The `sidebar` prop is a snippet that receives `{ onClose }` so the sidebar content can dismiss itself.',
|
||||||
|
},
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
isOpen: {
|
||||||
|
control: 'boolean',
|
||||||
|
description: 'Whether the sidebar is open (bindable)',
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Additional CSS classes applied to the desktop wrapper element',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Desktop Open">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<div class="h-64 flex relative overflow-hidden">
|
||||||
|
<SidebarContainer isOpen={true}>
|
||||||
|
{#snippet sidebar({ onClose })}
|
||||||
|
<div class="w-80 h-full bg-white p-4 border-r border-neutral-200 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="self-start text-sm text-neutral-500 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-neutral-700">Sidebar Content</p>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</SidebarContainer>
|
||||||
|
<div class="flex-1 p-4 bg-neutral-50 text-sm text-neutral-500">Main content</div>
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Desktop Closed">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers>
|
||||||
|
<div class="h-64 flex relative overflow-hidden">
|
||||||
|
<SidebarContainer isOpen={false}>
|
||||||
|
{#snippet sidebar({ onClose })}
|
||||||
|
<div class="w-80 h-full bg-white p-4 border-r border-neutral-200 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="self-start text-sm text-neutral-500 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-neutral-700">Sidebar Content</p>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</SidebarContainer>
|
||||||
|
<div class="flex-1 p-4 bg-neutral-50 text-sm text-neutral-500">
|
||||||
|
Main content — sidebar is collapsed to zero width
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Mobile Open">
|
||||||
|
{#snippet template()}
|
||||||
|
<Providers initialWidth={375}>
|
||||||
|
<div class="h-64 relative overflow-hidden">
|
||||||
|
<SidebarContainer isOpen={true}>
|
||||||
|
{#snippet sidebar({ onClose })}
|
||||||
|
<div class="w-80 h-full bg-white p-4 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="self-start text-sm text-neutral-500 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-neutral-700">Sidebar Content</p>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</SidebarContainer>
|
||||||
|
<div class="p-4 bg-neutral-50 text-sm text-neutral-500">Main content</div>
|
||||||
|
</div>
|
||||||
|
</Providers>
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -23,13 +23,17 @@ const { Story } = defineMeta({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
name="Default"
|
name="Default"
|
||||||
args={{
|
args={{
|
||||||
animate: true,
|
animate: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#snippet template(args)}
|
{#snippet template(args: ComponentProps<typeof Skeleton>)}
|
||||||
<div class="flex flex-col gap-4 p-4 w-full">
|
<div class="flex flex-col gap-4 p-4 w-full">
|
||||||
<div class="flex flex-col gap-2 p-4 border rounded-xl border-border-subtle bg-background-40">
|
<div class="flex flex-col gap-2 p-4 border rounded-xl border-border-subtle bg-background-40">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { render } from '@testing-library/svelte';
|
||||||
|
import Skeleton from './Skeleton.svelte';
|
||||||
|
|
||||||
|
describe('Skeleton', () => {
|
||||||
|
it('renders a div element', () => {
|
||||||
|
const { container } = render(Skeleton);
|
||||||
|
expect(container.querySelector('div')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('animates by default', () => {
|
||||||
|
const { container } = render(Skeleton);
|
||||||
|
expect(container.querySelector('div')).toHaveClass('animate-pulse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables animation when animate=false', () => {
|
||||||
|
const { container } = render(Skeleton, { animate: false });
|
||||||
|
expect(container.querySelector('div')).not.toHaveClass('animate-pulse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes additional class', () => {
|
||||||
|
const { container } = render(Skeleton, { class: 'w-24 h-4' });
|
||||||
|
expect(container.querySelector('div')).toHaveClass('w-24', 'h-4');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,13 +37,23 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
let value = $state(50);
|
let value = $state(50);
|
||||||
let valueLow = $state(25);
|
let valueLow = $state(25);
|
||||||
let valueHigh = $state(75);
|
let valueHigh = $state(75);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Horizontal" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
|
<Story
|
||||||
{#snippet template(args)}
|
name="Horizontal"
|
||||||
|
args={{
|
||||||
|
orientation: 'horizontal',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
value,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof Slider>)}
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<Slider {...args} />
|
<Slider {...args} />
|
||||||
<p class="mt-4 text-sm text-muted-foreground">Value: {args.value}</p>
|
<p class="mt-4 text-sm text-muted-foreground">Value: {args.value}</p>
|
||||||
@@ -54,8 +64,17 @@ let valueHigh = $state(75);
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Vertical" args={{ orientation: 'vertical', min: 0, max: 100, step: 1, value }}>
|
<Story
|
||||||
{#snippet template(args)}
|
name="Vertical"
|
||||||
|
args={{
|
||||||
|
orientation: 'vertical',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
value,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof Slider>)}
|
||||||
<div class="p-8 flex items-center gap-8 h-72">
|
<div class="p-8 flex items-center gap-8 h-72">
|
||||||
<Slider {...args} />
|
<Slider {...args} />
|
||||||
<div>
|
<div>
|
||||||
@@ -66,8 +85,17 @@ let valueHigh = $state(75);
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="With Label" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
|
<Story
|
||||||
{#snippet template(args)}
|
name="With Label"
|
||||||
|
args={{
|
||||||
|
orientation: 'horizontal',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
value,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof Slider>)}
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<Slider {...args} />
|
<Slider {...args} />
|
||||||
<p class="mt-4 text-sm text-muted-foreground">Slider with inline label</p>
|
<p class="mt-4 text-sm text-muted-foreground">Slider with inline label</p>
|
||||||
@@ -75,8 +103,17 @@ let valueHigh = $state(75);
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Interactive States" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value: 50 }}>
|
<Story
|
||||||
{#snippet template(args)}
|
name="Interactive States"
|
||||||
|
args={{
|
||||||
|
orientation: 'horizontal',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
value: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof Slider>)}
|
||||||
<div class="p-8 space-y-8">
|
<div class="p-8 space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium mb-2">Thumb: 45° rotated square</p>
|
<p class="text-sm font-medium mb-2">Thumb: 45° rotated square</p>
|
||||||
@@ -103,8 +140,17 @@ let valueHigh = $state(75);
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Step Sizes" args={{ orientation: 'horizontal', min: 0, max: 100, step: 1, value }}>
|
<Story
|
||||||
{#snippet template(args)}
|
name="Step Sizes"
|
||||||
|
args={{
|
||||||
|
orientation: 'horizontal',
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
value,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof Slider>)}
|
||||||
<div class="p-8 space-y-6">
|
<div class="p-8 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium mb-2">Step: 1 (default)</p>
|
<p class="text-sm font-medium mb-2">Step: 1 (default)</p>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import Slider from './Slider.svelte';
|
||||||
|
|
||||||
|
describe('Slider', () => {
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders a slider element', () => {
|
||||||
|
render(Slider);
|
||||||
|
expect(screen.getByRole('slider')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays formatted value', () => {
|
||||||
|
render(Slider, { value: 50 });
|
||||||
|
expect(screen.getByText('50')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a custom formatter', () => {
|
||||||
|
const { container } = render(Slider, { value: 25, format: (v: number) => `${v}%` });
|
||||||
|
expect(container.textContent).toContain('25%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Props', () => {
|
||||||
|
it('respects min and max attributes', () => {
|
||||||
|
render(Slider, { min: 10, max: 90, value: 50 });
|
||||||
|
const slider = screen.getByRole('slider');
|
||||||
|
expect(slider).toHaveAttribute('aria-valuemin', '10');
|
||||||
|
expect(slider).toHaveAttribute('aria-valuemax', '90');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects value as aria-valuenow', () => {
|
||||||
|
render(Slider, { value: 42 });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '42');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled when disabled=true', () => {
|
||||||
|
render(Slider, { disabled: true });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not disabled by default', () => {
|
||||||
|
render(Slider, { value: 0 });
|
||||||
|
expect(screen.getByRole('slider')).not.toHaveAttribute('aria-disabled', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Orientations', () => {
|
||||||
|
it('renders horizontal by default', () => {
|
||||||
|
const { container } = render(Slider);
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-orientation', 'horizontal');
|
||||||
|
expect(container.querySelector('.cursor-col-resize')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders vertical when orientation="vertical"', () => {
|
||||||
|
const { container } = render(Slider, { orientation: 'vertical' });
|
||||||
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-orientation', 'vertical');
|
||||||
|
expect(container.querySelector('.cursor-row-resize')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import Stat from './Stat.svelte';
|
||||||
|
|
||||||
|
describe('Stat', () => {
|
||||||
|
it('renders label and value', () => {
|
||||||
|
render(Stat, { label: 'weight', value: '400' });
|
||||||
|
expect(screen.getByText('weight:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('400')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders numeric value', () => {
|
||||||
|
render(Stat, { label: 'size', value: 16 });
|
||||||
|
expect(screen.getByText('16')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders separator when separator=true', () => {
|
||||||
|
const { container } = render(Stat, { label: 'x', value: 'y', separator: true });
|
||||||
|
expect(container.querySelector('.w-px.h-2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render separator by default', () => {
|
||||||
|
const { container } = render(Stat, { label: 'x', value: 'y' });
|
||||||
|
expect(container.querySelector('.w-px.h-2')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script module lang="ts">
|
||||||
|
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||||
|
import StatGroup from './StatGroup.svelte';
|
||||||
|
|
||||||
|
const { Story } = defineMeta({
|
||||||
|
title: 'Shared/StatGroup',
|
||||||
|
component: StatGroup,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: { component: 'Horizontal row of Stat items with automatic separators between them.' },
|
||||||
|
story: { inline: false },
|
||||||
|
},
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
stats: {
|
||||||
|
control: 'object',
|
||||||
|
description: 'Array of stat items with label, value, and optional variant',
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
control: 'text',
|
||||||
|
description: 'Additional CSS classes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Story name="Default/Basic" args={{ stats: [{ label: 'Size', value: '16px' }, { label: 'Weight', value: 400 }] }}>
|
||||||
|
{#snippet template(args: ComponentProps<typeof StatGroup>)}
|
||||||
|
<StatGroup {...args} />
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story
|
||||||
|
name="Three stats"
|
||||||
|
args={{ stats: [{ label: 'Size', value: '24px' }, { label: 'Weight', value: 700 }, { label: 'Line height', value: 1.5 }] }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof StatGroup>)}
|
||||||
|
<StatGroup {...args} />
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
|
|
||||||
|
<Story name="Single stat" args={{ stats: [{ label: 'Style', value: 'Italic' }] }}>
|
||||||
|
{#snippet template(args: ComponentProps<typeof StatGroup>)}
|
||||||
|
<StatGroup {...args} />
|
||||||
|
{/snippet}
|
||||||
|
</Story>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '@testing-library/svelte';
|
||||||
|
import { createRawSnippet } from 'svelte';
|
||||||
|
import TechText from './TechText.svelte';
|
||||||
|
|
||||||
|
function textSnippet(text: string) {
|
||||||
|
return createRawSnippet(() => ({ render: () => `<span>${text}</span>` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TechText', () => {
|
||||||
|
it('renders text content in a code element', () => {
|
||||||
|
render(TechText, { children: textSnippet('400px') });
|
||||||
|
expect(screen.getByText('400px')).toBeInTheDocument();
|
||||||
|
const { container } = render(TechText, { children: textSnippet('400px') });
|
||||||
|
expect(container.querySelector('code')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when no children', () => {
|
||||||
|
const { container } = render(TechText);
|
||||||
|
expect(container.querySelector('code')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('code')?.textContent?.trim()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['default', 'accent', 'muted', 'success', 'warning', 'error'] as const)(
|
||||||
|
'renders %s variant without error',
|
||||||
|
variant => {
|
||||||
|
render(TechText, { children: textSnippet('val'), variant });
|
||||||
|
expect(screen.getAllByText('val')[0]).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['xs', 'sm', 'md', 'lg'] as const)('renders %s size without error', size => {
|
||||||
|
render(TechText, { children: textSnippet('val'), size });
|
||||||
|
expect(screen.getAllByText('val')[0]).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,8 @@ const { Story } = defineMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`);
|
const smallDataSet = Array.from({ length: 20 }, (_, i) => `${i + 1}) I will not waste chalk.`);
|
||||||
const mediumDataSet = Array.from(
|
const mediumDataSet = Array.from(
|
||||||
{ length: 200 },
|
{ length: 200 },
|
||||||
@@ -45,10 +47,13 @@ const mediumDataSet = Array.from(
|
|||||||
const emptyDataSet: string[] = [];
|
const emptyDataSet: string[] = [];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story name="Small Dataset">
|
<Story
|
||||||
{#snippet template(args)}
|
name="Small Dataset"
|
||||||
|
args={{ items: smallDataSet, itemHeight: 40 }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof VirtualList>)}
|
||||||
<div class="h-[400px]">
|
<div class="h-[400px]">
|
||||||
<VirtualList items={smallDataSet} itemHeight={40} {...args}>
|
<VirtualList {...args}>
|
||||||
{#snippet children({ item })}
|
{#snippet children({ item })}
|
||||||
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -57,10 +62,13 @@ const emptyDataSet: string[] = [];
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Medium Dataset (200 items)">
|
<Story
|
||||||
{#snippet template(args)}
|
name="Medium Dataset (200 items)"
|
||||||
|
args={{ items: mediumDataSet, itemHeight: 40 }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof VirtualList>)}
|
||||||
<div class="h-[400px]">
|
<div class="h-[400px]">
|
||||||
<VirtualList items={mediumDataSet} itemHeight={40} {...args}>
|
<VirtualList {...args}>
|
||||||
{#snippet children({ item })}
|
{#snippet children({ item })}
|
||||||
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -69,9 +77,12 @@ const emptyDataSet: string[] = [];
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story name="Empty Dataset">
|
<Story
|
||||||
{#snippet template(args)}
|
name="Empty Dataset"
|
||||||
<VirtualList items={emptyDataSet} itemHeight={40} {...args}>
|
args={{ items: emptyDataSet, itemHeight: 40 }}
|
||||||
|
>
|
||||||
|
{#snippet template(args: ComponentProps<typeof VirtualList>)}
|
||||||
|
<VirtualList {...args}>
|
||||||
{#snippet children({ item })}
|
{#snippet children({ item })}
|
||||||
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
<div class="p-2 m-0.5 rounded-sm hover:bg-accent">{item}</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
|||||||
const isMobile = $derived(responsive?.isMobile ?? false);
|
const isMobile = $derived(responsive?.isMobile ?? false);
|
||||||
|
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
|
let isTypographyMenuOpen = $state(false);
|
||||||
|
|
||||||
// New high-performance layout engine
|
// New high-performance layout engine
|
||||||
const comparisonEngine = new CharacterComparisonEngine();
|
const comparisonEngine = new CharacterComparisonEngine();
|
||||||
@@ -76,6 +77,8 @@ function handleMove(e: PointerEvent) {
|
|||||||
|
|
||||||
function startDragging(e: PointerEvent) {
|
function startDragging(e: PointerEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// Close typography menu popover
|
||||||
|
isTypographyMenuOpen = false;
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
handleMove(e);
|
handleMove(e);
|
||||||
}
|
}
|
||||||
@@ -222,7 +225,7 @@ const scaleClass = $derived(
|
|||||||
class="
|
class="
|
||||||
relative w-full max-w-6xl h-full
|
relative w-full max-w-6xl h-full
|
||||||
flex flex-col justify-center
|
flex flex-col justify-center
|
||||||
select-none touch-none cursor-ew-resize
|
select-none touch-none outline-none cursor-ew-resize
|
||||||
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
|
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
|
||||||
"
|
"
|
||||||
in:fade={{ duration: 300, delay: 300 }}
|
in:fade={{ duration: 300, delay: 300 }}
|
||||||
@@ -253,8 +256,12 @@ const scaleClass = $derived(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TypographyMenu
|
<TypographyMenu
|
||||||
|
bind:open={isTypographyMenuOpen}
|
||||||
class={clsx(
|
class={clsx(
|
||||||
'absolute bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2 z-50',
|
'absolute z-50',
|
||||||
|
responsive.isMobileOrTablet
|
||||||
|
? 'bottom-4 right-4 -translate-1/2'
|
||||||
|
: 'bottom-5 left-1/2 right-[unset] -translate-x-1/2',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
|
||||||
|
test: {
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
provider: playwright({}),
|
||||||
|
instances: [{ browser: 'chromium' }],
|
||||||
|
},
|
||||||
|
include: ['src/**/*.svelte.test.ts'],
|
||||||
|
setupFiles: ['./vitest.setup.component.ts'],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
$lib: path.resolve(__dirname, './src/lib'),
|
||||||
|
$app: path.resolve(__dirname, './src/app'),
|
||||||
|
$shared: path.resolve(__dirname, './src/shared'),
|
||||||
|
$entities: path.resolve(__dirname, './src/entities'),
|
||||||
|
$features: path.resolve(__dirname, './src/features'),
|
||||||
|
$routes: path.resolve(__dirname, './src/routes'),
|
||||||
|
$widgets: path.resolve(__dirname, './src/widgets'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user