Update environment configuration, enhance component functionality, and improve project structure. Added Umami analytics configuration to .env.example and updated astro.config.mjs to use the new site URL. Introduced pagination component in BlogOverview.svelte, refactored image handling in ImageGalleryBlock.svelte, and improved SearchableTextBlock.svelte with tooltip support. Updated package.json with new scripts for Firebase deployment and added new dependencies for Storybook and Firebase. Enhanced styling and layout across various components for better user experience.

This commit is contained in:
Peter Meier
2026-02-27 14:13:38 +01:00
parent bb6ec20d45
commit 1f5454d04e
68 changed files with 3668 additions and 265 deletions
+76
View File
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Badge from './Badge.svelte';
const meta = {
title: 'UI/Badge',
component: Badge,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Kleine Label-Badge: nur Text (span), als Link (href) oder als Button (onclick). Varianten: default, primary (Wald), accent (Himmel), inactive, date.',
},
},
},
argTypes: {
variant: {
control: 'select',
options: ['default', 'primary', 'accent', 'inactive', 'date'],
},
},
} satisfies Meta<Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Badge',
variant: 'default',
},
};
export const Primary: Story = {
args: {
label: 'Primär',
variant: 'primary',
},
};
export const Accent: Story = {
args: {
label: 'Akzent',
variant: 'accent',
},
};
export const Inactive: Story = {
args: {
label: 'Inaktiv',
variant: 'inactive',
},
};
export const Date: Story = {
args: {
label: '12.03.2025',
variant: 'date',
},
};
export const AsLink: Story = {
args: {
label: 'Als Link',
variant: 'primary',
href: '#',
},
};
export const Active: Story = {
args: {
label: 'Aktiver Tab',
variant: 'primary',
active: true,
},
};
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
type Variant = 'default' | 'primary' | 'accent' | 'inactive' | 'date';
let {
label = '',
variant = 'default',
href = null as string | null,
active = false,
onclick = null as (() => void) | null,
} = $props();
const baseClass =
'inline-flex shrink-0 whitespace-nowrap rounded-full py-1 px-2.5 text-[0.6875rem] font-medium transition-all ring-1 no-underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1';
const variantClasses = $derived(
{
default: 'bg-stein-0 text-stein-800 ring-stein-200 hover:bg-stein-50',
primary: 'bg-wald-500 text-white ring-wald-600/30 hover:bg-wald-600',
accent: 'bg-himmel-400 text-white ring-himmel-500/30 hover:bg-himmel-500',
inactive: 'bg-stein-100 text-stein-500 ring-stein-200',
date: 'bg-wald-600 text-white ring-wald-700/40',
}[variant]
);
const shadowClass = $derived(
href || onclick
? variant === 'date'
? 'shadow-[0_0_6px_rgba(0,0,0,0.25)]'
: 'shadow-[0_2px_6px_rgba(0,0,0,0.12)]'
: ''
);
const classes = $derived(
`${baseClass} ${variantClasses} ${shadowClass} ${active ? 'pointer-events-none ring-2' : ''}`
);
</script>
{#if href}
<a
href={href}
class={classes}
aria-current={active ? 'true' : undefined}
>
{label}
</a>
{:else if onclick}
<button
type="button"
class={classes}
aria-current={active ? 'true' : undefined}
onclick={onclick}
>
{label}
</button>
{:else}
<span class={classes}>{label}</span>
{/if}
+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Breadcrumbs from './Breadcrumbs.svelte';
const meta = {
title: 'UI/Breadcrumbs',
component: Breadcrumbs,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Breadcrumb-Navigation. `items`: Array aus `{ href?, label }`. Letzter Eintrag ohne href = aktueller Seiten-Titel.',
},
},
},
} satisfies Meta<Breadcrumbs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
items: [
{ href: '/', label: 'Startseite' },
{ href: '/aktuelles', label: 'Aktuelles' },
{ label: 'Artikel-Titel' },
],
},
};
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import "../iconify-offline";
import Icon from "@iconify/svelte";
/** @type {{ href?: string; label: string }[]} */
let { items = [] } = $props();
</script>
<nav aria-label="Breadcrumb" class="overflow-x-auto">
<ol class="list-none flex flex-nowrap items-center gap-x-1 text-xs py-4 pl-0 m-0!">
{#each items as item, i}
<li class="flex items-center gap-x-1.5 shrink-0 whitespace-nowrap">
{#if item.href && i < items.length - 1}
<a
href={item.href}
class="inline-flex font-medium no-underline items-center gap-1.5 text-stein-500 hover:text-wald-600 transition-colors duration-150 rounded px-1 -mx-1 py-0.5 -my-0.5"
aria-label={i === 0 ? item.label : undefined}
>
{#if i === 0}
<Icon icon="mdi:home" class="size-4 shrink-0" aria-hidden="true" />
{:else}
<span>{item.label}</span>
{/if}
</a>
{:else}
<span
class="font-medium text-stein-800"
aria-current={i === items.length - 1 ? 'page' : undefined}
>
{item.label}
</span>
{/if}
{#if i < items.length - 1}
<span aria-hidden="true" class="shrink-0 text-stein-300">
<Icon icon="mdi:chevron-right" class="size-3.5" />
</span>
{/if}
</li>
{/each}
</ol>
</nav>
+81
View File
@@ -0,0 +1,81 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Button from './Button.svelte';
const meta = {
title: 'UI/Button',
component: Button,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Primärer Aktions-Button. Varianten: primary, secondary, ghost. Größen: sm, md, large.',
},
},
},
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost'],
},
size: {
control: 'select',
options: ['sm', 'md', 'large'],
},
},
} satisfies Meta<Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
label: 'Mitmachen',
variant: 'primary',
size: 'md',
},
};
export const Secondary: Story = {
args: {
label: 'Mehr erfahren',
variant: 'secondary',
},
};
export const Ghost: Story = {
args: {
label: 'Abbrechen',
variant: 'ghost',
},
};
export const Small: Story = {
args: {
label: 'Klein',
variant: 'primary',
size: 'sm',
},
};
export const Large: Story = {
args: {
label: 'Großer Button',
variant: 'primary',
size: 'large',
},
};
export const Loading: Story = {
args: {
label: 'Laden',
loading: true,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
disabled: true,
},
};
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'large';
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
type = 'button' as 'button' | 'submit' | 'reset',
label = '',
} = $props();
const base =
'inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40';
const sizeClasses = $derived(
{ sm: 'h-9 px-4 py-2 text-sm', md: 'h-12 min-w-[120px] px-6 py-3 text-base', large: 'h-14 px-8 py-4 text-lg' }[
size
]
);
const variantClasses = $derived(
{
primary: 'bg-wald-500 text-white hover:bg-wald-600 active:bg-wald-700',
secondary:
'border-[1.5px] border-wald-500 bg-transparent text-wald-500 hover:bg-wald-50 active:bg-wald-100',
ghost: 'bg-transparent px-4 py-3 text-stein-700 hover:bg-stein-100 active:bg-stein-200',
}[variant]
);
const allClasses = $derived([base, sizeClasses, variantClasses].filter(Boolean).join(' '));
</script>
<button
{type}
{disabled}
class={allClasses}
>
{#if loading}
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
<span>Laden...</span>
{:else}
<slot>{label}</slot>
{/if}
</button>
+43
View File
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Checkbox from './Checkbox.svelte';
const meta = {
title: 'UI/Checkbox',
component: Checkbox,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Checkbox mit optionalem Label. `checked` und `disabled` steuerbar.',
},
},
},
argTypes: {
checked: { control: 'boolean' },
},
} satisfies Meta<Checkbox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Unchecked: Story = {
args: {
label: 'Option auswählen',
checked: false,
},
};
export const Checked: Story = {
args: {
label: 'Option ausgewählt',
checked: true,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
checked: false,
disabled: true,
},
};
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
name = '',
value = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="checkbox"
bind:checked
{disabled}
{name}
{value}
class="h-5 w-5 rounded border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import ColorsShowcase from './ColorsShowcase.svelte';
const meta = {
title: 'Foundation/Colors',
component: ColorsShowcase,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Farbpaletten des Windwiderstand Design Systems: Wald (Primary), Erde (Secondary), Himmel (Accent), Stein (Neutrals) sowie semantische Farben.',
},
},
},
} satisfies Meta<ColorsShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Palettes: Story = {};
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
/** Farbwerte aus global.css @theme damit Paletten in Storybook garantiert sichtbar sind */
const paletteHex: Record<string, Record<string, string>> = {
wald: {
'50': '#f0f5f1', '100': '#d9e8dc', '200': '#b3d1b9', '300': '#7fb58a',
'400': '#4a9960', '500': '#2d7a45', '600': '#236335', '700': '#1a4d28',
'800': '#12361c', '900': '#0a2011',
},
erde: {
'50': '#faf6f1', '100': '#f0e6d6', '200': '#e0ccad', '300': '#c9a87a',
'400': '#b08a52', '500': '#8b6d3f', '600': '#6e5530', '700': '#524023',
'800': '#372b18', '900': '#1c160c',
},
himmel: {
'50': '#f2f5f7', '100': '#dce4ea', '200': '#b8c9d4', '300': '#8aaabb',
'400': '#5e8ba2', '500': '#436e85', '600': '#34576a', '700': '#264150',
'800': '#1a2c36', '900': '#0e181e',
},
stein: {
'0': '#ffffff', '50': '#f7f8f7', '100': '#eceeed', '200': '#d5d8d6',
'300': '#b0b5b2', '400': '#868c89', '500': '#636966', '600': '#4a4f4c',
'700': '#333735', '800': '#1f2221', '900': '#0f1110',
},
};
const semanticHex = [
{ name: 'Success', main: '#2d7a45', subtle: '#f0f5f1' },
{ name: 'Warning', main: '#a6780a', subtle: '#fdf8ec' },
{ name: 'Error', main: '#b53629', subtle: '#fdf2f1' },
{ name: 'Info', main: '#436e85', subtle: '#f2f5f7' },
] as const;
const palettes = [
{
name: 'Wald (Primary)',
tokens: ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
prefix: 'wald',
},
{
name: 'Erde (Secondary)',
tokens: ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
prefix: 'erde',
},
{
name: 'Himmel (Accent / Links)',
tokens: ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
prefix: 'himmel',
},
{
name: 'Stein (Neutrals)',
tokens: ['0', '50', '100', '200', '300', '400', '500', '600', '700', '800', '900'],
prefix: 'stein',
},
] as const;
const appColors = [
{ name: 'Text Primary', var: '--text-primary' },
{ name: 'Text Secondary', var: '--text-secondary' },
{ name: 'Text Tertiary', var: '--text-tertiary' },
{ name: 'Bg Primary', var: '--bg-primary' },
{ name: 'Bg Secondary', var: '--bg-secondary' },
{ name: 'Bg Tertiary', var: '--bg-tertiary' },
{ name: 'Accent', var: '--accent' },
{ name: 'Accent Hover', var: '--accent-hover' },
{ name: 'Link', var: '--link' },
{ name: 'Border Default', var: '--border-default' },
{ name: 'Border Strong', var: '--border-strong' },
{ name: 'Btn BG', var: '--color-btn-bg' },
{ name: 'Btn Hover BG', var: '--color-btn-hover-bg' },
{ name: 'Btn Text', var: '--color-btn-txt' },
{ name: 'Navigation', var: '--color-navigation' },
{ name: 'Font Highlight (Error)', var: '--color-font-highlight' },
{ name: 'Container Breakout', var: '--color-container-breakout' },
] as const;
</script>
<div class="space-y-10 text-stein-800">
{#each palettes as palette}
<section>
<h3 class="mb-3 text-lg font-semibold">{palette.name}</h3>
<div class="flex flex-wrap gap-2">
{#each palette.tokens as t}
{@const hex = paletteHex[palette.prefix]?.[t]}
<div class="flex flex-col items-center gap-1">
<div
class="h-14 w-20 rounded-lg border border-stein-200 shadow-sm"
style="background-color: {hex ?? 'transparent'}"
title="--color-{palette.prefix}-{t}"
></div>
<span class="text-xs text-stein-500">{t}</span>
</div>
{/each}
</div>
</section>
{/each}
<section>
<h3 class="mb-3 text-lg font-semibold">Semantik (Success, Warning, Error, Info)</h3>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4">
{#each semanticHex as s}
<div class="flex flex-col gap-2 rounded-lg border border-stein-200 p-3">
<div
class="h-12 rounded border border-stein-200"
style="background-color: {s.main}"
title="{s.name} main"
></div>
<div
class="h-8 rounded border border-stein-200"
style="background-color: {s.subtle}"
title="{s.name} subtle"
></div>
<span class="text-sm font-medium">{s.name}</span>
<span class="text-xs text-stein-500">+ subtle</span>
</div>
{/each}
</div>
</section>
<section>
<h3 class="mb-3 text-lg font-semibold">Semantik & App ( :root )</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4 md:grid-cols-5">
{#each appColors as a}
<div class="flex flex-col gap-1 rounded-lg border border-stein-200 p-2">
<div
class="h-10 w-full rounded border border-stein-200"
style="background-color: var({a.var})"
title="{a.var}"
></div>
<span class="text-xs font-medium">{a.name}</span>
<span class="truncate text-[0.65rem] text-stein-500" title="{a.var}">{a.var}</span>
</div>
{/each}
</div>
</section>
</div>
+34
View File
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Header from './Header.svelte';
const meta = {
title: 'UI/Header',
component: Header,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Seiten-Header mit Navigation (links), CTA-Button und optionalem Mobilmenü.',
},
},
},
argTypes: {
mobileMenuOpen: { control: 'boolean' },
},
} satisfies Meta<Header>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
links: [
{ href: '/ueber-uns', label: 'Über uns' },
{ href: '/aktuelles', label: 'Aktuelles', active: true },
{ href: '/fakten', label: 'Fakten' },
{ href: '/initiativen', label: 'Initiativen' },
],
ctaLabel: 'Mitmachen',
ctaHref: '/mitmachen',
},
};
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
/** @type {{ href: string; label: string; active?: boolean }[]} */
let {
links = [],
ctaHref = '/mitmachen',
ctaLabel = 'Mitmachen',
logoSrc = '/logo.svg',
logoAlt = 'Windwiderstand',
mobileMenuOpen = $bindable(false),
} = $props();
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
</script>
<a href="#main" class="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[51] focus:rounded focus:bg-wald-500 focus:px-2 focus:py-1 focus:text-white focus:outline-none">
Zum Inhalt springen
</a>
<header
class="sticky top-0 z-50 border-b border-stein-200 bg-stein-0/95 shadow-sm backdrop-blur-sm lg:shadow-none"
>
<nav
aria-label="Hauptnavigation"
class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 lg:h-[72px] lg:px-8"
>
<a href="/" class="flex items-center gap-2">
<img src={logoSrc} alt={logoAlt} class="h-8" />
</a>
<div class="hidden items-center gap-8 lg:flex">
{#each links as link}
{#if link.active}
<a
href={link.href}
class="border-b-2 border-wald-500 text-wald-500 text-[0.9375rem] font-medium"
aria-current="page"
>
{link.label}
</a>
{:else}
<a
href={link.href}
class="text-stein-700 text-[0.9375rem] font-medium hover:text-wald-600"
>
{link.label}
</a>
{/if}
{/each}
</div>
<a
href={ctaHref}
class="hidden rounded-lg bg-wald-500 px-5 py-2.5 text-sm font-semibold text-white hover:bg-wald-600 lg:inline-block"
>
{ctaLabel}
</a>
<button
type="button"
class="lg:hidden"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
onclick={toggleMobileMenu}
>
<span class="sr-only">Menu öffnen</span>
{#if mobileMenuOpen}
<span class="text-2xl" aria-hidden="true"></span>
{:else}
<span class="text-2xl" aria-hidden="true"></span>
{/if}
</button>
</nav>
{#if mobileMenuOpen}
<div
id="mobile-menu"
class="absolute left-0 right-0 top-16 z-50 flex flex-col gap-4 border-b border-stein-200 bg-stein-0 p-4 lg:hidden"
role="dialog"
aria-label="Mobile Menü"
>
{#each links as link}
<a
href={link.href}
class="text-stein-700 text-[0.9375rem] font-medium hover:text-wald-600 {link.active
? 'border-b-2 border-wald-500 text-wald-500'
: ''}"
aria-current={link.active ? 'page' : undefined}
>
{link.label}
</a>
{/each}
<a
href={ctaHref}
class="rounded-lg bg-wald-500 px-5 py-2.5 text-center text-sm font-semibold text-white hover:bg-wald-600"
>
{ctaLabel}
</a>
</div>
{/if}
</header>
+49
View File
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Radio from './Radio.svelte';
const meta = {
title: 'UI/Radio',
component: Radio,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Radio-Button für Gruppen. `bind:group` vom Parent hält den value des gewählten Radios. Gleicher `name` für alle in einer Gruppe.',
},
},
},
argTypes: {
group: { control: 'text', description: 'Wert der ausgewählten Option (value des gewählten Radios)' },
},
} satisfies Meta<Radio>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Unselected: Story = {
args: {
name: 'choice',
value: 'a',
label: 'Option A',
group: '',
},
};
export const Selected: Story = {
args: {
name: 'choice',
value: 'b',
label: 'Option B',
group: 'b',
},
};
export const Disabled: Story = {
args: {
name: 'choice',
value: 'c',
label: 'Option C (deaktiviert)',
group: '',
disabled: true,
},
};
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
/** Gleicher name für alle Radios in einer Gruppe; bind:group vom Parent hält den gewählten value. */
let {
name = '',
value = '',
/** Gebundener Wert der Gruppe entspricht dem value des ausgewählten Radios. */
group = $bindable(''),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<input
type="radio"
{name}
{value}
bind:group={group}
{disabled}
class="h-5 w-5 rounded-full border-[1.5px] border-stein-400 text-wald-500 hover:border-wald-400 focus:ring-2 focus:ring-wald-100 focus:ring-offset-0 checked:border-wald-500 checked:bg-wald-500"
/>
{#if label}
<span class="ml-2 text-base text-stein-700">{label}</span>
{/if}
</label>
+41
View File
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Select from './Select.svelte';
const meta = {
title: 'UI/Select',
component: Select,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Dropdown-Auswahl. `options`: Array aus `{ value, label }`. `bind:value` für die gewählte value.',
},
},
},
} satisfies Meta<Select>;
export default meta;
type Story = StoryObj<typeof meta>;
const options = [
{ value: 'a', label: 'Option A' },
{ value: 'b', label: 'Option B' },
{ value: 'c', label: 'Option C' },
];
export const Default: Story = {
args: {
label: 'Auswahl',
placeholder: 'Bitte wählen',
options,
value: '',
},
};
export const WithValue: Story = {
args: {
label: 'Auswahl',
options,
value: 'b',
},
};
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
/** @type {{ value: string; label: string }[]} */
let {
options = [],
value = $bindable(''),
label = '',
placeholder = 'Bitte wählen',
disabled = false,
} = $props();
const selectId = $derived(`select-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<label
for={selectId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
</label>
{/if}
<div class="relative">
<select
id={selectId}
bind:value
{disabled}
class="h-12 w-full appearance-none rounded-lg border border-stein-300 bg-white px-4 py-3 pr-10 text-base text-stein-800 outline-none hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100 disabled:bg-stein-100 disabled:opacity-60"
>
<option value="" disabled>{placeholder}</option>
{#each options as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
<span
class="pointer-events-none absolute right-3 top-1/2 h-5 w-5 -translate-y-1/2 text-stein-500"
aria-hidden="true"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</span>
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import SidebarNav from './SidebarNav.svelte';
const meta = {
title: 'UI/SidebarNav',
component: SidebarNav,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Seitennavigation mit gruppierten Links. `groups`: Array aus `{ label, links: [{ href, label, active? }] }`.',
},
},
},
} satisfies Meta<SidebarNav>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
groups: [
{
label: 'Kategorie',
links: [
{ href: '#', label: 'Link 1' },
{ href: '#', label: 'Link 2', active: true },
{ href: '#', label: 'Link 3' },
],
},
{
label: 'Kategorie 2',
links: [
{ href: '#', label: 'Link 4' },
{ href: '#', label: 'Link 5' },
],
},
],
},
};
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts">
/**
* @type {{ label: string; links: { href: string; label: string; active?: boolean }[] }[]}
*/
let { groups = [] } = $props();
</script>
<nav aria-label="Seitennavigation" class="w-60 bg-stein-50 p-4">
{#each groups as group, i}
<div class="{i > 0 ? 'mt-6' : ''}">
<p
class="mb-2 text-xs font-medium uppercase tracking-wider text-stein-500"
>
{group.label}
</p>
<ul class="space-y-0">
{#each group.links as link}
<li>
<a
href={link.href}
class="block rounded-lg px-3 py-2 text-sm {link.active
? 'bg-wald-50 font-medium text-wald-500'
: 'text-stein-700 hover:bg-wald-50'}"
aria-current={link.active ? 'page' : undefined}
>
{link.label}
</a>
</li>
{/each}
</ul>
</div>
{/each}
</nav>
+49
View File
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Slider from './Slider.svelte';
const meta = {
title: 'UI/Slider',
component: Slider,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Schieberegler für numerische Werte. `min`, `max`, `value` (bindbar). Optionales Label.',
},
},
},
argTypes: {
value: { control: { type: 'number', min: 0, max: 100 } },
min: { control: 'number' },
max: { control: 'number' },
},
} satisfies Meta<Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Wert',
min: 0,
max: 100,
value: 50,
},
};
export const WithRange: Story = {
args: {
label: 'Jahre',
min: 1990,
max: 2030,
value: 2025,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
value: 25,
disabled: true,
},
};
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
let {
min = 0,
max = 100,
value = $bindable(50),
label = '',
disabled = false,
} = $props();
const inputId = $derived(`slider-${Math.random().toString(36).slice(2)}`);
</script>
<div>
{#if label}
<div class="mb-1.5 flex items-center justify-between">
<label for={inputId} class="text-sm font-medium text-stein-700">
{label}
</label>
<span class="text-sm font-semibold text-stein-700">{value}</span>
</div>
{/if}
<input
type="range"
id={inputId}
{min}
{max}
bind:value
{disabled}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={String(value)}
class="h-5 w-5 accent-wald-500 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-wald-500 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-stein-200"
/>
<div class="mt-1 flex justify-between text-xs text-stein-500">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import TabBar from './TabBar.svelte';
const meta = {
title: 'UI/TabBar',
component: TabBar,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Horizontale Tab-Leiste. `tabs`: Array aus `{ id, label }`. `selectedId` / `bind:selectedId` für aktiven Tab.',
},
},
},
argTypes: {
selectedId: { control: 'text' },
},
} satisfies Meta<TabBar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
tabs: [
{ id: 'tab1', label: 'Tab 1' },
{ id: 'tab2', label: 'Tab 2' },
{ id: 'tab3', label: 'Tab 3' },
],
selectedId: 'tab2',
labelledBy: 'Inhalte',
},
};
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
/** @type {{ id: string; label: string }[]} */
let {
tabs = [],
selectedId = $bindable(''),
labelledBy = '',
} = $props();
$effect(() => {
if (tabs.length && !selectedId) selectedId = tabs[0].id;
});
function selectTab(id: string) {
selectedId = id;
}
function handleKeydown(e: KeyboardEvent) {
const idx = tabs.findIndex((t) => t.id === selectedId);
if (e.key === 'ArrowRight' && idx < tabs.length - 1) {
e.preventDefault();
selectedId = tabs[idx + 1].id;
} else if (e.key === 'ArrowLeft' && idx > 0) {
e.preventDefault();
selectedId = tabs[idx - 1].id;
}
}
</script>
<div
role="tablist"
aria-label={labelledBy || 'Tabs'}
class="flex h-12 border-b border-stein-200"
onkeydown={handleKeydown}
>
{#each tabs as tab}
<button
type="button"
role="tab"
aria-selected={selectedId === tab.id}
aria-controls="panel-{tab.id}"
id="tab-{tab.id}"
class="border-b-2 px-4 text-sm font-medium transition-colors {selectedId === tab.id
? 'border-wald-500 text-wald-600 font-semibold'
: 'border-transparent text-stein-500 hover:text-stein-700'}"
tabindex={selectedId === tab.id ? 0 : -1}
onclick={() => selectTab(tab.id)}
>
{tab.label}
</button>
{/each}
</div>
+52
View File
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import TextInput from './TextInput.svelte';
const meta = {
title: 'UI/TextInput',
component: TextInput,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Textfeld mit Label, optionaler Hilfetext- und Fehleranzeige. Unterstützt type (text, email, password, search), required, disabled.',
},
},
},
argTypes: {
type: {
control: 'select',
options: ['text', 'email', 'password', 'search'],
},
},
} satisfies Meta<TextInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'E-Mail',
type: 'email',
placeholder: 'ihre@email.de',
required: true,
helpText: 'Wir geben Ihre Daten nicht weiter.',
},
};
export const WithError: Story = {
args: {
label: 'E-Mail',
type: 'email',
value: 'ungueltig',
error: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.',
invalid: true,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
value: 'Nur Lesen',
disabled: true,
},
};
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
type = 'text',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
} = $props();
const inputId = $derived(id || `input-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<input
{type}
{name}
{placeholder}
{required}
{disabled}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="h-12 w-full rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+34
View File
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Textarea from './Textarea.svelte';
const meta = {
title: 'UI/Textarea',
component: Textarea,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Mehrzeiliges Textfeld mit Label, optionaler Hilfetext- und Fehleranzeige. `bind:value` für zweiweite Bindung.',
},
},
},
} satisfies Meta<Textarea>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Nachricht',
placeholder: 'Ihre Nachricht an uns...',
helpText: 'Optional. Max. 500 Zeichen.',
},
};
export const WithError: Story = {
args: {
label: 'Nachricht',
error: 'Dieses Feld ist erforderlich.',
invalid: true,
},
};
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
let {
id = '',
name = '',
value = $bindable(''),
placeholder = '',
label = '',
required = false,
disabled = false,
error = '',
helpText = '',
invalid = false,
rows = 4,
} = $props();
const inputId = $derived(id || `textarea-${Math.random().toString(36).slice(2)}`);
const errorId = $derived(`${inputId}-error`);
const helpId = $derived(`${inputId}-help`);
</script>
<div>
{#if label}
<label
for={inputId}
class="mb-1.5 block text-sm font-medium text-stein-700"
>
{label}
{#if required}
<span class="text-error">*</span>
{/if}
</label>
{/if}
<textarea
{name}
{placeholder}
{required}
{disabled}
{rows}
bind:value
id={inputId}
aria-required={required}
aria-invalid={invalid || !!error}
aria-describedby={[error ? errorId : '', helpText ? helpId : ''].filter(Boolean).join(' ')}
class="min-h-[120px] w-full resize-y rounded-lg border bg-white px-4 py-3 text-base text-stein-800 outline-none placeholder:text-stein-400 {error || invalid
? 'border-error ring-2 ring-error-subtle'
: 'border-stein-300 hover:border-stein-400 focus:border-wald-500 focus:ring-2 focus:ring-wald-100'} disabled:bg-stein-100 disabled:opacity-60"
/>
{#if error}
<p id={errorId} class="mt-1.5 text-[0.8125rem] text-error" role="alert">
{error}
</p>
{:else if helpText}
<p id={helpId} class="mt-1.5 text-[0.8125rem] text-stein-500">
{helpText}
</p>
{/if}
</div>
+49
View File
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Toggle from './Toggle.svelte';
const meta = {
title: 'UI/Toggle',
component: Toggle,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Ein/Aus-Schalter mit optionalem Label. `checked` und `disabled` steuerbar.',
},
},
},
argTypes: {
checked: { control: 'boolean' },
},
} satisfies Meta<Toggle>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Off: Story = {
args: {
label: 'Aus',
checked: false,
},
};
export const On: Story = {
args: {
label: 'An',
checked: true,
},
};
export const NoLabel: Story = {
args: {
checked: false,
},
};
export const Disabled: Story = {
args: {
label: 'Deaktiviert',
checked: false,
disabled: true,
},
};
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
let {
checked = $bindable(false),
disabled = false,
label = '',
} = $props();
</script>
<label class="inline-flex cursor-pointer items-center gap-2 {disabled ? 'cursor-not-allowed opacity-60' : ''}">
<span
class="relative inline-block h-6 w-11 shrink-0 rounded-full transition-all duration-200 {checked ? 'bg-wald-500' : 'bg-stein-300'}"
role="switch"
aria-checked={checked}
>
<input
type="checkbox"
class="sr-only"
bind:checked
{disabled}
onkeydown={(e) => e.key === ' ' && (checked = !checked)}
/>
<span
class="absolute top-0.5 inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 {checked ? 'left-[22px] translate-x-0.5' : 'left-0.5'}"
/>
</span>
{#if label}
<span class="text-base text-stein-700">{label}</span>
{/if}
</label>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
content,
placement: preferredPlacement = "top",
children,
}: { content: string; placement?: "top" | "bottom"; children?: Snippet } = $props();
const TOOLTIP_GAP = 8;
const TOOLTIP_MAX_WIDTH_PX = 352; // 22rem
let wrapperEl = $state<HTMLDivElement | null>(null);
let showTooltip = $state(false);
let placement = $state<"top" | "bottom">("top");
let leftPx = $state<number | null>(null); // null = centered (CSS), number = clamped px from wrapper left
function updatePosition() {
if (!wrapperEl) return;
const rect = wrapperEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceAbove = rect.top;
const spaceBelow = vh - rect.bottom;
placement =
preferredPlacement === "top"
? spaceAbove >= spaceBelow
? "top"
: "bottom"
: spaceBelow >= spaceAbove
? "bottom"
: "top";
const iconCenterX = rect.left + rect.width / 2;
const idealLeft = iconCenterX - TOOLTIP_MAX_WIDTH_PX / 2;
const clampedLeft = Math.max(
TOOLTIP_GAP,
Math.min(idealLeft, vw - TOOLTIP_MAX_WIDTH_PX - TOOLTIP_GAP),
);
leftPx = clampedLeft - rect.left;
}
function handleShow() {
updatePosition();
showTooltip = true;
}
function handleHide() {
showTooltip = false;
leftPx = null;
}
const placementClasses = $derived(
placement === "top"
? "bottom-full mb-2"
: "top-full mt-2",
);
const horizontalStyle = $derived(
leftPx != null ? `left: ${leftPx}px; transform: none;` : "left: 50%; transform: translateX(-50%);",
);
</script>
<div
bind:this={wrapperEl}
role="group"
class="relative inline-flex focus-within:outline-none"
onmouseenter={handleShow}
onmouseleave={handleHide}
onfocusin={handleShow}
onfocusout={handleHide}
>
{@render children?.()}
{#if showTooltip}
<div
role="tooltip"
class="absolute z-50 {placementClasses} px-3 py-2 text-xs font-normal text-stein-0 bg-stein-800 rounded-md shadow-lg min-w-56 max-w-88 text-center pointer-events-none whitespace-normal"
style={horizontalStyle}
>
{content}
</div>
{/if}
</div>
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import TypographyShowcase from './TypographyShowcase.svelte';
const meta = {
title: 'Foundation/Typography',
component: TypographyShowcase,
tags: ['autodocs'],
parameters: {
layout: 'padded',
docs: {
description: {
component:
'Typo-Skala des Windwiderstand Design Systems: Inter (Primary), Lora (Zitate). Gewichte 300, 400, 500, 600, 700.',
},
},
},
} satisfies Meta<TypographyShowcase>;
export default meta;
type Story = StoryObj<typeof meta>;
export const TypeScale: Story = {};
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
// Zeigt die Typo-Skala aus global.css (02-typography)
</script>
<div class="space-y-8 text-stein-800">
<section class="space-y-2">
<h1>Überschrift 1 (H1)</h1>
<p class="text-sm text-stein-500">1.75rem / 2.125rem · 700 · -0.015em</p>
</section>
<section class="space-y-2">
<h2>Überschrift 2 (H2)</h2>
<p class="text-sm text-stein-500">1.5rem / 1.875rem · 700 · -0.01em</p>
</section>
<section class="space-y-2">
<h3>Überschrift 3 (H3)</h3>
<p class="text-sm text-stein-500">1.25rem / 1.625rem · 600 · -0.005em</p>
</section>
<section class="space-y-2">
<h4>Überschrift 4 (H4)</h4>
<p class="text-sm text-stein-500">1.125rem / 1.5rem · 600</p>
</section>
<section class="space-y-2">
<p class="text-base">
Fließtext (Body): Inter, 1.125rem (18px), line-height 1.556. Erlaubte Gewichte: 300, 400, 500, 600, 700.
</p>
<p class="text-sm text-stein-500">--font-body · 1.125rem · 1.556</p>
</section>
<section class="space-y-2">
<p class="font-light">Leicht (300): Windwiderstand für mehr Artenvielfalt.</p>
<p class="font-normal">Normal (400): Windwiderstand für mehr Artenvielfalt.</p>
<p class="font-medium">Medium (500): Windwiderstand für mehr Artenvielfalt.</p>
<p class="font-semibold">Semibold (600): Windwiderstand für mehr Artenvielfalt.</p>
<p class="font-bold">Bold (700): Windwiderstand für mehr Artenvielfalt.</p>
</section>
<section class="space-y-2">
<p class="text-xl italic text-stein-700" style="font-family: var(--font-secondary)">
„Lora Variable für Zitate und sekundäre Texte.“
</p>
<p class="text-sm text-stein-500">--font-secondary · Lora Variable</p>
</section>
<section class="space-y-2">
<div class="pageTitle">
<h1>Page-Titel (Beispiel)</h1>
<h2>Unterzeile oder Teaser</h2>
</div>
<p class="text-sm text-stein-500">.pageTitle (wie windwiderstand.de)</p>
</section>
</div>
+18
View File
@@ -0,0 +1,18 @@
/**
* Windwiderstand UI Komponenten aus 04-components-a-navigation-input.md
* Einbau in die App erfolgt später.
*/
export { default as Header } from './Header.svelte';
export { default as TabBar } from './TabBar.svelte';
export { default as SidebarNav } from './SidebarNav.svelte';
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
export { default as Button } from './Button.svelte';
export { default as TextInput } from './TextInput.svelte';
export { default as Textarea } from './Textarea.svelte';
export { default as Select } from './Select.svelte';
export { default as Toggle } from './Toggle.svelte';
export { default as Checkbox } from './Checkbox.svelte';
export { default as Radio } from './Radio.svelte';
export { default as Slider } from './Slider.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Tooltip } from './Tooltip.svelte';