feat(design-system): Storybook + Button-Atom-Vereinheitlichung + SectionHeader
Storybook (SvelteKit-Framework, Tailwind v4, MSW, Translation-Decorator): Stories für Atoms/Molecules/Organisms + alle CMS-Blocks. Button-Atom: 5 .btn-*-Varianten (primary/secondary/tertiary/outline/warning), class-Merge-Prop, href→<a>, Loading-Spinner neben Label, rounded-md/font-medium. Alle rohen .btn-*- und Hand-Style-Buttons app-weit aufs Atom migriert. SectionHeader-Molecule (kicker/number/title/subtitle/action) + Integration in 7 CMS-Blocks (post_overview, organisations, organisations_map, searchable_text, image_gallery, youtube_video_gallery, files) via neuem section_header-Schema. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Badge from './Badge.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/Badge',
|
||||
component: Badge,
|
||||
tags: ['autodocs'],
|
||||
args: { label: 'Neu' },
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const AlsLink: Story = { args: { label: 'Mehr', href: '#' } };
|
||||
export const Aktiv: Story = { args: { label: 'Aktiv', active: true } };
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Breadcrumbs from './Breadcrumbs.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/Breadcrumbs',
|
||||
component: Breadcrumbs,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Breadcrumbs>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Start', href: '/' },
|
||||
{ label: 'Themen', href: '/themen' },
|
||||
{ label: 'Windkraft im Wald' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Atoms/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'tertiary', 'outline', 'warning'],
|
||||
},
|
||||
size: { control: 'select', options: ['sm', 'md', 'lg'] },
|
||||
disabled: { control: 'boolean' },
|
||||
loading: { control: 'boolean' },
|
||||
href: { control: 'text' },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Fünf Varianten (Quelle: .btn-* aus app.css) -->
|
||||
<Story name="Primary" args={{ variant: 'primary' }}>Speichern</Story>
|
||||
<Story name="Secondary" args={{ variant: 'secondary' }}>Abbrechen</Story>
|
||||
<Story name="Tertiary" args={{ variant: 'tertiary' }}>Mehr Infos</Story>
|
||||
<Story name="Outline" args={{ variant: 'outline' }}>Filter</Story>
|
||||
<Story name="Warning" args={{ variant: 'warning' }}>Termin melden</Story>
|
||||
|
||||
<!-- Drei Größen -->
|
||||
<Story name="Klein (sm)" args={{ variant: 'primary', size: 'sm' }}>Klein</Story>
|
||||
<Story name="Mittel (md)" args={{ variant: 'primary', size: 'md' }}>Mittel</Story>
|
||||
<Story name="Groß (lg)" args={{ variant: 'primary', size: 'lg' }}>Jetzt einwenden</Story>
|
||||
|
||||
<!-- Zustände -->
|
||||
<Story name="Loading" args={{ variant: 'primary', loading: true }}>Senden</Story>
|
||||
<Story name="Disabled" args={{ variant: 'primary', disabled: true }}>Gesperrt</Story>
|
||||
<Story name="Disabled Outline" args={{ variant: 'outline', disabled: true }}>Gesperrt</Story>
|
||||
|
||||
<!-- href → rendert <a> statt <button> -->
|
||||
<Story name="Als Link" args={{ variant: 'primary', href: '/mitmachen' }}>Zur Aktion</Story>
|
||||
+68
-31
@@ -1,59 +1,96 @@
|
||||
<script lang="ts">
|
||||
type Variant = 'primary' | 'secondary' | 'ghost';
|
||||
type Size = 'sm' | 'md' | 'large';
|
||||
|
||||
/**
|
||||
* Button — einzige Quelle: die .btn-*-Varianten aus app.css.
|
||||
* Rendert <a> wenn href gesetzt ist, sonst <button>.
|
||||
* Loading: Spinner NEBEN dem Label (kein Breitensprung), aria-busy.
|
||||
*/
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes, HTMLAnchorAttributes } from 'svelte/elements';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'tertiary' | 'outline' | 'warning';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
href = undefined,
|
||||
type = 'button' as 'button' | 'submit' | 'reset',
|
||||
label = '',
|
||||
children = undefined,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: {
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'large';
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
href?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
label?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
/** Zusätzliche Layout-Klassen (w-full, justify-*, min-h-*, …). Werden an
|
||||
* base/size/variant ANGEHÄNGT — überschreiben die Varianten-Styles nicht.
|
||||
* Aus rest herausgezogen, sonst würde {...rest} das class-Attribut kapern. */
|
||||
class?: string;
|
||||
} & HTMLButtonAttributes &
|
||||
HTMLAnchorAttributes = $props();
|
||||
|
||||
/* Basis: identisch zur .btn-*-Basis in app.css (font-medium = 500,
|
||||
rounded-md, 200ms). Kein eigener Focus-Ring — der globale
|
||||
*:focus-visible (wald-500) übernimmt. */
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-xs 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';
|
||||
'inline-flex items-center justify-center gap-1.5 rounded-md font-medium no-underline whitespace-nowrap transition-colors duration-200 disabled:cursor-not-allowed';
|
||||
|
||||
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
|
||||
]
|
||||
/* Größen setzen NUR Maße — Varianten setzen NUR Farben. Kein Konflikt. */
|
||||
const sizeClasses: Record<Size, string> = {
|
||||
sm: 'px-2 py-1 text-xs gap-1',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
};
|
||||
|
||||
/* 1:1 aus app.css. Disabled: explizite Stein-Farben statt opacity —
|
||||
auf Wald-Grün ist opacity-40 kaum als deaktiviert erkennbar. */
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
primary:
|
||||
'shadow-sm bg-btn-bg text-btn-txt hover:bg-btn-hover-bg active:bg-wald-700 disabled:bg-stein-200 disabled:text-stein-400 disabled:shadow-none',
|
||||
secondary:
|
||||
'shadow-sm border border-stein-300 bg-white text-stein-700 hover:bg-stein-100 hover:border-stein-400 active:bg-stein-200 disabled:bg-stein-50 disabled:text-stein-300 disabled:border-stein-200 disabled:shadow-none',
|
||||
tertiary:
|
||||
'shadow-sm bg-himmel-700 text-white hover:bg-himmel-800 active:bg-himmel-900 disabled:bg-stein-200 disabled:text-stein-400 disabled:shadow-none',
|
||||
outline:
|
||||
'border border-stein-300 text-stein-700 hover:bg-stein-100 active:bg-stein-200 disabled:text-stein-300 disabled:border-stein-200 disabled:hover:bg-transparent',
|
||||
warning:
|
||||
'shadow-sm border border-gelb-500 bg-gelb-300 text-gelb-900 hover:bg-gelb-400 active:bg-gelb-500 disabled:bg-stein-100 disabled:text-stein-400 disabled:border-stein-200 disabled:shadow-none',
|
||||
};
|
||||
|
||||
const allClasses = $derived(
|
||||
[base, sizeClasses[size], variantClasses[variant], className].filter(Boolean).join(' '),
|
||||
);
|
||||
|
||||
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(' '));
|
||||
const isDisabled = $derived(disabled || loading);
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
{disabled}
|
||||
class={allClasses}
|
||||
>
|
||||
{#snippet inner()}
|
||||
{#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 if children}
|
||||
<span
|
||||
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{label}
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if href && !isDisabled}
|
||||
<a {href} class={allClasses} {...rest}>
|
||||
{@render inner()}
|
||||
</a>
|
||||
{:else}
|
||||
<button {type} disabled={isDisabled} aria-busy={loading || undefined} class={allClasses} {...rest}>
|
||||
{@render inner()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Checkbox from './Checkbox.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/Checkbox',
|
||||
component: Checkbox,
|
||||
tags: ['autodocs'],
|
||||
args: { label: 'Ich stimme zu' },
|
||||
} satisfies Meta<typeof Checkbox>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Unchecked: Story = {};
|
||||
export const Checked: Story = { args: { checked: true } };
|
||||
export const Disabled: Story = { args: { disabled: true } };
|
||||
export const Klein: Story = { args: { size: 'sm', label: 'Kompakt' } };
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Radio from './Radio.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/Radio',
|
||||
component: Radio,
|
||||
tags: ['autodocs'],
|
||||
args: { name: 'auswahl', value: 'a', label: 'Option A', group: 'a' },
|
||||
} satisfies Meta<typeof Radio>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Ausgewaehlt: Story = {};
|
||||
export const NichtAusgewaehlt: Story = { args: { group: 'b' } };
|
||||
export const Disabled: Story = { args: { disabled: true } };
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import RustyImage from '$lib/components/RustyImage.svelte';
|
||||
|
||||
// Bild-Primitive: baut aus roher CMS-URL eine transformierte /cms-images-URL
|
||||
// (AVIF/WebP-`<picture>`, Retina-srcset, CLS=0). Läuft über den Storybook-Proxy.
|
||||
const CMS_ASSET =
|
||||
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/RustyImage',
|
||||
component: RustyImage,
|
||||
tags: ['autodocs'],
|
||||
args: { src: CMS_ASSET, width: 640, aspect: '16/9', alt: 'Steigerwald' },
|
||||
} satisfies Meta<typeof RustyImage>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Breitbild: Story = {};
|
||||
export const Quadratisch: Story = { args: { aspect: '1/1', width: 400 } };
|
||||
export const Portrait: Story = { args: { aspect: '3/4', width: 360 } };
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import SearchField from './SearchField.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/SearchField',
|
||||
component: SearchField,
|
||||
tags: ['autodocs'],
|
||||
args: { placeholder: 'Beiträge durchsuchen …' },
|
||||
} satisfies Meta<typeof SearchField>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Hell: Story = { args: { tone: 'light' } };
|
||||
export const Dunkel: Story = {
|
||||
args: { tone: 'dark' },
|
||||
parameters: { backgrounds: { default: 'stein' } },
|
||||
};
|
||||
export const MitWert: Story = { args: { value: 'Windkraft' } };
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Select from './Select.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/Select',
|
||||
component: Select,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
label: 'Sortierung',
|
||||
options: [
|
||||
{ value: 'newest', label: 'Neueste zuerst' },
|
||||
{ value: 'oldest', label: 'Älteste zuerst' },
|
||||
{ value: 'az', label: 'A–Z' },
|
||||
],
|
||||
},
|
||||
} satisfies Meta<typeof Select>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {};
|
||||
export const MitAuswahl: Story = { args: { value: 'oldest' } };
|
||||
export const Disabled: Story = { args: { disabled: true } };
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import SidebarNav from './SidebarNav.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/SidebarNav',
|
||||
component: SidebarNav,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SidebarNav>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {
|
||||
args: {
|
||||
groups: [
|
||||
{
|
||||
label: 'Verfahren',
|
||||
links: [
|
||||
{ href: '/verfahren/ablauf', label: 'Ablauf', active: true },
|
||||
{ href: '/verfahren/fristen', label: 'Fristen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Mitmachen',
|
||||
links: [
|
||||
{ href: '/mitmachen/einwenden', label: 'Einwenden' },
|
||||
{ href: '/mitmachen/spenden', label: 'Spenden' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Slider from './Slider.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/Slider',
|
||||
component: Slider,
|
||||
tags: ['autodocs'],
|
||||
args: { min: 0, max: 100, value: 50, label: 'Umkreis (km)' },
|
||||
} satisfies Meta<typeof Slider>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {};
|
||||
export const Disabled: Story = { args: { disabled: true } };
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import TabBar from './TabBar.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/TabBar',
|
||||
component: TabBar,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
tabs: [
|
||||
{ id: 'karte', label: 'Karte' },
|
||||
{ id: 'liste', label: 'Liste' },
|
||||
{ id: 'info', label: 'Info' },
|
||||
],
|
||||
selectedId: 'karte',
|
||||
},
|
||||
} satisfies Meta<typeof TabBar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import TextInput from './TextInput.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/TextInput',
|
||||
component: TextInput,
|
||||
tags: ['autodocs'],
|
||||
args: { label: 'Name', placeholder: 'Vor- und Nachname' },
|
||||
} satisfies Meta<typeof TextInput>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {};
|
||||
export const MitHilfetext: Story = {
|
||||
args: { label: 'E-Mail', placeholder: 'name@example.org', helpText: 'Wird nicht veröffentlicht.' },
|
||||
};
|
||||
export const Fehler: Story = {
|
||||
args: { label: 'E-Mail', value: 'ungültig', invalid: true, error: 'Bitte gültige E-Mail eingeben.' },
|
||||
};
|
||||
export const Disabled: Story = { args: { disabled: true, value: 'gesperrt' } };
|
||||
export const Klein: Story = { args: { size: 'sm', label: 'PLZ', placeholder: '00000' } };
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Textarea from './Textarea.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/Textarea',
|
||||
component: Textarea,
|
||||
tags: ['autodocs'],
|
||||
args: { label: 'Nachricht', placeholder: 'Ihre Nachricht …', rows: 4 },
|
||||
} satisfies Meta<typeof Textarea>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Standard: Story = {};
|
||||
export const MitHilfetext: Story = { args: { helpText: 'Max. 500 Zeichen.' } };
|
||||
export const Fehler: Story = { args: { invalid: true, error: 'Pflichtfeld.' } };
|
||||
export const Disabled: Story = { args: { disabled: true, value: 'gesperrt' } };
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/sveltekit';
|
||||
import Toggle from './Toggle.svelte';
|
||||
|
||||
const meta = {
|
||||
title: 'Atoms/Toggle',
|
||||
component: Toggle,
|
||||
tags: ['autodocs'],
|
||||
args: { label: 'Vergangene ausblenden' },
|
||||
} satisfies Meta<typeof Toggle>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Aus: Story = {};
|
||||
export const An: Story = { args: { checked: true } };
|
||||
export const Disabled: Story = { args: { disabled: true } };
|
||||
@@ -0,0 +1,33 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Tooltip from './Tooltip.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Atoms/Tooltip',
|
||||
component: Tooltip,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placement: { control: 'select', options: ['top', 'bottom'] },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Tooltip umschließt einen Trigger (children). Zum Sehen: über den Button
|
||||
hovern/fokussieren. -->
|
||||
<Story name="Oben" args={{ content: 'Erklärung erscheint hier', placement: 'top' }}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-sm border border-stein-300 bg-white px-3 py-1.5 text-sm"
|
||||
>
|
||||
Hover mich
|
||||
</button>
|
||||
</Story>
|
||||
|
||||
<Story name="Unten" args={{ content: 'Tooltip unterhalb', placement: 'bottom' }}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-sm border border-stein-300 bg-white px-3 py-1.5 text-sm"
|
||||
>
|
||||
Hover mich
|
||||
</button>
|
||||
</Story>
|
||||
Reference in New Issue
Block a user