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:
+13
-1
@@ -127,7 +127,7 @@ export function getBlockLayoutClasses(
|
||||
const spaceClass = SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
|
||||
|
||||
if (breakout) {
|
||||
return `w-full ${spaceClass}`;
|
||||
return "w-full";
|
||||
}
|
||||
|
||||
const mobile = String(layout.mobile ?? "12");
|
||||
@@ -143,6 +143,18 @@ export function getBlockLayoutClasses(
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/** Tailwind-Klasse für spaceBottom (z. B. am Breakout-Wrapper). */
|
||||
export function getSpaceBottomClass(
|
||||
layout: BlockLayout | undefined | null,
|
||||
): string {
|
||||
if (!layout || typeof layout !== "object") return "mb-4";
|
||||
const sb =
|
||||
typeof layout.spaceBottom === "number"
|
||||
? layout.spaceBottom
|
||||
: Number(layout.spaceBottom);
|
||||
return SPACE_BOTTOM[sb as keyof typeof SPACE_BOTTOM] ?? "mb-4";
|
||||
}
|
||||
|
||||
/** Prüft, ob das Layout breakout (fullwidth) haben soll. */
|
||||
export function isBlockLayoutBreakout(
|
||||
layout: BlockLayout | undefined | null,
|
||||
|
||||
@@ -87,6 +87,8 @@ export interface ImageGalleryImage {
|
||||
src?: string;
|
||||
description?: string;
|
||||
file?: { url?: string };
|
||||
/** Nach resolveContentImages: lokaler Pfad zum transformierten Bild (16:9). */
|
||||
resolvedSrc?: string;
|
||||
}
|
||||
|
||||
/** Bildergalerie (_type: "image_gallery"). images: Array von img-Objekten (src, description). */
|
||||
|
||||
@@ -230,7 +230,7 @@ export async function resolvePostOverviewBlocks(
|
||||
}
|
||||
for (const post of item.postsResolved as PostEntry[]) {
|
||||
const raw = getPostImageUrl(post.postImage);
|
||||
if (raw) {
|
||||
if (raw && import.meta.env.SSR) {
|
||||
try {
|
||||
const { ensureTransformedImage } = await import("./rusty-image");
|
||||
const url = await ensureTransformedImage(raw, {
|
||||
|
||||
@@ -2543,7 +2543,7 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
alignment: "left" | "center" | "right";
|
||||
/** @description Markdown/body text content */
|
||||
/** @description Markdown/body text: either inline or file reference (file:path, e.g. file:slug.content.md) */
|
||||
content?: string;
|
||||
/** @description Column width (grid) like CF_ComponentLayout */
|
||||
layout?: {
|
||||
@@ -2587,7 +2587,7 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
alignment: "left" | "center" | "right";
|
||||
/** @description Markdown/body text content */
|
||||
/** @description Markdown/body text: either inline or file reference (file:path, e.g. file:slug.content.md) */
|
||||
content?: string;
|
||||
/** @description Column width (grid) like CF_ComponentLayout */
|
||||
layout?: {
|
||||
@@ -10469,7 +10469,7 @@ export interface operations {
|
||||
fit?: "fill" | "contain" | "cover";
|
||||
/** @description Output format */
|
||||
format?: "jpeg" | "png" | "webp" | "avif";
|
||||
/** @description JPEG quality (1–100) */
|
||||
/** @description Quality 1–100 for JPEG and WebP (lossy) */
|
||||
quality?: number;
|
||||
};
|
||||
header?: never;
|
||||
|
||||
@@ -524,3 +524,55 @@ export async function getFullwidthBannerBySlug(
|
||||
}
|
||||
return (await res.json()) as FullwidthBannerEntry;
|
||||
}
|
||||
|
||||
/** Translation-Eintrag (UI-Strings für i18n). Collection: translation. */
|
||||
export type TranslationEntry = { _slug?: string; text: string };
|
||||
|
||||
/**
|
||||
* Translation anhand Slug (GET /api/content/translation/:slug).
|
||||
* Für zukünftiges Translation-Management: Texte aus dem CMS statt hardcoded.
|
||||
*/
|
||||
export async function getTranslationBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<TranslationEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
return (await res.json()) as TranslationEntry;
|
||||
}
|
||||
|
||||
/** Translation-Bundle: ein Objekt mit vielen Keys (String → String). Collection: translation_bundle. */
|
||||
export type TranslationBundleEntry = {
|
||||
_slug?: string;
|
||||
strings: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Translation-Bundle anhand Slug (GET /api/content/translation_bundle/:slug).
|
||||
* Liefert ein Objekt mit allen UI-Strings (z. B. strings.searchable_text_help).
|
||||
* Slug z. B. "de" für content/de/translation_bundle/de.json5.
|
||||
*/
|
||||
export async function getTranslationBundleBySlug(
|
||||
slug: string,
|
||||
options?: { locale?: string },
|
||||
): Promise<TranslationBundleEntry | null> {
|
||||
const base = getBaseUrl();
|
||||
const url = new URL(
|
||||
`${base}/api/content/translation_bundle/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||
const res = await fetch(url.toString());
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
return (await res.json()) as TranslationBundleEntry;
|
||||
}
|
||||
|
||||
@@ -30,3 +30,6 @@ export const POST_RESOLVE = [...ROW_RESOLVE, "postImage", "postTag"];
|
||||
|
||||
/** Fallback-Slug für die Startseite, wenn page_config keine homePage liefert. */
|
||||
export const DEFAULT_HOME_PAGE_SLUG = "page-home";
|
||||
|
||||
/** Site-Name für og:site_name und JSON-LD (in Layout via PUBLIC_SITE_NAME überschreibbar). */
|
||||
export const SITE_NAME = "RustyAstro";
|
||||
|
||||
+55
-16
@@ -96,8 +96,11 @@ export async function ensureTransformedImage(
|
||||
throw new Error("rusty-image: project root (process.cwd) not available");
|
||||
}
|
||||
|
||||
const publicDir = path.join(projectRoot, "public");
|
||||
const dir = path.join(publicDir, SUBDIR);
|
||||
// Build: in dist schreiben, damit Preview/Deploy die Bilder ausliefert (public wird vor dem Rendern kopiert).
|
||||
// Dev: in public schreiben, damit der Dev-Server sie ausliefert.
|
||||
const isBuild = typeof import.meta !== "undefined" && (import.meta as { env?: { PROD?: boolean } }).env?.PROD === true;
|
||||
const baseDir = isBuild ? path.join(projectRoot, "dist") : path.join(projectRoot, "public");
|
||||
const dir = path.join(baseDir, SUBDIR);
|
||||
const filename = `${hash}${dimSuffix}.${ext}`;
|
||||
const filePath = path.join(dir, filename);
|
||||
const publicPath = `/${SUBDIR}/${filename}`;
|
||||
@@ -121,6 +124,7 @@ export async function ensureTransformedImage(
|
||||
searchParams.set("quality", String(params.quality));
|
||||
|
||||
const transformUrl = `${baseUrl}/api/transform?${searchParams.toString()}`;
|
||||
console.log("[rusty-image] GET", transformUrl);
|
||||
const res = await fetch(transformUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
@@ -183,6 +187,15 @@ function getImageBlockUrl(block: {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** URL aus einem Galerie-Bildeintrag (src oder file.url). */
|
||||
function getGalleryImageUrl(
|
||||
img: { src?: string; file?: { url?: string } },
|
||||
): string | null {
|
||||
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
|
||||
if (img.file?.url) return img.file.url.startsWith("//") ? `https:${img.file.url}` : img.file.url;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** RowContentLayout-typ: Zeilen mit content-Arrays. */
|
||||
export interface RowContentLayoutLike {
|
||||
row1Content?: unknown[];
|
||||
@@ -204,6 +217,16 @@ export async function resolveContentImages(
|
||||
layout.row3Content,
|
||||
].filter((r): r is unknown[] => Array.isArray(r));
|
||||
|
||||
/** Galerie: 1280×720 (16:9), WebP, Qualität 80 – kleinere Dateien. */
|
||||
const galleryParams: RustyImageTransformParams = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
fit: "cover",
|
||||
format: "webp",
|
||||
quality: 80,
|
||||
...transformParams,
|
||||
};
|
||||
|
||||
for (const content of rows) {
|
||||
for (const item of content) {
|
||||
if (typeof item !== "object" || item === null) continue;
|
||||
@@ -211,23 +234,39 @@ export async function resolveContentImages(
|
||||
_type?: string;
|
||||
image?: unknown;
|
||||
img?: unknown;
|
||||
images?: Array<{ src?: string; file?: { url?: string }; resolvedSrc?: string }>;
|
||||
resolvedImageSrc?: string;
|
||||
};
|
||||
if (block._type !== "image") continue;
|
||||
const url = getImageBlockUrl(
|
||||
block as {
|
||||
image?: string | { file?: { url?: string }; title?: string };
|
||||
img?: string | { file?: { url?: string }; title?: string };
|
||||
},
|
||||
);
|
||||
if (!url) continue;
|
||||
try {
|
||||
block.resolvedImageSrc = await ensureTransformedImage(
|
||||
url,
|
||||
transformParams,
|
||||
if (block._type === "image") {
|
||||
const url = getImageBlockUrl(
|
||||
block as {
|
||||
image?: string | { file?: { url?: string }; title?: string };
|
||||
img?: string | { file?: { url?: string }; title?: string };
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] resolve failed for", url, e);
|
||||
if (!url) continue;
|
||||
try {
|
||||
block.resolvedImageSrc = await ensureTransformedImage(
|
||||
url,
|
||||
transformParams,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] resolve failed for", url, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block._type === "image_gallery" && Array.isArray(block.images)) {
|
||||
for (const img of block.images) {
|
||||
if (!img || typeof img !== "object") continue;
|
||||
const url = getGalleryImageUrl(img);
|
||||
if (!url || !url.startsWith("http")) continue;
|
||||
try {
|
||||
(img as { resolvedSrc?: string }).resolvedSrc =
|
||||
await ensureTransformedImage(url, galleryParams);
|
||||
} catch (e) {
|
||||
console.warn("[rusty-image] gallery resolve failed for", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = {};
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user