Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c01fc79a3e | |||
| 6bfa7ca777 | |||
| 0d4356b8f1 | |||
| c18574d4c3 | |||
| 1c9a7f9fe1 | |||
| fae6694479 | |||
| a105c94176 | |||
| 77c2b27f8b | |||
| 1ce0d6c66f | |||
| 6c20a68e19 | |||
| 3894912a22 | |||
| e8d3727c6a | |||
| 5fbf090b24 | |||
| a94e1f8b65 | |||
| f8ba2d7eb0 | |||
| 3594033bcb | |||
| 2ae24912f7 | |||
| 877719f106 | |||
| 4eafb96d35 | |||
| 652dfa5c90 | |||
| 54087b7b2a | |||
| cffebf05e3 | |||
| ada484e2e0 | |||
| dbcc1caeb0 | |||
| 2c579a3336 | |||
| fe0d4e7daa | |||
| 108df323f9 | |||
| 2803bcd22c | |||
| 47a8487ce9 | |||
| 1d5af5ea70 | |||
| 2221ecad4c | |||
| cd8599d5b5 | |||
| 6c91d570ec | |||
| 91b80a5ada |
@@ -47,7 +47,8 @@ jobs:
|
||||
run: yarn test:unit
|
||||
|
||||
- name: Run Component Tests
|
||||
run: yarn test:component
|
||||
timeout-minutes: 5
|
||||
run: yarn test:component --reporter=verbose --logHeapUsage
|
||||
|
||||
publish:
|
||||
needs: build # Only runs if tests/lint pass
|
||||
|
||||
@@ -10,6 +10,9 @@ node_modules
|
||||
/build
|
||||
/dist
|
||||
|
||||
# IDE settings
|
||||
.vscode
|
||||
|
||||
# Git worktrees (isolated development branches)
|
||||
.worktrees
|
||||
|
||||
|
||||
+4
-5
@@ -13,7 +13,7 @@
|
||||
"https://plugins.dprint.dev/typescript-0.93.0.wasm",
|
||||
"https://plugins.dprint.dev/json-0.19.3.wasm",
|
||||
"https://plugins.dprint.dev/markdown-0.17.8.wasm",
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.0.wasm"
|
||||
],
|
||||
"typescript": {
|
||||
"lineWidth": 120,
|
||||
@@ -57,9 +57,8 @@
|
||||
"quotes": "double",
|
||||
"scriptIndent": false,
|
||||
"styleIndent": false,
|
||||
|
||||
"vBindStyle": "short",
|
||||
"vOnStyle": "short",
|
||||
"formatComments": true
|
||||
"formatComments": true,
|
||||
"svelteAttrShorthand": true,
|
||||
"svelteDirectiveShorthand": true
|
||||
}
|
||||
}
|
||||
|
||||
+33
-33
@@ -27,45 +27,45 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@storybook/addon-a11y": "^10.1.11",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-svelte-csf": "^5.0.10",
|
||||
"@storybook/addon-vitest": "^10.1.11",
|
||||
"@storybook/svelte-vite": "^10.1.11",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@chromatic-com/storybook": "5.1.2",
|
||||
"@internationalized/date": "3.12.1",
|
||||
"@lucide/svelte": "^1.14.0",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@storybook/addon-a11y": "10.3.6",
|
||||
"@storybook/addon-docs": "10.3.6",
|
||||
"@storybook/addon-svelte-csf": "5.1.2",
|
||||
"@storybook/addon-vitest": "10.3.6",
|
||||
"@storybook/svelte-vite": "10.3.6",
|
||||
"@sveltejs/vite-plugin-svelte": "7.1.0",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@tsconfig/svelte": "^5.0.6",
|
||||
"@types/jsdom": "^27",
|
||||
"@vitest/browser-playwright": "^4.0.16",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"bits-ui": "^2.14.4",
|
||||
"@tsconfig/svelte": "5.0.8",
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"bits-ui": "2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dprint": "^0.50.2",
|
||||
"jsdom": "^27.4.0",
|
||||
"lefthook": "^2.0.13",
|
||||
"oxlint": "^1.35.0",
|
||||
"playwright": "^1.57.0",
|
||||
"storybook": "^10.1.11",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-language-server": "^0.17.23",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"dprint": "0.54.0",
|
||||
"jsdom": "29.1.1",
|
||||
"lefthook": "2.1.6",
|
||||
"oxlint": "1.62.0",
|
||||
"playwright": "1.59.1",
|
||||
"storybook": "10.3.6",
|
||||
"svelte": "5.55.5",
|
||||
"svelte-check": "4.4.8",
|
||||
"svelte-language-server": "0.18.0",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.0.10",
|
||||
"vitest": "4.1.5",
|
||||
"vitest-browser-svelte": "2.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chenglou/pretext": "^0.0.5",
|
||||
"@tanstack/svelte-query": "^6.0.14"
|
||||
"@chenglou/pretext": "0.0.6",
|
||||
"@tanstack/svelte-query": "6.1.28"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,11 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-brand);
|
||||
color: var(--swiss-white);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: "Karla", system-ui, -apple-system, "Segoe UI", Inter, Roboto, Arial, sans-serif;
|
||||
|
||||
Vendored
+2
@@ -36,6 +36,8 @@ declare module '*.jpg' {
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.css';
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
|
||||
@@ -3,21 +3,12 @@
|
||||
Application shell with providers and page wrapper
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Layout Component
|
||||
*
|
||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
||||
*
|
||||
* Layout structure:
|
||||
* - Header area (currently empty, reserved for future use)
|
||||
*
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*/
|
||||
import { themeManager } from '$features/ChangeAppTheme';
|
||||
import GD from '$shared/assets/GD.svg';
|
||||
import G from '$shared/assets/G.svg';
|
||||
import { ResponsiveProvider } from '$shared/lib';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Footer } from '$widgets/Footer';
|
||||
|
||||
import {
|
||||
type Snippet,
|
||||
onDestroy,
|
||||
@@ -40,7 +31,7 @@ onDestroy(() => themeManager.destroy());
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={GD} />
|
||||
<link rel="icon" href={G} type="image/svg+xml" />
|
||||
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link
|
||||
@@ -82,14 +73,15 @@ onDestroy(() => themeManager.destroy());
|
||||
<ResponsiveProvider>
|
||||
<div
|
||||
id="app-root"
|
||||
class={clsx(
|
||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg',
|
||||
class={cn(
|
||||
'min-h-screen w-auto flex flex-col bg-surface dark:bg-dark-bg relative',
|
||||
theme === 'dark' ? 'dark' : '',
|
||||
)}
|
||||
>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
<footer></footer>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</ResponsiveProvider>
|
||||
|
||||
@@ -20,6 +20,7 @@ let mockObserverInstances: MockIntersectionObserver[] = [];
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
root = null;
|
||||
rootMargin = '';
|
||||
scrollMargin = '';
|
||||
thresholds: number[] = [];
|
||||
readonly callbacks: Array<(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => void> = [];
|
||||
readonly observedElements = new Set<Element>();
|
||||
|
||||
-2
@@ -2,9 +2,7 @@
|
||||
* Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() where available or MessageChannel fallback.
|
||||
*/
|
||||
export async function yieldToMainThread(): Promise<void> {
|
||||
// @ts-expect-error - scheduler not in TypeScript lib yet
|
||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
||||
await scheduler.yield();
|
||||
} else {
|
||||
await new Promise<void>(resolve => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Shows the skeleton snippet while loading; falls back to system font if no skeleton is provided.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
@@ -61,7 +61,7 @@ const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||
{:else}
|
||||
<div
|
||||
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
||||
class={clsx(className)}
|
||||
class={cn(className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<script lang="ts">
|
||||
import { fontStore } from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
@@ -45,7 +45,7 @@ function handleReset() {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
||||
'gap-1 md:gap-6',
|
||||
'pt-6 mt-6 md:pt-8 md:mt-8',
|
||||
@@ -77,7 +77,7 @@ function handleReset() {
|
||||
variant="ghost"
|
||||
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
|
||||
onclick={handleReset}
|
||||
class={clsx('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||
class={cn('group font-mono tracking-widest text-neutral-400', isMobileOrTabletPortrait && 'px-0')}
|
||||
iconPosition="left"
|
||||
>
|
||||
{#snippet icon()}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MULTIPLIER_S,
|
||||
} from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
Button,
|
||||
ComboControl,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Popover } from 'bits-ui';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
@@ -88,7 +88,7 @@ $effect(() => {
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'z-50 w-72',
|
||||
'bg-surface dark:bg-dark-card',
|
||||
'border border-subtle',
|
||||
@@ -142,11 +142,11 @@ $effect(() => {
|
||||
</Popover.Root>
|
||||
{:else}
|
||||
<div
|
||||
class={clsx('w-full md:w-auto', className)}
|
||||
class={cn('w-full md:w-auto', className)}
|
||||
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
||||
>
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
||||
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
||||
'border border-subtle',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="103" height="87" viewBox="0 0 103 87" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M50.688 86.144C43.008 86.144 36.0533 85.248 29.824 83.456C23.68 81.664 18.3467 78.976 13.824 75.392C9.38667 71.808 5.97333 67.3707 3.584 62.08C1.19467 56.7893 0 50.688 0 43.776C0 36.7787 1.23733 30.592 3.712 25.216C6.272 19.7547 9.856 15.1467 14.464 11.392C19.1573 7.63733 24.704 4.82133 31.104 2.944C37.5893 0.981333 44.7573 0 52.608 0C61.9093 0 69.9307 1.32267 76.672 3.968C83.4133 6.528 88.704 10.1547 92.544 14.848C96.4693 19.5413 98.688 25.1307 99.2 31.616H82.816C81.7067 28.2027 79.872 25.2587 77.312 22.784C74.8373 20.224 71.552 18.2613 67.456 16.896C63.36 15.4453 58.4107 14.72 52.608 14.72C45.184 14.72 38.8267 15.9147 33.536 18.304C28.3307 20.6933 24.3627 24.064 21.632 28.416C18.9013 32.768 17.536 37.888 17.536 43.776C17.536 49.4933 18.7307 54.4427 21.12 58.624C23.5093 62.72 27.1787 65.8773 32.128 68.096C37.1627 70.3147 43.5627 71.424 51.328 71.424C57.3013 71.424 62.5493 70.656 67.072 69.12C71.68 67.4987 75.52 65.3653 78.592 62.72C81.664 59.9893 83.84 56.96 85.12 53.632L91.776 51.2C90.6667 62.208 86.4853 70.784 79.232 76.928C72.064 83.072 62.5493 86.144 50.688 86.144ZM87.424 84.48C87.424 81.8347 87.5947 78.8053 87.936 75.392C88.2773 71.8933 88.704 68.3947 89.216 64.896C89.728 61.312 90.1973 58.0267 90.624 55.04H52.736V44.16H102.144V84.48H87.424Z" fill="#FF3B30"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
|
||||
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -258,12 +258,13 @@ export function createVirtualizer<T>(
|
||||
// Calculate initial offset ONCE
|
||||
const getElementOffset = () => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
const scrollY = typeof window !== 'undefined' ? window.scrollY : 0;
|
||||
return rect.top + scrollY;
|
||||
};
|
||||
|
||||
let cachedOffsetTop = 0;
|
||||
let rafId: number | null = null;
|
||||
containerHeight = window.innerHeight;
|
||||
containerHeight = typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafId !== null) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export {
|
||||
export {
|
||||
buildQueryString,
|
||||
clampNumber,
|
||||
cn,
|
||||
debounce,
|
||||
getDecimalPlaces,
|
||||
roundToStepPrecision,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
correctly via the HTML element's class attribute.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type {
|
||||
Component,
|
||||
Snippet,
|
||||
@@ -32,7 +32,7 @@ let { icon: Icon, class: className, attrs = {} }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if Icon}
|
||||
{@const __iconClass__ = clsx('size-4', className)}
|
||||
{@const __iconClass__ = cn('size-4', className)}
|
||||
<!-- Render icon component dynamically with class prop -->
|
||||
<Icon
|
||||
class={__iconClass__}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from 'vitest';
|
||||
import { cn } from './cn';
|
||||
|
||||
describe('cn utility', () => {
|
||||
it('should merge classes with clsx', () => {
|
||||
expect(cn('class1', 'class2')).toBe('class1 class2');
|
||||
expect(cn('class1', { class2: true, class3: false })).toBe('class1 class2');
|
||||
});
|
||||
|
||||
it('should resolve tailwind specificity conflicts', () => {
|
||||
// text-neutral-400 vs text-brand (text-brand should win)
|
||||
expect(cn('text-neutral-400', 'text-brand')).toBe('text-brand');
|
||||
|
||||
// p-4 vs p-2
|
||||
expect(cn('p-4', 'p-2')).toBe('p-2');
|
||||
|
||||
// dark mode classes should be handled correctly too
|
||||
expect(cn('text-neutral-400 dark:text-neutral-400', 'text-brand dark:text-brand')).toBe(
|
||||
'text-brand dark:text-brand',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle undefined and null inputs', () => {
|
||||
expect(cn('class1', undefined, null, 'class2')).toBe('class1 class2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
type ClassValue,
|
||||
clsx,
|
||||
} from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
/**
|
||||
* Utility for merging Tailwind classes with clsx and tailwind-merge.
|
||||
* This resolves specificity conflicts between Tailwind classes.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
type QueryParamValue,
|
||||
} from './buildQueryString/buildQueryString';
|
||||
export { clampNumber } from './clampNumber/clampNumber';
|
||||
export { cn } from './cn';
|
||||
export { debounce } from './debounce/debounce';
|
||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
Pill badge with border and optional status dot.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
type LabelSize,
|
||||
labelSizeConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -64,7 +64,7 @@ let {
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 border rounded-full',
|
||||
'font-mono uppercase tracking-wide',
|
||||
labelSizeConfig[size],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
design-system button. Uppercase, zero border-radius, Space Grotesk.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
import type {
|
||||
@@ -71,7 +71,7 @@ let {
|
||||
const isIconOnly = $derived(!!icon && !children);
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: clsx(
|
||||
primary: cn(
|
||||
'bg-swiss-red text-white',
|
||||
'hover:bg-swiss-red/90',
|
||||
'active:bg-swiss-red/80',
|
||||
@@ -87,7 +87,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:cursor-not-allowed',
|
||||
'disabled:transform-none',
|
||||
),
|
||||
secondary: clsx(
|
||||
secondary: cn(
|
||||
'bg-surface dark:bg-paper',
|
||||
'text-swiss-black dark:text-neutral-200',
|
||||
'border border-black/10 dark:border-white/10',
|
||||
@@ -98,7 +98,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
outline: clsx(
|
||||
outline: cn(
|
||||
'bg-transparent',
|
||||
'text-swiss-black dark:text-neutral-200',
|
||||
'border border-black/20 dark:border-white/20',
|
||||
@@ -109,7 +109,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
ghost: clsx(
|
||||
ghost: cn(
|
||||
'bg-transparent',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
@@ -119,7 +119,7 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
icon: clsx(
|
||||
icon: cn(
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'text-secondary',
|
||||
'border border-transparent',
|
||||
@@ -130,8 +130,8 @@ const variantStyles: Record<ButtonVariant, string> = {
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
tertiary: clsx(
|
||||
// Font override — must come after base in clsx() to win via tailwind-merge
|
||||
tertiary: cn(
|
||||
// Font override — must come after base in cn() to win via tailwind-merge
|
||||
'font-secondary font-medium normal-case tracking-normal',
|
||||
// Inactive state
|
||||
'bg-transparent',
|
||||
@@ -168,14 +168,13 @@ const iconSizeStyles: Record<ButtonSize, string> = {
|
||||
|
||||
const activeStyles: Partial<Record<ButtonVariant, string>> = {
|
||||
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
|
||||
tertiary:
|
||||
'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
|
||||
tertiary: 'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-brand dark:text-brand',
|
||||
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
|
||||
outline: 'bg-surface dark:bg-paper border-brand',
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
|
||||
};
|
||||
|
||||
const classes = $derived(clsx(
|
||||
const classes = $derived(cn(
|
||||
// Base
|
||||
'inline-flex items-center justify-center',
|
||||
'font-primary font-bold tracking-tight uppercase',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Use for segmented controls, view toggles, or any mutually exclusive button set.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -23,7 +23,7 @@ let { children, class: className, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex items-center gap-1 p-1',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'border border-subtle',
|
||||
|
||||
@@ -106,7 +106,7 @@ let selected = $state(false);
|
||||
<div class="flex items-center gap-4">
|
||||
<ToggleButton
|
||||
{...args}
|
||||
selected={selected}
|
||||
{selected}
|
||||
onclick={() => {
|
||||
selected = !selected;
|
||||
}}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Slider } from '$shared/ui';
|
||||
import { Button } from '$shared/ui/Button';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { Popover } from 'bits-ui';
|
||||
import clsx from 'clsx';
|
||||
import TechText from '../TechText/TechText.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -78,7 +78,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
-->
|
||||
{#if reduced}
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex gap-4 items-end w-full',
|
||||
className,
|
||||
)}
|
||||
@@ -98,7 +98,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
|
||||
<!-- ── FULL MODE ──────────────────────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
<div class={clsx('flex items-center px-1 relative', className)}>
|
||||
<div class={cn('flex items-center px-1 relative', className)}>
|
||||
<!-- Decrease button -->
|
||||
<Button
|
||||
variant="icon"
|
||||
@@ -119,7 +119,7 @@ const displayLabel = $derived(label ?? controlLabel ?? '');
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex flex-col items-center justify-center w-14 py-1',
|
||||
'select-none rounded-none transition-all duration-150',
|
||||
'border border-transparent',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Labeled container for form controls
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -24,7 +24,7 @@ interface Props {
|
||||
const { label, children, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
|
||||
<div class={cn('flex flex-col gap-3 py-6 border-b border-subtle last:border-0', className)}>
|
||||
<div class="flex justify-between items-center text-xs font-primary font-bold tracking-tight text-neutral-900 dark:text-neutral-100 uppercase leading-none">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
1px separator line, horizontal or vertical.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ let {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'bg-black/10 dark:bg-white/10',
|
||||
orientation === 'horizontal' ? 'w-full h-px' : 'w-px h-full',
|
||||
className,
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Filter } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||
import clsx from 'clsx';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
draw,
|
||||
@@ -68,7 +68,7 @@ $effect(() => {
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class={clsx('flex flex-col', className)}>
|
||||
<div class={cn('flex flex-col', className)}>
|
||||
<Label
|
||||
variant="default"
|
||||
size="sm"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Provides classes for styling footnotes
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -26,14 +26,14 @@ const { children, class: className, render }: Props = $props();
|
||||
|
||||
{#if render}
|
||||
{@render render({
|
||||
class: clsx(
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
),
|
||||
})}
|
||||
class: cn(
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
),
|
||||
})}
|
||||
{:else if children}
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'font-mono text-3xs sm:text-2xs lowercase tracking-wider-mono text-text-soft',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
design-system input. Zero border-radius, Space Grotesk, precise states.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
@@ -90,7 +90,7 @@ const hasRightSlot = $derived(!!rightIcon || showClearButton);
|
||||
const cfg = $derived(inputSizeConfig[size]);
|
||||
const styles = $derived(inputVariantConfig[variant]);
|
||||
|
||||
const inputClasses = $derived(clsx(
|
||||
const inputClasses = $derived(cn(
|
||||
'font-primary rounded-none outline-none transition-all duration-200',
|
||||
'text-neutral-900 dark:text-neutral-100',
|
||||
'placeholder:text-neutral-400 dark:placeholder:text-neutral-600',
|
||||
@@ -107,8 +107,8 @@ const inputClasses = $derived(clsx(
|
||||
));
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex flex-col gap-1', fullWidth && 'w-full')}>
|
||||
<div class={clsx('relative group', fullWidth && 'w-full')}>
|
||||
<div class={cn('flex flex-col gap-1', fullWidth && 'w-full')}>
|
||||
<div class={cn('relative group', fullWidth && 'w-full')}>
|
||||
<!-- Left icon slot -->
|
||||
{#if leftIcon}
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 dark:text-neutral-600 pointer-events-none z-10 flex items-center">
|
||||
@@ -147,7 +147,7 @@ const inputClasses = $derived(clsx(
|
||||
<!-- Helper / error text -->
|
||||
{#if helperText}
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'text-2xs font-mono tracking-wide px-1',
|
||||
error ? 'text-brand ' : 'text-secondary',
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Inline monospace label. The base primitive for all micrographic text.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type LabelFont,
|
||||
@@ -72,7 +72,7 @@ let {
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'font-mono tracking-widest leading-none',
|
||||
'inline-flex items-center gap-1.5',
|
||||
font === 'primary' && 'font-primary tracking-tight',
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<script module lang="ts">
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Link from './Link.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/Link',
|
||||
component: Link,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Styled link component based on the footer link design. Supports optional icon snippet and standard anchor attributes.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
argTypes: {
|
||||
href: {
|
||||
control: 'text',
|
||||
description: 'Link URL',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
href: 'https://fonts.google.com',
|
||||
target: '_blank',
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof Link>)}
|
||||
<Link {...args}>
|
||||
<span>Google Fonts</span>
|
||||
</Link>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="With Icon"
|
||||
args={{
|
||||
href: 'https://fonts.google.com',
|
||||
target: '_blank',
|
||||
}}
|
||||
>
|
||||
{#snippet template(args: ComponentProps<typeof Link>)}
|
||||
<Link {...args}>
|
||||
<span>Google Fonts</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Multiple Links">
|
||||
{#snippet template()}
|
||||
<div class="flex gap-4 p-8 bg-neutral-100 dark:bg-neutral-900 rounded-lg">
|
||||
<Link href="https://fonts.google.com" target="_blank">
|
||||
<span>Google Fonts</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
<Link href="https://www.fontshare.com" target="_blank">
|
||||
<span>Fontshare</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
<Link href="https://github.com" target="_blank">
|
||||
<span>GitHub</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class="fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200"
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -0,0 +1,45 @@
|
||||
<!--
|
||||
Component: Link
|
||||
A styled link component based on the footer link design.
|
||||
Supports optional icon snippet and standard anchor attributes.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLAnchorAttributes {
|
||||
/**
|
||||
* Link content
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* Optional icon snippet
|
||||
*/
|
||||
icon?: Snippet;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
icon,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<a
|
||||
class={cn(
|
||||
'group inline-flex items-center gap-1 text-2xs font-mono uppercase tracking-wider-mono',
|
||||
'text-neutral-400 hover:text-brand transition-colors',
|
||||
'bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 pointer-events-auto',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
{@render icon?.()}
|
||||
</a>
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import Link from './Link.svelte';
|
||||
|
||||
/**
|
||||
* Helper to create a plain text snippet
|
||||
*/
|
||||
function textSnippet(text: string) {
|
||||
return createRawSnippet(() => ({
|
||||
render: () => `<span>${text}</span>`,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an icon snippet
|
||||
*/
|
||||
function iconSnippet() {
|
||||
return createRawSnippet(() => ({
|
||||
render: () => `<svg class="lucide-arrow-up-right"></svg>`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('Link', () => {
|
||||
const defaultProps = {
|
||||
href: 'https://fonts.google.com',
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders text content via children snippet', () => {
|
||||
render(Link, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
children: textSnippet('Google Fonts'),
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('Google Fonts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as an anchor element with correct href', () => {
|
||||
render(Link, { props: defaultProps });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', 'https://fonts.google.com');
|
||||
});
|
||||
|
||||
it('renders the icon when provided via snippet', () => {
|
||||
const { container } = render(Link, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
children: textSnippet('Google Fonts'),
|
||||
icon: iconSnippet(),
|
||||
},
|
||||
});
|
||||
const icon = container.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass('lucide-arrow-up-right');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attributes', () => {
|
||||
it('applies custom CSS classes', () => {
|
||||
render(Link, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
class: 'custom-class',
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('link')).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('spreads additional anchor attributes', () => {
|
||||
render(Link, {
|
||||
props: {
|
||||
...defaultProps,
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
},
|
||||
});
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@
|
||||
Project logo with apropriate styles
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Badge } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -18,7 +18,7 @@ const { class: className }: Props = $props();
|
||||
const title = 'GLYPHDIFF';
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex items-center gap-2 md:gap-3 select-none', className)}>
|
||||
<div class={cn('flex items-center gap-2 md:gap-3 select-none', className)}>
|
||||
<h1 class="font-logo font-extrabold text-base md:text-xl tracking-tight text-swiss-black dark:text-neutral-200">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { PerspectiveManager } from '$shared/lib';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -73,7 +73,7 @@ const isVisible = $derived(manager.isFront);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx('will-change-transform', className)}
|
||||
class={cn('will-change-transform', className)}
|
||||
style:transform-style="preserve-3d"
|
||||
style:transform={style?.transform}
|
||||
style:filter={style?.filter}
|
||||
|
||||
@@ -93,7 +93,7 @@ const flyParams: FlyParams = {
|
||||
>
|
||||
<div>
|
||||
{#if headerTitle}
|
||||
<SectionHeader title={headerTitle} subtitle={headerSubtitle} index={index} />
|
||||
<SectionHeader title={headerTitle} subtitle={headerSubtitle} {index} />
|
||||
{/if}
|
||||
<SectionTitle text={title} />
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Numbered section heading with optional subtitle and pulse dot.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Label } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -41,7 +41,7 @@ let {
|
||||
const indexStr = $derived(String(index).padStart(2, '0'));
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex items-center gap-3 md:gap-4 mb-2', className)}>
|
||||
<div class={cn('flex items-center gap-3 md:gap-4 mb-2', className)}>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if pulse}
|
||||
<span class="w-1.5 h-1.5 bg-brand rounded-full animate-pulse"></span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
A horizontal separator line used to visually separate sections within a page.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -15,4 +15,4 @@ interface Props {
|
||||
const { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={clsx('w-full h-px bg-swiss-black/5 dark:bg-white/10 my-8 md:my-12', className)}></div>
|
||||
<div class={cn('w-full h-px bg-swiss-black/5 dark:bg-white/10 my-8 md:my-12', className)}></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
@@ -79,7 +79,7 @@ function close() {
|
||||
The inner div stays w-80 so Sidebar layout never reflows mid-animation.
|
||||
-->
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'shrink-0 z-30 h-full relative',
|
||||
'overflow-hidden',
|
||||
'will-change-[width]',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Generic loading placeholder with shimmer animation.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
@@ -18,7 +18,7 @@ let { class: className, animate = true, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'rounded-md bg-background-subtle/50 backdrop-blur-sm',
|
||||
animate && 'animate-pulse',
|
||||
className,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
A single key:value pair in Space Mono. Optional trailing divider.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Label } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
interface Props extends Pick<ComponentProps<typeof Label>, 'variant'> {
|
||||
@@ -36,7 +36,7 @@ let {
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex items-center gap-1', className)}>
|
||||
<div class={cn('flex items-center gap-1', className)}>
|
||||
<Label variant="muted" size="xs">{label}:</Label>
|
||||
<Label {variant} size="xs" bold>{value}</Label>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Renders multiple Stat components in a row with auto-separators.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Stat } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
interface StatItem extends Partial<Pick<ComponentProps<typeof Stat>, 'variant'>> {
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
let { stats, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={clsx('flex items-center gap-4', className)}>
|
||||
<div class={cn('flex items-center gap-4', className)}>
|
||||
{#each stats as stat, i}
|
||||
<Stat
|
||||
label={stat.label}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
Monospace <code> element for technical values, measurements, identifiers.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
type LabelSize,
|
||||
type LabelVariant,
|
||||
labelSizeConfig,
|
||||
labelVariantConfig,
|
||||
} from '$shared/ui/Label/config';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -42,7 +42,7 @@ let {
|
||||
</script>
|
||||
|
||||
<code
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'font-mono tracking-tight tabular-nums',
|
||||
labelSizeConfig[size],
|
||||
labelVariantConfig[variant],
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
-->
|
||||
<script lang="ts" generics="T">
|
||||
import { createVirtualizer } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -288,24 +288,24 @@ $effect(() => {
|
||||
>
|
||||
{#if itemIndex < items.length}
|
||||
{@render children({
|
||||
item: items[itemIndex],
|
||||
index: itemIndex,
|
||||
isFullyVisible: row.isFullyVisible,
|
||||
isPartiallyVisible: row.isPartiallyVisible,
|
||||
proximity: row.proximity,
|
||||
})}
|
||||
item: items[itemIndex],
|
||||
index: itemIndex,
|
||||
isFullyVisible: row.isFullyVisible,
|
||||
isPartiallyVisible: row.isPartiallyVisible,
|
||||
proximity: row.proximity,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-0">
|
||||
{#if itemIndex < items.length}
|
||||
{@render children({
|
||||
item: items[itemIndex],
|
||||
index: itemIndex,
|
||||
isFullyVisible: row.isFullyVisible,
|
||||
isPartiallyVisible: row.isPartiallyVisible,
|
||||
proximity: row.proximity,
|
||||
})}
|
||||
item: items[itemIndex],
|
||||
index: itemIndex,
|
||||
isFullyVisible: row.isFullyVisible,
|
||||
isPartiallyVisible: row.isPartiallyVisible,
|
||||
proximity: row.proximity,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -324,13 +324,13 @@ $effect(() => {
|
||||
{/snippet}
|
||||
|
||||
{#if useWindowScroll}
|
||||
<div class={clsx('relative w-full', className)} bind:this={viewportRef} {...rest}>
|
||||
<div class={cn('relative w-full', className)} bind:this={viewportRef} {...rest}>
|
||||
{@render content()}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={viewportRef}
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'relative overflow-y-auto overflow-x-hidden',
|
||||
'rounded-md bg-background',
|
||||
'w-full',
|
||||
|
||||
@@ -70,6 +70,12 @@ export {
|
||||
*/
|
||||
default as Label,
|
||||
} from './Label/Label.svelte';
|
||||
export {
|
||||
/**
|
||||
* Styled link with optional icon
|
||||
*/
|
||||
default as Link,
|
||||
} from './Link/Link.svelte';
|
||||
export {
|
||||
/**
|
||||
* Full-page or component-level progress spinner
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { typographySettingsStore } from '$features/SetupFont';
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
interface Props {
|
||||
@@ -53,7 +53,7 @@ $effect(() => {
|
||||
>
|
||||
{#each [0, 1] as s (s)}
|
||||
<span
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'char-inner',
|
||||
'transition-colors duration-300',
|
||||
isPast
|
||||
|
||||
@@ -101,6 +101,7 @@ function isFontReady(font: UnifiedFont): boolean {
|
||||
data-font-list
|
||||
weight={DEFAULT_FONT_WEIGHT}
|
||||
itemHeight={44}
|
||||
gap={2}
|
||||
class="bg-transparent min-h-0 h-full scroll-stable py-2 pl-6 pr-4"
|
||||
>
|
||||
{#snippet skeleton()}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<script lang="ts">
|
||||
import { ThemeSwitch } from '$features/ChangeAppTheme';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
Badge,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '$shared/ui';
|
||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { comparisonStore } from '../../model';
|
||||
|
||||
@@ -49,7 +48,7 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');
|
||||
</script>
|
||||
|
||||
<header
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex items-center justify-between',
|
||||
'px-4 md:px-8 py-4 md:py-6',
|
||||
'h-16 md:h-20 z-20',
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
Content (font list, controls) is injected via snippets.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
ButtonGroup,
|
||||
Label,
|
||||
ToggleButton,
|
||||
} from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type Side,
|
||||
@@ -40,7 +40,7 @@ let {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'flex flex-col h-full',
|
||||
'w-80',
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
type ResponsiveManager,
|
||||
debounce,
|
||||
} from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
CharacterComparisonEngine,
|
||||
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
||||
import { Loader } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -61,6 +61,26 @@ const comparisonEngine = new CharacterComparisonEngine();
|
||||
|
||||
let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
|
||||
|
||||
// Track container width changes (window resize, sidebar toggle, etc.)
|
||||
$effect(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
// Use borderBoxSize if available, fallback to contentRect
|
||||
const width = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
||||
if (width > 0) {
|
||||
containerWidth = width;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const sliderSpring = new Spring(50, {
|
||||
stiffness: 0.2,
|
||||
damping: 0.7,
|
||||
@@ -124,25 +144,25 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Layout effect — depends on content, settings AND containerWidth
|
||||
$effect(() => {
|
||||
const _text = comparisonStore.text;
|
||||
const _weight = typography.weight;
|
||||
const _size = typography.renderedSize;
|
||||
const _height = typography.height;
|
||||
const _spacing = typography.spacing;
|
||||
const _width = containerWidth;
|
||||
const _isMobile = isMobile;
|
||||
|
||||
if (container && fontA && fontB) {
|
||||
if (container && fontA && fontB && _width > 0) {
|
||||
// PRETEXT API strings: "weight sizepx family"
|
||||
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
|
||||
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
|
||||
|
||||
// Use offsetWidth to avoid transform scaling issues
|
||||
const width = container.offsetWidth;
|
||||
const padding = isMobile ? 48 : 96;
|
||||
const availableWidth = width - padding;
|
||||
const padding = _isMobile ? 48 : 96;
|
||||
const availableWidth = Math.max(0, _width - padding);
|
||||
const lineHeight = _size * _height;
|
||||
|
||||
containerWidth = width;
|
||||
layoutResult = comparisonEngine.layout(
|
||||
_text,
|
||||
fontAStr,
|
||||
@@ -155,30 +175,6 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handleResize = () => {
|
||||
if (container && fontA && fontB) {
|
||||
const width = container.offsetWidth;
|
||||
const padding = isMobile ? 48 : 96;
|
||||
containerWidth = width;
|
||||
layoutResult = comparisonEngine.layout(
|
||||
comparisonStore.text,
|
||||
getPretextFontString(typography.weight, typography.renderedSize, fontA.name),
|
||||
getPretextFontString(typography.weight, typography.renderedSize, fontB.name),
|
||||
width - padding,
|
||||
typography.renderedSize * typography.height,
|
||||
typography.spacing,
|
||||
typography.renderedSize,
|
||||
);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
// Dynamic backgroundSize based on isMobile — can't express this in Tailwind.
|
||||
// Color is set to currentColor so it respects dark mode via text color.
|
||||
const gridStyle = $derived(
|
||||
@@ -198,10 +194,10 @@ const scaleClass = $derived(
|
||||
Outer flex container — fills parent.
|
||||
The paper div inside scales down when the sidebar opens on desktop.
|
||||
-->
|
||||
<div class={clsx('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
|
||||
<div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
|
||||
<!-- Paper surface -->
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'w-full h-full flex flex-col items-center justify-center relative',
|
||||
'bg-paper dark:bg-dark-card',
|
||||
'shadow-2xl shadow-black/5 dark:shadow-black/20',
|
||||
@@ -270,11 +266,11 @@ const scaleClass = $derived(
|
||||
|
||||
<TypographyMenu
|
||||
bind:open={isTypographyMenuOpen}
|
||||
class={clsx(
|
||||
'absolute z-50',
|
||||
class={cn(
|
||||
'absolute z-10',
|
||||
responsive.isMobileOrTablet
|
||||
? 'bottom-4 right-4 -translate-1/2'
|
||||
: 'bottom-5 left-1/2 right-[unset] -translate-x-1/2',
|
||||
? 'bottom-0 right-0 -translate-1/2'
|
||||
: 'bottom-2.5 left-1/2 -translate-x-1/2',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
1px red vertical rule with square handles at top and bottom.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import clsx from 'clsx';
|
||||
import { cn } from '$shared/lib';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -31,7 +31,7 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
>
|
||||
<!-- Top handle -->
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'w-5 h-5 md:w-6 md:h-6',
|
||||
'-ml-2.5 md:-ml-3',
|
||||
'mt-2 md:mt-4',
|
||||
@@ -47,7 +47,7 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
|
||||
<!-- Bottom handle -->
|
||||
<div
|
||||
class={clsx(
|
||||
class={cn(
|
||||
'w-5 h-5 md:w-6 md:h-6',
|
||||
'-ml-2.5 md:-ml-3',
|
||||
'mb-2 md:mb-4',
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<script lang="ts">
|
||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import { Section } from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
@@ -38,7 +38,7 @@ $effect(() => {
|
||||
headerAction={registerAction}
|
||||
>
|
||||
{#snippet content({ className })}
|
||||
<div class={clsx(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Footer } from './ui/Footer/Footer.svelte';
|
||||
@@ -0,0 +1,47 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Footer from './Footer.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Widgets/Footer',
|
||||
component: Footer,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Application footer with project information and portfolio link. Visible only on desktop screens.',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Desktop View"
|
||||
parameters={{
|
||||
viewport: { defaultViewport: 'desktop' },
|
||||
}}
|
||||
>
|
||||
{#snippet template()}
|
||||
<div class="h-[200px] relative bg-neutral-50 dark:bg-neutral-900">
|
||||
<Footer />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Mobile View (Hidden)"
|
||||
parameters={{
|
||||
viewport: { defaultViewport: 'mobile1' },
|
||||
}}
|
||||
>
|
||||
{#snippet template()}
|
||||
<div class="h-[200px] relative bg-neutral-50 dark:bg-neutral-900">
|
||||
<p class="p-4 text-sm text-neutral-500 italic">Footer should be hidden on mobile.</p>
|
||||
<Footer />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!--
|
||||
Widget: Footer
|
||||
Application footer with project information and portfolio link.
|
||||
Visible only on desktop screens.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import type { ResponsiveManager } from '$shared/lib/helpers';
|
||||
import { getContext } from 'svelte';
|
||||
import FooterLink from '../FooterLink/FooterLink.svelte';
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const isVertical = $derived(responsive?.isDesktop || responsive?.isDesktopLarge);
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<footer
|
||||
class={cn(
|
||||
'fixed z-10 flex flex-row items-end gap-1 pointer-events-none',
|
||||
isVertical ? 'bottom-2.5 right-2.5 [writing-mode:vertical-rl] rotate-180' : 'bottom-4 left-4',
|
||||
)}
|
||||
>
|
||||
<!-- Project Name (Horizontal) -->
|
||||
{#if isVertical}
|
||||
<div class="flex flex-row pointer-events-auto items-center gap-2 bg-surface/80 dark:bg-dark-bg/80 backdrop-blur-sm px-2 py-1 border border-subtle">
|
||||
<div class="w-1.5 h-1.5 bg-brand"></div>
|
||||
<span class="text-2xs font-mono uppercase tracking-wider-mono text-neutral-500 dark:text-neutral-400">
|
||||
GlyphDiff © 2025 — {currentYear}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Portfolio Link (Vertical) -->
|
||||
<div class="pointer-events-auto">
|
||||
<FooterLink
|
||||
text="allmy.work"
|
||||
href="https://allmy.work/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={cn('border border-subtle', isVertical ? 'text-2xs' : 'text-4xs')}
|
||||
iconClass={isVertical ? 'rotate-90' : ''}
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
} from '@testing-library/svelte';
|
||||
import { setContext } from 'svelte';
|
||||
import Footer from './Footer.svelte';
|
||||
|
||||
// Mock component to provide context
|
||||
import ContextWrapper from '$shared/lib/providers/ResponsiveProvider/ResponsiveProvider.svelte';
|
||||
|
||||
describe('Footer', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
it('renders on desktop', () => {
|
||||
// Mock responsive context
|
||||
const mockResponsive = {
|
||||
isDesktop: true,
|
||||
isDesktopLarge: false,
|
||||
};
|
||||
|
||||
const { container } = render(Footer, {
|
||||
context: new Map([['responsive', mockResponsive]]),
|
||||
});
|
||||
|
||||
expect(screen.getByText(`GlyphDiff © 2025 — ${currentYear}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('allmy.work')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders on large desktop', () => {
|
||||
const mockResponsive = {
|
||||
isDesktop: false,
|
||||
isDesktopLarge: true,
|
||||
};
|
||||
|
||||
render(Footer, {
|
||||
context: new Map([['responsive', mockResponsive]]),
|
||||
});
|
||||
|
||||
expect(screen.getByText(`GlyphDiff © 2025 — ${currentYear}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render on mobile or tablet', () => {
|
||||
const mockResponsive = {
|
||||
isDesktop: false,
|
||||
isDesktopLarge: false,
|
||||
};
|
||||
|
||||
render(Footer, {
|
||||
context: new Map([['responsive', mockResponsive]]),
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/GlyphDiff/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<!--
|
||||
Component: FooterLink
|
||||
Specific footer link implementation that uses the generic Link component
|
||||
and adds the default arrow icon.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/lib';
|
||||
import { Link } from '$shared/ui';
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLAnchorAttributes {
|
||||
/**
|
||||
* Link text
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* CSS classes for the default icon
|
||||
*/
|
||||
iconClass?: string;
|
||||
/**
|
||||
* Link URL
|
||||
*/
|
||||
href: string;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
text,
|
||||
iconClass,
|
||||
href,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Link
|
||||
{href}
|
||||
class={className}
|
||||
{...rest}
|
||||
>
|
||||
<span>{text}</span>
|
||||
{#snippet icon()}
|
||||
<ArrowUpRightIcon
|
||||
size={10}
|
||||
class={cn(
|
||||
'fill-body group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200',
|
||||
iconClass,
|
||||
)}
|
||||
/>
|
||||
{/snippet}
|
||||
</Link>
|
||||
@@ -6,11 +6,11 @@
|
||||
import { NavigationWrapper } from '$entities/Breadcrumb';
|
||||
import { fontStore } from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/lib';
|
||||
import {
|
||||
Label,
|
||||
Section,
|
||||
} from '$shared/ui';
|
||||
import clsx from 'clsx';
|
||||
import { getContext } from 'svelte';
|
||||
import { layoutManager } from '../../model';
|
||||
import LayoutSwitch from '../LayoutSwitch/LayoutSwitch.svelte';
|
||||
@@ -50,7 +50,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
||||
{/snippet}
|
||||
|
||||
{#snippet content({ className })}
|
||||
<div class={clsx(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<SampleList />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
+1
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ESNext",
|
||||
@@ -22,7 +23,6 @@
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"$lib/*": ["./src/lib/*"],
|
||||
"$app/*": ["./src/app/*"],
|
||||
|
||||
@@ -12,6 +12,10 @@ export default defineConfig({
|
||||
restoreMocks: true,
|
||||
setupFiles: ['./vitest.setup.component.ts', './vitest.setup.jsdom.ts'],
|
||||
globals: true,
|
||||
testTimeout: 15000,
|
||||
pool: 'forks',
|
||||
maxWorkers: 1,
|
||||
isolate: false,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user