shared-ui: Sync auf mana/shared-ui v1.0.0 + AppSlider tot weg

Workspace-Kopie in packages/shared-ui synchronisiert mit
mana@1dc8a98 (Compat-Layer für alle v0.1.x-Patterns). 219 Files
geändert — alter Code (Charts, Quick-Input-Originale, Help, Onboarding,
Settings, Bottom-Stack, Search-Core, ColorPicker, Actions) entfällt;
neue v1.0.0-Komponenten kommen rein.

tsconfig.json self-contained (kein extends auf nicht-existierenden
managarten/tsconfig.base.json).

pnpm check ergibt jetzt 0 Errors über alle 10086 Files
(Stand vorher: 204 Errors mit dem unverarbeiteten Sync). Zwei
non-blocking Warnings stehen offen (SSR-nested-button bei TagChip,
ARIA-Role bei Pill mit click-handler).

AppSlider toter Code in apps/mana/apps/web/src/lib/components/
AppSlider.svelte entfernt — der Wrapper hatte keine Aufrufer mehr.

mana-internal Configs (Storybook, lost-pixel, vite.config, Dockerfile,
infrastructure, PORTING_PLAN.md) bewusst NICHT gesynced — die wandern
nur im mana-Repo. managarten-shared-ui ist eingefrorene Kopie, kein
publish-target.

scripts/validate-disziplin.mjs: ungenutzte lines-Variable entfernt
(ESLint no-unused-vars).
This commit is contained in:
Till JS 2026-05-21 14:56:54 +02:00
parent 3b61ab64a4
commit ce923bbdc7
213 changed files with 6712 additions and 20619 deletions

View file

@ -1,33 +0,0 @@
<script lang="ts">
import { AppSlider } from '@mana/shared-ui';
import type { AppItem } from '@mana/shared-ui';
import { getActiveManaApps, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@mana/shared-branding';
// Convert MANA_APPS to AppItem format (German)
const apps: AppItem[] = getActiveManaApps().map((app) => ({
name: app.name,
description: app.description.de,
longDescription: app.longDescription.de,
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}));
const statusLabels = APP_STATUS_LABELS.de;
const labels = APP_SLIDER_LABELS.de;
function handleAppClick(_app: AppItem, _index: number) {
// Navigation handled by AppSlider component
}
</script>
<AppSlider
{apps}
title={labels.title}
isDark={false}
{statusLabels}
comingSoonLabel={labels.comingSoon}
openAppLabel={labels.openApp}
onAppClick={handleAppClick}
/>

10
packages/shared-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
# Storybook static-build (Output, kein Source)
storybook-static/
# Lost-Pixel-Artefakte pro Run — nur die Baseline wird versioniert
lost-pixel/current/
lost-pixel/diff/
# Vite/Svelte caches
.svelte-kit/
.vite/

View file

@ -0,0 +1,75 @@
# `@mana/shared-ui`
Vereins-UI-Komponenten — Svelte 5, strikte 12-Token-Disziplin nach
[`mana/docs/THEMING.md`](../../docs/THEMING.md). Konsumiert von allen
mana-e.V.-Apps (managarten, zitare, nutriphi, wordeck, manawald, …).
> **Stand:** v1.0.0 (2026-05-21). Konsolidiert aus `shared-ui@0.1.x` +
> `shared-ui-2@0.1.x`. Ehemalige Doppel-Bibliothek ist Geschichte —
> eine UI-Foundation, ein Disziplin-Set, ein Verzeichnis.
## Disziplin
1. **Styles ausschließlich in `<style>`-Block.** Keine
Tailwind-Utility-Klassen für Farben (`bg-card`, `text-foreground`).
Layout-Klassen (`flex`, `gap-2`) sind okay, wenn der Konsument
Tailwind nutzt.
2. **Nur die 12 Tokens** aus `THEMING.md`:
`background, foreground, surface, surface-hover, muted,
muted-foreground, border, primary, primary-foreground, error,
success, warning`. Kein 13. Token. Sub-Tokens werden via
`color-mix(in srgb, var(--color-surface) 95%, var(--color-foreground))`
oder Alpha-Modifier (`hsl(var(--color-primary) / 0.12)`) gelöst.
3. **`hsl()`-Wrap immer.** `color: hsl(var(--color-foreground))`,
nie bare `var(--color-X)`.
4. **Keine Hex-Farben** außer in der `--brand-*`-Schicht (für
App-Brand-Identität, NICHT für Theme-Tokens).
5. **Phosphor-Icons sind raus.** `DynamicIcon` (40+ Inline-SVGs, 16×16,
currentColor, stroke 1.4-1.6) ersetzt die phosphor-svelte-Peer-Dep.
Komponenten die in v0.x noch `icon: Component` hatten, akzeptieren
in v1.0.0 nur noch `iconSvg: string`.
6. **A11y-Pflichten:** ARIA-Labels für Icon-Buttons, semantisches HTML,
`:focus-visible` mit sichtbarem Outline, keyboard-Navigation.
7. **Storybook + Lost-Pixel** für die Komponenten, die diese Disziplin
schon mit Stories+Baseline belegt haben (siehe
[`PORTING_PLAN.md`](./PORTING_PLAN.md), Status ✅ vs 🚧).
## Installation
```bash
pnpm add @mana/shared-ui
```
Voraussetzung: 12 Tokens aus
[`@mana/themes`](../themes/README.md) (oder eigene Variant) im
`<html data-theme="...">`-Kontext aktiv.
## Nutzung
```svelte
<script>
import { Button, Card, Badge } from '@mana/shared-ui/atoms';
import { PillTabGroup, PillDropdown } from '@mana/shared-ui/navigation';
import { Modal, ContextMenu, NotificationBar } from '@mana/shared-ui/organisms';
import { dragSource, dropTarget } from '@mana/shared-ui/dnd';
</script>
```
## Tests
```bash
pnpm validate # Disziplin-Validator (12-Token-Allowlist, hsl-wrap-Pflicht)
pnpm storybook # Storybook auf :6006
pnpm test:visual # Lost-Pixel-Run gegen Baseline
pnpm test:visual:update # Baseline aktualisieren (nach bewusster Änderung)
```
## Vorgeschichte
v0.1.1 (alt, organisch über 3 Jahre gewachsen) und v0.1.0 als
`shared-ui-2` (Greenfield-Refactor seit 2026-05-09) liefen 12 Tage
parallel. Konsolidierung am 2026-05-21: alle 12 zusätzlich benötigten
Komponenten portiert, AppSlider als tot identifiziert, alte v1
vollständig durch v2-Code ersetzt, Paket-Name zurück auf
`@mana/shared-ui`. Details: Phasen-Log in
[`PORTING_PLAN.md`](./PORTING_PLAN.md).

View file

@ -1,7 +1,7 @@
{
"name": "@mana/shared-ui",
"version": "0.1.0",
"private": true,
"version": "1.0.0",
"description": "Vereins-UI-Komponenten — Svelte 5, strikte 12-Token-Disziplin (siehe mana/docs/THEMING.md). Konsolidiert aus shared-ui v0.1.x + shared-ui-2 v0.1.x in 2026-05-21.",
"type": "module",
"sideEffects": [
"**/*.svelte",
@ -21,6 +21,11 @@
"types": "./src/atoms/index.ts",
"default": "./src/atoms/index.ts"
},
"./navigation": {
"svelte": "./src/navigation/index.ts",
"types": "./src/navigation/index.ts",
"default": "./src/navigation/index.ts"
},
"./molecules": {
"svelte": "./src/molecules/index.ts",
"types": "./src/molecules/index.ts",
@ -31,11 +36,21 @@
"types": "./src/organisms/index.ts",
"default": "./src/organisms/index.ts"
},
"./pages": {
"svelte": "./src/pages/index.ts",
"types": "./src/pages/index.ts",
"default": "./src/pages/index.ts"
},
"./dnd": {
"svelte": "./src/dnd/index.ts",
"types": "./src/dnd/index.ts",
"default": "./src/dnd/index.ts"
},
"./quick-input": {
"svelte": "./src/quick-input/index.ts",
"types": "./src/quick-input/index.ts",
"default": "./src/quick-input/index.ts"
},
"./toast": {
"svelte": "./src/toast/index.ts",
"types": "./src/toast/index.ts",
@ -43,35 +58,38 @@
}
},
"scripts": {
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"test:visual": "lost-pixel",
"test:visual:update": "lost-pixel update",
"validate": "node scripts/validate-disziplin.mjs",
"test": "pnpm validate"
},
"peerDependencies": {
"svelte": "^5.0.0"
"svelte": "^5.0.0",
"@mana/shared-icons": "*"
},
"dependencies": {
"@mana/shared-branding": "workspace:*",
"@mana/shared-icons": "workspace:*",
"@mana/shared-theme": "workspace:*",
"@mana/shared-types": "workspace:*",
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.0",
"d3-zoom": "^3.0.0",
"date-fns": "^4.1.0"
"peerDependenciesMeta": {
"@mana/shared-icons": {
"optional": true
}
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/d3-force": "^3.0.10",
"@types/d3-selection": "^3.0.11",
"@types/d3-transition": "^3.0.9",
"@types/d3-zoom": "^3.0.8",
"@vitest/coverage-v8": "^4.1.2",
"jsdom": "^29.0.1",
"vitest": "^4.1.2"
"@storybook/addon-essentials": "^8.4.0",
"@storybook/addon-interactions": "^8.4.0",
"@storybook/addon-svelte-csf": "^5.0.0",
"@storybook/svelte": "^8.4.0",
"@storybook/sveltekit": "^8.4.0",
"@storybook/test": "^8.4.0",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"lost-pixel": "^3.22.0",
"storybook": "^8.4.0",
"svelte": "^5.0.0",
"vite": "^5.4.0"
},
"publishConfig": {
"access": "restricted",
"registry": "https://npm.mana.how/"
}
}

View file

@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* shared-ui-2 Disziplin-Validator
*
* Prüft jede .svelte-Datei in src/ gegen die Konvention aus README.md:
*
* 1. Keine Tailwind-Color-Utility-Klassen in `class=`
* (`bg-card`, `text-foreground`, `border-primary`, ...)
* 2. Keine bare `var(--color-X)` nur `hsl(var(--color-X))`
* 3. Tokens nur aus der 12-Token-Allowlist
* 4. Keine Hex-Farben (außer in `--brand-*`-Definitionen oder Kommentaren)
*
* Keine externen Dependencies. Pure Node.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = join(__dirname, '..', 'src');
const ALLOWED_TOKENS = new Set([
'background',
'foreground',
'surface',
'surface-hover',
'muted',
'muted-foreground',
'border',
'primary',
'primary-foreground',
'error',
'success',
'warning',
]);
// Tailwind-Color-Utility-Patterns (in class= attribute or Tailwind arbitrary values)
const FORBIDDEN_TAILWIND_COLOR_PATTERNS = [
/\b(?:bg|text|border|ring|outline|fill|stroke|from|via|to|placeholder|caret|accent|decoration|divide|shadow)-(?:foreground|background|surface|surface-hover|muted|muted-foreground|border|primary|primary-foreground|error|success|warning|card|popover|secondary|accent|input|ring|destructive)\b/g,
];
function walkSvelteFiles(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const s = statSync(p);
if (s.isDirectory()) out.push(...walkSvelteFiles(p));
else if (entry.endsWith('.svelte')) out.push(p);
}
return out;
}
function checkFile(file) {
const text = readFileSync(file, 'utf8');
const errors = [];
// Split into class attributes and style blocks for pattern-specific checks
const styleBlockRe = /<style[^>]*>([\s\S]*?)<\/style>/g;
const styleBlocks = [];
let m;
while ((m = styleBlockRe.exec(text)) !== null) {
styleBlocks.push({ start: m.index, end: m.index + m[0].length, content: m[1] });
}
// Check 1: bare var(--color-X) without hsl-wrap (anywhere in file)
const varColorRe = /var\(--color-([a-z-]+)\)/g;
let varM;
while ((varM = varColorRe.exec(text)) !== null) {
const idx = varM.index;
const before = text.slice(Math.max(0, idx - 4), idx);
// Allow bare var() inside --brand-* definitions and comments — coarse heuristic
const lineStart = text.lastIndexOf('\n', idx) + 1;
const lineEnd = text.indexOf('\n', idx);
const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd);
if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
if (before !== 'hsl(') {
const lineNo = text.slice(0, idx).split('\n').length;
errors.push(` L${lineNo}: bare var(--color-${varM[1]}) — wrap with hsl()`);
}
}
// Check 2: forbidden Tailwind-Color-Utility-Klassen (in class= attributes)
const classAttrRe = /\bclass(?:Name)?\s*=\s*"([^"]*)"|\bclass(?:Name)?\s*=\s*'([^']*)'/g;
let cm;
while ((cm = classAttrRe.exec(text)) !== null) {
const classValue = cm[1] || cm[2] || '';
for (const pattern of FORBIDDEN_TAILWIND_COLOR_PATTERNS) {
pattern.lastIndex = 0;
let pm;
while ((pm = pattern.exec(classValue)) !== null) {
const lineNo = text.slice(0, cm.index).split('\n').length;
errors.push(` L${lineNo}: Tailwind-Color-Klasse "${pm[0]}" — Styles in <style>-Block`);
}
}
}
// Check 3: tokens outside the 12-set in --color-* definitions or var() refs
const tokenRefRe = /--color-([a-z-]+)/g;
let tm;
const seenViolations = new Set();
while ((tm = tokenRefRe.exec(text)) !== null) {
const token = tm[1];
if (!ALLOWED_TOKENS.has(token)) {
const key = `${token}@${tm.index}`;
if (seenViolations.has(key)) continue;
seenViolations.add(key);
const lineNo = text.slice(0, tm.index).split('\n').length;
errors.push(` L${lineNo}: --color-${token} not in 12-token allowlist`);
}
}
// Check 4: hex colors in <style>-Blocks (excluding --brand-* lines)
for (const block of styleBlocks) {
const blockLines = block.content.split('\n');
blockLines.forEach((line, i) => {
// allow --brand-* hex declarations
if (/^\s*--brand-/.test(line)) return;
// allow url(...data:...) literals (not actual color values)
if (/data:image/.test(line)) return;
// allow comments
if (/^\s*\/\*/.test(line) || /^\s*\*/.test(line)) return;
const hexRe = /#[0-9a-fA-F]{3,8}\b/g;
let hm;
while ((hm = hexRe.exec(line)) !== null) {
// Compute rough line number in original file
const blockLineStart = text.slice(0, block.start).split('\n').length;
const lineNo = blockLineStart + i;
errors.push(` L${lineNo}: hex color "${hm[0]}" — use 12 tokens or --brand-*`);
}
});
}
return errors;
}
let totalErrors = 0;
const files = walkSvelteFiles(SRC);
console.log(`Validating ${files.length} components...\n`);
for (const file of files) {
const rel = relative(SRC, file);
const errors = checkFile(file);
if (errors.length === 0) {
console.log(`${rel}`);
} else {
console.log(`${rel}`);
errors.forEach((e) => console.log(e));
totalErrors += errors.length;
}
}
console.log();
if (totalErrors > 0) {
console.error(`FAIL: ${totalErrors} disziplin violation(s)`);
process.exit(1);
}
console.log(
'PASS: all components pass disziplin check (12 tokens, hsl-wrap, no Tailwind color classes, no bare hex)'
);

View file

@ -1,69 +0,0 @@
/**
* Svelte action that traps focus within an element.
* Useful for modals and dialogs to prevent tabbing outside.
*/
export function focusTrap(node: HTMLElement) {
const focusableSelectors = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
let previouslyFocused: HTMLElement | null = null;
function getFocusableElements(): HTMLElement[] {
return Array.from(node.querySelectorAll<HTMLElement>(focusableSelectors)).filter(
(el) => !el.hasAttribute('disabled') && el.offsetParent !== null
);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
// Save currently focused element and focus first focusable in trap
previouslyFocused = document.activeElement as HTMLElement;
// Use requestAnimationFrame to ensure the DOM is ready
requestAnimationFrame(() => {
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
} else {
node.focus();
}
});
node.addEventListener('keydown', handleKeydown);
return {
destroy() {
node.removeEventListener('keydown', handleKeydown);
// Restore focus to previously focused element
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
},
};
}

View file

@ -1 +0,0 @@
export { focusTrap } from './focusTrap';

View file

@ -0,0 +1,47 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Badge from './Badge.svelte';
const { Story } = defineMeta({
title: 'Atoms/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['neutral', 'primary', 'success', 'warning', 'error'],
},
size: {
control: { type: 'select' },
options: ['sm', 'md'],
},
},
});
</script>
<Story name="Default" args={{ variant: 'neutral' }}>
{#snippet children(args: any)}
<Badge {...args}>Entwurf</Badge>
{/snippet}
</Story>
<Story name="VariantSweep">
{#snippet children()}
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<Badge variant="neutral">Neutral</Badge>
<Badge variant="primary">Primary</Badge>
<Badge variant="success">Erfolg</Badge>
<Badge variant="warning">Warnung</Badge>
<Badge variant="error">Fehler</Badge>
</div>
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; gap:0.5rem; align-items:center;">
<Badge variant="primary" size="sm">Klein</Badge>
<Badge variant="primary" size="md">Mittel</Badge>
</div>
{/snippet}
</Story>

View file

@ -1,37 +1,93 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
type BadgeSize = 'sm' | 'md';
type Variant =
| 'neutral'
| 'primary'
| 'success'
| 'warning'
| 'error'
| 'default'
| 'info'
| 'danger';
type Size = 'sm' | 'md';
interface Props {
variant?: BadgeVariant;
size?: BadgeSize;
class?: string;
variant?: Variant;
size?: Size;
children?: Snippet;
}
let { variant = 'default', size = 'md', class: className = '', children }: Props = $props();
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-menu text-theme border-theme',
primary: 'bg-primary/20 text-primary border-primary/30',
success: 'bg-green-500/20 text-green-600 dark:text-green-400 border-green-500/30',
warning: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/30',
danger: 'bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/30',
info: 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30',
};
const sizeClasses: Record<BadgeSize, string> = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-sm',
};
const classes = $derived(
`inline-flex items-center rounded-full border font-medium ${variantClasses[variant]} ${sizeClasses[size]} ${className}`
);
let { variant = 'neutral', size = 'sm', children }: Props = $props();
</script>
<span class={classes}>
{@render children?.()}
<span class="badge badge-{variant} badge-{size}">
{#if children}{@render children()}{/if}
</span>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 9999px;
border: 1px solid transparent;
font-weight: 500;
white-space: nowrap;
font-family: inherit;
}
.badge-sm {
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
line-height: 1.25;
}
.badge-md {
padding: 0.25rem 0.625rem;
font-size: 0.8125rem;
line-height: 1.25;
}
.badge-neutral {
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
}
.badge-primary {
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.3);
}
.badge-success {
background: hsl(var(--color-success) / 0.12);
color: hsl(var(--color-success));
border-color: hsl(var(--color-success) / 0.3);
}
.badge-warning {
background: hsl(var(--color-warning) / 0.15);
color: hsl(var(--color-warning));
border-color: hsl(var(--color-warning) / 0.4);
}
.badge-error,
.badge-danger {
background: hsl(var(--color-error) / 0.12);
color: hsl(var(--color-error));
border-color: hsl(var(--color-error) / 0.3);
}
/* v0.1.x-Compat */
.badge-default {
background: hsl(var(--color-surface));
color: hsl(var(--color-muted-foreground));
border-color: hsl(var(--color-border));
}
.badge-info {
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.3);
}
</style>

View file

@ -0,0 +1,77 @@
<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: { type: 'select' },
options: ['primary', 'secondary', 'ghost', 'danger'],
},
size: {
control: { type: 'select' },
options: ['sm', 'md', 'lg'],
},
},
});
</script>
<Story name="Default" args={{ variant: 'secondary' }}>
{#snippet children(args: any)}
<Button {...args}>Speichern</Button>
{/snippet}
</Story>
<Story name="Primary" args={{ variant: 'primary' }}>
{#snippet children(args: any)}
<Button {...args}>Aktion ausführen</Button>
{/snippet}
</Story>
<Story name="Ghost" args={{ variant: 'ghost' }}>
{#snippet children(args: any)}
<Button {...args}>Abbrechen</Button>
{/snippet}
</Story>
<Story name="Danger" args={{ variant: 'danger' }}>
{#snippet children(args: any)}
<Button {...args}>Löschen</Button>
{/snippet}
</Story>
<Story name="Loading" args={{ variant: 'primary', loading: true }}>
{#snippet children(args: any)}
<Button {...args}>Speichern</Button>
{/snippet}
</Story>
<Story name="Disabled" args={{ variant: 'primary', disabled: true }}>
{#snippet children(args: any)}
<Button {...args}>Speichern</Button>
{/snippet}
</Story>
<Story name="VariantSweep">
{#snippet children()}
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
</div>
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; gap:0.5rem; align-items:center;">
<Button variant="primary" size="sm">Klein</Button>
<Button variant="primary" size="md">Mittel</Button>
<Button variant="primary" size="lg">Groß</Button>
</div>
{/snippet}
</Story>

View file

@ -1,62 +1,190 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline' | 'success';
type ButtonSize = 'sm' | 'md' | 'lg' | 'xl';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'default' | 'info';
type Size = 'sm' | 'md' | 'lg';
interface Props {
variant?: ButtonVariant;
size?: ButtonSize;
variant?: Variant;
size?: Size;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
class?: string;
fullWidth?: boolean;
ariaLabel?: string;
onclick?: (e: MouseEvent) => void;
type?: 'button' | 'submit' | 'reset';
children?: Snippet;
leading?: Snippet;
trailing?: Snippet;
/** v0.1.x-Compat. */
class?: string;
}
let {
variant = 'primary',
variant = 'secondary',
size = 'md',
type = 'button',
disabled = false,
loading = false,
fullWidth = false,
ariaLabel,
class: className = '',
onclick,
type = 'button',
children,
leading,
trailing,
}: Props = $props();
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-primary text-white hover:bg-primary/90 border-transparent',
secondary: 'bg-menu text-theme hover:bg-menu-hover border-theme',
ghost: 'bg-transparent text-theme hover:bg-menu-hover border-transparent',
danger: 'bg-red-600 text-white hover:bg-red-700 border-transparent',
outline: 'bg-transparent text-primary border-primary hover:bg-primary/10',
success: 'bg-green-600 text-white hover:bg-green-700 border-transparent',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
xl: 'px-8 py-4 text-xl',
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-lg border font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed ${variantClasses[variant]} ${sizeClasses[size]} ${className}`
);
</script>
<button {type} class={classes} disabled={disabled || loading} {onclick}>
{#if loading}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<button
{type}
class="btn btn-{variant} btn-{size} {className}"
class:full={fullWidth}
class:loading
disabled={disabled || loading}
aria-label={ariaLabel}
aria-busy={loading || undefined}
{onclick}
>
{#if leading}
<span class="leading">{@render leading()}</span>
{/if}
{#if loading}
<span class="spinner" aria-hidden="true"></span>
{/if}
{#if children}
<span class="label">{@render children()}</span>
{/if}
{#if trailing}
<span class="trailing">{@render trailing()}</span>
{/if}
{@render children?.()}
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: 0.5rem;
border: 1px solid transparent;
font-weight: 500;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
font-family: inherit;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.btn.full {
width: 100%;
}
/* sizes */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.btn-md {
padding: 0.5rem 1rem;
font-size: 0.9375rem;
}
.btn-lg {
padding: 0.75rem 1.25rem;
font-size: 1rem;
}
/* primary */
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:not(:disabled):hover {
background: hsl(var(--color-primary) / 0.9);
}
/* secondary */
.btn-secondary {
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border-color: hsl(var(--color-border));
}
.btn-secondary:not(:disabled):hover {
background: hsl(var(--color-surface-hover));
}
/* ghost */
.btn-ghost {
background: transparent;
color: hsl(var(--color-foreground));
}
.btn-ghost:not(:disabled):hover {
background: hsl(var(--color-surface-hover));
}
/* danger */
.btn-danger {
background: hsl(var(--color-error) / 0.1);
color: hsl(var(--color-error));
border-color: hsl(var(--color-error) / 0.3);
}
.btn-danger:not(:disabled):hover {
background: hsl(var(--color-error) / 0.15);
border-color: hsl(var(--color-error) / 0.5);
}
/* v0.1.x-Compat: 'default' = secondary, 'info' = ghost mit primary-Akzent */
.btn-default {
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border-color: hsl(var(--color-border));
}
.btn-default:not(:disabled):hover {
background: hsl(var(--color-surface-hover));
}
.btn-info {
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary) / 0.3);
}
.btn-info:not(:disabled):hover {
background: hsl(var(--color-primary) / 0.15);
}
/* spinner */
.spinner {
width: 0.875em;
height: 0.875em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.btn {
transition: none;
}
.spinner {
animation: none;
border-right-color: currentColor;
}
}
</style>

View file

@ -0,0 +1,94 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Card from './Card.svelte';
import Badge from './Badge.svelte';
import Button from './Button.svelte';
const { Story } = defineMeta({
title: 'Atoms/Card',
component: Card,
tags: ['autodocs'],
argTypes: {
padding: {
control: { type: 'select' },
options: ['none', 'sm', 'md', 'lg'],
},
interactive: { control: 'boolean' },
},
});
</script>
<Story name="Default" args={{ padding: 'md' }}>
{#snippet children(args: any)}
<div style="width: 320px;">
<Card {...args}>
<h3 style="margin:0 0 0.5rem;">Karten-Titel</h3>
<p style="margin:0; color: hsl(var(--color-muted-foreground));">
Beschreibender Sekundärtext, ungefähr eine Zeile lang.
</p>
</Card>
</div>
{/snippet}
</Story>
<Story name="WithBadge" args={{ padding: 'md' }}>
{#snippet children(args: any)}
<div style="width: 320px;">
<Card {...args}>
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.5rem;">
<Badge variant="primary">Neu</Badge>
<Badge variant="warning">3 ausstehend</Badge>
</div>
<h3 style="margin:0 0 0.5rem;">Mit Badges</h3>
<p style="margin:0; color: hsl(var(--color-muted-foreground));">
Badges sitzen oben links.
</p>
</Card>
</div>
{/snippet}
</Story>
<Story name="Interactive" args={{ padding: 'md', interactive: true }}>
{#snippet children(args: any)}
<div style="width: 320px;">
<Card {...args} ariaLabel="Klick mich">
<h3 style="margin:0 0 0.5rem;">Klickbare Karte</h3>
<p style="margin:0; color: hsl(var(--color-muted-foreground));">
Hover/Focus zeigt Primary-Border.
</p>
</Card>
</div>
{/snippet}
</Story>
<Story name="WithActions" args={{ padding: 'md' }}>
{#snippet children(args: any)}
<div style="width: 320px;">
<Card {...args}>
<h3 style="margin:0 0 0.5rem;">Karte mit Aktionen</h3>
<p style="margin:0 0 1rem; color: hsl(var(--color-muted-foreground));">
Action-Row am Ende.
</p>
<div style="display:flex; gap:0.5rem; justify-content:flex-end;">
<Button variant="ghost" size="sm">Abbrechen</Button>
<Button variant="primary" size="sm">Speichern</Button>
</div>
</Card>
</div>
{/snippet}
</Story>
<Story name="PaddingSweep">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:1rem; width: 280px;">
<Card padding="none">
<div style="padding:0.25rem; background: hsl(var(--color-muted));">
padding=none (kein inneres Spacing)
</div>
</Card>
<Card padding="sm">padding=sm</Card>
<Card padding="md">padding=md</Card>
<Card padding="lg">padding=lg</Card>
</div>
{/snippet}
</Story>

View file

@ -1,189 +1,88 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type CardVariant = 'elevated' | 'outlined' | 'ghost' | 'filled';
type CardPadding = 'none' | 'sm' | 'md' | 'lg';
type Padding = 'none' | 'sm' | 'md' | 'lg';
interface Props {
/** Visual variant of the card */
variant?: CardVariant;
/** Padding size */
padding?: CardPadding;
/** Make card interactive (adds hover effects) */
padding?: Padding;
interactive?: boolean;
/** Makes card take full width */
fullWidth?: boolean;
/** Additional CSS classes */
class?: string;
/** Click handler */
ariaLabel?: string;
onclick?: (e: MouseEvent) => void;
/** Header slot */
header?: Snippet;
/** Footer slot */
footer?: Snippet;
/** Main content */
children: Snippet;
children?: Snippet;
/** v0.1.x-Compat: zusätzliche CSS-Klassen auf dem Wrapper. */
class?: string;
}
let {
variant = 'elevated',
padding = 'md',
interactive = false,
fullWidth = false,
class: className = '',
ariaLabel,
onclick,
header,
footer,
children,
class: className = '',
}: Props = $props();
// Determine if card should be interactive
const isInteractive = $derived(interactive || !!onclick);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="card card--{variant} card--padding-{padding} {isInteractive
? 'card--interactive'
: ''} {fullWidth ? 'card--full-width' : ''} {className}"
{onclick}
role={isInteractive ? 'button' : undefined}
tabindex={isInteractive ? 0 : undefined}
onkeydown={(e) => {
if (isInteractive && onclick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onclick(e as unknown as MouseEvent);
}
}}
>
{#if header}
<div class="card__header">
{@render header()}
</div>
{/if}
<div class="card__body">
{@render children()}
{#if interactive}
<button
class="card card-{padding} interactive {className}"
aria-label={ariaLabel}
{onclick}
type="button"
>
{#if children}{@render children()}{/if}
</button>
{:else}
<div class="card card-{padding} {className}">
{#if children}{@render children()}{/if}
</div>
{#if footer}
<div class="card__footer">
{@render footer()}
</div>
{/if}
</div>
{/if}
<style>
.card {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
overflow: hidden;
transition: all 0.15s ease;
color: hsl(var(--color-foreground));
font-family: inherit;
text-align: inherit;
width: 100%;
display: block;
}
/* Variants */
.card--elevated {
background-color: hsl(var(--color-surface-elevated));
border: 1px solid hsl(var(--color-border));
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.card--outlined {
background-color: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
}
.card--ghost {
background-color: transparent;
}
.card--filled {
background-color: hsl(var(--color-muted));
}
/* Padding */
.card--padding-none .card__body {
.card-none {
padding: 0;
}
.card--padding-sm .card__body {
.card-sm {
padding: 0.75rem;
}
.card--padding-md .card__body {
.card-md {
padding: 1rem;
}
.card--padding-lg .card__body {
.card-lg {
padding: 1.5rem;
}
/* Full width */
.card--full-width {
width: 100%;
}
/* Interactive */
.card--interactive {
button.card.interactive {
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.card--interactive:hover {
border-color: hsl(var(--color-border-strong));
button.card.interactive:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-primary) / 0.3);
}
.card--elevated.card--interactive:hover {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.card--interactive:focus-visible {
outline: 2px solid hsl(var(--color-ring));
button.card.interactive:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
/* Header */
.card__header {
padding: 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.card--padding-sm .card__header {
padding: 0.75rem;
}
.card--padding-lg .card__header {
padding: 1.25rem 1.5rem;
}
.card--padding-none .card__header {
padding: 0;
border-bottom: none;
}
/* Body - padding applied via variant classes above */
/* Footer */
.card__footer {
padding: 1rem;
border-top: 1px solid hsl(var(--color-border));
background-color: hsl(var(--color-muted) / 0.3);
}
.card--padding-sm .card__footer {
padding: 0.75rem;
}
.card--padding-lg .card__footer {
padding: 1.25rem 1.5rem;
}
.card--padding-none .card__footer {
padding: 0;
border-top: none;
background-color: transparent;
@media (prefers-reduced-motion: reduce) {
button.card.interactive {
transition: none;
}
}
</style>

View file

@ -0,0 +1,112 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import DynamicIcon from './DynamicIcon.svelte';
const ALL_ICONS = [
'check',
'x',
'plus',
'minus',
'search',
'user',
'users',
'gear',
'home',
'tag',
'heart',
'star',
'caret-down',
'caret-up',
'caret-left',
'caret-right',
'arrow-left',
'arrow-right',
'info',
'warning',
'error',
'success',
'trash',
'edit',
'eye',
'eye-off',
'calendar',
'clock',
'mail',
'link',
'external',
];
const { Story } = defineMeta({
title: 'Atoms/DynamicIcon',
component: DynamicIcon,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<DynamicIcon name="check" />
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; gap:1rem; align-items:center;">
<DynamicIcon name="star" size="xs" />
<DynamicIcon name="star" size="sm" />
<DynamicIcon name="star" size="md" />
<DynamicIcon name="star" size="lg" />
</div>
{/snippet}
</Story>
<Story name="AllIcons">
{#snippet children()}
<div
style="display:grid; grid-template-columns:repeat(8, minmax(4rem, 1fr)); gap:0.75rem; max-width:48rem;"
>
{#each ALL_ICONS as iconName (iconName)}
<div
style="display:flex; flex-direction:column; align-items:center; gap:0.25rem; padding:0.5rem; border:1px solid hsl(var(--color-border)); border-radius:0.375rem;"
>
<DynamicIcon name={iconName as any} size="md" />
<span style="font-size:0.625rem; color:hsl(var(--color-muted-foreground));"
>{iconName}</span
>
</div>
{/each}
</div>
{/snippet}
</Story>
<Story name="ColoredContext">
{#snippet children()}
<div style="display:flex; gap:1rem; align-items:center; font-size:0.875rem;">
<span
style="color:hsl(var(--color-success)); display:inline-flex; gap:0.375rem; align-items:center;"
>
<DynamicIcon name="success" /> erledigt
</span>
<span
style="color:hsl(var(--color-warning)); display:inline-flex; gap:0.375rem; align-items:center;"
>
<DynamicIcon name="warning" /> warnung
</span>
<span
style="color:hsl(var(--color-error)); display:inline-flex; gap:0.375rem; align-items:center;"
>
<DynamicIcon name="error" /> fehler
</span>
</div>
{/snippet}
</Story>
<Story name="CustomSvg">
{#snippet children()}
<DynamicIcon
ariaLabel="Custom Icon"
iconSvg="<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="8" cy="8" r="2" fill="currentColor"/>"
size="lg"
/>
{/snippet}
</Story>

View file

@ -1,26 +1,200 @@
<!--
DynamicIcon — renders a Phosphor icon by string name.
Uses the curated icon registry from @mana/shared-icons.
Usage:
<DynamicIcon name="coffee" size={24} weight="bold" />
-->
<script lang="ts">
import { getIconComponent } from '@mana/shared-icons';
/**
* DynamicIcon — kleine Inline-SVG-Map für die meistgenutzten UI-Icons.
*
* Bewusst KEIN phosphor-svelte-Peer-Dep — shared-ui-2 hält die
* Konsumenten-Bundle klein. Konsumenten die eigene Icons brauchen,
* passen sie als `iconSvg`-String oder direkt als child-Snippet an
* Komponenten wie Pill / Button / TagChip.
*
* Icons sind als Inline-Strings im 16×16-viewBox, `currentColor`,
* `stroke-width: 1.6`. Das passt zu den Pill/Badge/Button-Größen
* und folgt dem Mana-Stroke-basierten Symbol-Vokabular (siehe
* mana/docs/SYMBOLS.md — gleicher Pattern, nur generischere
* Funktional-Icons).
*/
type Size = 'xs' | 'sm' | 'md' | 'lg';
interface Props {
name: string;
size?: number;
name?: IconName | string;
iconSvg?: string;
/** Symbolische Größe oder Pixel-Wert (v0.1.x-Compat). */
size?: Size | number;
ariaLabel?: string;
/** v0.1.x-Compat: Phosphor-Property, heute ignoriert (alle Icons sind outline-only mit Fill-Varianten als eigene Namen). */
weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone';
/** v0.1.x-Compat: Custom-Class auf SVG. */
class?: string;
/** v0.1.x-Compat: Direkte Farbe (heute über CSS-color steuerbar; Prop wird ignoriert wenn leer). */
color?: string;
}
let { name, size = 24, weight = 'regular', class: className = '', color }: Props = $props();
let { name, iconSvg, size = 'md', ariaLabel, class: className = '', color }: Props = $props();
let IconComponent = $derived(getIconComponent(name));
const normalizedSize = $derived.by<Size>(() => {
if (typeof size === 'number') {
if (size <= 12) return 'xs';
if (size <= 14) return 'sm';
if (size <= 20) return 'md';
return 'lg';
}
return size;
});
type IconName =
| 'check'
| 'x'
| 'plus'
| 'minus'
| 'search'
| 'user'
| 'users'
| 'gear'
| 'home'
| 'tag'
| 'heart'
| 'star'
| 'caret-down'
| 'caret-up'
| 'caret-left'
| 'caret-right'
| 'arrow-left'
| 'arrow-right'
| 'info'
| 'warning'
| 'error'
| 'success'
| 'trash'
| 'edit'
| 'eye'
| 'eye-off'
| 'calendar'
| 'clock'
| 'mail'
| 'link'
| 'external'
| 'pin'
| 'pin-fill'
| 'heart-fill'
| 'star-fill'
| 'bell'
| 'bell-fill'
| 'bell-slash'
| 'archive'
| 'folder-open';
const ICONS: Record<IconName, string> = {
check:
'<path d="M3 8l3.5 3.5L13 5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
x: '<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
plus: '<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
minus: '<path d="M3 8h10" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
search:
'<circle cx="7" cy="7" r="4" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M10 10l3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
user: '<circle cx="8" cy="6" r="3" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M3 14c0-2.5 2.2-4 5-4s5 1.5 5 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>',
users:
'<circle cx="6" cy="6" r="2.5" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="11.5" cy="7" r="2" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M2 13.5c0-2 1.7-3.5 4-3.5s4 1.5 4 3.5M14.5 13c0-1.5-1.2-2.5-3-2.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>',
gear: '<circle cx="8" cy="8" r="2" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 1.5v2M8 12.5v2M14.5 8h-2M3.5 8h-2M12.6 3.4l-1.4 1.4M4.8 11.2l-1.4 1.4M12.6 12.6l-1.4-1.4M4.8 4.8l-1.4-1.4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>',
home: '<path d="M2 7.5L8 2l6 5.5V14H2.5V7.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
tag: '<path d="M8 1.5h5.5V7L7 13.5l-5.5-5.5L8 1.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><circle cx="11" cy="5" r="0.9" fill="currentColor"/>',
heart:
'<path d="M8 13.5s-5-3-5-7a3 3 0 015-2 3 3 0 015 2c0 4-5 7-5 7z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
star: '<path d="M8 1.5l1.9 4 4.4.6-3.2 3 .8 4.3L8 11.4l-3.9 2 .8-4.3-3.2-3 4.4-.6L8 1.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
'caret-down':
'<path d="M3 6l5 5 5-5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
'caret-up':
'<path d="M3 10l5-5 5 5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
'caret-left':
'<path d="M10 3l-5 5 5 5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
'caret-right':
'<path d="M6 3l5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
'arrow-left':
'<path d="M13 8H3M7 4l-4 4 4 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
'arrow-right':
'<path d="M3 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
info: '<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 7v4M8 5v0.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
warning:
'<path d="M8 1.5L14.5 13.5h-13L8 1.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6.5v3.5M8 11.5v0.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
error:
'<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 4.5v4M8 11v0.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
success:
'<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M5 8l2 2 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>',
trash:
'<path d="M3 4.5h10M5.5 4.5V3a1 1 0 011-1h3a1 1 0 011 1v1.5M4.5 4.5v9.5h7V4.5M7 7v5M9 7v5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
edit: '<path d="M11 2.5l2.5 2.5L5 13.5H2.5V11L11 2.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
eye: '<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><circle cx="8" cy="8" r="2" fill="none" stroke="currentColor" stroke-width="1.4"/>',
'eye-off':
'<path d="M2 2l12 12M3 5C2 6.5 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1.2 0 2.2-.4 3.1-.9M6.5 4c.5-.2 1-.3 1.5-.3 4 0 6.5 4.3 6.5 4.3s-.6 1-1.7 2.2" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
calendar:
'<rect x="2.5" y="3.5" width="11" height="10" rx="1" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M2.5 6.5h11M5.5 2v3M10.5 2v3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>',
clock:
'<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 4.5V8l2.5 2" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
mail: '<rect x="2" y="3.5" width="12" height="9" rx="1" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M2 4.5l6 4 6-4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
link: '<path d="M7 9l2-2M9 4l1-1a3 3 0 014.2 4.2L13 8.5M7 12l-1 1a3 3 0 01-4.2-4.2L3 7.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>',
external:
'<path d="M9 2.5h4.5V7M13.5 2.5L7.5 8.5M11 9v4.5H2.5V5H7" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
pin: '<path d="M10 1.5l4.5 4.5-3 1-2 4-1.5-1.5-3 3v-3l3-3-1.5-1.5 4-2 1.5-1.5z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
'pin-fill':
'<path d="M10 1.5l4.5 4.5-3 1-2 4-1.5-1.5-3 3v-3l3-3-1.5-1.5 4-2 1.5-1.5z" fill="currentColor" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
'heart-fill':
'<path d="M8 13.5s-5-3-5-7a3 3 0 015-2 3 3 0 015 2c0 4-5 7-5 7z" fill="currentColor" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
'star-fill':
'<path d="M8 1.5l1.9 4 4.4.6-3.2 3 .8 4.3L8 11.4l-3.9 2 .8-4.3-3.2-3 4.4-.6L8 1.5z" fill="currentColor" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
bell: '<path d="M8 2v1M4.5 6a3.5 3.5 0 117 0c0 3 1 4 1.5 5h-10c.5-1 1.5-2 1.5-5zM6.5 13a1.5 1.5 0 003 0" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
'bell-fill':
'<path d="M4.5 6a3.5 3.5 0 117 0c0 3 1 4 1.5 5h-10c.5-1 1.5-2 1.5-5z" fill="currentColor" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 2v1M6.5 13a1.5 1.5 0 003 0" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>',
'bell-slash':
'<path d="M2 2l12 12M4.5 6a3.5 3.5 0 016-2.5M11.5 6c0 3 1 4 1.5 5H6M3 11h.5c.5-1 1-2 1-5M6.5 13a1.5 1.5 0 003 0" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
archive:
'<path d="M2 3.5h12v3H2zM3 6.5v8h10v-8M6 9h4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>',
'folder-open':
'<path d="M2 5.5V13a1 1 0 001 1h10.5l1.5-6H4.5L3 13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M2 5.5V4a1 1 0 011-1h3l1.5 1.5H13a1 1 0 011 1V8" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>',
};
const resolvedSvg = $derived.by(() => {
if (iconSvg) return iconSvg;
if (name && (ICONS as Record<string, string>)[name])
return (ICONS as Record<string, string>)[name];
// Fallback: Frage-Markierung
return '<path d="M5 6a3 3 0 016 0c0 1.5-1.5 2-2 2.5v1M8 12.5v0.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>';
});
</script>
{#if IconComponent}
<IconComponent {size} {weight} class={className} {color} />
{/if}
<svg
class="icon size-{normalizedSize} {className}"
style:color={color ?? undefined}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
role={ariaLabel ? 'img' : undefined}
aria-label={ariaLabel}
aria-hidden={ariaLabel ? undefined : 'true'}
>
{@html resolvedSvg}
</svg>
<style>
.icon {
display: inline-block;
flex-shrink: 0;
color: currentColor;
vertical-align: middle;
}
.size-xs {
width: 0.75rem;
height: 0.75rem;
}
.size-sm {
width: 0.875rem;
height: 0.875rem;
}
.size-md {
width: 1rem;
height: 1rem;
}
.size-lg {
width: 1.25rem;
height: 1.25rem;
}
</style>

View file

@ -0,0 +1,58 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Skeleton from './Skeleton.svelte';
const { Story } = defineMeta({
title: 'Atoms/Skeleton',
component: Skeleton,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<Skeleton width="240px" height="0.875rem" />
{/snippet}
</Story>
<Story name="ShapeSweep">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:1rem; width: 280px;">
<Skeleton shape="rect" width="100%" height="2.5rem" />
<Skeleton shape="line" width="80%" />
<Skeleton shape="circle" height="3rem" />
</div>
{/snippet}
</Story>
<Story name="ListLoading">
{#snippet children()}
<div style="width: 280px;">
<Skeleton shape="line" count={5} />
</div>
{/snippet}
</Story>
<Story name="CardSkeleton">
{#snippet children()}
<div style="width: 320px;">
<Skeleton shape="card" />
</div>
{/snippet}
</Story>
<Story name="ListWithAvatar">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:0.75rem; width: 320px;">
{#each Array(3) as _, i (i)}
<div style="display:flex; gap:0.75rem; align-items:center;">
<Skeleton shape="circle" height="2.5rem" />
<div style="flex:1; display:flex; flex-direction:column; gap:0.375rem;">
<Skeleton shape="line" width="60%" />
<Skeleton shape="line" width="100%" />
</div>
</div>
{/each}
</div>
{/snippet}
</Story>

View file

@ -0,0 +1,111 @@
<script lang="ts">
type Shape = 'rect' | 'line' | 'circle' | 'card';
interface Props {
shape?: Shape;
width?: string;
height?: string;
count?: number;
ariaLabel?: string;
}
let { shape = 'rect', width, height, count = 1, ariaLabel = 'Lade Inhalt …' }: Props = $props();
</script>
{#if shape === 'card'}
<div
class="skeleton-card"
style:width
style:height
role="status"
aria-busy="true"
aria-label={ariaLabel}
>
<div class="skeleton shape-line" style:width="40%"></div>
<div class="skeleton shape-line" style:width="100%"></div>
<div class="skeleton shape-line" style:width="80%"></div>
</div>
{:else if count > 1}
<div role="status" aria-busy="true" aria-label={ariaLabel} class="skeleton-stack">
{#each Array(count) as _, i (i)}
<div class="skeleton shape-{shape}" style:width style:height></div>
{/each}
</div>
{:else}
<div
class="skeleton shape-{shape}"
style:width
style:height
role="status"
aria-busy="true"
aria-label={ariaLabel}
></div>
{/if}
<style>
.skeleton {
background: hsl(var(--color-muted));
position: relative;
overflow: hidden;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, hsl(var(--color-surface) / 0.6), transparent);
animation: shimmer 1.6s linear infinite;
}
.shape-rect {
border-radius: 0.375rem;
min-height: 1rem;
}
.shape-line {
border-radius: 0.25rem;
height: 0.875rem;
}
.shape-circle {
border-radius: 9999px;
aspect-ratio: 1;
min-height: 2rem;
}
.skeleton-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
}
.skeleton-stack {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.skeleton-stack > .skeleton {
min-height: 0.875rem;
}
@keyframes shimmer {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton::after {
animation: none;
background: hsl(var(--color-surface) / 0.4);
}
}
</style>

View file

@ -0,0 +1,49 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Spinner from './Spinner.svelte';
const { Story } = defineMeta({
title: 'Atoms/Spinner',
component: Spinner,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<Spinner />
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; gap:1rem; align-items:center;">
<Spinner size="xs" />
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
</div>
{/snippet}
</Story>
<Story name="ToneSweep">
{#snippet children()}
<div style="display:flex; gap:1rem; align-items:center;">
<Spinner tone="default" />
<Spinner tone="muted" />
<Spinner tone="primary" />
<span style="color: hsl(var(--color-error));">
<Spinner tone="inherit" />
</span>
</div>
{/snippet}
</Story>
<Story name="InText">
{#snippet children()}
<p style="display:inline-flex; gap:0.5rem; align-items:center;">
<Spinner size="sm" tone="primary" />
<span>Lade Karten …</span>
</p>
{/snippet}
</Story>

View file

@ -0,0 +1,93 @@
<script lang="ts">
type Size = 'xs' | 'sm' | 'md' | 'lg';
type Tone = 'default' | 'muted' | 'primary' | 'inherit';
interface Props {
size?: Size;
tone?: Tone;
ariaLabel?: string;
/** v0.1.x-Compat. */
class?: string;
}
let {
size = 'md',
tone = 'inherit',
ariaLabel = 'Lade …',
class: className = '',
}: Props = $props();
</script>
<span
class="spinner size-{size} tone-{tone} {className}"
role="status"
aria-live="polite"
aria-label={ariaLabel}
></span>
<style>
.spinner {
display: inline-block;
border-style: solid;
border-radius: 50%;
border-right-color: transparent;
animation: spin 0.6s linear infinite;
}
.size-xs {
width: 0.75rem;
height: 0.75rem;
border-width: 1.5px;
}
.size-sm {
width: 1rem;
height: 1rem;
border-width: 2px;
}
.size-md {
width: 1.5rem;
height: 1.5rem;
border-width: 2px;
}
.size-lg {
width: 2rem;
height: 2rem;
border-width: 2.5px;
}
.tone-default {
border-color: hsl(var(--color-foreground));
border-right-color: transparent;
}
.tone-muted {
border-color: hsl(var(--color-muted-foreground));
border-right-color: transparent;
}
.tone-primary {
border-color: hsl(var(--color-primary));
border-right-color: transparent;
}
.tone-inherit {
border-color: currentColor;
border-right-color: transparent;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
/* Static ring full-color so it remains a meaningful loading indicator */
border-right-color: currentColor;
}
.tone-default,
.tone-muted,
.tone-primary {
border-right-color: inherit;
}
}
</style>

View file

@ -0,0 +1,53 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Text from './Text.svelte';
const { Story } = defineMeta({
title: 'Atoms/Text',
component: Text,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<Text>Standardtext im Body-Modus.</Text>
{/snippet}
</Story>
<Story name="ToneSweep">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:0.5rem;">
<Text tone="default">Default — Standard-Foreground</Text>
<Text tone="muted">Muted — sekundärer Text, Caption, Meta</Text>
<Text tone="primary">Primary — Akzent-Farbe</Text>
<Text tone="error">Error — Fehler, kritisch</Text>
<Text tone="success">Success — Erfolg</Text>
<Text tone="warning">Warning — Warnung</Text>
</div>
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:0.5rem;">
<Text size="2xl" weight="bold" as="h1">2xl bold (h1)</Text>
<Text size="xl" weight="semibold" as="h2">xl semibold (h2)</Text>
<Text size="lg" weight="medium" as="h3">lg medium (h3)</Text>
<Text size="base">base normal</Text>
<Text size="sm" tone="muted">sm muted (caption)</Text>
<Text size="xs" tone="muted">xs muted (meta)</Text>
</div>
{/snippet}
</Story>
<Story name="Truncate">
{#snippet children()}
<div style="width: 240px;">
<Text truncate>
Sehr langer Text, der über die verfügbare Breite hinausgeht und mit Ellipsis abgeschnitten
werden soll.
</Text>
</div>
{/snippet}
</Story>

View file

@ -1,54 +1,111 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
type TextVariant = 'body' | 'body-secondary' | 'small' | 'large' | 'muted';
type TextAlign = 'left' | 'center' | 'right';
type TextWeight = 'normal' | 'medium' | 'semibold' | 'bold';
type As = 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
type Tone = 'default' | 'muted' | 'primary' | 'error' | 'success' | 'warning';
type Size = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl';
type Weight = 'normal' | 'medium' | 'semibold' | 'bold';
interface Props extends HTMLAttributes<HTMLParagraphElement> {
variant?: TextVariant;
align?: TextAlign;
weight?: TextWeight;
class?: string;
interface Props {
as?: As;
tone?: Tone;
size?: Size;
weight?: Weight;
truncate?: boolean;
ariaLabel?: string;
id?: string;
children?: Snippet;
}
let {
variant = 'body',
align = 'left',
as = 'p',
tone = 'default',
size = 'base',
weight = 'normal',
class: className = '',
truncate = false,
ariaLabel,
id,
children,
...restProps
}: Props = $props();
const variantClasses: Record<TextVariant, string> = {
body: 'text-base text-theme leading-relaxed',
'body-secondary': 'text-base text-theme-secondary leading-relaxed',
small: 'text-sm text-theme',
large: 'text-lg text-theme leading-relaxed',
muted: 'text-sm text-theme-muted',
};
const alignClasses: Record<TextAlign, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
};
const weightClasses: Record<TextWeight, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
const classes = $derived(
`${variantClasses[variant]} ${alignClasses[align]} ${weightClasses[weight]} ${className}`
);
</script>
<p class={classes} {...restProps}>
{@render children?.()}
</p>
<svelte:element
this={as}
class="text tone-{tone} size-{size} weight-{weight}"
class:truncate
aria-label={ariaLabel}
{id}
>
{#if children}{@render children()}{/if}
</svelte:element>
<style>
.text {
margin: 0;
font-family: inherit;
line-height: 1.5;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* tones */
.tone-default {
color: hsl(var(--color-foreground));
}
.tone-muted {
color: hsl(var(--color-muted-foreground));
}
.tone-primary {
color: hsl(var(--color-primary));
}
.tone-error {
color: hsl(var(--color-error));
}
.tone-success {
color: hsl(var(--color-success));
}
.tone-warning {
color: hsl(var(--color-warning));
}
/* sizes */
.size-xs {
font-size: 0.75rem;
}
.size-sm {
font-size: 0.875rem;
}
.size-base {
font-size: 1rem;
}
.size-lg {
font-size: 1.125rem;
line-height: 1.4;
}
.size-xl {
font-size: 1.25rem;
line-height: 1.35;
}
.size-2xl {
font-size: 1.5rem;
line-height: 1.3;
}
/* weights */
.weight-normal {
font-weight: 400;
}
.weight-medium {
font-weight: 500;
}
.weight-semibold {
font-weight: 600;
}
.weight-bold {
font-weight: 700;
}
</style>

View file

@ -1,5 +1,7 @@
export { default as Text } from './Text.svelte';
export { default as Button } from './Button.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Card } from './Card.svelte';
export { default as Text } from './Text.svelte';
export { default as Skeleton } from './Skeleton.svelte';
export { default as Spinner } from './Spinner.svelte';
export { default as DynamicIcon } from './DynamicIcon.svelte';

View file

@ -1,121 +0,0 @@
<script lang="ts">
/**
* BottomStack — offset coordinator for the fixed bottom bar stack.
*
* Stack order (bottom → top):
* QuickInputBar (always at bottom, fixed offset)
* → PillNav (above input bar)
* → TagStrip (above PillNav)
* → children (e.g. MinimizedTabs)
* → top (e.g. NotificationBar)
*
* Computes and exposes offsets for each layer so apps don't
* need manual pixel arithmetic. Renders "middle" and "top"
* content at the correct positions.
*/
import type { Snippet } from 'svelte';
interface Props {
/** Height of the QuickInputBar in px (default: 72) */
inputBarHeight?: number;
/** Is PillNav currently visible? */
pillNavVisible?: boolean;
/** Height of PillNav in px (default: 68) */
pillNavHeight?: number;
/** Is TagStrip currently visible? */
tagStripVisible?: boolean;
/** Height of TagStrip in px (default: 50) */
tagStripHeight?: number;
/** Content rendered above TagStrip (e.g. MinimizedTabs) */
children?: Snippet;
/** Content rendered at the very top (e.g. NotificationBar) */
top?: Snippet;
/** Computed bottom offset for PillNav (bind this) */
pillNavOffset?: string;
/** Computed bottom offset for TagStrip (bind this) */
tagStripOffset?: string;
/** Computed bottom offset for FAB (bind this) */
fabOffset?: string;
}
let {
inputBarHeight = 72,
pillNavVisible = false,
pillNavHeight = 68,
tagStripVisible = false,
tagStripHeight = 50,
children,
top,
pillNavOffset = $bindable('0px'),
tagStripOffset = $bindable('72px'),
fabOffset = $bindable('20px'),
}: Props = $props();
const BASE = 16;
// PillNav sits above the InputBar
let pillNavBottom = $derived(inputBarHeight);
// TagStrip sits above PillNav (only when PillNav is visible)
let tagStripBottom = $derived(inputBarHeight + (pillNavVisible ? pillNavHeight : 0));
// Middle content sits above all fixed bars
let aboveFixedBars = $derived(
inputBarHeight +
(pillNavVisible ? pillNavHeight : 0) +
(pillNavVisible && tagStripVisible ? tagStripHeight : 0)
);
// Measure middle and top content heights
let middleHeight = $state(0);
let topHeight = $state(0);
// Top content sits above middle
let topBottom = $derived(aboveFixedBars + middleHeight);
// FAB should be above everything
let fabBottom = $derived(BASE + 4 + aboveFixedBars + middleHeight + topHeight);
// Sync bindable outputs
$effect(() => {
pillNavOffset = `${pillNavBottom}px`;
});
$effect(() => {
tagStripOffset = `${tagStripBottom}px`;
});
$effect(() => {
fabOffset = `${fabBottom}px`;
});
</script>
{#if children}
<div
class="bottom-stack-layer"
style="bottom: calc({aboveFixedBars}px + env(safe-area-inset-bottom, 0px))"
bind:clientHeight={middleHeight}
>
{@render children()}
</div>
{/if}
{#if top}
<div
class="bottom-stack-layer"
style="bottom: calc({topBottom}px + env(safe-area-inset-bottom, 0px))"
bind:clientHeight={topHeight}
>
{@render top()}
</div>
{/if}
<style>
.bottom-stack-layer {
position: fixed;
left: 50%;
transform: translateX(-50%);
z-index: 1001;
display: flex;
justify-content: center;
padding: 0.25rem 0;
}
</style>

View file

@ -1,174 +0,0 @@
<script lang="ts">
import { X, Plus, CornersOut, ArrowLineUp } from '@mana/shared-icons';
import type { MinimizedPage } from './types';
interface Props {
pages: MinimizedPage[];
onRestore: (pageId: string) => void;
onRemove: (pageId: string) => void;
onMaximize?: (pageId: string) => void;
onAdd: () => void;
}
let { pages, onRestore, onRemove, onMaximize, onAdd }: Props = $props();
</script>
{#if pages.length > 0}
<div class="minimized-tabs">
{#each pages as pg (pg.id)}
<div
class="minimized-tab"
role="button"
tabindex="0"
onclick={() => onRestore(pg.id)}
onkeydown={(e) => e.key === 'Enter' && onRestore(pg.id)}
>
<span class="minimized-tab-dot" style="background-color: {pg.color}"></span>
<span class="minimized-tab-title">{pg.title}</span>
<div class="minimized-tab-actions">
<button
class="minimized-tab-btn"
onclick={(e) => {
e.stopPropagation();
onRestore(pg.id);
}}
title="Wiederherstellen"
>
<ArrowLineUp size={12} />
</button>
{#if onMaximize}
<button
class="minimized-tab-btn"
onclick={(e) => {
e.stopPropagation();
onMaximize(pg.id);
}}
title="Maximieren"
>
<CornersOut size={12} />
</button>
{/if}
<button
class="minimized-tab-btn"
onclick={(e) => {
e.stopPropagation();
onRemove(pg.id);
}}
title="Schließen"
>
<X size={12} />
</button>
</div>
</div>
{/each}
<button class="minimized-tab-add" onclick={onAdd} title="Neue Seite hinzufügen">
<Plus size={14} />
</button>
</div>
{/if}
<style>
.minimized-tabs {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.3rem 0.5rem;
background: var(--color-surface-elevated, #fffef5);
border: 1px solid var(--color-border, rgba(0, 0, 0, 0.12));
border-radius: 0.625rem;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow-x: auto;
scrollbar-width: none;
width: fit-content;
}
:global(.dark) .minimized-tabs {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
.minimized-tabs::-webkit-scrollbar {
display: none;
}
.minimized-tab {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
border-radius: 0.3rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
flex-shrink: 0;
font-family: inherit;
}
.minimized-tab:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .minimized-tab:hover {
background: rgba(255, 255, 255, 0.08);
}
.minimized-tab-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
.minimized-tab-title {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-muted-foreground, #6b7280);
}
.minimized-tab-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.minimized-tab-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--color-muted-foreground, #d1d5db);
border-radius: 0.25rem;
cursor: pointer;
padding: 0;
transition: all 0.15s;
opacity: 0.5;
}
.minimized-tab-btn:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.06);
}
:global(.dark) .minimized-tab-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.minimized-tab-add {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 0.3rem;
border: none;
background: transparent;
color: var(--color-muted-foreground, #9ca3af);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
opacity: 0.6;
}
.minimized-tab-add:hover {
opacity: 1;
color: var(--color-primary, #8b5cf6);
}
</style>

View file

@ -1,165 +0,0 @@
<script lang="ts">
import { X, ArrowRight } from '@mana/shared-icons';
import type { BottomNotification } from './types';
interface Props {
notifications: BottomNotification[];
}
let { notifications }: Props = $props();
// Show highest priority notification (error > warning > info)
const PRIORITY: Record<string, number> = { error: 3, warning: 2, info: 1 };
let active = $derived(
notifications.length > 0
? [...notifications].sort((a, b) => (PRIORITY[b.type] ?? 0) - (PRIORITY[a.type] ?? 0))[0]
: null
);
function handleDismiss() {
if (active?.onDismiss) active.onDismiss();
}
</script>
{#if active}
<div
class="notification-bar"
class:warning={active.type === 'warning'}
class:error={active.type === 'error'}
>
<p class="notification-message">{active.message}</p>
<div class="notification-actions">
{#if active.action}
<button class="notification-action" onclick={active.action.onClick}>
{#if active.action.icon}
{@const ActionIcon = active.action.icon}
<ActionIcon size={14} weight="bold" />
{/if}
{active.action.label}
<ArrowRight size={12} />
</button>
{/if}
{#if active.dismissible !== false}
<button class="notification-dismiss" onclick={handleDismiss} aria-label="Schließen">
<X size={14} />
</button>
{/if}
</div>
</div>
{/if}
<style>
.notification-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.625rem 0.625rem 1rem;
background: hsl(var(--color-surface-elevated));
border: 1px solid hsl(var(--color-border-strong, var(--color-border)));
border-radius: 0.875rem;
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.12),
0 2px 6px rgba(0, 0, 0, 0.06);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
max-width: 480px;
width: max-content;
animation: slideUp 250ms ease-out;
}
:global(.dark) .notification-bar {
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.45),
0 2px 8px rgba(0, 0, 0, 0.25);
}
.notification-bar.warning {
border-color: rgba(245, 158, 11, 0.3);
}
.notification-bar.error {
border-color: rgba(239, 68, 68, 0.3);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.notification-message {
flex: 1;
margin: 0;
font-size: 0.8125rem;
line-height: 1.4;
color: hsl(var(--color-foreground) / 0.85);
}
.notification-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.notification-action {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.95rem;
font-size: 0.8125rem;
font-weight: 600;
color: white;
background: var(--color-primary, #7c3aed);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 0.625rem;
cursor: pointer;
transition:
background 150ms ease,
transform 150ms ease,
box-shadow 150ms ease;
white-space: nowrap;
font-family: inherit;
box-shadow:
0 4px 12px rgba(124, 58, 237, 0.35),
0 1px 2px rgba(0, 0, 0, 0.1);
}
.notification-action:hover {
filter: brightness(1.08);
transform: translateY(-1px);
box-shadow:
0 6px 16px rgba(124, 58, 237, 0.45),
0 2px 4px rgba(0, 0, 0, 0.12);
}
.notification-action:active {
transform: translateY(0);
}
.notification-dismiss {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
color: hsl(var(--color-foreground) / 0.45);
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
transition: all 150ms ease;
}
.notification-dismiss:hover {
background: hsl(var(--color-foreground) / 0.08);
color: hsl(var(--color-foreground) / 0.85);
}
@media (max-width: 480px) {
.notification-bar {
max-width: calc(100vw - 2rem);
}
}
</style>

View file

@ -1,4 +0,0 @@
export { default as BottomStack } from './BottomStack.svelte';
export { default as MinimizedTabs } from './MinimizedTabs.svelte';
export { default as NotificationBar } from './NotificationBar.svelte';
export type { MinimizedPage, MinimizedTabsCallbacks, BottomNotification } from './types';

View file

@ -1,20 +0,0 @@
export interface MinimizedPage {
id: string;
title: string;
color: string;
}
export interface MinimizedTabsCallbacks {
restore: (pageId: string) => void;
remove: (pageId: string) => void;
add: () => void;
}
export interface BottomNotification {
id: string;
message: string;
type: 'info' | 'warning' | 'error';
action?: { label: string; icon?: any; onClick: () => void };
dismissible?: boolean;
onDismiss?: () => void;
}

View file

@ -1,294 +0,0 @@
<script lang="ts">
import { format, parseISO, getMonth } from 'date-fns';
import { de } from 'date-fns/locale';
import type { HeatmapDataPoint } from './types';
interface Props {
data: HeatmapDataPoint[];
title?: string;
/** Number of days to display (default: 180) */
daysCount?: number;
/** Custom tooltip formatter */
tooltipFormatter?: (point: HeatmapDataPoint) => string;
/** Item name for tooltip (e.g., "Aufgabe", "Event", "Kontakt") */
itemName?: string;
/** Plural item name for tooltip (e.g., "Aufgaben", "Events", "Kontakte") */
itemNamePlural?: string;
}
let {
data,
title = 'Aktivität',
daysCount = 180,
tooltipFormatter,
itemName = 'Aufgabe',
itemNamePlural = 'Aufgaben',
}: Props = $props();
// Constants
const CELL_SIZE = 12;
const CELL_GAP = 3;
const DAY_LABELS = ['Mo', '', 'Mi', '', 'Fr', '', 'So'];
// Calculate max for color scaling
let maxCount = $derived(Math.max(...data.map((d) => d.count), 1));
// Get color intensity based on count (uses CSS variable --primary)
function getColorClass(count: number): string {
if (count === 0) return 'intensity-0';
const ratio = count / maxCount;
if (ratio <= 0.25) return 'intensity-1';
if (ratio <= 0.5) return 'intensity-2';
if (ratio <= 0.75) return 'intensity-3';
return 'intensity-4';
}
// Group data by weeks
let weeks = $derived.by(() => {
const result: HeatmapDataPoint[][] = [];
let currentWeek: HeatmapDataPoint[] = [];
// Adjust for Monday start
const adjustedData = [...data];
// Fill initial gap if first day isn't Monday
if (adjustedData.length > 0) {
const firstDay = adjustedData[0];
// Convert Sunday (0) to 6, Monday (1) to 0, etc.
const adjustedDayOfWeek = firstDay.dayOfWeek === 0 ? 6 : firstDay.dayOfWeek - 1;
for (let i = 0; i < adjustedDayOfWeek; i++) {
currentWeek.push({ date: '', count: 0, dayOfWeek: i });
}
}
adjustedData.forEach((day) => {
// Convert to Monday-based index
const adjustedDayOfWeek = day.dayOfWeek === 0 ? 6 : day.dayOfWeek - 1;
if (adjustedDayOfWeek === 0 && currentWeek.length > 0) {
result.push(currentWeek);
currentWeek = [];
}
currentWeek.push({ ...day, dayOfWeek: adjustedDayOfWeek });
});
if (currentWeek.length > 0) {
result.push(currentWeek);
}
return result;
});
// Calculate month labels
let monthLabels = $derived.by(() => {
const labels: { month: string; weekIndex: number }[] = [];
let lastMonth = -1;
weeks.forEach((week, weekIndex) => {
const validDay = week.find((d) => d.date);
if (validDay) {
const date = parseISO(validDay.date);
const month = getMonth(date);
if (month !== lastMonth) {
labels.push({
month: format(date, 'MMM', { locale: de }),
weekIndex,
});
lastMonth = month;
}
}
});
return labels;
});
// Calculate SVG dimensions
let svgWidth = $derived(weeks.length * (CELL_SIZE + CELL_GAP) + 30);
let svgHeight = 7 * (CELL_SIZE + CELL_GAP) + 30;
function formatTooltip(day: HeatmapDataPoint): string {
if (!day.date) return '';
if (tooltipFormatter) return tooltipFormatter(day);
const date = format(parseISO(day.date), 'EEEE, d. MMMM yyyy', { locale: de });
const name = day.count === 1 ? itemName : itemNamePlural;
return `${day.count} ${name} am ${date}`;
}
</script>
<div class="heatmap-container">
<h3 class="heatmap-title">{title}</h3>
<div class="heatmap-scroll">
<svg
width={svgWidth}
height={svgHeight}
viewBox="0 0 {svgWidth} {svgHeight}"
class="heatmap-svg"
>
<!-- Month labels -->
{#each monthLabels as label}
<text x={30 + label.weekIndex * (CELL_SIZE + CELL_GAP)} y={10} class="month-label">
{label.month}
</text>
{/each}
<!-- Day labels -->
{#each DAY_LABELS as label, i}
{#if label}
<text x={0} y={22 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE / 2 + 4} class="day-label">
{label}
</text>
{/if}
{/each}
<!-- Cells -->
{#each weeks as week, weekIndex}
{#each week as day, dayIndex}
{#if day.date}
<rect
x={30 + weekIndex * (CELL_SIZE + CELL_GAP)}
y={20 + dayIndex * (CELL_SIZE + CELL_GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
class="cell {getColorClass(day.count)}"
>
<title>{formatTooltip(day)}</title>
</rect>
{:else}
<rect
x={30 + weekIndex * (CELL_SIZE + CELL_GAP)}
y={20 + dayIndex * (CELL_SIZE + CELL_GAP)}
width={CELL_SIZE}
height={CELL_SIZE}
rx={2}
class="cell empty"
/>
{/if}
{/each}
{/each}
</svg>
</div>
<!-- Legend -->
<div class="legend">
<span class="legend-label">Weniger</span>
<div class="legend-cells">
<div class="legend-cell intensity-0"></div>
<div class="legend-cell intensity-1"></div>
<div class="legend-cell intensity-2"></div>
<div class="legend-cell intensity-3"></div>
<div class="legend-cell intensity-4"></div>
</div>
<span class="legend-label">Mehr</span>
</div>
</div>
<style>
.heatmap-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .heatmap-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.heatmap-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 1rem 0;
}
.heatmap-scroll {
overflow-x: auto;
padding-bottom: 0.5rem;
}
.heatmap-svg {
display: block;
}
.month-label {
font-size: 10px;
fill: hsl(var(--color-muted-foreground));
}
.day-label {
font-size: 10px;
fill: hsl(var(--color-muted-foreground));
}
.cell {
transition: opacity 0.15s ease;
}
.cell:hover {
opacity: 0.8;
}
.cell.empty {
fill: transparent;
}
:global(.dark) .cell.empty {
fill: transparent;
}
.legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.legend-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.legend-cells {
display: flex;
gap: 3px;
}
.legend-cell {
width: 12px;
height: 12px;
border-radius: 2px;
}
/* Intensity classes using theme primary color */
.intensity-0 {
fill: hsl(var(--color-muted) / 0.3);
background: hsl(var(--color-muted) / 0.3);
}
.intensity-1 {
fill: hsl(var(--color-primary) / 0.3);
background: hsl(var(--color-primary) / 0.3);
}
.intensity-2 {
fill: hsl(var(--color-primary) / 0.5);
background: hsl(var(--color-primary) / 0.5);
}
.intensity-3 {
fill: hsl(var(--color-primary) / 0.7);
background: hsl(var(--color-primary) / 0.7);
}
.intensity-4 {
fill: hsl(var(--color-primary));
background: hsl(var(--color-primary));
}
</style>

View file

@ -1,260 +0,0 @@
<script lang="ts">
import type { DonutSegment } from './types';
interface Props {
data: DonutSegment[];
title?: string;
centerLabel?: string;
centerValue?: number | string;
showLegend?: boolean;
}
let {
data,
title = 'Verteilung',
centerLabel = 'Gesamt',
centerValue,
showLegend = true,
}: Props = $props();
// Chart settings
const SIZE = 200;
const CENTER = SIZE / 2;
const RADIUS = 80;
const INNER_RADIUS = 50;
// Total count
let total = $derived(centerValue ?? data.reduce((sum, d) => sum + d.count, 0));
// Generate arc paths
let arcs = $derived.by(() => {
const totalCount = data.reduce((sum, d) => sum + d.count, 0);
if (totalCount === 0) return [];
const result: Array<{
path: string;
color: string;
id: string;
label: string;
count: number;
percentage: number;
}> = [];
let currentAngle = -90; // Start at top
data.forEach((segment) => {
if (segment.count === 0) return;
const angle = (segment.count / totalCount) * 360;
const startAngle = currentAngle;
const endAngle = currentAngle + angle;
// Convert angles to radians
const startRad = (startAngle * Math.PI) / 180;
const endRad = (endAngle * Math.PI) / 180;
// Calculate points
const x1 = CENTER + RADIUS * Math.cos(startRad);
const y1 = CENTER + RADIUS * Math.sin(startRad);
const x2 = CENTER + RADIUS * Math.cos(endRad);
const y2 = CENTER + RADIUS * Math.sin(endRad);
const x3 = CENTER + INNER_RADIUS * Math.cos(endRad);
const y3 = CENTER + INNER_RADIUS * Math.sin(endRad);
const x4 = CENTER + INNER_RADIUS * Math.cos(startRad);
const y4 = CENTER + INNER_RADIUS * Math.sin(startRad);
const largeArc = angle > 180 ? 1 : 0;
// Create arc path
const path = [
`M ${x1} ${y1}`,
`A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${x2} ${y2}`,
`L ${x3} ${y3}`,
`A ${INNER_RADIUS} ${INNER_RADIUS} 0 ${largeArc} 0 ${x4} ${y4}`,
'Z',
].join(' ');
result.push({
path,
color: segment.color,
id: segment.id,
label: segment.label,
count: segment.count,
percentage: segment.percentage,
});
currentAngle = endAngle;
});
return result;
});
// Hover state
let hoveredSegment = $state<string | null>(null);
</script>
<div class="donut-container">
<h3 class="donut-title">{title}</h3>
<div class="donut-content">
<div class="donut-chart">
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
{#each arcs as arc}
<path
d={arc.path}
fill={arc.color}
class="arc-segment"
class:hovered={hoveredSegment === arc.id}
onmouseenter={() => (hoveredSegment = arc.id)}
onmouseleave={() => (hoveredSegment = null)}
role="graphics-symbol"
aria-label="{arc.label}: {arc.count}"
>
<title>{arc.label}: {arc.count} ({arc.percentage}%)</title>
</path>
{/each}
<!-- Center text -->
<text x={CENTER} y={CENTER - 8} class="center-count">
{total}
</text>
<text x={CENTER} y={CENTER + 12} class="center-label">
{centerLabel}
</text>
</svg>
</div>
<!-- Legend -->
{#if showLegend}
<div class="donut-legend">
{#each data as item}
<div
class="legend-item"
class:active={hoveredSegment === item.id}
onmouseenter={() => (hoveredSegment = item.id)}
onmouseleave={() => (hoveredSegment = null)}
role="button"
tabindex="0"
>
<span class="legend-color" style="background-color: {item.color}"></span>
<span class="legend-label">{item.label}</span>
<span class="legend-count">{item.count}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.donut-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .donut-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.donut-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 1rem 0;
}
.donut-content {
display: flex;
align-items: center;
gap: 1.5rem;
}
@media (max-width: 400px) {
.donut-content {
flex-direction: column;
}
}
.donut-chart {
flex-shrink: 0;
}
.donut-svg {
width: 140px;
height: 140px;
}
.arc-segment {
transition:
opacity 0.15s ease,
transform 0.15s ease;
transform-origin: center;
cursor: pointer;
}
.arc-segment:hover,
.arc-segment.hovered {
opacity: 0.85;
transform: scale(1.02);
}
.center-count {
font-size: 28px;
font-weight: 700;
fill: hsl(var(--color-foreground));
text-anchor: middle;
}
.center-label {
font-size: 12px;
fill: hsl(var(--color-muted-foreground));
text-anchor: middle;
}
.donut-legend {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.legend-item:hover,
.legend-item.active {
background: hsl(var(--color-muted) / 0.3);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.legend-label {
font-size: 0.875rem;
color: hsl(var(--color-foreground));
flex: 1;
}
.legend-count {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -1,192 +0,0 @@
<script lang="ts">
import type { ProgressItem } from './types';
interface Props {
data: ProgressItem[];
title?: string;
maxItems?: number;
emptyMessage?: string;
}
let {
data,
title = 'Fortschritt',
maxItems = 8,
emptyMessage = 'Keine Daten vorhanden',
}: Props = $props();
// Sort by total (descending) and limit to maxItems
let sortedData = $derived(data.slice(0, maxItems));
</script>
<div class="progress-container">
<h3 class="progress-title">{title}</h3>
{#if sortedData.length === 0}
<p class="no-data">{emptyMessage}</p>
{:else}
<div class="progress-list">
{#each sortedData as item (item.id)}
<div class="progress-row">
<div class="progress-header">
<div class="progress-name">
<span class="progress-dot" style="background-color: {item.color}"></span>
<span class="name-text">{item.name}</span>
</div>
<span class="progress-stats">
{item.completed}/{item.total}
</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar">
<!-- Completed segment -->
{#if item.completed > 0}
<div
class="progress-segment completed"
style="width: {(item.completed / item.total) *
100}%; background-color: {item.color}"
></div>
{/if}
<!-- In Progress segment -->
{#if item.inProgress && item.inProgress > 0}
<div
class="progress-segment in-progress"
style="width: {(item.inProgress / item.total) *
100}%; background-color: {item.color}; opacity: 0.4"
></div>
{/if}
</div>
<span class="percentage">{item.percentage}%</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.progress-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .progress-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.progress-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 1rem 0;
}
.no-data {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
text-align: center;
padding: 2rem;
}
.progress-list {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.progress-row {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-name {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.progress-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.name-text {
font-size: 0.875rem;
color: hsl(var(--color-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-stats {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: hsl(var(--color-muted) / 0.3);
border-radius: 4px;
overflow: hidden;
display: flex;
}
:global(.dark) .progress-bar {
background: rgba(255, 255, 255, 0.1);
}
.progress-segment {
height: 100%;
transition: width 0.3s ease;
}
.progress-segment.completed {
border-radius: 4px 0 0 4px;
}
.progress-segment.in-progress {
/* Striped pattern for in-progress */
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 4px,
rgba(255, 255, 255, 0.3) 4px,
rgba(255, 255, 255, 0.3) 8px
);
}
.percentage {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
width: 36px;
text-align: right;
flex-shrink: 0;
}
</style>

View file

@ -1,272 +0,0 @@
<script lang="ts">
/**
* StatisticsSkeleton - Skeleton for statistics page loading
*/
import { SkeletonBox } from '../molecules';
interface Props {
/** Number of stat cards to show (default: 6) */
statCards?: number;
/** Number of progress items to show (default: 4) */
progressItems?: number;
/** Number of legend items for donut chart (default: 4) */
legendItems?: number;
/** Show additional stats section (default: true) */
showAdditionalStats?: boolean;
}
let {
statCards = 6,
progressItems = 4,
legendItems = 4,
showAdditionalStats = true,
}: Props = $props();
</script>
<div class="statistics-skeleton" role="status" aria-label="Statistiken werden geladen...">
<!-- Stats Overview Cards -->
<div class="stats-overview">
{#each Array(statCards) as _, i}
<div class="stat-card" style="opacity: {Math.max(0.5, 1 - i * 0.08)};">
<SkeletonBox width="40px" height="40px" borderRadius="10px" />
<div class="stat-content">
<SkeletonBox width="48px" height="28px" />
<SkeletonBox width="80px" height="14px" />
</div>
</div>
{/each}
</div>
<!-- Charts Grid -->
<div class="charts-grid">
<!-- Activity Heatmap -->
<div class="chart-card heatmap">
<div class="chart-header">
<SkeletonBox width="140px" height="20px" />
</div>
<div class="heatmap-grid">
{#each Array(7) as _}
<div class="heatmap-row">
{#each Array(12) as _}
<SkeletonBox width="16px" height="16px" borderRadius="3px" />
{/each}
</div>
{/each}
</div>
</div>
<!-- Charts Row -->
<div class="charts-row">
<!-- Weekly Trend Chart -->
<div class="chart-card trend">
<div class="chart-header">
<SkeletonBox width="120px" height="20px" />
</div>
<div class="trend-bars">
{#each Array(7) as _, i}
<div class="bar-wrapper">
<SkeletonBox width="32px" height="{40 + Math.random() * 60}px" borderRadius="4px" />
<SkeletonBox width="24px" height="12px" />
</div>
{/each}
</div>
</div>
<!-- Priority Donut Chart -->
<div class="chart-card donut">
<div class="chart-header">
<SkeletonBox width="100px" height="20px" />
</div>
<div class="donut-wrapper">
<SkeletonBox width="140px" height="140px" borderRadius="50%" />
</div>
<div class="legend">
{#each Array(legendItems) as _}
<div class="legend-item">
<SkeletonBox width="12px" height="12px" borderRadius="3px" />
<SkeletonBox width="60px" height="14px" />
</div>
{/each}
</div>
</div>
</div>
<!-- Project Progress -->
<div class="chart-card projects">
<div class="chart-header">
<SkeletonBox width="130px" height="20px" />
</div>
<div class="progress-bars">
{#each Array(progressItems) as _, i}
<div class="progress-item" style="opacity: {Math.max(0.4, 1 - i * 0.15)};">
<div class="progress-header">
<SkeletonBox width="{100 + i * 20}px" height="16px" />
<SkeletonBox width="40px" height="14px" />
</div>
<SkeletonBox width="100%" height="8px" borderRadius="4px" />
</div>
{/each}
</div>
</div>
</div>
<!-- Additional Stats -->
{#if showAdditionalStats}
<div class="additional-stats">
{#each Array(3) as _}
<div class="small-stat">
<SkeletonBox width="120px" height="12px" />
<SkeletonBox width="80px" height="18px" />
</div>
{/each}
</div>
{/if}
</div>
<style>
.statistics-skeleton {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
/* Stats Overview */
.stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.stat-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
}
.stat-content {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Charts Grid */
.charts-grid {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.chart-card {
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
padding: 1.25rem;
}
.chart-header {
margin-bottom: 1rem;
}
/* Heatmap */
.heatmap-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.heatmap-row {
display: flex;
gap: 4px;
}
/* Charts Row */
.charts-row {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
}
@media (min-width: 768px) {
.charts-row {
grid-template-columns: 2fr 1fr;
}
}
/* Trend Chart */
.trend-bars {
display: flex;
align-items: flex-end;
justify-content: space-between;
height: 120px;
padding-top: 1rem;
}
.bar-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
/* Donut Chart */
.donut-wrapper {
display: flex;
justify-content: center;
padding: 1rem 0;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
}
/* Project Progress */
.progress-bars {
display: flex;
flex-direction: column;
gap: 1rem;
}
.progress-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Additional Stats */
.additional-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.small-stat {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 1rem;
}
</style>

View file

@ -1,136 +0,0 @@
<script lang="ts">
import type { StatItem } from './types';
import { STAT_VARIANT_COLORS } from './types';
interface Props {
items: StatItem[];
columns?: 2 | 3 | 4 | 6;
}
let { items, columns = 6 }: Props = $props();
// Filter items based on showCondition
let visibleItems = $derived(items.filter((item) => item.showCondition !== false));
</script>
<div
class="stats-grid"
class:cols-2={columns === 2}
class:cols-3={columns === 3}
class:cols-4={columns === 4}
class:cols-6={columns === 6}
>
{#each visibleItems as item (item.id)}
<div class="stat-card">
<div
class="stat-icon"
style="background-color: {STAT_VARIANT_COLORS[item.variant]
.bg}; color: {STAT_VARIANT_COLORS[item.variant].color}"
>
<item.icon size={24} />
</div>
<div class="stat-content">
<span class="stat-value">{item.value}</span>
<span class="stat-label">{item.label}</span>
</div>
</div>
{/each}
</div>
<style>
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
/* Default responsive behavior for 6 columns */
.stats-grid.cols-6 {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 640px) {
.stats-grid.cols-6 {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid.cols-3 {
grid-template-columns: repeat(3, 1fr);
}
.stats-grid.cols-4 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.stats-grid.cols-6 {
grid-template-columns: repeat(6, 1fr);
}
.stats-grid.cols-4 {
grid-template-columns: repeat(4, 1fr);
}
}
.stats-grid.cols-2 {
grid-template-columns: repeat(2, 1fr);
}
.stats-grid.cols-3 {
grid-template-columns: repeat(2, 1fr);
}
.stat-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
}
:global(.dark) .stat-card {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 0.75rem;
flex-shrink: 0;
}
.stat-content {
display: flex;
flex-direction: column;
min-width: 0;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.2;
color: hsl(var(--color-foreground));
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -1,241 +0,0 @@
<script lang="ts">
import type { TrendDataPoint } from './types';
interface Props {
data: TrendDataPoint[];
title?: string;
height?: number;
/** Item name for tooltip (e.g., "Aufgabe", "Event", "Kontakt") */
itemName?: string;
/** Plural item name for tooltip (e.g., "Aufgaben", "Events", "Kontakte") */
itemNamePlural?: string;
}
let {
data,
title = 'Trend (letzte 4 Wochen)',
height = 200,
itemName = 'Aufgabe',
itemNamePlural = 'Aufgaben',
}: Props = $props();
// Chart dimensions
const WIDTH = 600;
const PADDING = { top: 20, right: 20, bottom: 30, left: 40 };
let chartWidth = WIDTH - PADDING.left - PADDING.right;
// svelte-ignore state_referenced_locally
let chartHeight = height - PADDING.top - PADDING.bottom;
// Calculate max for scaling
let maxCount = $derived(Math.max(...data.map((d) => d.count), 1));
// Scale functions
function scaleX(index: number): number {
if (data.length <= 1) return PADDING.left;
return PADDING.left + (index / (data.length - 1)) * chartWidth;
}
function scaleY(value: number): number {
return PADDING.top + chartHeight - (value / maxCount) * chartHeight;
}
// Generate path for the line
let linePath = $derived.by(() => {
if (data.length === 0) return '';
const points = data.map((d, i) => ({
x: scaleX(i),
y: scaleY(d.count),
}));
// Create smooth curve using cubic bezier
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const cpX = (prev.x + curr.x) / 2;
path += ` C ${cpX} ${prev.y}, ${cpX} ${curr.y}, ${curr.x} ${curr.y}`;
}
return path;
});
// Generate path for the area fill
let areaPath = $derived.by(() => {
if (data.length === 0) return '';
const baseline = PADDING.top + chartHeight;
return `${linePath} L ${scaleX(data.length - 1)} ${baseline} L ${scaleX(0)} ${baseline} Z`;
});
// Y-axis ticks
let yTicks = $derived.by(() => {
const tickCount = 4;
const step = maxCount / tickCount;
return Array.from({ length: tickCount + 1 }, (_, i) => Math.round(i * step));
});
// X-axis labels (show every 7th day for weekly labels)
let xLabels = $derived.by(() => {
const labels: { index: number; label: string }[] = [];
const step = Math.max(1, Math.floor(data.length / 4));
for (let i = 0; i < data.length; i += step) {
if (data[i]) {
labels.push({ index: i, label: data[i].date.slice(5) }); // MM-DD format
}
}
return labels;
});
// Generate unique gradient ID
let gradientId = $derived(`areaGradient-${Math.random().toString(36).slice(2, 9)}`);
function formatTooltip(point: TrendDataPoint): string {
const name = point.count === 1 ? itemName : itemNamePlural;
return `${point.count} ${name} am ${point.date}`;
}
</script>
<div class="chart-container">
<h3 class="chart-title">{title}</h3>
<svg viewBox="0 0 {WIDTH} {height}" class="chart-svg" preserveAspectRatio="xMidYMid meet">
<!-- Grid lines -->
{#each yTicks as tick}
<line
x1={PADDING.left}
y1={scaleY(tick)}
x2={WIDTH - PADDING.right}
y2={scaleY(tick)}
class="grid-line"
/>
{/each}
<!-- Area fill with gradient -->
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" class="gradient-start" />
<stop offset="100%" class="gradient-end" />
</linearGradient>
</defs>
<path d={areaPath} fill="url(#{gradientId})" class="area-path" />
<!-- Line -->
<path d={linePath} class="line-path" />
<!-- Data points -->
{#each data as point, i}
<circle cx={scaleX(i)} cy={scaleY(point.count)} r={4} class="data-point">
<title>{formatTooltip(point)}</title>
</circle>
{/each}
<!-- Y-axis labels -->
{#each yTicks as tick}
<text x={PADDING.left - 8} y={scaleY(tick) + 4} class="y-label">
{tick}
</text>
{/each}
<!-- X-axis labels -->
{#each xLabels as label}
<text x={scaleX(label.index)} y={height - 8} class="x-label">
{label.label}
</text>
{/each}
</svg>
</div>
<style>
.chart-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1.5rem;
padding: 1.5rem;
}
:global(.dark) .chart-container {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.chart-title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0 0 1rem 0;
}
.chart-svg {
width: 100%;
height: auto;
max-height: 200px;
}
.grid-line {
stroke: hsl(var(--color-muted) / 0.3);
stroke-width: 1;
stroke-dasharray: 4 4;
}
:global(.dark) .grid-line {
stroke: rgba(255, 255, 255, 0.1);
}
.area-path {
transition: opacity 0.3s ease;
}
.gradient-start {
stop-color: hsl(var(--color-primary));
stop-opacity: 0.3;
}
.gradient-end {
stop-color: hsl(var(--color-primary));
stop-opacity: 0.05;
}
.line-path {
fill: none;
stroke: hsl(var(--color-primary));
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.data-point {
fill: hsl(var(--color-primary));
stroke: white;
stroke-width: 2;
cursor: pointer;
transition: r 0.15s ease;
}
.data-point:hover {
r: 6;
}
:global(.dark) .data-point {
stroke: #1e1e1e;
}
.y-label {
font-size: 10px;
fill: hsl(var(--color-muted-foreground));
text-anchor: end;
}
.x-label {
font-size: 10px;
fill: hsl(var(--color-muted-foreground));
text-anchor: middle;
}
</style>

View file

@ -1,20 +0,0 @@
// Charts - Statistics Visualization Components
export { default as StatsGrid } from './StatsGrid.svelte';
export { default as ActivityHeatmap } from './ActivityHeatmap.svelte';
export { default as TrendLineChart } from './TrendLineChart.svelte';
export { default as DonutChart } from './DonutChart.svelte';
export { default as ProgressBars } from './ProgressBars.svelte';
export { default as StatisticsSkeleton } from './StatisticsSkeleton.svelte';
// Types
export type {
StatVariant,
StatItem,
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from './types';
// Constants
export { STAT_VARIANT_COLORS } from './types';

View file

@ -1,62 +0,0 @@
/**
* Shared Types for Chart Components
*/
import type { Component } from 'svelte';
// Stat card variant colors
export type StatVariant = 'success' | 'primary' | 'neutral' | 'danger' | 'info' | 'accent';
export const STAT_VARIANT_COLORS: Record<StatVariant, { bg: string; color: string }> = {
success: { bg: 'rgba(16, 185, 129, 0.15)', color: '#10B981' },
primary: { bg: 'rgba(139, 92, 246, 0.15)', color: '#8B5CF6' },
neutral: { bg: 'rgba(107, 114, 128, 0.15)', color: '#6B7280' },
danger: { bg: 'rgba(239, 68, 68, 0.15)', color: '#EF4444' },
info: { bg: 'rgba(59, 130, 246, 0.15)', color: '#3B82F6' },
accent: { bg: 'rgba(236, 72, 153, 0.15)', color: '#EC4899' },
};
// StatsGrid types
export interface StatItem {
id: string;
label: string;
value: number | string;
icon: Component;
variant: StatVariant;
/** Optional: only show this stat if condition is true */
showCondition?: boolean;
}
// ActivityHeatmap types
export interface HeatmapDataPoint {
date: string; // YYYY-MM-DD format
count: number;
dayOfWeek: number; // 0-6 (Sunday-Saturday)
}
// TrendLineChart types
export interface TrendDataPoint {
date: string; // YYYY-MM-DD format
count: number;
label?: string;
}
// DonutChart types
export interface DonutSegment {
id: string;
label: string;
count: number;
percentage: number;
color: string;
}
// ProgressBars types
export interface ProgressItem {
id: string;
name: string;
color: string;
total: number;
completed: number;
inProgress?: number;
percentage: number;
}

View file

@ -1,77 +0,0 @@
<script lang="ts">
interface Props {
commitHash?: string;
buildTime?: string;
}
let { commitHash = 'unknown', buildTime = '' }: Props = $props();
let formattedTime = $derived.by(() => {
if (!buildTime) return '';
try {
const d = new Date(buildTime);
return d.toLocaleString('de-DE', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return buildTime;
}
});
let expanded = $state(false);
</script>
<button
class="dev-badge"
class:expanded
onclick={() => (expanded = !expanded)}
title="Build: {commitHash} | {buildTime}"
>
{#if expanded}
<span class="dev-badge-detail">{commitHash} &middot; {formattedTime}</span>
{:else}
<span class="dev-badge-hash">{commitHash}</span>
{/if}
</button>
<style>
.dev-badge {
position: fixed;
bottom: 8px;
right: 8px;
z-index: 9999;
font-family: ui-monospace, monospace;
font-size: 10px;
line-height: 1;
padding: 3px 6px;
border-radius: 4px;
border: none;
background: rgba(0, 0, 0, 0.4);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
pointer-events: auto;
opacity: 0.5;
transition: opacity 150ms ease;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.dev-badge:hover {
opacity: 1;
}
.dev-badge.expanded {
opacity: 0.85;
}
.dev-badge-hash {
letter-spacing: 0.5px;
}
.dev-badge-detail {
white-space: nowrap;
}
</style>

View file

@ -1,113 +0,0 @@
<script lang="ts">
import { CaretDown, CaretUp } from '@mana/shared-icons';
interface Props {
/** Whether immersive mode is currently enabled */
isImmersive: boolean;
/** Callback to toggle immersive mode */
onToggle: () => void;
/** Whether to show the toggle (e.g., only on main page) */
visible?: boolean;
}
let { isImmersive, onToggle, visible = true }: Props = $props();
</script>
{#if visible}
<button
class="immersive-toggle"
class:immersive={isImmersive}
onclick={onToggle}
title={isImmersive ? 'UI anzeigen (F)' : 'UI verstecken (F)'}
>
{#if isImmersive}
<CaretUp size={20} />
{:else}
<CaretDown size={20} />
{/if}
</button>
{/if}
<style>
.immersive-toggle {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 24px;
border-radius: 8px 8px 0 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
pointer-events: auto;
transition:
opacity 300ms ease,
background 150ms ease,
color 150ms ease;
}
.immersive-toggle:hover {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
color: #374151;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.immersive-toggle:active {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Dark mode hover */
:global(.dark) .immersive-toggle:hover {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
:global(.dark) .immersive-toggle:active {
background: rgba(255, 255, 255, 0.2);
}
/* Immersive mode: even more subtle, full opacity on hover */
.immersive-toggle.immersive {
opacity: 0.2;
color: hsl(var(--color-muted-foreground));
}
.immersive-toggle.immersive:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
color: #374151;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
:global(.dark) .immersive-toggle.immersive:hover {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
/* Mobile adjustments */
@media (max-width: 640px) {
.immersive-toggle {
bottom: env(safe-area-inset-bottom);
}
}
</style>

View file

@ -1,66 +0,0 @@
<!--
SyncIndicator — Shows online/offline status as a floating pill.
Appears when browser goes offline. Shows "Wieder online" briefly on reconnect.
Usage: Just add <SyncIndicator /> to any layout. No props needed.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Check } from '@mana/shared-icons';
let isOnline = $state(true);
let showReconnected = $state(false);
let visible = $state(false);
function handleOnline() {
isOnline = true;
showReconnected = true;
visible = true;
setTimeout(() => {
showReconnected = false;
visible = false;
}, 3000);
}
function handleOffline() {
isOnline = false;
visible = true;
}
onMount(() => {
isOnline = navigator.onLine;
visible = !isOnline;
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
});
onDestroy(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
}
});
</script>
{#if visible}
{@const colorClass = isOnline ? 'bg-green-600 text-green-50' : 'bg-amber-600 text-amber-50'}
<div
class="fixed bottom-20 right-4 z-40 flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur-sm transition-all duration-300 {colorClass}"
>
{#if !isOnline}
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18.364 5.636a9 9 0 010 12.728M5.636 5.636a9 9 0 000 12.728"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1l22 22" />
</svg>
<span>Offline</span>
{:else if showReconnected}
<Check size={20} />
<span>Wieder online</span>
{/if}
</div>
{/if}

View file

@ -1,3 +0,0 @@
export { default as ContextMenu } from './ContextMenu.svelte';
export type { ContextMenuItem, ContextMenuState } from './types';
export { createContextMenuState } from './types';

View file

@ -1,46 +0,0 @@
import type { Component } from 'svelte';
export interface ContextMenuItem {
/** Unique identifier for the item */
id: string;
/** Display label */
label: string;
/** Icon component to render (Phosphor icon or any Svelte component) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: Component<any>;
/** Keyboard shortcut hint */
shortcut?: string;
/** Whether the item is disabled */
disabled?: boolean;
/** Visual variant */
variant?: 'default' | 'danger';
/** Item type - use 'divider' for separator */
type?: 'item' | 'divider';
/** Action to perform when clicked */
action?: () => void;
/** Additional data attached to the item */
data?: unknown;
/** Show a toggle switch (for boolean settings) */
toggle?: boolean;
/** Current toggle state (only used when toggle is true) */
checked?: boolean;
}
export interface ContextMenuState<T = unknown> {
visible: boolean;
x: number;
y: number;
target: T | null;
}
/**
* Creates a context menu state object
*/
export function createContextMenuState<T = unknown>(): ContextMenuState<T> {
return {
visible: false,
x: 0,
y: 0,
target: null,
};
}

View file

@ -1,57 +1,45 @@
<script lang="ts">
/**
* Drop zone that appears when a drag is active (Layer 1 or Layer 2).
*
* Slides in from the bottom during any drag, acts as a drop target
* for actions like delete, archive, etc.
*
* Usage:
* <ActionZone
* accepts={['task', 'card']}
* onDrop={(payload) => deleteItem(payload.data.id)}
* variant="danger"
* label="Löschen"
* />
*/
import { dragState } from './drag-state.svelte';
import { dropTarget } from './drop-target';
import { passiveDropZone } from './passive-drop';
import type { DragPayload, DragType } from './types';
import { Trash, Archive, FolderOpen } from '@mana/shared-icons';
import DynamicIcon from '../atoms/DynamicIcon.svelte';
type Variant = 'danger' | 'warning' | 'info' | 'success';
interface Props {
accepts: DragType[];
onDrop: (payload: DragPayload) => void;
canDrop?: (payload: DragPayload) => boolean;
variant?: 'danger' | 'warning' | 'info' | 'success';
variant?: Variant;
label?: string;
icon?: typeof Trash;
/** Custom inline SVG path for the icon. Falls back to a per-variant default. */
iconSvg?: string;
}
let { accepts, onDrop, canDrop, variant = 'danger', label = '', icon }: Props = $props();
let { accepts, onDrop, canDrop, variant = 'danger', label = '', iconSvg }: Props = $props();
const visible = $derived(dragState.anyDragActive);
const iconComponent = $derived(
icon ?? (variant === 'danger' ? Trash : variant === 'warning' ? Archive : FolderOpen)
);
const defaultIconName = $derived.by(() => {
if (variant === 'danger') return 'trash' as const;
if (variant === 'warning') return 'archive' as const;
if (variant === 'info') return 'info' as const;
return 'check' as const;
});
// The zone is both a Layer 1 drop target and a Layer 2 passive zone
function handleDrop(payload: DragPayload) {
onDrop(payload);
}
</script>
{#if visible}
{@const Icon = iconComponent}
<div
class="action-zone variant-{variant}"
use:dropTarget={{
accepts,
onDrop: handleDrop,
canDrop,
}}
use:dropTarget={{ accepts, onDrop: handleDrop, canDrop }}
use:passiveDropZone={{
accepts,
onDrop: handleDrop,
@ -61,7 +49,11 @@
role="button"
tabindex="-1"
>
<Icon size={20} weight="bold" />
{#if iconSvg}
<DynamicIcon {iconSvg} size="lg" />
{:else}
<DynamicIcon name={defaultIconName} size="lg" />
{/if}
{#if label}
<span class="action-label">{label}</span>
{/if}
@ -80,11 +72,15 @@
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 9999px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
border-width: 1.5px;
border-style: solid;
transition:
background-color 200ms ease,
border-color 200ms ease,
transform 200ms ease;
animation: action-zone-in 200ms ease-out;
cursor: default;
font-family: inherit;
}
@keyframes action-zone-in {
@ -104,32 +100,30 @@
white-space: nowrap;
}
/* Variants */
.variant-danger {
background: rgba(239, 68, 68, 0.15);
border: 1.5px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
background: hsl(var(--color-error) / 0.15);
border-color: hsl(var(--color-error) / 0.3);
color: hsl(var(--color-error));
}
.variant-warning {
background: rgba(245, 158, 11, 0.15);
border: 1.5px solid rgba(245, 158, 11, 0.3);
color: #f59e0b;
background: hsl(var(--color-warning) / 0.15);
border-color: hsl(var(--color-warning) / 0.3);
color: hsl(var(--color-warning));
}
.variant-info {
background: rgba(59, 130, 246, 0.15);
border: 1.5px solid rgba(59, 130, 246, 0.3);
color: #3b82f6;
background: hsl(var(--color-primary) / 0.15);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
.variant-success {
background: rgba(16, 185, 129, 0.15);
border: 1.5px solid rgba(16, 185, 129, 0.3);
color: #10b981;
background: hsl(var(--color-success) / 0.15);
border-color: hsl(var(--color-success) / 0.3);
color: hsl(var(--color-success));
}
/* Hover state (when item is over the zone) */
:global(.action-zone.mana-drop-target-hover),
:global(.action-zone.action-zone-active) {
transform: translateX(-50%) scale(1.1);
@ -137,33 +131,28 @@
:global(.variant-danger.mana-drop-target-hover),
:global(.variant-danger.action-zone-active) {
background: rgba(239, 68, 68, 0.3);
border-color: rgba(239, 68, 68, 0.6);
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
background: hsl(var(--color-error) / 0.3);
border-color: hsl(var(--color-error) / 0.6);
}
:global(.variant-warning.mana-drop-target-hover),
:global(.variant-warning.action-zone-active) {
background: rgba(245, 158, 11, 0.3);
border-color: rgba(245, 158, 11, 0.6);
box-shadow: 0 0 20px rgba(245, 158, 11, 0.3);
background: hsl(var(--color-warning) / 0.3);
border-color: hsl(var(--color-warning) / 0.6);
}
:global(.variant-info.mana-drop-target-hover),
:global(.variant-info.action-zone-active) {
background: rgba(59, 130, 246, 0.3);
border-color: rgba(59, 130, 246, 0.6);
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3);
background: hsl(var(--color-primary) / 0.3);
border-color: hsl(var(--color-primary) / 0.6);
}
:global(.variant-success.mana-drop-target-hover),
:global(.variant-success.action-zone-active) {
background: rgba(16, 185, 129, 0.3);
border-color: rgba(16, 185, 129, 0.6);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
background: hsl(var(--color-success) / 0.3);
border-color: hsl(var(--color-success) / 0.6);
}
/* Success flash after drop */
:global(.action-zone.mana-drop-target-success),
:global(.action-zone.mana-passive-zone-success) {
animation: action-success 400ms ease-out;
@ -180,24 +169,4 @@
transform: translateX(-50%) scale(1);
}
}
:global(.dark) .variant-danger {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.4);
}
:global(.dark) .variant-warning {
background: rgba(245, 158, 11, 0.2);
border-color: rgba(245, 158, 11, 0.4);
}
:global(.dark) .variant-info {
background: rgba(59, 130, 246, 0.2);
border-color: rgba(59, 130, 246, 0.4);
}
:global(.dark) .variant-success {
background: rgba(16, 185, 129, 0.2);
border-color: rgba(16, 185, 129, 0.4);
}
</style>

View file

@ -6,15 +6,12 @@
* <DragPreview />
*
* It reads from dragState and renders a pill showing what's being dragged.
* For tags: colored dot + tag name.
* For entities: app color dot + item title + app name.
*/
import { dragState } from './drag-state.svelte';
import type { TagDragData } from './types';
interface Props {
/** Resolve display data for a dragged entity. */
resolveEntity?: (
type: string,
data: Record<string, unknown>
@ -55,7 +52,11 @@
<span class="preview-title">{tagData.name}</span>
{:else if entityData()}
{@const entity = entityData()}
<span class="preview-dot" style="background-color: {entity?.color ?? '#6B7280'}"></span>
{#if entity?.color}
<span class="preview-dot" style="background-color: {entity.color}"></span>
{:else}
<span class="preview-dot fallback-dot"></span>
{/if}
<span class="preview-title">{entity?.title}</span>
{#if entity?.appName}
<span class="preview-app">{entity.appName}</span>
@ -76,23 +77,14 @@
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.12);
box-shadow:
0 8px 24px -4px rgba(0, 0, 0, 0.15),
0 2px 6px -1px rgba(0, 0, 0, 0.1);
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
font-size: 0.8125rem;
white-space: nowrap;
max-width: 280px;
transform: scale(1.05);
animation: drag-preview-in 150ms ease-out;
}
:global(.dark) .drag-preview {
background: rgba(30, 30, 30, 0.95);
border-color: rgba(255, 255, 255, 0.15);
font-family: inherit;
}
@keyframes drag-preview-in {
@ -113,18 +105,19 @@
flex-shrink: 0;
}
.fallback-dot {
background: hsl(var(--color-muted-foreground));
}
.preview-title {
font-weight: 600;
color: #1a1a1a;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .preview-title {
color: #e5e5e5;
}
.preview-title.fallback {
color: #6b7280;
color: hsl(var(--color-muted-foreground));
text-transform: capitalize;
font-weight: 500;
}
@ -132,7 +125,7 @@
.preview-app {
font-size: 0.6875rem;
font-weight: 400;
color: #9ca3af;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
</style>

View file

@ -1,184 +0,0 @@
<script lang="ts">
import { Modal } from '../organisms';
import { Keyboard, Hash, X } from '@mana/shared-icons';
import KeyboardShortcutsPanel from './KeyboardShortcutsPanel.svelte';
import SyntaxHelpPanel from './SyntaxHelpPanel.svelte';
import type { HelpModalConfig } from './types';
interface Props {
/** Whether the modal is open */
open: boolean;
/** Close handler */
onClose: () => void;
/** Configuration for the modal content */
config: HelpModalConfig;
}
let { open, onClose, config }: Props = $props();
// Determine which tabs to show
const hasShortcuts = $derived((config.shortcuts?.length ?? 0) > 0);
const hasSyntax = $derived((config.syntax?.length ?? 0) > 0);
const showTabs = $derived(config.showTabs ?? (hasShortcuts && hasSyntax));
// Active tab state
// svelte-ignore state_referenced_locally
let activeTab = $state<'shortcuts' | 'syntax'>(config.defaultTab ?? 'shortcuts');
// Reset to default tab when modal opens
$effect(() => {
if (open) {
activeTab = config.defaultTab ?? 'shortcuts';
}
});
// If only one type is available, show that one
const effectiveTab = $derived(() => {
if (!hasShortcuts) return 'syntax';
if (!hasSyntax) return 'shortcuts';
return activeTab;
});
</script>
<Modal visible={open} {onClose} title="" showHeader={false} maxWidth="md">
<div class="help-modal">
<!-- Header with Tabs -->
<div class="modal-header">
{#if showTabs}
<div class="tabs">
{#if hasShortcuts}
<button
class="tab"
class:active={effectiveTab() === 'shortcuts'}
onclick={() => (activeTab = 'shortcuts')}
>
<Keyboard size={16} weight="bold" />
<span>Tastenkürzel</span>
</button>
{/if}
{#if hasSyntax}
<button
class="tab"
class:active={effectiveTab() === 'syntax'}
onclick={() => (activeTab = 'syntax')}
>
<Hash size={16} weight="bold" />
<span>Syntax</span>
</button>
{/if}
</div>
{:else}
<div class="header-title">
{#if hasShortcuts}
<Keyboard size={18} weight="bold" />
<span>Tastenkürzel</span>
{:else if hasSyntax}
<Hash size={18} weight="bold" />
<span>Syntax-Hilfe</span>
{/if}
</div>
{/if}
<button class="close-btn" onclick={onClose} aria-label="Schließen">
<X size={18} weight="bold" />
</button>
</div>
<!-- Content -->
<div class="modal-content">
{#if effectiveTab() === 'shortcuts' && config.shortcuts}
<KeyboardShortcutsPanel categories={config.shortcuts} />
{:else if effectiveTab() === 'syntax' && config.syntax}
<SyntaxHelpPanel
groups={config.syntax}
showLiveExample={!!config.liveExample}
liveExample={config.liveExample}
/>
{/if}
</div>
</div>
</Modal>
<style>
.help-modal {
margin: -1.5rem;
}
/* Header */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid hsl(var(--color-border));
background: hsl(var(--color-muted) / 0.3);
}
.header-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
/* Tabs */
.tabs {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-lg);
}
.tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
}
.tab:hover {
color: hsl(var(--color-foreground));
}
.tab.active {
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
box-shadow: 0 1px 3px hsl(var(--color-foreground) / 0.1);
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: none;
border-radius: var(--radius-md);
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
}
.close-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
/* Content */
.modal-content {
padding: 1.25rem;
max-height: 70vh;
overflow-y: auto;
}
</style>

View file

@ -1,251 +0,0 @@
<script lang="ts">
import {
Keyboard,
ArrowBendDownLeft,
ArrowsVertical,
X,
Mouse,
Sparkle,
ChatCircle,
NavigationArrow,
} from '@mana/shared-icons';
import type { ShortcutCategory } from './types';
interface Props {
/** Shortcut categories to display */
categories: ShortcutCategory[];
/** Compact mode for smaller displays */
compact?: boolean;
}
let { categories, compact = false }: Props = $props();
// Default icons for common categories
const categoryIcons: Record<string, typeof Keyboard> = {
inputbar: Keyboard,
dialogs: ChatCircle,
navigation: NavigationArrow,
};
// Default icons for common shortcuts based on first key
const shortcutIcons: Record<string, typeof Keyboard> = {
Enter: ArrowBendDownLeft,
Cmd: Sparkle,
Ctrl: Sparkle,
Esc: X,
'↑': ArrowsVertical,
'↓': ArrowsVertical,
Rechtsklick: Mouse,
};
function getShortcutIcon(keys: string[]) {
for (const key of keys) {
if (shortcutIcons[key]) {
return shortcutIcons[key];
}
}
return Keyboard;
}
function getCategoryIcon(category: ShortcutCategory) {
if (category.icon) return category.icon;
return categoryIcons[category.id] || Keyboard;
}
</script>
<div class="shortcuts-panel" class:compact>
{#each categories as category}
{@const CategoryIcon = getCategoryIcon(category)}
<div class="category">
<div class="category-header">
<CategoryIcon size={16} weight="bold" />
<span>{category.title}</span>
</div>
<div class="shortcuts-list">
{#each category.shortcuts as shortcut}
{@const ShortcutIcon = getShortcutIcon(shortcut.keys)}
<div class="shortcut-item">
<div class="shortcut-icon">
<ShortcutIcon size={18} weight="bold" />
</div>
<div class="shortcut-keys">
{#each shortcut.keys as key, i}
{#if i > 0}<span class="key-separator">+</span>{/if}
<kbd>{key}</kbd>
{/each}
{#if shortcut.altKeys && !compact}
<span class="alt-keys">
oder
{#each shortcut.altKeys as key, i}
{#if i > 0}<span class="key-separator">+</span>{/if}
<kbd>{key}</kbd>
{/each}
</span>
{/if}
</div>
<span class="shortcut-desc">{shortcut.description}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
<style>
.shortcuts-panel {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.category {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.category-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.shortcuts-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.shortcut-item {
display: grid;
grid-template-columns: 36px 1fr 1fr;
gap: 0.75rem;
align-items: center;
padding: 0.625rem 0.75rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: var(--radius-md);
transition: background 0.15s ease;
}
.shortcut-item:hover {
background: hsl(var(--color-muted) / 0.5);
}
.shortcut-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
border-radius: var(--radius-sm);
}
.shortcut-keys {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.shortcut-keys kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
font-family: inherit;
color: hsl(var(--color-foreground));
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 6px;
box-shadow:
0 1px 0 1px hsl(var(--color-border)),
0 2px 0 hsl(var(--color-muted));
}
.key-separator {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin: 0 0.1875rem;
}
.alt-keys {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.shortcut-desc {
font-size: 0.9375rem;
color: hsl(var(--color-foreground));
}
/* Compact mode */
.shortcuts-panel.compact .shortcut-item {
grid-template-columns: 24px 1fr;
grid-template-rows: auto auto;
padding: 0.375rem 0.5rem;
}
.shortcuts-panel.compact .shortcut-icon {
grid-row: span 2;
width: 24px;
height: 24px;
}
.shortcuts-panel.compact .shortcut-keys {
grid-column: 2;
}
.shortcuts-panel.compact .shortcut-desc {
grid-column: 2;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.shortcuts-panel.compact .alt-keys {
display: none;
}
/* Responsive */
@media (max-width: 480px) {
.shortcut-item {
grid-template-columns: 24px 1fr;
grid-template-rows: auto auto;
}
.shortcut-icon {
grid-row: span 2;
width: 24px;
height: 24px;
}
.shortcut-keys {
grid-column: 2;
}
.shortcut-desc {
grid-column: 2;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.alt-keys {
display: none;
}
}
</style>

View file

@ -1,415 +0,0 @@
<script lang="ts">
import { Hash, At, Calendar, Clock, ArrowFatLineRight } from '@mana/shared-icons';
import type { SyntaxGroup, SyntaxColor } from './types';
interface Props {
/** Syntax groups to display */
groups: SyntaxGroup[];
/** Show live example at the bottom */
showLiveExample?: boolean;
/** Custom live example */
liveExample?: {
text: string;
highlights: Array<{
type: 'text' | 'tag' | 'reference' | 'date' | 'time' | 'priority';
content: string;
}>;
};
/** Intro text shown at the top */
introText?: string;
/** Compact mode for smaller displays */
compact?: boolean;
}
let {
groups,
showLiveExample = true,
liveExample,
introText = 'Erstelle Einträge mit natürlicher Sprache. Schreibe einfach los die InputBar erkennt automatisch Daten, Zeiten, Tags und mehr.',
compact = false,
}: Props = $props();
// Default icons for common patterns
const patternIcons: Record<string, typeof Hash> = {
'#tag': Hash,
'@name': At,
Datum: Calendar,
Uhrzeit: Clock,
Priorität: ArrowFatLineRight,
};
function getPatternIcon(pattern: string) {
return patternIcons[pattern] || Hash;
}
// Default live example if none provided
const defaultLiveExample = {
text: 'Meeting mit Team morgen 14:00 @arbeit #wichtig',
highlights: [
{ type: 'text' as const, content: 'Meeting mit Team ' },
{ type: 'date' as const, content: 'morgen' },
{ type: 'text' as const, content: ' ' },
{ type: 'time' as const, content: '14:00' },
{ type: 'text' as const, content: ' ' },
{ type: 'reference' as const, content: '@arbeit' },
{ type: 'text' as const, content: ' ' },
{ type: 'tag' as const, content: '#wichtig' },
],
};
const example = $derived(liveExample ?? defaultLiveExample);
</script>
<div class="syntax-panel" class:compact>
{#if introText && !compact}
<p class="intro-text">{introText}</p>
{/if}
{#each groups as group}
<div class="syntax-group">
<h3 class="group-title">{group.title}</h3>
<div class="group-items">
{#each group.items as item}
{@const Icon = item.icon ?? getPatternIcon(item.pattern)}
<div class="syntax-item">
<div class="syntax-icon" data-color={item.color}>
<Icon size={compact ? 16 : 20} weight="bold" />
</div>
<div class="syntax-content">
<div class="syntax-header">
<code class="pattern" data-color={item.color}>{item.pattern}</code>
<span class="syntax-desc">{item.description}</span>
</div>
<div class="syntax-examples">
{#each item.examples as ex}
{#if typeof ex === 'string'}
<span class="example-tag" data-color={item.color}>{ex}</span>
{:else}
<span class="example-tag" data-color={ex.color ?? item.color}>
{ex.text}
{#if ex.label}
<span class="example-label">{ex.label}</span>
{/if}
</span>
{/if}
{/each}
</div>
</div>
</div>
{/each}
</div>
</div>
{/each}
{#if showLiveExample && !compact}
<div class="live-example">
<div class="live-label">Beispiel-Eingabe</div>
<div class="live-input">
{#each example.highlights as hl}
<span class="hl-{hl.type}">{hl.content}</span>
{/each}
</div>
</div>
{/if}
</div>
<style>
.syntax-panel {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.intro-text {
font-size: 0.9375rem;
line-height: 1.5;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.syntax-group {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.group-title {
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.group-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.syntax-item {
display: flex;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--color-muted) / 0.3);
border-radius: var(--radius-md);
transition: background 0.15s ease;
}
.syntax-item:hover {
background: hsl(var(--color-muted) / 0.5);
}
.syntax-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.syntax-icon[data-color='primary'] {
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
}
.syntax-icon[data-color='success'] {
background: hsl(var(--color-success) / 0.15);
color: hsl(var(--color-success));
}
.syntax-icon[data-color='accent'] {
background: hsl(var(--color-accent, 262 83% 58%) / 0.15);
color: hsl(var(--color-accent, 262 83% 58%));
}
.syntax-icon[data-color='error'] {
background: hsl(var(--color-error) / 0.15);
color: hsl(var(--color-error));
}
.syntax-icon[data-color='warning'] {
background: hsl(var(--color-warning, 25 95% 53%) / 0.15);
color: hsl(var(--color-warning, 25 95% 53%));
}
.syntax-icon[data-color='warning-soft'] {
background: hsl(var(--color-warning, 48 96% 53%) / 0.15);
color: hsl(var(--color-warning, 48 96% 53%));
}
.syntax-content {
flex: 1;
min-width: 0;
}
.syntax-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.pattern {
font-size: 0.9375rem;
font-weight: 600;
padding: 0.1875rem 0.5rem;
border-radius: var(--radius-sm);
background: transparent;
}
.pattern[data-color='primary'] {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.15);
}
.pattern[data-color='success'] {
color: hsl(var(--color-success));
background: hsl(var(--color-success) / 0.15);
}
.pattern[data-color='accent'] {
color: hsl(var(--color-accent, 262 83% 58%));
background: hsl(var(--color-accent, 262 83% 58%) / 0.15);
}
.pattern[data-color='error'] {
color: hsl(var(--color-error));
background: hsl(var(--color-error) / 0.15);
}
.pattern[data-color='warning'] {
color: hsl(var(--color-warning, 25 95% 53%));
background: hsl(var(--color-warning, 25 95% 53%) / 0.15);
}
.pattern[data-color='warning-soft'] {
color: hsl(var(--color-warning, 48 96% 53%));
background: hsl(var(--color-warning, 48 96% 53%) / 0.15);
}
.syntax-desc {
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
}
.syntax-examples {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.example-tag {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
}
.example-tag[data-color='primary'] {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.1);
}
.example-tag[data-color='success'] {
color: hsl(var(--color-success));
background: hsl(var(--color-success) / 0.1);
}
.example-tag[data-color='accent'] {
color: hsl(var(--color-accent, 262 83% 58%));
background: hsl(var(--color-accent, 262 83% 58%) / 0.1);
}
.example-tag[data-color='error'] {
color: hsl(var(--color-error));
background: hsl(var(--color-error) / 0.1);
}
.example-tag[data-color='warning'] {
color: hsl(var(--color-warning, 25 95% 53%));
background: hsl(var(--color-warning, 25 95% 53%) / 0.1);
}
.example-tag[data-color='warning-soft'] {
color: hsl(var(--color-warning, 48 96% 53%));
background: hsl(var(--color-warning, 48 96% 53%) / 0.1);
}
.example-label {
font-size: 0.625rem;
opacity: 0.7;
}
/* Live Example */
.live-example {
padding: 1rem;
background: hsl(var(--color-muted) / 0.5);
border-radius: var(--radius-lg);
border: 1px dashed hsl(var(--color-border));
}
.live-label {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.625rem;
}
.live-input {
font-size: 0.9375rem;
font-weight: 500;
line-height: 1.6;
color: hsl(var(--color-foreground));
}
.live-input :global(.hl-text) {
color: hsl(var(--color-foreground));
}
.live-input :global(.hl-tag) {
color: hsl(var(--color-primary));
font-weight: 600;
}
.live-input :global(.hl-reference) {
color: hsl(var(--color-success));
font-weight: 600;
}
.live-input :global(.hl-date),
.live-input :global(.hl-time) {
color: hsl(var(--color-accent, 262 83% 58%));
font-weight: 600;
}
.live-input :global(.hl-priority) {
color: hsl(var(--color-error));
font-weight: 600;
}
/* Compact mode */
.syntax-panel.compact {
gap: 1rem;
}
.syntax-panel.compact .syntax-item {
padding: 0.5rem 0.625rem;
gap: 0.5rem;
}
.syntax-panel.compact .syntax-icon {
width: 28px;
height: 28px;
}
.syntax-panel.compact .syntax-header {
margin-bottom: 0.25rem;
}
.syntax-panel.compact .pattern {
font-size: 0.6875rem;
}
.syntax-panel.compact .syntax-desc {
font-size: 0.75rem;
}
.syntax-panel.compact .example-tag {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
}
/* Responsive */
@media (max-width: 480px) {
.syntax-item {
flex-direction: column;
gap: 0.5rem;
}
.syntax-icon {
width: 28px;
height: 28px;
}
.syntax-header {
flex-wrap: wrap;
}
.live-example {
padding: 0.75rem;
}
.live-input {
font-size: 0.875rem;
}
}
</style>

View file

@ -1,122 +0,0 @@
import type { ShortcutCategory, SyntaxGroup } from './types';
/**
* Common keyboard shortcuts shared across all apps with InputBar
*/
export const COMMON_SHORTCUTS: ShortcutCategory[] = [
{
id: 'inputbar',
title: 'Eingabefeld',
shortcuts: [
{
keys: ['Enter'],
description: 'Auswahl bestätigen / Erstellen',
category: 'inputbar',
},
{
keys: ['Cmd', 'Enter'],
altKeys: ['Ctrl', 'Enter'],
description: 'Direkt erstellen',
category: 'inputbar',
},
{
keys: ['Esc'],
description: 'Schließen & Eingabe löschen',
category: 'inputbar',
},
{
keys: ['↑', '↓'],
description: 'Durch Ergebnisse navigieren',
category: 'inputbar',
},
{
keys: ['Rechtsklick'],
description: 'Einstellungen öffnen',
category: 'inputbar',
},
],
},
{
id: 'dialogs',
title: 'Dialoge',
shortcuts: [
{
keys: ['Esc'],
description: 'Dialog schließen',
category: 'dialogs',
},
],
},
];
/**
* Common syntax patterns shared across all apps with InputBar
*/
export const COMMON_SYNTAX: SyntaxGroup[] = [
{
title: 'Kategorien & Tags',
items: [
{
pattern: '#tag',
description: 'Tag hinzufügen',
examples: ['#arbeit', '#privat', '#wichtig'],
color: 'primary',
},
{
pattern: '@name',
description: 'Kalender oder Projekt zuweisen',
examples: ['@team', '@privat', '@projekt'],
color: 'success',
},
],
},
{
title: 'Zeit & Datum',
items: [
{
pattern: 'Datum',
description: 'Natürliche Datumsangaben',
examples: ['heute', 'morgen', 'montag', 'in 3 tagen', 'nächste woche'],
color: 'accent',
},
{
pattern: 'Uhrzeit',
description: 'Zeitangaben',
examples: ['14:00', '9 uhr', 'um 15:30'],
color: 'accent',
},
],
},
{
title: 'Priorität',
items: [
{
pattern: 'Priorität',
description: 'Dringlichkeit festlegen',
examples: [
{ text: '!!!', label: 'dringend', color: 'error' },
{ text: '!!', label: 'hoch', color: 'warning' },
{ text: '!', label: 'normal', color: 'warning-soft' },
],
color: 'error',
},
],
},
];
/**
* Default live example for syntax highlighting demo
*/
export const DEFAULT_LIVE_EXAMPLE = {
text: 'Meeting mit Team morgen 14:00 @arbeit #wichtig',
highlights: [
{ type: 'text' as const, content: 'Meeting mit Team ' },
{ type: 'date' as const, content: 'morgen' },
{ type: 'text' as const, content: ' ' },
{ type: 'time' as const, content: '14:00' },
{ type: 'text' as const, content: ' ' },
{ type: 'reference' as const, content: '@arbeit' },
{ type: 'text' as const, content: ' ' },
{ type: 'tag' as const, content: '#wichtig' },
],
};

View file

@ -1,18 +0,0 @@
// Help Components
export { default as HelpModal } from './HelpModal.svelte';
export { default as KeyboardShortcutsPanel } from './KeyboardShortcutsPanel.svelte';
export { default as SyntaxHelpPanel } from './SyntaxHelpPanel.svelte';
// Types
export type {
KeyboardShortcut,
ShortcutCategory,
SyntaxColor,
SyntaxExample,
SyntaxPattern,
SyntaxGroup,
HelpModalConfig,
} from './types';
// Constants
export { COMMON_SHORTCUTS, COMMON_SYNTAX, DEFAULT_LIVE_EXAMPLE } from './constants';

View file

@ -1,93 +0,0 @@
import type { Component } from 'svelte';
/**
* Represents a single keyboard shortcut
*/
export interface KeyboardShortcut {
/** Keys to press, e.g. ['Cmd', 'Enter'] or ['↑', '↓'] */
keys: string[];
/** Description of what the shortcut does */
description: string;
/** Category ID for grouping */
category: string;
/** Alternative keys (e.g. Ctrl instead of Cmd) */
altKeys?: string[];
}
/**
* A category/group of related shortcuts
*/
export interface ShortcutCategory {
/** Unique identifier */
id: string;
/** Display title */
title: string;
/** Optional icon component */
icon?: Component;
/** Shortcuts in this category */
shortcuts: KeyboardShortcut[];
}
/**
* Color variants for syntax highlighting
*/
export type SyntaxColor = 'primary' | 'success' | 'accent' | 'error' | 'warning' | 'warning-soft';
/**
* A syntax example - can be a simple string or an object with label
*/
export type SyntaxExample =
| string
| {
text: string;
label?: string;
color?: SyntaxColor;
};
/**
* A syntax pattern that can be used in the InputBar
*/
export interface SyntaxPattern {
/** The pattern syntax, e.g. '#tag', '@name', 'Datum' */
pattern: string;
/** Description of what the pattern does */
description: string;
/** Example usages */
examples: SyntaxExample[];
/** Color for highlighting */
color: SyntaxColor;
/** Optional icon component */
icon?: Component;
}
/**
* A group of related syntax patterns
*/
export interface SyntaxGroup {
/** Group title */
title: string;
/** Patterns in this group */
items: SyntaxPattern[];
}
/**
* Configuration for the HelpModal
*/
export interface HelpModalConfig {
/** Shortcut categories to display */
shortcuts?: ShortcutCategory[];
/** Syntax groups to display */
syntax?: SyntaxGroup[];
/** Whether to show tabs (auto-detected if both shortcuts and syntax are provided) */
showTabs?: boolean;
/** Default tab to show */
defaultTab?: 'shortcuts' | 'syntax';
/** Live example text for syntax highlighting demo */
liveExample?: {
text: string;
highlights: Array<{
type: 'text' | 'tag' | 'reference' | 'date' | 'time' | 'priority';
content: string;
}>;
};
}

View file

@ -1,304 +1,15 @@
// Atoms
export { Text, Button, Badge, Card } from './atoms';
/**
* @mana/shared-ui Vereins-UI-Komponenten, strikte 12-Token-Disziplin.
*
* Detail-Spec: mana/docs/THEMING.md
* Status: PORTING_PLAN.md
*/
// Molecules
export {
Toggle,
Input,
Select,
Textarea,
Checkbox,
FilterDropdown,
FavoriteButton,
ColorPicker,
COLORS_12,
COLORS_16,
DEFAULT_COLOR,
getRandomColor,
ReminderPicker,
} from './molecules';
export type { SelectOption, FilterDropdownOption } from './molecules';
// Stats
export { GlassCard, StatRow } from './molecules';
// Tags
export {
TagBadge,
TagChip,
TagColorPicker,
TagEditModal,
TagSelector,
TagField,
TagList,
TAG_COLORS,
DEFAULT_TAG_COLOR,
getRandomTagColor,
getTagColorByName,
} from './molecules';
export type { Tag, TagData, TagColorName, TagColorHex } from './molecules';
// Media
export { AudioPlayer } from './molecules';
// Loading/Skeletons
export {
SkeletonBox,
SkeletonText,
SkeletonAvatar,
SkeletonRow,
SkeletonList,
SkeletonCard,
SkeletonGrid,
AppLoadingSkeleton,
calculateFadeOpacity,
} from './molecules';
// Feedback
export { EmptyState } from './molecules';
// Contacts
export { ContactAvatar, ContactBadge, ContactSelector } from './molecules';
// Layout
export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './molecules';
// Confirmation (inline popover)
export { ConfirmationPopover } from './molecules';
// Organisms
export { Modal, ConfirmationModal, FormModal, AppSlider, BaseListView } from './organisms';
export type { AppItem } from './organisms';
// Network Graph
export {
NetworkGraph,
NetworkControls,
stringToColor,
getInitials,
SIMULATION_CONFIG,
NODE_CONFIG,
LABEL_CONFIG,
} from './organisms';
export type {
NetworkNode,
NetworkLink,
NetworkTag,
NetworkTransform,
NetworkGraphProps,
NetworkControlsProps,
NetworkGraphResponse,
SimulationNode,
SimulationLink,
} from './organisms';
// Navigation
export {
NavLink,
Navbar,
Sidebar,
SidebarSection,
PillNavigation,
PillDropdown,
PillDropdownBar,
AppDrawer,
GlobalSpotlight,
createGlobalSpotlightState,
PillTabGroup,
PillTagSelector,
PillTimeRangeSelector,
PillViewSwitcher,
PillToolbar,
PillToolbarButton,
PillToolbarDivider,
TagStrip,
ExpandableToolbar,
createAppNavigationStore,
getFavoriteApps,
getRecentApps,
getUsageCounts,
toggleFavoriteApp,
recordAppVisit,
clearRecentApps,
} from './navigation';
export type {
NavItem,
NavbarProps,
SidebarProps,
NavLinkProps,
KeyboardShortcut,
PillNavItem,
PillDropdownItem,
PillNavElement,
PillBarConfig,
PillNavigationProps,
PillTabOption,
PillTabGroupConfig,
PillTagItem,
PillTagSelectorConfig,
ExpandableToolbarProps,
RecentAppEntry,
SpotlightAction,
ContentSearcher,
ContentSearchResult,
ContentSearchGroup,
} from './navigation';
// Settings
export {
SettingsPage,
SettingsSection,
SettingsCard,
SettingsRow,
SettingsToggle,
SettingsSelect,
SettingsNumberInput,
SettingsTimeInput,
SettingsDangerZone,
SettingsDangerButton,
GlobalSettingsSection,
} from './settings';
// Input Bar
export {
InputBar,
QuickInputBar,
InputBarContextMenu,
InputBarHelpModal,
// Recent history
getRecentTags,
getRecentReferences,
addRecentTag,
addRecentReference,
extractAndSaveFromInput,
clearRecentHistory,
createRecentInputHistoryStore,
// Settings
loadInputBarSettings,
saveInputBarSettings,
updateInputBarSetting,
resetInputBarSettings,
createInputBarSettingsStore,
getInputBarSettingsStore,
} from './quick-input';
export type { QuickInputItem, QuickAction, CreatePreview, InputBarSettings } from './quick-input';
// Shared search/command core — highlight patterns, debounce, common helpers.
export { getHighlightPatterns, highlightText, SEARCH_DEBOUNCE_MS } from './search-core';
export type { HighlightPattern } from './search-core';
// Pages
export { default as AppsPage } from './pages/AppsPage.svelte';
export { default as OfflinePage } from './pages/OfflinePage.svelte';
export { default as ProfilePage } from './pages/ProfilePage.svelte';
export type { UserProfile, ProfileActions } from './pages/profile-types';
// Onboarding
export { createAppOnboardingStore } from './onboarding/create-app-onboarding.svelte';
export { default as MiniOnboardingModal } from './onboarding/MiniOnboardingModal.svelte';
export type {
AppOnboardingOption,
AppOnboardingStepType,
AppOnboardingStepBase,
AppOnboardingSelectStep,
AppOnboardingToggleStep,
AppOnboardingInfoStep,
AppOnboardingStep,
AppOnboardingConfig,
AppOnboardingPreferences,
AppOnboardingStore,
MiniOnboardingModalProps,
} from './onboarding/types';
// Charts - Statistics Visualization
export {
StatsGrid,
ActivityHeatmap,
TrendLineChart,
DonutChart,
ProgressBars,
StatisticsSkeleton,
STAT_VARIANT_COLORS,
} from './charts';
export type {
StatVariant,
StatItem,
HeatmapDataPoint,
TrendDataPoint,
DonutSegment,
ProgressItem,
} from './charts';
// Context Menu
export { ContextMenu, createContextMenuState } from './context-menu';
export type { ContextMenuItem, ContextMenuState } from './context-menu';
// Help Components
export {
HelpModal,
KeyboardShortcutsPanel as HelpKeyboardShortcutsPanel,
SyntaxHelpPanel,
COMMON_SHORTCUTS,
COMMON_SYNTAX,
DEFAULT_LIVE_EXAMPLE,
} from './help';
export type {
KeyboardShortcut as HelpKeyboardShortcut,
ShortcutCategory,
SyntaxColor,
SyntaxExample,
SyntaxPattern,
SyntaxGroup,
HelpModalConfig,
} from './help';
// Immersive Mode
export { default as ImmersiveModeToggle } from './components/ImmersiveModeToggle.svelte';
export { default as DevBuildBadge } from './components/DevBuildBadge.svelte';
export { default as SyncIndicator } from './components/SyncIndicator.svelte';
// Toast & Global Error Handling
export {
toastStore,
toast,
handleApiError,
ToastContainer,
setupGlobalErrorHandler,
GLOBAL_ERROR_TRANSLATIONS,
} from './toast';
export type {
Toast,
ToastType,
GlobalErrorHandlerOptions,
GlobalErrorHandlerTranslations,
} from './toast';
// Bottom Stack
export { BottomStack, MinimizedTabs, NotificationBar } from './bottom-stack';
export type { MinimizedPage, MinimizedTabsCallbacks, BottomNotification } from './bottom-stack';
// Actions
export { focusTrap } from './actions';
// Drag & Drop
export {
dragSource,
dropTarget,
passiveDropZone,
dragState,
registerSvelteActionDrag,
clearSvelteActionDrag,
isTypeBeingDragged,
DragPreview,
ActionZone,
} from './dnd';
export type {
DragType,
DragPayload,
TagDragData,
TaskDragData,
DragSourceOptions,
DropTargetOptions,
PassiveDropZoneOptions,
ActionZoneProps,
} from './dnd';
export * from './atoms/index';
export * from './molecules/index';
export * from './navigation/index';
export * from './organisms/index';
export * from './pages/index';
export * from './toast/index';
export * from './dnd/index';
export * from './quick-input/index';

View file

@ -0,0 +1,52 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Checkbox from './Checkbox.svelte';
const { Story } = defineMeta({
title: 'Molecules/Checkbox',
component: Checkbox,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<Checkbox label="Newsletter abonnieren" />
{/snippet}
</Story>
<Story name="Checked">
{#snippet children()}
<Checkbox label="Datenschutz akzeptiert" checked={true} />
{/snippet}
</Story>
<Story name="WithHint">
{#snippet children()}
<Checkbox
label="Verein-Mitgliedschaft anzeigen"
hint="Auf deinem öffentlichen Profil sichtbar."
/>
{/snippet}
</Story>
<Story name="Indeterminate">
{#snippet children()}
<Checkbox label="Alle 12 Karten ausgewählt" indeterminate={true} />
{/snippet}
</Story>
<Story name="Disabled">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:0.5rem;">
<Checkbox label="Disabled (unchecked)" disabled />
<Checkbox label="Disabled (checked)" checked={true} disabled />
</div>
{/snippet}
</Story>
<Story name="Required">
{#snippet children()}
<Checkbox label="AGB akzeptieren" required hint="Pflicht für die Anmeldung." />
{/snippet}
</Story>

View file

@ -1,173 +1,160 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Whether the checkbox is checked */
checked: boolean;
/** Called when checked state changes */
onchange?: (checked: boolean) => void;
/** Label text */
checked?: boolean;
label?: string;
/** Description text below label */
description?: string;
/** Disable the checkbox */
hint?: string;
disabled?: boolean;
/** Show indeterminate state */
required?: boolean;
indeterminate?: boolean;
/** Additional CSS classes */
class?: string;
id?: string;
name?: string;
ariaLabel?: string;
onchange?: (e: Event) => void;
children?: Snippet;
}
let {
checked = $bindable(),
onchange,
checked = $bindable(false),
label,
description,
hint,
disabled = false,
required = false,
indeterminate = false,
class: className = '',
id,
name,
ariaLabel,
onchange,
children,
}: Props = $props();
let inputElement: HTMLInputElement | null = $state(null);
const inputId = $derived(id ?? `checkbox-${Math.random().toString(36).slice(2, 9)}`);
const hintId = $derived(hint ? `${inputId}-hint` : undefined);
$effect(() => {
if (inputElement) {
inputElement.indeterminate = indeterminate;
}
});
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
checked = target.checked;
onchange?.(target.checked);
function handleRef(node: HTMLInputElement) {
node.indeterminate = indeterminate;
$effect(() => {
node.indeterminate = indeterminate;
});
}
</script>
<label class="checkbox-wrapper {disabled ? 'checkbox-wrapper--disabled' : ''} {className}">
<div class="checkbox-input-wrapper">
<input
bind:this={inputElement}
type="checkbox"
{checked}
{disabled}
onchange={handleChange}
class="checkbox-input"
/>
<div class="checkbox-box">
{#if indeterminate}
<svg
class="checkbox-icon"
viewBox="0 0 24 24"
<label class="checkbox" class:disabled for={inputId}>
<input
type="checkbox"
id={inputId}
{name}
{disabled}
{required}
aria-label={ariaLabel}
aria-describedby={hintId}
bind:checked
{onchange}
use:handleRef
/>
<span class="indicator" aria-hidden="true">
{#if indeterminate}
<svg viewBox="0 0 16 16" width="12" height="12">
<path d="M3 8h10" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
</svg>
{:else if checked}
<svg viewBox="0 0 16 16" width="12" height="12">
<path
d="M3 8l3.5 3.5L13 5"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{:else if checked}
<svg
class="checkbox-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</div>
</div>
{#if label || description}
<div class="checkbox-content">
{#if label}
<span class="checkbox-label">{label}</span>
{/if}
{#if description}
<span class="checkbox-description">{description}</span>
{/if}
</div>
{/if}
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</span>
<span class="label-text">
{#if children}
{@render children()}
{:else if label}
{label}
{/if}
{#if required}<span class="required" aria-hidden="true">*</span>{/if}
{#if hint}
<span class="hint" id={hintId}>{hint}</span>
{/if}
</span>
</label>
<style>
.checkbox-wrapper {
display: flex;
.checkbox {
display: inline-flex;
align-items: flex-start;
gap: 0.75rem;
gap: 0.5rem;
cursor: pointer;
font: inherit;
color: hsl(var(--color-foreground));
}
.checkbox-wrapper--disabled {
opacity: 0.5;
.checkbox.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.checkbox-input-wrapper {
position: relative;
flex-shrink: 0;
}
.checkbox-input {
input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
width: 1rem;
height: 1rem;
}
.checkbox-box {
display: flex;
.indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
background-color: hsl(var(--color-surface));
border: 2px solid hsl(var(--color-border-strong));
width: 1rem;
height: 1rem;
flex-shrink: 0;
margin-top: 0.125rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
transition: all 0.15s ease;
color: hsl(var(--color-primary-foreground));
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.checkbox-input:hover:not(:disabled) + .checkbox-box {
input:checked + .indicator,
input:indeterminate + .indicator {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
}
.checkbox-input:focus-visible + .checkbox-box {
outline: 2px solid hsl(var(--color-ring));
input:focus-visible + .indicator {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.checkbox-input:checked + .checkbox-box,
.checkbox-input:indeterminate + .checkbox-box {
background-color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
.label-text {
font-size: 0.9375rem;
line-height: 1.35;
}
.checkbox-icon {
width: 0.875rem;
height: 0.875rem;
stroke: hsl(var(--color-primary-foreground));
.required {
color: hsl(var(--color-error));
margin-left: 0.125rem;
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding-top: 0.0625rem;
}
.checkbox-label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
line-height: 1.25rem;
}
.checkbox-description {
font-size: 0.75rem;
.hint {
display: block;
margin-top: 0.125rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.25;
}
@media (prefers-reduced-motion: reduce) {
.indicator {
transition: none;
}
}
</style>

View file

@ -1,47 +0,0 @@
/**
* Standard color palettes for use with ColorPicker.
*/
/** 12-color palette (same as TAG_COLORS) — good for tags, labels, categories */
export const COLORS_12 = [
'#ef4444',
'#f97316',
'#f59e0b',
'#84cc16',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#ec4899',
'#64748b',
] as const;
/** 16-color extended palette — good for projects, clients, folders */
export const COLORS_16 = [
'#ef4444',
'#f97316',
'#f59e0b',
'#eab308',
'#84cc16',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#0ea5e9',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#d946ef',
'#ec4899',
'#f43f5e',
] as const;
/** Default color (blue) */
export const DEFAULT_COLOR = '#3b82f6';
/** Get a random color from the 12-color palette */
export function getRandomColor(): string {
return COLORS_12[Math.floor(Math.random() * COLORS_12.length)];
}

View file

@ -1,83 +0,0 @@
<script lang="ts">
import { Check } from '@mana/shared-icons';
/**
* Generic color picker with predefined palette.
* Renders a grid of color circles with selection indicator.
*/
interface Props {
/** Available colors (hex strings) */
colors: string[];
/** Currently selected color */
selectedColor?: string;
/** Called when a color is selected */
onColorChange: (color: string) => void;
/** Button size */
size?: 'sm' | 'md' | 'lg';
/** Accessible label for the group */
label?: string;
}
let {
colors,
selectedColor,
onColorChange,
size = 'md',
label = 'Farbe wählen',
}: Props = $props();
const sizeClasses = {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-10 h-10',
};
const iconSizes = {
sm: 12,
md: 14,
lg: 18,
};
const gapClasses = {
sm: 'gap-1.5',
md: 'gap-2',
lg: 'gap-2.5',
};
function handleKeyDown(e: KeyboardEvent, hex: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onColorChange(hex);
}
}
</script>
<div class="flex flex-wrap {gapClasses[size]}" role="radiogroup" aria-label={label}>
{#each colors as color}
{@const isSelected = selectedColor?.toLowerCase() === color.toLowerCase()}
<button
type="button"
class="
{sizeClasses[size]}
rounded-full
flex items-center justify-center
transition-all duration-150
ring-offset-2 ring-offset-white dark:ring-offset-gray-900
focus:outline-none focus:ring-2 focus:ring-primary
{isSelected ? 'ring-2 ring-black/30 dark:ring-white/50 scale-110' : 'hover:scale-110'}
"
style="background-color: {color}"
onclick={() => onColorChange(color)}
onkeydown={(e) => handleKeyDown(e, color)}
role="radio"
aria-checked={isSelected}
aria-label={color}
title={color}
>
{#if isSelected}
<Check size={iconSizes[size]} weight="bold" class="text-white drop-shadow-sm" />
{/if}
</button>
{/each}
</div>

View file

@ -1,69 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import ColorPicker from './ColorPicker.svelte';
import { COLORS_12, COLORS_16, DEFAULT_COLOR, getRandomColor } from './ColorPicker.constants';
const testColors = ['#ef4444', '#3b82f6', '#22c55e'];
describe('ColorPicker', () => {
it('renders all provided colors as radio buttons', () => {
render(ColorPicker, { props: { colors: testColors, onColorChange: vi.fn() } });
const radios = screen.getAllByRole('radio');
expect(radios).toHaveLength(3);
});
it('marks selected color as checked', () => {
render(ColorPicker, {
props: { colors: testColors, selectedColor: '#3b82f6', onColorChange: vi.fn() },
});
const blue = screen.getByRole('radio', { name: '#3b82f6' });
expect(blue.getAttribute('aria-checked')).toBe('true');
});
it('calls onColorChange when clicked', async () => {
const onColorChange = vi.fn();
render(ColorPicker, { props: { colors: testColors, onColorChange } });
await fireEvent.click(screen.getByRole('radio', { name: '#22c55e' }));
expect(onColorChange).toHaveBeenCalledWith('#22c55e');
});
it('supports keyboard selection', async () => {
const onColorChange = vi.fn();
render(ColorPicker, { props: { colors: testColors, onColorChange } });
await fireEvent.keyDown(screen.getByRole('radio', { name: '#ef4444' }), { key: 'Enter' });
expect(onColorChange).toHaveBeenCalledWith('#ef4444');
});
it('has accessible radiogroup role', () => {
render(ColorPicker, { props: { colors: testColors, onColorChange: vi.fn() } });
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
});
it('uses custom label', () => {
render(ColorPicker, {
props: { colors: testColors, onColorChange: vi.fn(), label: 'Pick a color' },
});
expect(screen.getByRole('radiogroup', { name: 'Pick a color' })).toBeInTheDocument();
});
});
describe('Color constants', () => {
it('COLORS_12 has 12 entries', () => {
expect(COLORS_12).toHaveLength(12);
});
it('COLORS_16 has 16 entries', () => {
expect(COLORS_16).toHaveLength(16);
});
it('DEFAULT_COLOR is blue', () => {
expect(DEFAULT_COLOR).toBe('#3b82f6');
});
it('getRandomColor returns a color from COLORS_12', () => {
const validColors = new Set(COLORS_12);
for (let i = 0; i < 30; i++) {
expect(validColors.has(getRandomColor() as (typeof COLORS_12)[number])).toBe(true);
}
});
});

View file

@ -1,415 +0,0 @@
<script lang="ts">
/**
* ConfirmationPopover - Inline confirmation dialog
*
* A wrapper component that shows a confirmation popover directly at the
* trigger element position, minimizing mouse travel for quick confirmations.
* Uses a portal to escape parent overflow constraints.
*
* @example Delete confirmation
* ```svelte
* <ConfirmationPopover
* onConfirm={handleDelete}
* variant="danger"
* title="Löschen?"
* confirmLabel="Löschen"
* >
* <button class="delete-btn">🗑️</button>
* </ConfirmationPopover>
* ```
*/
import type { Snippet } from 'svelte';
import { Trash, Warning, Check, X } from '@mana/shared-icons';
type ConfirmationVariant = 'danger' | 'warning' | 'info';
type Placement = 'top' | 'bottom' | 'left' | 'right';
interface Props {
/** Trigger element (usually a button) */
children: Snippet;
/** Called when user confirms the action */
onConfirm: () => void | Promise<void>;
/** Visual variant */
variant?: ConfirmationVariant;
/** Popover title */
title?: string;
/** Optional message */
message?: string;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
/** Whether confirm action is in progress */
loading?: boolean;
/** Preferred placement */
placement?: Placement;
/** Disabled state - prevents popover from opening */
disabled?: boolean;
}
let {
children,
onConfirm,
variant = 'danger',
title = 'Bestätigen?',
message,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
loading = false,
placement = 'bottom',
disabled = false,
}: Props = $props();
let visible = $state(false);
let triggerRef = $state<HTMLDivElement | null>(null);
let popoverRef = $state<HTMLDivElement | null>(null);
let confirmBtnRef = $state<HTMLButtonElement | null>(null);
let popoverPosition = $state({ top: 0, left: 0 });
// Portal action - moves element to body to escape overflow constraints
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
const variantConfig: Record<
ConfirmationVariant,
{
iconColor: string;
iconBg: string;
buttonColor: string;
buttonHover: string;
borderColor: string;
}
> = {
danger: {
iconColor: 'text-red-500',
iconBg: 'bg-red-500/10',
buttonColor: 'bg-red-500 text-white',
buttonHover: 'hover:bg-red-600',
borderColor: 'border-red-500/20',
},
warning: {
iconColor: 'text-yellow-500',
iconBg: 'bg-yellow-500/10',
buttonColor: 'bg-yellow-500 text-white',
buttonHover: 'hover:bg-yellow-600',
borderColor: 'border-yellow-500/20',
},
info: {
iconColor: 'text-blue-500',
iconBg: 'bg-blue-500/10',
buttonColor: 'bg-blue-500 text-white',
buttonHover: 'hover:bg-blue-600',
borderColor: 'border-blue-500/20',
},
};
const config = $derived(variantConfig[variant]);
function handleTriggerClick(e: MouseEvent) {
if (disabled || loading) return;
e.stopPropagation();
// Get position from the clicked element
const target = e.currentTarget as HTMLElement;
if (target) {
const rect = target.getBoundingClientRect();
calculatePosition(rect);
}
visible = true;
// Focus confirm button after popover appears
requestAnimationFrame(() => {
confirmBtnRef?.focus();
});
}
function calculatePosition(rect: DOMRect) {
if (rect.width === 0 && rect.height === 0) return;
const popoverWidth = 240;
const popoverHeight = 120;
const gap = 8;
let top = 0;
let left = 0;
switch (placement) {
case 'top':
top = rect.top - popoverHeight - gap;
left = rect.left + rect.width / 2 - popoverWidth / 2;
break;
case 'bottom':
top = rect.bottom + gap;
left = rect.left + rect.width / 2 - popoverWidth / 2;
break;
case 'left':
top = rect.top + rect.height / 2 - popoverHeight / 2;
left = rect.left - popoverWidth - gap;
break;
case 'right':
top = rect.top + rect.height / 2 - popoverHeight / 2;
left = rect.right + gap;
break;
}
// Keep within viewport bounds
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (left < 8) left = 8;
if (left + popoverWidth > viewportWidth - 8) left = viewportWidth - popoverWidth - 8;
if (top < 8) top = 8;
if (top + popoverHeight > viewportHeight - 8) {
if (placement === 'bottom') {
top = rect.top - popoverHeight - gap;
}
}
popoverPosition = { top, left };
}
function handleCancel() {
if (loading) return;
visible = false;
}
async function handleConfirm() {
try {
await onConfirm();
visible = false;
} catch {
// Keep popover open on error
}
}
function handleKeydown(e: KeyboardEvent) {
if (!visible) return;
if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
} else if (e.key === 'Enter' && !loading) {
e.preventDefault();
handleConfirm();
}
}
function handleClickOutside(e: MouseEvent) {
if (!visible || loading) return;
const target = e.target as Node;
// Check if click is inside trigger or popover
if (triggerRef?.contains(target)) return;
if (popoverRef?.contains(target)) return;
visible = false;
}
</script>
<svelte:window onkeydown={handleKeydown} onclick={handleClickOutside} />
<!-- Trigger wrapper -->
<div
class="confirmation-popover-trigger"
bind:this={triggerRef}
onclick={handleTriggerClick}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleTriggerClick(e as unknown as MouseEvent);
}}
role="button"
tabindex="0"
>
{@render children()}
</div>
<!-- Portal: Popover rendered to body to escape overflow constraints -->
{#if visible}
<div
use:portal
class="confirmation-popover {config.borderColor}"
bind:this={popoverRef}
style="position: fixed; top: {popoverPosition.top}px; left: {popoverPosition.left}px; z-index: 999999;"
role="dialog"
aria-modal="true"
aria-label={title}
>
<!-- Content -->
<div class="popover-content">
<div class="popover-header">
<div class="popover-icon {config.iconBg} {config.iconColor}">
{#if variant === 'danger'}
<Trash size={16} weight="bold" />
{:else if variant === 'warning'}
<Warning size={16} weight="bold" />
{:else}
<Check size={16} weight="bold" />
{/if}
</div>
<span class="popover-title">{title}</span>
</div>
{#if message}
<p class="popover-message">{message}</p>
{/if}
<div class="popover-actions">
<button type="button" class="btn-cancel" onclick={handleCancel} disabled={loading}>
<X size={14} weight="bold" />
{cancelLabel}
</button>
<button
type="button"
class="btn-confirm {config.buttonColor} {config.buttonHover}"
onclick={handleConfirm}
disabled={loading}
bind:this={confirmBtnRef}
>
{#if loading}
<svg class="spinner" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
{:else if variant === 'danger'}
<Trash size={14} weight="bold" />
{:else}
<Check size={14} weight="bold" />
{/if}
{confirmLabel}
</button>
</div>
</div>
</div>
{/if}
<style>
.confirmation-popover-trigger {
display: inline-flex;
}
.confirmation-popover {
min-width: 220px;
max-width: 280px;
background: var(--color-surface-elevated-3);
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-lg, 12px);
box-shadow:
0 10px 25px -5px rgb(0 0 0 / 0.15),
0 8px 10px -6px rgb(0 0 0 / 0.1);
animation: popoverIn 150ms ease-out;
}
@keyframes popoverIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.popover-content {
padding: 12px;
}
.popover-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.popover-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md, 8px);
flex-shrink: 0;
}
.popover-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.popover-message {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
margin: 0 0 12px;
line-height: 1.4;
}
.popover-actions {
display: flex;
gap: 8px;
}
.btn-cancel,
.btn-confirm {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 12px;
font-size: 0.8125rem;
font-weight: 500;
border-radius: var(--radius-md, 8px);
border: none;
cursor: pointer;
transition: all 150ms;
}
.btn-cancel {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.btn-cancel:hover:not(:disabled) {
background: hsl(var(--color-muted));
}
.btn-cancel:disabled,
.btn-confirm:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 14px;
height: 14px;
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,50 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import ContactAvatar from './ContactAvatar.svelte';
const { Story } = defineMeta({
title: 'Molecules/ContactAvatar',
component: ContactAvatar,
tags: ['autodocs'],
});
</script>
<Story name="Initials">
{#snippet children()}
<ContactAvatar name="Till Zimmer" />
{/snippet}
</Story>
<Story name="Email">
{#snippet children()}
<ContactAvatar email="anna.maier@beispiel.de" />
{/snippet}
</Story>
<Story name="Placeholder">
{#snippet children()}
<ContactAvatar />
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; gap:0.75rem; align-items:center;">
<ContactAvatar name="Till Zimmer" size="xs" />
<ContactAvatar name="Till Zimmer" size="sm" />
<ContactAvatar name="Till Zimmer" size="md" />
<ContactAvatar name="Till Zimmer" size="lg" />
<ContactAvatar name="Till Zimmer" size="xl" />
</div>
{/snippet}
</Story>
<Story name="WithStatus">
{#snippet children()}
<div style="display:flex; gap:1rem; align-items:center;">
<ContactAvatar name="Till Zimmer" status="online" size="lg" />
<ContactAvatar name="Anna Maier" status="busy" size="lg" />
<ContactAvatar name="Lukas Berg" status="offline" size="lg" />
</div>
{/snippet}
</Story>

View file

@ -0,0 +1,133 @@
<script lang="ts">
import DynamicIcon from '../atoms/DynamicIcon.svelte';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
type Status = null | 'online' | 'busy' | 'offline';
interface Props {
name?: string;
email?: string;
imageUrl?: string;
size?: Size;
status?: Status;
ariaLabel?: string;
}
let { name, email, imageUrl, size = 'md', status = null, ariaLabel }: Props = $props();
const initials = $derived.by(() => {
const source = (name ?? email ?? '').trim();
if (!source) return '';
// Bei Email: lokalen Teil nehmen, sonst Wort-Anfänge
const base = source.includes('@') ? source.split('@')[0] : source;
const parts = base.split(/[\s._-]+/).filter(Boolean);
if (parts.length === 0) return '';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
});
const label = $derived(ariaLabel ?? name ?? email ?? 'Avatar');
</script>
<span class="avatar size-{size}" aria-label={label} title={label}>
{#if imageUrl}
<img src={imageUrl} alt="" loading="lazy" />
{:else if initials}
<span class="initials" aria-hidden="true">{initials}</span>
{:else}
<span class="placeholder" aria-hidden="true">
<DynamicIcon name="user" size={size === 'xs' || size === 'sm' ? 'xs' : 'sm'} />
</span>
{/if}
{#if status}
<span class="status status-{status}" aria-label={status}></span>
{/if}
</span>
<style>
.avatar {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 9999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
font-family: inherit;
font-weight: 600;
text-transform: uppercase;
overflow: visible;
}
.size-xs {
width: 1.25rem;
height: 1.25rem;
font-size: 0.5625rem;
}
.size-sm {
width: 1.75rem;
height: 1.75rem;
font-size: 0.6875rem;
}
.size-md {
width: 2.25rem;
height: 2.25rem;
font-size: 0.8125rem;
}
.size-lg {
width: 3rem;
height: 3rem;
font-size: 1rem;
}
.size-xl {
width: 4rem;
height: 4rem;
font-size: 1.25rem;
}
img {
width: 100%;
height: 100%;
border-radius: inherit;
object-fit: cover;
display: block;
}
.initials,
.placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: inherit;
}
.placeholder {
color: hsl(var(--color-muted-foreground));
}
.status {
position: absolute;
bottom: 0;
right: 0;
width: 30%;
height: 30%;
min-width: 0.5rem;
min-height: 0.5rem;
border-radius: 50%;
border: 2px solid hsl(var(--color-background));
}
.status-online {
background: hsl(var(--color-success));
}
.status-busy {
background: hsl(var(--color-error));
}
.status-offline {
background: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,53 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import DataCard from './DataCard.svelte';
import DynamicIcon from '../atoms/DynamicIcon.svelte';
const { Story } = defineMeta({
title: 'Molecules/DataCard',
component: DataCard,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<div style="width:14rem;">
<DataCard label="Reviews heute" value={42} />
</div>
{/snippet}
</Story>
<Story name="WithChange">
{#snippet children()}
<div style="display:grid; grid-template-columns:repeat(3, 14rem); gap:1rem;">
<DataCard label="Reviews heute" value={42} change="+12" trend="up" hint="vs gestern" />
<DataCard label="Streak" value="7 Tage" change="1" trend="down" hint="vs Ziel" />
<DataCard label="Karten gelernt" value={234} change="±0" trend="flat" hint="diese Woche" />
</div>
{/snippet}
</Story>
<Story name="WithIcon">
{#snippet children()}
<div style="width:14rem;">
<DataCard label="Stapel" value={12} hint="3 mit fälligen Karten">
{#snippet icon()}<DynamicIcon name="tag" size="sm" />{/snippet}
</DataCard>
</div>
{/snippet}
</Story>
<Story name="LongLabel">
{#snippet children()}
<div style="width:14rem;">
<DataCard
label="Durchschnittliche Antwortzeit"
value="2.4s"
change="0.3s"
trend="up"
hint="schneller als Vorwoche"
/>
</div>
{/snippet}
</Story>

View file

@ -1,161 +1,109 @@
<script lang="ts">
/**
* DataCard - Generic card for displaying data items
*
* Used for displaying items like memos, decks, blueprints, etc.
* Provides consistent layout with title, description, metadata, and actions.
*
* @example Basic usage
* ```svelte
* <DataCard
* title="My Deck"
* description="A collection of flashcards"
* onclick={() => openDeck(deck.id)}
* />
* ```
*
* @example With metadata and actions
* ```svelte
* <DataCard title={memo.title} description={memo.summary}>
* {#snippet metadata()}
* <span>5 min ago</span>
* <Badge>Audio</Badge>
* {/snippet}
* {#snippet actions()}
* <Button variant="ghost" size="sm" onclick={edit}>Edit</Button>
* <Button variant="ghost" size="sm" onclick={del}>Delete</Button>
* {/snippet}
* </DataCard>
* ```
*/
import type { Snippet } from 'svelte';
import { Text } from '../atoms';
import DynamicIcon from '../atoms/DynamicIcon.svelte';
type CardVariant = 'default' | 'elevated' | 'outlined' | 'ghost';
type Trend = 'up' | 'down' | 'flat';
interface Props {
/** Card title */
title: string;
/** Card description/subtitle */
description?: string;
/** Card variant */
variant?: CardVariant;
/** Whether card is interactive (clickable) */
interactive?: boolean;
/** Click handler */
onclick?: () => void;
/** Icon/thumbnail snippet (left side) */
label: string;
value: string | number;
hint?: string;
change?: string;
trend?: Trend;
icon?: Snippet;
/** Metadata snippet (below description) */
metadata?: Snippet;
/** Actions snippet (right side or bottom) */
actions?: Snippet;
/** Badge/status snippet (top right) */
badge?: Snippet;
/** Additional CSS classes */
class?: string;
}
let {
title,
description,
variant = 'default',
interactive = false,
onclick,
icon,
metadata,
actions,
badge,
class: className = '',
}: Props = $props();
const variantClasses: Record<CardVariant, string> = {
default: 'bg-menu border border-theme',
elevated: 'bg-menu border border-theme shadow-md',
outlined: 'bg-transparent border-2 border-theme',
ghost: 'bg-transparent border-transparent hover:bg-menu-hover',
};
const isClickable = $derived(interactive || !!onclick);
let { label, value, hint, change, trend, icon }: Props = $props();
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="data-card rounded-xl p-4 transition-colors {variantClasses[variant]} {isClickable
? 'cursor-pointer hover:bg-menu-hover'
: ''} {className}"
{onclick}
onkeydown={(e) => {
if (isClickable && onclick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onclick();
}
}}
role={isClickable ? 'button' : undefined}
tabindex={isClickable ? 0 : undefined}
>
<div class="flex items-start gap-3">
<!-- Icon/Thumbnail -->
{#if icon}
<div class="data-card__icon flex-shrink-0">
{@render icon()}
</div>
{/if}
<!-- Content -->
<div class="data-card__content flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<!-- Title -->
<Text variant="body" weight="semibold" class="truncate">
{title}
</Text>
<!-- Description -->
{#if description}
<Text variant="muted" class="mt-1 line-clamp-2">
{description}
</Text>
<article class="data-card">
<header class="head">
<span class="label">{label}</span>
{#if icon}<span class="icon-slot">{@render icon()}</span>{/if}
</header>
<p class="value">{value}</p>
{#if change || hint}
<footer class="foot">
{#if change}
<span class="change trend-{trend ?? 'flat'}">
{#if trend === 'up'}
<DynamicIcon name="caret-up" size="xs" />
{:else if trend === 'down'}
<DynamicIcon name="caret-down" size="xs" />
{/if}
</div>
<!-- Badge -->
{#if badge}
<div class="data-card__badge flex-shrink-0">
{@render badge()}
</div>
{/if}
</div>
<!-- Metadata -->
{#if metadata}
<div class="data-card__metadata mt-2 flex items-center gap-2 text-sm text-theme-secondary">
{@render metadata()}
</div>
{change}
</span>
{/if}
</div>
<!-- Actions -->
{#if actions}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="data-card__actions flex-shrink-0 flex items-center gap-1"
onclick={(e) => e.stopPropagation()}
role="none"
>
{@render actions()}
</div>
{/if}
</div>
</div>
{#if hint}<span class="hint">{hint}</span>{/if}
</footer>
{/if}
</article>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
.data-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem 1.125rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
font-family: inherit;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.label {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.icon-slot {
color: hsl(var(--color-muted-foreground));
display: inline-flex;
}
.value {
margin: 0;
font-size: 1.875rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
line-height: 1.1;
}
.foot {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
font-size: 0.8125rem;
}
.change {
display: inline-flex;
align-items: center;
gap: 0.125rem;
font-weight: 500;
}
.trend-up {
color: hsl(var(--color-success));
}
.trend-down {
color: hsl(var(--color-error));
}
.trend-flat {
color: hsl(var(--color-muted-foreground));
}
.hint {
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,99 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Text from '../atoms/Text.svelte';
import Button from '../atoms/Button.svelte';
import DynamicIcon from '../atoms/DynamicIcon.svelte';
type Variant = 'default' | 'compact' | 'centered';
interface Props {
title: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
secondaryActionLabel?: string;
onSecondaryAction?: () => void;
variant?: Variant;
icon?: Snippet;
}
let {
title,
message,
actionLabel,
onAction,
secondaryActionLabel,
onSecondaryAction,
variant = 'default',
icon,
}: Props = $props();
</script>
<div class="empty-state variant-{variant}">
<div class="icon-wrap">
{#if icon}
{@render icon()}
{:else}
<DynamicIcon name="search" size="lg" ariaLabel="Leer" />
{/if}
</div>
<Text as="p" weight="semibold">{title}</Text>
{#if message}
<p class="message">{message}</p>
{/if}
{#if actionLabel || secondaryActionLabel}
<div class="actions">
{#if secondaryActionLabel && onSecondaryAction}
<Button variant="ghost" onclick={onSecondaryAction}>{secondaryActionLabel}</Button>
{/if}
{#if actionLabel && onAction}
<Button variant="primary" onclick={onAction}>{actionLabel}</Button>
{/if}
</div>
{/if}
</div>
<style>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
font-family: inherit;
}
.empty-state.variant-default {
padding: 3rem 1.5rem;
}
.empty-state.variant-compact {
padding: 1.5rem 1rem;
}
.empty-state.variant-centered {
padding: 4rem 2rem;
}
.icon-wrap {
margin-bottom: 1rem;
color: hsl(var(--color-muted-foreground));
opacity: 0.6;
}
.message {
margin: 0.5rem 0 1rem 0;
max-width: 24rem;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
</style>

View file

@ -1,38 +1,24 @@
<script lang="ts">
import { Heart, Star, PushPin } from '@mana/shared-icons';
import DynamicIcon from '../atoms/DynamicIcon.svelte';
/**
* Reusable favorite/pin toggle button.
* Renders a heart, star, or pin icon that toggles between filled and outline.
*/
type Variant = 'heart' | 'star' | 'pin';
interface Props {
active: boolean;
onclick: () => void;
/** Icon variant */
variant?: 'heart' | 'star' | 'pin';
/** Icon size in pixels */
size?: number;
/** Active color (CSS color) */
activeColor?: string;
/** Inactive color (CSS color) */
inactiveColor?: string;
/** Extra CSS classes on the button */
class?: string;
/** Accessible label */
variant?: Variant;
size?: 'sm' | 'md' | number;
label?: string;
/** v0.1.x-Compat: aktive Farbe (heute ignoriert — Token primary). */
activeColor?: string;
/** v0.1.x-Compat. */
class?: string;
}
let {
active,
onclick,
variant = 'heart',
size = 18,
activeColor = variant === 'pin' ? 'var(--color-primary, #3b82f6)' : '#ef4444',
inactiveColor = 'currentColor',
class: className = '',
label,
}: Props = $props();
let { active, onclick, variant = 'heart', size = 'md', label }: Props = $props();
const normalizedSize = $derived<'sm' | 'md'>(
typeof size === 'number' ? (size < 16 ? 'sm' : 'md') : size
);
const defaultLabel = $derived(
variant === 'pin'
@ -44,21 +30,63 @@
: 'Favorit'
);
const icons = { heart: Heart, star: Star, pin: PushPin };
const Icon = $derived(icons[variant]);
const iconName = $derived.by(() => {
if (active) return `${variant}-fill` as const;
return variant;
});
const iconSize = $derived(size === 'sm' ? ('sm' as const) : ('md' as const));
</script>
<button
type="button"
{onclick}
class="inline-flex items-center justify-center rounded-md p-1 transition-colors hover:bg-black/5 dark:hover:bg-white/10 {className}"
class="favorite-btn size-{normalizedSize}"
class:active
aria-label={label ?? defaultLabel}
aria-pressed={active}
title={label ?? defaultLabel}
>
<Icon
{size}
weight={active ? 'fill' : 'regular'}
class="transition-colors"
style="color: {active ? activeColor : inactiveColor}"
/>
<DynamicIcon name={iconName} size={iconSize} />
</button>
<style>
.favorite-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: transparent;
border: none;
border-radius: 0.375rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition:
background-color 120ms,
color 120ms;
font-family: inherit;
}
.favorite-btn.size-sm {
padding: 0.125rem;
}
.favorite-btn:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.favorite-btn:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.favorite-btn.active {
color: hsl(var(--color-primary));
}
.favorite-btn.active:hover {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
}
</style>

View file

@ -1,47 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import FavoriteButton from './FavoriteButton.svelte';
describe('FavoriteButton', () => {
it('renders with heart icon by default', () => {
const { container } = render(FavoriteButton, {
props: { active: false, onclick: vi.fn() },
});
expect(container.querySelector('button')).toBeInTheDocument();
});
it('has correct aria-label when inactive', () => {
render(FavoriteButton, {
props: { active: false, onclick: vi.fn() },
});
expect(screen.getByRole('button', { name: 'Favorit' })).toBeInTheDocument();
});
it('has correct aria-label when active', () => {
render(FavoriteButton, {
props: { active: true, onclick: vi.fn() },
});
expect(screen.getByRole('button', { name: 'Favorit entfernen' })).toBeInTheDocument();
});
it('calls onclick when clicked', async () => {
const onclick = vi.fn();
render(FavoriteButton, { props: { active: false, onclick } });
await fireEvent.click(screen.getByRole('button'));
expect(onclick).toHaveBeenCalledOnce();
});
it('uses pin labels for pin variant', () => {
render(FavoriteButton, {
props: { active: false, onclick: vi.fn(), variant: 'pin' },
});
expect(screen.getByRole('button', { name: 'Anpinnen' })).toBeInTheDocument();
});
it('uses custom label when provided', () => {
render(FavoriteButton, {
props: { active: false, onclick: vi.fn(), label: 'Custom' },
});
expect(screen.getByRole('button', { name: 'Custom' })).toBeInTheDocument();
});
});

View file

@ -1,716 +0,0 @@
<script lang="ts">
import { tick } from 'svelte';
import { fly } from 'svelte/transition';
import type { FilterDropdownOption } from './FilterDropdown.types';
interface Props {
/** Available options */
options: FilterDropdownOption[];
/** Current selected value(s) - string for single, string[] for multi */
value: string | string[] | null;
/** Callback when selection changes */
onChange: (value: string | string[] | null) => void;
/** Placeholder text when no value selected */
placeholder?: string;
/** Enable multi-select mode with checkboxes */
multiSelect?: boolean;
/** Force searchable mode (auto-enabled at 8+ options) */
searchable?: boolean;
/** Dropdown direction */
direction?: 'up' | 'down';
/** Embedded mode for toolbar usage (smaller, no shadow) */
embedded?: boolean;
/** Max dropdown height */
maxHeight?: string;
/** Disabled state */
disabled?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
options,
value,
onChange,
placeholder = 'Select...',
multiSelect = false,
searchable = false,
direction = 'down',
embedded = false,
maxHeight = '300px',
disabled = false,
class: className = '',
}: Props = $props();
// State
let isOpen = $state(false);
let searchQuery = $state('');
let triggerRef: HTMLButtonElement | undefined = $state();
let searchInputRef: HTMLInputElement | undefined = $state();
let dropdownPosition = $state({ top: 0, left: 0, width: 0 });
let focusedIndex = $state(-1);
// Auto-searchable at 8+ options
let showSearch = $derived(searchable || options.length >= 8);
// Filtered options based on search
let filteredOptions = $derived(
searchQuery
? options.filter(
(o) => !o.divider && o.label.toLowerCase().includes(searchQuery.toLowerCase())
)
: options
);
// Group options by group property
let groupedOptions = $derived.by(() => {
const groups = new Map<string | null, FilterDropdownOption[]>();
for (const opt of filteredOptions) {
if (opt.divider) continue;
const key = opt.group || null;
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(opt);
}
return groups;
});
// Flat list of selectable options for keyboard navigation
let selectableOptions = $derived(filteredOptions.filter((o) => !o.divider && !o.disabled));
// Display label for trigger
let displayLabel = $derived.by(() => {
if (multiSelect && Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
const opt = options.find((o) => o.value === value[0]);
return opt?.label || placeholder;
}
return `${value.length} ausgewählt`;
}
if (typeof value === 'string' && value) {
const selected = options.find((o) => o.value === value);
return selected?.label || placeholder;
}
return placeholder;
});
// Is active (has non-default value)
let isActive = $derived(
multiSelect
? Array.isArray(value) && value.length > 0
: value !== null && value !== '' && value !== undefined
);
// Portal action
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
// Update dropdown position
async function updatePosition() {
if (!triggerRef) return;
await tick();
const rect = triggerRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
let top: number;
if (direction === 'up') {
top = rect.top - 8;
} else {
top = rect.bottom + 8;
}
// Adjust if would go off-screen
const dropdownHeight = 250; // Approximate
if (direction === 'down' && top + dropdownHeight > viewportHeight - 20) {
top = rect.top - dropdownHeight - 8;
}
dropdownPosition = {
top,
left: rect.left,
width: Math.max(rect.width, 160),
};
}
function open() {
if (disabled) return;
isOpen = true;
searchQuery = '';
focusedIndex = -1;
updatePosition();
// Focus search input if shown
tick().then(() => {
if (showSearch && searchInputRef) {
searchInputRef.focus();
}
});
}
function close() {
isOpen = false;
searchQuery = '';
focusedIndex = -1;
}
function toggle() {
if (isOpen) {
close();
} else {
open();
}
}
function isSelected(optionValue: string): boolean {
if (multiSelect && Array.isArray(value)) {
return value.includes(optionValue);
}
return value === optionValue;
}
function select(option: FilterDropdownOption) {
if (option.disabled) return;
if (multiSelect) {
const currentValue = Array.isArray(value) ? value : [];
if (currentValue.includes(option.value)) {
// Remove
const newValue = currentValue.filter((v) => v !== option.value);
onChange(newValue.length > 0 ? newValue : null);
} else {
// Add
onChange([...currentValue, option.value]);
}
} else {
// Single select - also allow deselecting by clicking same option
if (value === option.value) {
onChange(null);
} else {
onChange(option.value);
}
close();
}
}
function handleKeydown(e: KeyboardEvent) {
if (!isOpen) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
open();
}
return;
}
switch (e.key) {
case 'Escape':
e.preventDefault();
close();
triggerRef?.focus();
break;
case 'ArrowDown':
e.preventDefault();
focusedIndex = Math.min(focusedIndex + 1, selectableOptions.length - 1);
break;
case 'ArrowUp':
e.preventDefault();
focusedIndex = Math.max(focusedIndex - 1, 0);
break;
case 'Enter':
case ' ':
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < selectableOptions.length) {
select(selectableOptions[focusedIndex]);
}
break;
case 'Tab':
close();
break;
}
}
// Icon paths for common icons
const iconPaths: Record<string, string> = {
check: 'M5 13l4 4L19 7',
chevronDown: 'M19 9l-7 7-7-7',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
x: 'M6 18L18 6M6 6l12 12',
};
</script>
<div class="filter-dropdown-wrapper {className}" class:embedded>
<!-- Trigger Button -->
<button
bind:this={triggerRef}
type="button"
onclick={toggle}
onkeydown={handleKeydown}
class="filter-trigger"
class:active={isActive}
class:open={isOpen}
class:embedded
{disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<span class="trigger-label">{displayLabel}</span>
<svg
class="trigger-chevron"
class:rotated={isOpen}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths.chevronDown}
/>
</svg>
</button>
{#if isOpen}
<!-- Backdrop -->
<button
use:portal
type="button"
class="filter-backdrop"
onclick={close}
aria-label="Close dropdown"
></button>
<!-- Dropdown Panel -->
<div
use:portal
class="filter-dropdown-panel"
class:direction-up={direction === 'up'}
class:embedded
style="
top: {dropdownPosition.top}px;
left: {dropdownPosition.left}px;
min-width: {dropdownPosition.width}px;
max-height: {maxHeight};
"
transition:fly={{ duration: 150, y: direction === 'up' ? 8 : -8 }}
role="listbox"
aria-multiselectable={multiSelect}
>
<!-- Search Input -->
{#if showSearch}
<div class="search-container">
<svg class="search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths.search}
/>
</svg>
<input
bind:this={searchInputRef}
type="text"
bind:value={searchQuery}
placeholder="Suchen..."
class="search-input"
onkeydown={handleKeydown}
/>
{#if searchQuery}
<button
type="button"
class="search-clear"
onclick={() => (searchQuery = '')}
aria-label="Suche löschen"
>
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths.x}
/>
</svg>
</button>
{/if}
</div>
{/if}
<!-- Options List -->
<div class="options-list">
{#if filteredOptions.length === 0}
<div class="no-results">Keine Ergebnisse</div>
{:else}
{#each [...groupedOptions] as [groupName, groupOptions], groupIndex}
{#if groupName}
<div class="group-header">{groupName}</div>
{/if}
{#each groupOptions as option, optionIndex}
{@const flatIndex = selectableOptions.indexOf(option)}
<button
type="button"
class="option-item"
class:selected={isSelected(option.value)}
class:focused={flatIndex === focusedIndex}
class:disabled={option.disabled}
onclick={() => select(option)}
role="option"
aria-selected={isSelected(option.value)}
disabled={option.disabled}
>
<!-- Checkbox/Check indicator -->
<span class="option-indicator">
{#if multiSelect}
<span class="checkbox" class:checked={isSelected(option.value)}>
{#if isSelected(option.value)}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d={iconPaths.check}
/>
</svg>
{/if}
</span>
{:else if isSelected(option.value)}
<svg class="check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={iconPaths.check}
/>
</svg>
{/if}
</span>
<span class="option-label">{option.label}</span>
</button>
{/each}
{/each}
{/if}
</div>
<!-- Multi-select footer -->
{#if multiSelect && Array.isArray(value) && value.length > 0}
<div class="dropdown-footer">
<button type="button" class="clear-all-btn" onclick={() => onChange(null)}>
Alle entfernen
</button>
</div>
{/if}
</div>
{/if}
</div>
<style>
.filter-dropdown-wrapper {
position: relative;
display: inline-flex;
}
/* Trigger Button */
.filter-trigger {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.filter-trigger:hover:not(:disabled) {
border-color: hsl(var(--color-border-strong, var(--color-border)));
background: hsl(var(--color-muted) / 0.5);
}
.filter-trigger:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.2);
}
.filter-trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.filter-trigger.active {
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
color: #3b82f6;
border-color: #3b82f6;
}
.filter-trigger.open {
border-color: hsl(var(--color-primary));
}
/* Embedded mode - pill style for toolbars */
.filter-trigger.embedded {
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
background: hsl(var(--color-muted) / 0.5);
border: 1px solid transparent;
border-radius: 9999px;
}
.filter-trigger.embedded:hover:not(:disabled) {
background: hsl(var(--color-muted));
border-color: hsl(var(--color-border));
}
.filter-trigger.embedded.active {
background: color-mix(in srgb, #3b82f6 15%, transparent 85%);
color: #3b82f6;
border-color: #3b82f6;
}
.trigger-label {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
.trigger-chevron {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
transition: transform 0.2s ease;
color: hsl(var(--color-muted-foreground));
}
.trigger-chevron.rotated {
transform: rotate(180deg);
}
.filter-trigger.active .trigger-chevron {
color: #3b82f6;
}
/* Backdrop */
:global(.filter-backdrop) {
position: fixed;
inset: 0;
z-index: 9998;
background: transparent;
border: none;
cursor: default;
}
/* Dropdown Panel */
:global(.filter-dropdown-panel) {
position: fixed;
z-index: 9999;
display: flex;
flex-direction: column;
min-width: 160px;
max-width: 320px;
background: hsl(var(--color-surface) / 0.98);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.15),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
:global(.filter-dropdown-panel.direction-up) {
transform: translateY(-100%);
}
:global(.filter-dropdown-panel.embedded) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* Search Container */
.search-container {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
}
.search-icon {
width: 1rem;
height: 1rem;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
}
.search-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: transparent;
border: none;
border-radius: 0.25rem;
cursor: pointer;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s;
}
.search-clear:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.search-clear svg {
width: 0.75rem;
height: 0.75rem;
}
/* Options List */
.options-list {
overflow-y: auto;
padding: 0.375rem;
}
.no-results {
padding: 0.75rem;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
/* Group Header */
.group-header {
padding: 0.5rem 0.625rem 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
color: hsl(var(--color-muted-foreground));
}
/* Option Item */
.option-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
background: transparent;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.1s ease;
text-align: left;
}
.option-item:hover:not(.disabled) {
background: hsl(var(--color-muted));
}
.option-item.focused {
background: hsl(var(--color-muted));
outline: 2px solid hsl(var(--color-primary) / 0.5);
outline-offset: -2px;
}
.option-item.selected {
color: #3b82f6;
}
.option-item.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Option Indicator (checkbox or check) */
.option-indicator {
width: 1.125rem;
height: 1.125rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.checkbox {
width: 1rem;
height: 1rem;
border: 2px solid hsl(var(--color-border-strong, var(--color-border)));
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.checkbox.checked {
background: #3b82f6;
border-color: #3b82f6;
}
.checkbox svg {
width: 0.625rem;
height: 0.625rem;
color: white;
}
.check-icon {
width: 1rem;
height: 1rem;
color: #3b82f6;
}
.option-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Dropdown Footer */
.dropdown-footer {
padding: 0.5rem 0.625rem;
border-top: 1px solid hsl(var(--color-border) / 0.5);
}
.clear-all-btn {
width: 100%;
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.clear-all-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
</style>

View file

@ -1,8 +0,0 @@
export interface FilterDropdownOption {
value: string;
label: string;
icon?: string;
disabled?: boolean;
divider?: boolean;
group?: string;
}

View file

@ -1,3 +0,0 @@
export { ICON_CATEGORIES, getAllIconNames } from '@mana/shared-icons';
export const DEFAULT_ICON = 'star';

View file

@ -1,65 +1,48 @@
<!--
IconPicker — reusable Phosphor icon picker with search and categories.
Follows the same pattern as ColorPicker (size variants, a11y, Tailwind).
-->
<script lang="ts">
import { ICON_CATEGORIES, getIconComponent, type IconName } from '@mana/shared-icons';
import { Check } from '@mana/shared-icons';
import DynamicIcon from '../atoms/DynamicIcon.svelte';
export interface IconSlot {
name: string;
svg: string;
}
type Size = 'sm' | 'md' | 'lg';
interface Props {
selectedIcon?: string;
onIconChange: (icon: string) => void;
size?: 'sm' | 'md' | 'lg';
/** Wenn nicht gesetzt, rendert IconPicker eine leere Liste (v0.1.x-Compat: Konsument lieferte phosphor-Inventar implizit). */
categories?: Record<string, IconSlot[]>;
size?: Size;
label?: string;
showSearch?: boolean;
showCategories?: boolean;
searchPlaceholder?: string;
emptyLabel?: string;
}
let {
selectedIcon,
onIconChange,
categories = {},
size = 'md',
label = 'Icon wählen',
showSearch = true,
showCategories = true,
searchPlaceholder = 'Icon suchen …',
emptyLabel = 'Kein Icon gefunden',
}: Props = $props();
let searchQuery = $state('');
const sizeClasses: Record<string, string> = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12',
};
const iconSizes: Record<string, number> = {
sm: 16,
md: 20,
lg: 24,
};
const checkSizes: Record<string, number> = {
sm: 8,
md: 10,
lg: 12,
};
const gapClasses: Record<string, string> = {
sm: 'gap-1',
md: 'gap-1.5',
lg: 'gap-2',
};
let filteredCategories = $derived.by(() => {
const filteredCategories = $derived.by(() => {
const query = searchQuery.toLowerCase().trim();
if (!query) return ICON_CATEGORIES;
if (!query) return categories;
const result: Record<string, string[]> = {};
for (const [category, icons] of Object.entries(ICON_CATEGORIES)) {
const matched = icons.filter((name) => name.includes(query));
if (matched.length > 0) {
result[category] = matched;
}
const result: Record<string, IconSlot[]> = {};
for (const [category, icons] of Object.entries(categories)) {
const matched = icons.filter((icon) => icon.name.toLowerCase().includes(query));
if (matched.length > 0) result[category] = matched;
}
return result;
});
@ -70,76 +53,179 @@
onIconChange(iconName);
}
}
const iconSize = $derived(
size === 'sm' ? ('sm' as const) : size === 'lg' ? ('md' as const) : ('md' as const)
);
</script>
<div class="flex flex-col gap-2" role="group" aria-label={label}>
<div class="icon-picker" role="group" aria-label={label}>
{#if showSearch}
<input
type="text"
class="w-full rounded-md border border-white/10 bg-transparent px-3 py-1.5 text-sm
text-[var(--color-foreground,#fff)] placeholder-[var(--color-muted-foreground,#888)]
outline-none focus:border-[var(--color-primary,#6366f1)]"
placeholder="Icon suchen..."
bind:value={searchQuery}
/>
<input type="text" class="search" placeholder={searchPlaceholder} bind:value={searchQuery} />
{/if}
{#each Object.entries(filteredCategories) as [category, icons]}
<div>
{#each Object.entries(filteredCategories) as [category, icons] (category)}
<div class="category">
{#if showCategories}
<div
class="mb-1 text-xs font-semibold uppercase tracking-wider text-[var(--color-muted-foreground,#888)]"
>
{category}
</div>
<div class="category-label">{category}</div>
{/if}
<div class="flex flex-wrap {gapClasses[size]}" role="radiogroup" aria-label={category}>
{#each icons as iconName}
{@const isSelected = selectedIcon === iconName}
{@const IconComp = getIconComponent(iconName)}
{#if IconComp}
<button
type="button"
class="
{sizeClasses[size]}
relative rounded-lg
flex items-center justify-center
transition-all duration-150
focus:outline-none focus:ring-2 focus:ring-[var(--color-primary,#6366f1)]
{isSelected
? 'bg-[var(--color-primary,#6366f1)]/20 ring-2 ring-[var(--color-primary,#6366f1)] scale-110'
: 'bg-white/5 hover:bg-white/10 hover:scale-110'}
"
onclick={() => onIconChange(iconName)}
onkeydown={(e) => handleKeyDown(e, iconName)}
role="radio"
aria-checked={isSelected}
aria-label={iconName}
title={iconName}
>
<IconComp
size={iconSizes[size]}
weight={isSelected ? 'bold' : 'regular'}
class="text-[var(--color-foreground,#fff)]"
/>
{#if isSelected}
<div
class="absolute -right-0.5 -top-0.5 flex h-3.5 w-3.5 items-center justify-center
rounded-full bg-[var(--color-primary,#6366f1)]"
>
<Check size={checkSizes[size]} weight="bold" class="text-white" />
</div>
{/if}
</button>
{/if}
<div class="grid size-{size}" role="radiogroup" aria-label={category}>
{#each icons as icon (icon.name)}
{@const isSelected = selectedIcon === icon.name}
<button
type="button"
class="slot size-{size}"
class:selected={isSelected}
onclick={() => onIconChange(icon.name)}
onkeydown={(e) => handleKeyDown(e, icon.name)}
role="radio"
aria-checked={isSelected}
aria-label={icon.name}
title={icon.name}
>
<DynamicIcon iconSvg={icon.svg} size={iconSize} />
{#if isSelected}
<span class="check" aria-hidden="true">
<DynamicIcon name="check" size="xs" />
</span>
{/if}
</button>
{/each}
</div>
</div>
{/each}
{#if Object.keys(filteredCategories).length === 0}
<p class="py-2 text-center text-sm text-[var(--color-muted-foreground,#888)]">
Kein Icon gefunden
</p>
<p class="empty">{emptyLabel}</p>
{/if}
</div>
<style>
.icon-picker {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-family: inherit;
}
.search {
width: 100%;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
outline: none;
font: inherit;
font-size: 0.875rem;
}
.search::placeholder {
color: hsl(var(--color-muted-foreground));
}
.search:focus-visible {
border-color: hsl(var(--color-primary));
outline: 2px solid hsl(var(--color-primary) / 0.3);
outline-offset: 0;
}
.category-label {
margin-bottom: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.grid {
display: flex;
flex-wrap: wrap;
}
.grid.size-sm {
gap: 0.25rem;
}
.grid.size-md {
gap: 0.375rem;
}
.grid.size-lg {
gap: 0.5rem;
}
.slot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: hsl(var(--color-foreground));
background: hsl(var(--color-surface));
border: 1px solid transparent;
border-radius: 0.5rem;
cursor: pointer;
transition:
background-color 120ms,
transform 120ms,
border-color 120ms;
font-family: inherit;
}
.slot.size-sm {
width: 2rem;
height: 2rem;
}
.slot.size-md {
width: 2.5rem;
height: 2.5rem;
}
.slot.size-lg {
width: 3rem;
height: 3rem;
}
.slot:hover {
background: hsl(var(--color-surface-hover));
transform: scale(1.08);
}
.slot:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.slot.selected {
background: hsl(var(--color-primary) / 0.12);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
transform: scale(1.08);
}
.check {
position: absolute;
top: -0.25rem;
right: -0.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.875rem;
height: 0.875rem;
color: hsl(var(--color-primary-foreground));
background: hsl(var(--color-primary));
border-radius: 50%;
}
.empty {
margin: 0.5rem 0;
padding: 0.5rem 0;
text-align: center;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,57 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Input from './Input.svelte';
const { Story } = defineMeta({
title: 'Molecules/Input',
component: Input,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<div style="width: 320px;">
<Input label="E-Mail" type="email" placeholder="dich@beispiel.de" />
</div>
{/snippet}
</Story>
<Story name="WithHint">
{#snippet children()}
<div style="width: 320px;">
<Input
label="Passwort"
type="password"
hint="Mindestens 12 Zeichen, sicher generiert."
required
/>
</div>
{/snippet}
</Story>
<Story name="WithError">
{#snippet children()}
<div style="width: 320px;">
<Input label="Benutzername" value="🐭" error="Mindestens 3 Buchstaben oder Zahlen." />
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet children()}
<div style="width: 320px;">
<Input label="Versionsname" value="Phase 0" disabled hint="Wird aus dem Repo abgeleitet." />
</div>
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:0.75rem; width: 320px;">
<Input size="sm" label="sm" placeholder="kompakt" />
<Input size="md" label="md" placeholder="standard" />
<Input size="lg" label="lg" placeholder="komfortabel" />
</div>
{/snippet}
</Story>

View file

@ -1,80 +1,193 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
type Type = 'text' | 'email' | 'url' | 'password' | 'number' | 'search' | 'tel' | 'date' | 'time';
type Size = 'sm' | 'md' | 'lg';
interface Props {
value: string;
oninput?: (value: string) => void;
onchange?: (value: string) => void;
onkeydown?: (e: KeyboardEvent) => void;
label?: string;
type?: Type;
value?: string | number;
placeholder?: string;
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
label?: string;
hint?: string;
error?: string;
disabled?: boolean;
readonly?: boolean;
required?: boolean;
autocomplete?: HTMLInputAttributes['autocomplete'];
class?: string;
size?: Size;
id?: string;
name?: string;
autocomplete?: string;
ariaLabel?: string;
leading?: Snippet;
trailing?: Snippet;
oninput?: (e: Event) => void;
onchange?: (e: Event) => void;
onblur?: (e: FocusEvent) => void;
onfocus?: (e: FocusEvent) => void;
/** v0.1.x-Compat. */
class?: string;
}
let {
value = $bindable(),
oninput,
onchange,
onkeydown,
label,
placeholder,
type = 'text',
value = $bindable(''),
placeholder,
label,
hint,
error,
disabled = false,
readonly = false,
required = false,
autocomplete,
class: className = '',
id = `input-${Math.random().toString(36).slice(2, 9)}`,
size = 'md',
id,
name,
autocomplete,
ariaLabel,
leading,
trailing,
oninput,
onchange,
onblur,
onfocus,
class: className = '',
}: Props = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
value = target.value;
oninput?.(target.value);
}
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
onchange?.(target.value);
}
const inputId = $derived(id ?? `input-${Math.random().toString(36).slice(2, 9)}`);
const hintId = $derived(hint || error ? `${inputId}-hint` : undefined);
</script>
<div class="flex flex-col gap-1.5 {className}">
<div class="field {className}">
{#if label}
<label for={id} class="text-sm font-medium text-theme">
<label for={inputId}>
{label}
{#if required}
<span class="text-red-500">*</span>
{/if}
{#if required}<span class="required" aria-hidden="true">*</span>{/if}
</label>
{/if}
<input
{id}
{name}
{type}
{value}
{placeholder}
{disabled}
{required}
autocomplete={autocomplete as HTMLInputAttributes['autocomplete']}
oninput={handleInput}
onchange={handleChange}
{onkeydown}
class="w-full rounded-lg border px-4 py-2.5 text-theme bg-content transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed {error
? 'border-red-500 focus:ring-red-500/50'
: 'border-theme'}"
/>
<div class="input-wrap size-{size}" class:disabled class:has-error={!!error}>
{#if leading}<span class="affix leading">{@render leading()}</span>{/if}
<input
id={inputId}
{type}
{name}
{placeholder}
{disabled}
{readonly}
{required}
autocomplete={autocomplete as never}
aria-label={ariaLabel}
aria-invalid={error ? 'true' : undefined}
aria-describedby={hintId}
bind:value
{oninput}
{onchange}
{onblur}
{onfocus}
/>
{#if trailing}<span class="affix trailing">{@render trailing()}</span>{/if}
</div>
{#if error}
<p class="text-sm text-red-500">{error}</p>
<p class="hint error" id={hintId} role="alert">{error}</p>
{:else if hint}
<p class="hint" id={hintId}>{hint}</p>
{/if}
</div>
<style>
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.required {
color: hsl(var(--color-error));
margin-left: 0.125rem;
}
.input-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.input-wrap:focus-within {
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.2);
}
.input-wrap.disabled {
opacity: 0.6;
background: hsl(var(--color-muted));
}
.input-wrap.has-error {
border-color: hsl(var(--color-error));
}
.input-wrap.has-error:focus-within {
box-shadow: 0 0 0 2px hsl(var(--color-error) / 0.2);
}
.size-sm {
padding: 0.25rem 0.625rem;
}
.size-md {
padding: 0.5rem 0.75rem;
}
.size-lg {
padding: 0.625rem 0.875rem;
}
input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
outline: none;
}
input::placeholder {
color: hsl(var(--color-muted-foreground));
}
input:disabled {
cursor: not-allowed;
}
.affix {
display: inline-flex;
align-items: center;
color: hsl(var(--color-muted-foreground));
}
.hint {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.hint.error {
color: hsl(var(--color-error));
}
@media (prefers-reduced-motion: reduce) {
.input-wrap {
transition: none;
}
}
</style>

View file

@ -1,173 +0,0 @@
<script lang="ts">
/**
* KeyboardShortcutsPanel - Collapsible panel showing keyboard shortcuts
*
* Used in sidebars to show available keyboard shortcuts.
* Supports grouping shortcuts by category.
*
* @example Basic usage
* ```svelte
* <KeyboardShortcutsPanel
* shortcuts={[
* { keys: ['Ctrl', 'S'], label: 'Save' },
* { keys: ['Ctrl', 'Z'], label: 'Undo' }
* ]}
* />
* ```
*
* @example With categories
* ```svelte
* <KeyboardShortcutsPanel
* shortcuts={[
* { keys: ['Ctrl', 'W'], label: 'Close Tab', category: 'Navigation' },
* { keys: ['Ctrl', '1'], label: 'Go to Record', category: 'Quick Access' }
* ]}
* title="Keyboard Shortcuts"
* />
* ```
*/
import { Text } from '../atoms';
import { CaretDown } from '@mana/shared-icons';
export interface KeyboardShortcut {
/** Key combination (e.g., ['Ctrl', 'S'] or ['Cmd', 'Shift', 'P']) */
keys: string[];
/** Description of what the shortcut does */
label: string;
/** Category for grouping (optional) */
category?: string;
}
interface Props {
/** List of keyboard shortcuts */
shortcuts: KeyboardShortcut[];
/** Panel title */
title?: string;
/** Whether panel is initially expanded */
expanded?: boolean;
/** Whether panel is collapsible */
collapsible?: boolean;
/** Whether to show in compact mode (for minimized sidebar) */
compact?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
shortcuts,
title = 'Shortcuts',
expanded = $bindable(false),
collapsible = true,
compact = false,
class: className = '',
}: Props = $props();
// Group shortcuts by category
const groupedShortcuts = $derived(() => {
const groups: Record<string, KeyboardShortcut[]> = {};
for (const shortcut of shortcuts) {
const category = shortcut.category || 'General';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(shortcut);
}
return groups;
});
function toggleExpanded() {
if (collapsible) {
expanded = !expanded;
}
}
</script>
{#if !compact}
<div class="keyboard-shortcuts-panel {className}">
<!-- Header -->
<button
type="button"
class="w-full flex items-center justify-between px-3 py-2 text-left hover:bg-menu-hover rounded-lg transition-colors"
onclick={toggleExpanded}
disabled={!collapsible}
>
<div class="flex items-center gap-2">
<svg
class="w-4 h-4 text-theme-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707"
/>
</svg>
<Text variant="small" weight="medium">{title}</Text>
</div>
{#if collapsible}
<CaretDown
size={16}
class="text-theme-secondary transition-transform {expanded ? 'rotate-180' : ''}"
/>
{/if}
</button>
<!-- Content -->
{#if expanded || !collapsible}
<div class="mt-2 space-y-3 px-3 pb-3">
{#each Object.entries(groupedShortcuts()) as [category, categoryShortcuts]}
<div class="shortcut-group">
{#if Object.keys(groupedShortcuts()).length > 1}
<Text variant="small" class="text-theme-tertiary mb-1.5 block">
{category}
</Text>
{/if}
<div class="space-y-1.5">
{#each categoryShortcuts as shortcut}
<div class="flex items-center justify-between gap-2">
<Text variant="small" class="text-theme-secondary truncate">
{shortcut.label}
</Text>
<div class="flex items-center gap-1 flex-shrink-0">
{#each shortcut.keys as key, i}
<kbd
class="px-1.5 py-0.5 text-xs font-mono bg-surface-secondary rounded border border-theme"
>
{key}
</kbd>
{#if i < shortcut.keys.length - 1}
<span class="text-theme-tertiary text-xs">+</span>
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Compact mode: just show icon with tooltip -->
<button
type="button"
class="w-full flex items-center justify-center p-2 hover:bg-menu-hover rounded-lg transition-colors group relative"
{title}
>
<svg class="w-5 h-5 text-theme-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707"
/>
</svg>
</button>
{/if}

View file

@ -1,85 +0,0 @@
<script lang="ts">
/**
* ModalFooter - Standardized modal footer with button layout
*
* Provides consistent button arrangement for modal dialogs.
*
* @example Basic cancel/confirm
* ```svelte
* <ModalFooter
* cancelLabel="Cancel"
* confirmLabel="Save"
* onCancel={() => close()}
* onConfirm={() => save()}
* />
* ```
*
* @example With loading state
* ```svelte
* <ModalFooter
* confirmLabel="Deleting..."
* confirmVariant="danger"
* loading={isDeleting}
* onCancel={close}
* onConfirm={handleDelete}
* />
* ```
*/
import { Button } from '../atoms';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'outline';
interface Props {
/** Cancel button label (omit to hide) */
cancelLabel?: string;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button callback */
onCancel?: () => void;
/** Confirm button callback */
onConfirm?: () => void | Promise<void>;
/** Confirm button variant */
confirmVariant?: ButtonVariant;
/** Whether confirm action is loading */
loading?: boolean;
/** Disable all buttons */
disabled?: boolean;
/** Alignment of buttons */
align?: 'start' | 'center' | 'end' | 'between';
/** Additional CSS classes */
class?: string;
}
let {
cancelLabel = 'Cancel',
confirmLabel = 'Confirm',
onCancel,
onConfirm,
confirmVariant = 'primary',
loading = false,
disabled = false,
align = 'end',
class: className = '',
}: Props = $props();
const alignClasses: Record<string, string> = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
};
</script>
<div class="modal-footer flex gap-3 {alignClasses[align]} {className}">
{#if cancelLabel && onCancel}
<Button variant="ghost" onclick={onCancel} disabled={disabled || loading}>
{cancelLabel}
</Button>
{/if}
{#if confirmLabel && onConfirm}
<Button variant={confirmVariant} onclick={onConfirm} {loading} {disabled}>
{confirmLabel}
</Button>
{/if}
</div>

View file

@ -0,0 +1,72 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import PageHeader from './PageHeader.svelte';
import Button from '../atoms/Button.svelte';
import NavLink from '../navigation/NavLink.svelte';
const { Story } = defineMeta({
title: 'Molecules/PageHeader',
component: PageHeader,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<div style="width:42rem; max-width:100%;">
<PageHeader title="Decks" />
</div>
{/snippet}
</Story>
<Story name="WithSubtitle">
{#snippet children()}
<div style="width:42rem; max-width:100%;">
<PageHeader
title="Spanisch — Alltagsvokabular"
subtitle="24 Karten · 4 fällig · zuletzt gelernt vor 2 Tagen"
/>
</div>
{/snippet}
</Story>
<Story name="WithEyebrow">
{#snippet children()}
<div style="width:42rem; max-width:100%;">
<PageHeader
eyebrow="Phase 12"
title="Marketplace-Restore"
subtitle="6 Phasen, schrittweise Wiederherstellung des Marketplace-Codes."
/>
</div>
{/snippet}
</Story>
<Story name="WithActions">
{#snippet children()}
<div style="width:42rem; max-width:100%;">
<PageHeader title="Stapel" subtitle="6 Stapel insgesamt">
{#snippet actions()}
<Button variant="ghost" size="sm">✨ KI-Deck</Button>
<Button variant="primary" size="sm">Neues Deck</Button>
{/snippet}
</PageHeader>
</div>
{/snippet}
</Story>
<Story name="WithBreadcrumb">
{#snippet children()}
<div style="width:42rem; max-width:100%;">
<PageHeader title="Karte bearbeiten" subtitle="basic-reverse · zuletzt 03.05.">
{#snippet breadcrumb()}
<NavLink href="/decks">Decks</NavLink>
<span style="color:hsl(var(--color-muted-foreground));"> / </span>
<NavLink href="/decks/spanisch">Spanisch</NavLink>
<span style="color:hsl(var(--color-muted-foreground));"> / </span>
<span>Karte 12</span>
{/snippet}
</PageHeader>
</div>
{/snippet}
</Story>

View file

@ -1,213 +1,99 @@
<script lang="ts">
/**
* PageHeader - Standardized page header layout
*
* Provides consistent page title, description, and action buttons layout.
*
* @example Basic usage
* ```svelte
* <PageHeader title="My Memos" />
* ```
*
* @example With description and actions
* ```svelte
* <PageHeader
* title="Flashcard Decks"
* description="Manage your study decks"
* >
* {#snippet actions()}
* <Button onclick={createDeck}>New Deck</Button>
* {/snippet}
* </PageHeader>
* ```
*
* @example With back navigation
* ```svelte
* <PageHeader title="Space Details" backHref="/spaces" />
* ```
*
* @example With breadcrumb and icon
* ```svelte
* <PageHeader title="Edit Profile">
* {#snippet breadcrumb()}
* <a href="/settings">Settings</a> / Profile
* {/snippet}
* {#snippet icon()}
* <UserIcon />
* {/snippet}
* </PageHeader>
* ```
*/
import type { Snippet } from 'svelte';
import { Text } from '../atoms';
import { CaretLeft } from '@mana/shared-icons';
type HeaderSize = 'sm' | 'md' | 'lg';
interface Props {
/** Page title */
title: string;
/** Page description/subtitle */
subtitle?: string;
/** Alias für subtitle (v0.1.x-Compat). */
description?: string;
/** Header size variant */
size?: HeaderSize;
/** Whether to show bottom border */
bordered?: boolean;
/** Center the title (with actions on the right, back on the left) */
centered?: boolean;
/** Back navigation href (shows back arrow button) */
backHref?: string;
/** Sticky position at top of viewport with frosted-glass background */
sticky?: boolean;
/** Icon snippet (before title) */
icon?: Snippet;
/** Breadcrumb snippet (above title) */
breadcrumb?: Snippet;
/** Actions snippet (right side) */
eyebrow?: string;
actions?: Snippet;
/** Tabs or navigation snippet (below header) */
tabs?: Snippet;
/** Additional CSS classes */
breadcrumb?: Snippet;
/** v0.1.x-Compat: heute ignoriert, alle Header rendern in der gleichen Größe. */
size?: 'sm' | 'md' | 'lg';
/** v0.1.x-Compat: Header zentrieren statt links-bündig. */
centered?: boolean;
/** v0.1.x-Compat. */
class?: string;
}
let {
title,
description,
size = 'md',
bordered = false,
centered = false,
backHref,
sticky = false,
icon,
breadcrumb,
actions,
tabs,
class: className = '',
}: Props = $props();
const stickyClasses =
'sticky top-0 z-40 backdrop-blur-lg bg-[hsl(var(--color-background,0_0%_100%)/0.8)] border-b border-[hsl(var(--color-border)/0.3)]';
const sizeClasses: Record<HeaderSize, { container: string; title: string }> = {
sm: {
container: 'py-3',
title: 'text-lg',
},
md: {
container: 'py-4',
title: 'text-xl',
},
lg: {
container: 'py-6',
title: 'text-2xl',
},
};
let { title, subtitle, description, eyebrow, actions, breadcrumb }: Props = $props();
const effectiveSubtitle = $derived(subtitle ?? description);
</script>
<header
class="page-header {sizeClasses[size].container} {bordered ? 'border-b border-theme' : ''} {sticky
? stickyClasses
: ''} {className}"
>
<!-- Breadcrumb -->
<header class="page-header">
{#if breadcrumb}
<div
class="page-header__breadcrumb mb-2 text-sm text-theme-secondary {centered
? 'text-center'
: ''}"
>
{@render breadcrumb()}
</div>
<div class="breadcrumb">{@render breadcrumb()}</div>
{/if}
{#if centered}
<!-- Centered Layout -->
<div class="relative flex items-center justify-center min-h-[2.5rem]">
<!-- Back Button (left) -->
{#if backHref}
<a
href={backHref}
class="absolute left-0 p-1.5 rounded-lg text-theme-secondary hover:text-theme hover:bg-theme-secondary/10 transition-colors"
aria-label="Zurück"
>
<CaretLeft size={20} />
</a>
{/if}
<!-- Centered Title & Description -->
<div class="text-center">
{#if icon}
<div class="page-header__icon inline-block text-theme-secondary mb-1">
{@render icon()}
</div>
{/if}
<h1 class="font-semibold text-theme {sizeClasses[size].title}">
{title}
</h1>
{#if description}
<Text variant="muted" class="mt-1">
{description}
</Text>
{/if}
</div>
<!-- Actions (right) -->
{#if actions}
<div class="absolute right-0 flex items-center gap-2">
{@render actions()}
</div>
{/if}
<div class="header-row">
<div class="header-text">
{#if eyebrow}<p class="eyebrow">{eyebrow}</p>{/if}
<h1 class="title">{title}</h1>
{#if effectiveSubtitle}<p class="subtitle">{effectiveSubtitle}</p>{/if}
</div>
{:else}
<!-- Default Layout -->
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<!-- Back Button -->
{#if backHref}
<a
href={backHref}
class="page-header__back flex-shrink-0 p-1.5 -ml-1.5 rounded-lg text-theme-secondary hover:text-theme hover:bg-theme-secondary/10 transition-colors"
aria-label="Zurück"
>
<CaretLeft size={20} />
</a>
{/if}
<!-- Icon -->
{#if icon}
<div class="page-header__icon flex-shrink-0 text-theme-secondary">
{@render icon()}
</div>
{/if}
<!-- Title & Description -->
<div class="min-w-0">
<h1 class="font-semibold text-theme {sizeClasses[size].title} truncate">
{title}
</h1>
{#if description}
<Text variant="muted" class="mt-1">
{description}
</Text>
{/if}
</div>
</div>
<!-- Actions -->
{#if actions}
<div class="page-header__actions flex-shrink-0 flex items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
{/if}
<!-- Tabs/Navigation -->
{#if tabs}
<div class="page-header__tabs mt-4">
{@render tabs()}
</div>
{/if}
{#if actions}
<div class="header-actions">{@render actions()}</div>
{/if}
</div>
</header>
<style>
.page-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-bottom: 1rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid hsl(var(--color-border));
font-family: inherit;
}
.breadcrumb {
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.header-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.header-text {
flex: 1;
min-width: 0;
}
.eyebrow {
margin: 0 0 0.25rem;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
}
.title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.25;
color: hsl(var(--color-foreground));
}
.subtitle {
margin: 0.375rem 0 0;
font-size: 0.9375rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.45;
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
flex-shrink: 0;
}
</style>

View file

@ -1,24 +1,15 @@
<script lang="ts">
import { Bell, BellSlash } from '@mana/shared-icons';
import DynamicIcon from '../atoms/DynamicIcon.svelte';
/**
* Reusable reminder time picker dropdown.
* Lets user select "X minutes before" for reminders on tasks, events, etc.
*/
interface ReminderOption {
export interface ReminderOption {
value: number | null;
label: string;
}
interface Props {
/** Selected value in minutes (null = no reminder) */
value: number | null;
/** Called when selection changes */
onChange: (minutes: number | null) => void;
/** Custom options (defaults to standard set) */
options?: ReminderOption[];
/** Disable the picker */
disabled?: boolean;
}
@ -39,30 +30,56 @@
onChange(raw === '' ? null : parseInt(raw, 10));
}
const displayLabel = $derived(
options.find((o) => o.value === value)?.label ?? 'Keine Erinnerung'
);
const hasReminder = $derived(value !== null);
</script>
<div class="inline-flex items-center gap-1.5">
{#if hasReminder}
<Bell size={14} weight="fill" class="text-primary flex-shrink-0" />
{:else}
<BellSlash size={14} class="text-muted-foreground flex-shrink-0" />
{/if}
<select
class="appearance-none bg-transparent text-xs cursor-pointer
{hasReminder ? 'text-primary font-medium' : 'text-muted-foreground'}
focus:outline-none"
{disabled}
onchange={handleChange}
>
{#each options as option}
<option value={option.value ?? ''} selected={option.value === value}>
{option.label}
</option>
<label class="reminder-picker" class:active={hasReminder}>
<DynamicIcon name={hasReminder ? 'bell-fill' : 'bell-slash'} size="sm" ariaLabel="Erinnerung" />
<select {disabled} onchange={handleChange} value={value ?? ''}>
{#each options as option (option.value ?? 'none')}
<option value={option.value ?? ''}>{option.label}</option>
{/each}
</select>
</div>
</label>
<style>
.reminder-picker {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.25rem;
color: hsl(var(--color-muted-foreground));
font-family: inherit;
}
.reminder-picker.active {
color: hsl(var(--color-primary));
}
.reminder-picker select {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: none;
color: inherit;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
padding: 0;
}
.reminder-picker.active select {
font-weight: 500;
}
.reminder-picker select:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
border-radius: 0.25rem;
}
.reminder-picker select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View file

@ -1,57 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/svelte';
import ReminderPicker from './ReminderPicker.svelte';
describe('ReminderPicker', () => {
it('renders a select element', () => {
const { container } = render(ReminderPicker, {
props: { value: null, onChange: vi.fn() },
});
expect(container.querySelector('select')).toBeInTheDocument();
});
it('renders default options', () => {
const { container } = render(ReminderPicker, {
props: { value: null, onChange: vi.fn() },
});
const options = container.querySelectorAll('option');
expect(options.length).toBeGreaterThanOrEqual(5);
});
it('shows bell icon when reminder is set', () => {
const { container } = render(ReminderPicker, {
props: { value: 15, onChange: vi.fn() },
});
// Bell icon should be present (not BellSlash)
const svgs = container.querySelectorAll('svg');
expect(svgs.length).toBeGreaterThan(0);
});
it('calls onChange when selection changes', async () => {
const onChange = vi.fn();
const { container } = render(ReminderPicker, {
props: { value: null, onChange },
});
const select = container.querySelector('select')!;
await fireEvent.change(select, { target: { value: '30' } });
expect(onChange).toHaveBeenCalledWith(30);
});
it('calls onChange with null for "no reminder"', async () => {
const onChange = vi.fn();
const { container } = render(ReminderPicker, {
props: { value: 15, onChange },
});
const select = container.querySelector('select')!;
await fireEvent.change(select, { target: { value: '' } });
expect(onChange).toHaveBeenCalledWith(null);
});
it('supports disabled state', () => {
const { container } = render(ReminderPicker, {
props: { value: null, onChange: vi.fn(), disabled: true },
});
const select = container.querySelector('select')!;
expect(select.disabled).toBe(true);
});
});

View file

@ -0,0 +1,57 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Select from './Select.svelte';
const variantOptions = [
{ value: 'mana', label: 'Mana — Verein-Brand' },
{ value: 'paper', label: 'Paper — Sepia, lese-fokussiert' },
{ value: 'forest', label: 'Forest — Tiefgrün' },
{ value: 'lume', label: 'Lume — Gold-Akzent' },
{ value: 'twilight', label: 'Twilight — Indigo' },
{ value: 'monochrome', label: 'Monochrom' },
];
const { Story } = defineMeta({
title: 'Molecules/Select',
component: Select,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<div style="width: 320px;">
<Select label="Theme-Variant" placeholder="Wählen …" options={variantOptions} />
</div>
{/snippet}
</Story>
<Story name="WithValue">
{#snippet children()}
<div style="width: 320px;">
<Select label="Aktives Theme" value="forest" options={variantOptions} />
</div>
{/snippet}
</Story>
<Story name="WithError">
{#snippet children()}
<div style="width: 320px;">
<Select
label="Pflichtfeld"
placeholder="Wählen …"
options={variantOptions}
error="Bitte ein Theme auswählen."
required
/>
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet children()}
<div style="width: 320px;">
<Select label="Theme" value="mana" options={variantOptions} disabled />
</div>
{/snippet}
</Story>

View file

@ -1,159 +1,174 @@
<script lang="ts">
import type { SelectOption } from './Select.types';
import { CaretDown } from '@mana/shared-icons';
type Size = 'sm' | 'md' | 'lg';
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface Props {
/** Current selected value */
value: string;
/** Available options */
options: SelectOption[];
/** Called when selection changes */
onchange?: (value: string) => void;
/** Label text */
value?: string;
options: Option[];
label?: string;
/** Placeholder text (shown as first disabled option) */
placeholder?: string;
/** Error message */
hint?: string;
error?: string;
/** Disable the select */
disabled?: boolean;
/** Mark as required */
required?: boolean;
/** Additional CSS classes */
class?: string;
/** Unique ID for accessibility */
size?: Size;
id?: string;
name?: string;
ariaLabel?: string;
onchange?: (e: Event) => void;
}
let {
value = $bindable(),
value = $bindable(''),
options,
onchange,
label,
placeholder,
hint,
error,
disabled = false,
required = false,
class: className = '',
id = `select-${Math.random().toString(36).slice(2, 9)}`,
size = 'md',
id,
name,
ariaLabel,
onchange,
}: Props = $props();
function handleChange(e: Event) {
const target = e.target as HTMLSelectElement;
value = target.value;
onchange?.(target.value);
}
const inputId = $derived(id ?? `select-${Math.random().toString(36).slice(2, 9)}`);
const hintId = $derived(hint || error ? `${inputId}-hint` : undefined);
</script>
<div class="select-wrapper {className}">
<div class="field">
{#if label}
<label for={id} class="select-label">
<label for={inputId}>
{label}
{#if required}
<span class="select-required">*</span>
{/if}
{#if required}<span class="required" aria-hidden="true">*</span>{/if}
</label>
{/if}
<div class="select-container">
<div class="wrap size-{size}" class:disabled class:has-error={!!error}>
<select
{id}
{value}
id={inputId}
{name}
{disabled}
{required}
onchange={handleChange}
class="select-input {error ? 'select-input--error' : ''}"
aria-label={ariaLabel}
aria-invalid={error ? 'true' : undefined}
aria-describedby={hintId}
bind:value
{onchange}
>
{#if placeholder}
<option value="" disabled selected={!value}>{placeholder}</option>
<option value="" disabled selected hidden>{placeholder}</option>
{/if}
{#each options as option}
<option value={option.value} disabled={option.disabled}>
{option.label}
</option>
{#each options as opt}
<option value={opt.value} disabled={opt.disabled}>{opt.label}</option>
{/each}
</select>
<div class="select-icon">
<CaretDown size={16} />
</div>
<span class="caret" aria-hidden="true"></span>
</div>
{#if error}
<p class="select-error">{error}</p>
<p class="hint error" id={hintId} role="alert">{error}</p>
{:else if hint}
<p class="hint" id={hintId}>{hint}</p>
{/if}
</div>
<style>
.select-wrapper {
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.select-label {
label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.select-required {
.required {
color: hsl(var(--color-error));
margin-left: 0.125rem;
}
.select-container {
.wrap {
position: relative;
}
.select-input {
width: 100%;
appearance: none;
padding: 0.625rem 2.5rem 0.625rem 1rem;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
background-color: hsl(var(--color-surface));
display: flex;
align-items: center;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.select-input:hover:not(:disabled) {
border-color: hsl(var(--color-border-strong));
}
.select-input:focus {
outline: none;
.wrap:focus-within {
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.2);
}
.select-input:disabled {
opacity: 0.5;
.wrap.disabled {
opacity: 0.6;
background: hsl(var(--color-muted));
}
.wrap.has-error {
border-color: hsl(var(--color-error));
}
.size-sm select {
padding: 0.25rem 2rem 0.25rem 0.625rem;
}
.size-md select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
}
.size-lg select {
padding: 0.625rem 2rem 0.625rem 0.875rem;
}
select {
appearance: none;
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
outline: none;
cursor: pointer;
}
select:disabled {
cursor: not-allowed;
}
.select-input--error {
border-color: hsl(var(--color-error));
}
.select-input--error:focus {
border-color: hsl(var(--color-error));
box-shadow: 0 0 0 3px hsl(var(--color-error) / 0.1);
}
.select-icon {
.caret {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
right: 0.625rem;
pointer-events: none;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
}
.hint {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.select-error {
font-size: 0.75rem;
.hint.error {
color: hsl(var(--color-error));
margin: 0;
}
@media (prefers-reduced-motion: reduce) {
.wrap {
transition: none;
}
}
</style>

View file

@ -1,5 +0,0 @@
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}

View file

@ -0,0 +1,36 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import TagBadge from './TagBadge.svelte';
const { Story } = defineMeta({
title: 'Molecules/TagBadge',
component: TagBadge,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<TagBadge label="Sprache" />
{/snippet}
</Story>
<Story name="WithColor">
{#snippet children()}
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<TagBadge label="Spanisch" color="#FF6600" />
<TagBadge label="Geschichte" color="#DC2626" />
<TagBadge label="Botanik" color="#16a34a" />
<TagBadge label="Akkord" color="#07D6FF" />
</div>
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; gap:0.5rem; align-items:center;">
<TagBadge label="sm" color="#16a34a" size="sm" />
<TagBadge label="md" color="#16a34a" size="md" />
</div>
{/snippet}
</Story>

View file

@ -0,0 +1,64 @@
<script lang="ts">
type Size = 'sm' | 'md';
interface Props {
label: string;
color?: string | null;
size?: Size;
title?: string;
}
let { label, color = null, size = 'sm', title }: Props = $props();
</script>
<span
class="tag-badge size-{size}"
style:--tag-color={color || null}
class:has-color={!!color}
{title}
>
{#if color}
<span class="dot" aria-hidden="true"></span>
{/if}
<span class="label">{label}</span>
</span>
<style>
.tag-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.0625rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
color: hsl(var(--color-muted-foreground));
font-size: 0.75rem;
line-height: 1.5;
font-family: inherit;
white-space: nowrap;
}
.size-md {
padding: 0.125rem 0.625rem;
font-size: 0.8125rem;
}
.dot {
display: inline-block;
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--tag-color, hsl(var(--color-primary)));
flex-shrink: 0;
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.has-color {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -0,0 +1,56 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import TagChip from './TagChip.svelte';
const { Story } = defineMeta({
title: 'Molecules/TagChip',
component: TagChip,
tags: ['autodocs'],
});
let removed = $state(false);
</script>
<Story name="Default">
{#snippet children()}
<TagChip label="Spanisch" color="#FF6600" />
{/snippet}
</Story>
<Story name="Active">
{#snippet children()}
<TagChip label="aktiv" active />
{/snippet}
</Story>
<Story name="Removable">
{#snippet children()}
<div style="display:flex; gap:0.5rem; align-items:center;">
{#if !removed}
<TagChip label="entfernbar" color="#16a34a" removable onRemove={() => (removed = true)} />
{:else}
<span style="color:hsl(var(--color-muted-foreground)); font-size:0.875rem;">
Tag entfernt — Story neu laden zum Reset
</span>
{/if}
</div>
{/snippet}
</Story>
<Story name="Clickable">
{#snippet children()}
<TagChip label="klickbar" color="#6366F1" onclick={() => alert('clicked')} />
{/snippet}
</Story>
<Story name="ChipRow">
{#snippet children()}
<div style="display:flex; flex-wrap:wrap; gap:0.375rem; max-width:24rem;">
<TagChip label="Spanisch" color="#FF6600" />
<TagChip label="Vokabular" color="#DC2626" />
<TagChip label="A1" color="#07D6FF" />
<TagChip label="Reise" color="#16a34a" />
<TagChip label="2026" />
</div>
{/snippet}
</Story>

View file

@ -0,0 +1,169 @@
<script lang="ts">
import DynamicIcon from '../atoms/DynamicIcon.svelte';
type Size = 'sm' | 'md';
interface Props {
label?: string;
/** v0.1.x-Compat-Alias für `label`. */
name?: string;
color?: string | null;
size?: Size;
removable?: boolean;
onRemove?: () => void;
onclick?: (e: MouseEvent) => void;
active?: boolean;
removeLabel?: string;
}
let {
label,
name,
color = null,
size = 'sm',
removable = false,
onRemove,
onclick,
active = false,
removeLabel = 'Entfernen',
}: Props = $props();
const effectiveLabel = $derived(label ?? name ?? '');
function handleRemove(e: MouseEvent) {
e.stopPropagation();
onRemove?.();
}
</script>
{#if onclick}
<button
type="button"
class="chip size-{size}"
class:active
class:has-color={!!color}
style:--tag-color={color || null}
{onclick}
>
{#if color}
<span class="dot" aria-hidden="true"></span>
{/if}
<span class="label">{effectiveLabel}</span>
{#if removable && onRemove}
<button type="button" class="remove" aria-label={removeLabel} onclick={handleRemove}>
<DynamicIcon name="x" size="xs" />
</button>
{/if}
</button>
{:else}
<span
class="chip size-{size}"
class:active
class:has-color={!!color}
style:--tag-color={color || null}
>
{#if color}
<span class="dot" aria-hidden="true"></span>
{/if}
<span class="label">{effectiveLabel}</span>
{#if removable && onRemove}
<button type="button" class="remove" aria-label={removeLabel} onclick={handleRemove}>
<DynamicIcon name="x" size="xs" />
</button>
{/if}
</span>
{/if}
<style>
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
border: 1px solid hsl(var(--color-border));
font-size: 0.8125rem;
line-height: 1.4;
font-family: inherit;
white-space: nowrap;
cursor: default;
}
button.chip {
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
button.chip:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-primary) / 0.4);
}
button.chip:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.size-sm {
padding: 0.125rem 0.5rem 0.125rem 0.5rem;
font-size: 0.75rem;
}
.size-md {
padding: 0.1875rem 0.625rem;
font-size: 0.8125rem;
}
.dot {
display: inline-block;
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--tag-color, hsl(var(--color-primary)));
flex-shrink: 0;
}
.label {
min-width: 0;
}
.chip.active {
background: hsl(var(--color-primary) / 0.12);
border-color: hsl(var(--color-primary) / 0.4);
color: hsl(var(--color-primary));
}
.remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.0625rem;
margin-left: 0.0625rem;
margin-right: -0.1875rem;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
border-radius: 50%;
cursor: pointer;
font: inherit;
}
.remove:hover {
color: hsl(var(--color-error));
background: hsl(var(--color-error) / 0.1);
}
.remove:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
button.chip {
transition: none;
}
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import TagSelector, { type Tag as SelectorTag } from './TagSelector.svelte';
interface TagShape {
id: string;
name: string;
color?: string | null;
}
interface Props {
tags: TagShape[];
selectedIds: string[];
onChange: (ids: string[]) => void;
onCreate?: (name: string) => void;
maxTags?: number;
label?: string;
placeholder?: string;
}
let {
tags,
selectedIds,
onChange,
onCreate,
maxTags,
label,
placeholder = 'Tag suchen oder anlegen …',
}: Props = $props();
const selectorTags = $derived<SelectorTag[]>(
tags.map((t) => ({ id: t.id, label: t.name, color: t.color ?? null }))
);
function handleToggle(id: string) {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((sid) => sid !== id));
return;
}
if (maxTags && selectedIds.length >= maxTags) return;
onChange([...selectedIds, id]);
}
function handleClear() {
onChange([]);
}
</script>
<TagSelector
tags={selectorTags}
{selectedIds}
onToggle={handleToggle}
onCreate={onCreate ? (name) => onCreate(name) : undefined}
onClear={selectedIds.length > 0 ? handleClear : undefined}
{label}
{placeholder}
/>

View file

@ -0,0 +1,86 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import TagSelector, { type Tag } from './TagSelector.svelte';
const { Story } = defineMeta({
title: 'Molecules/TagSelector',
component: TagSelector,
tags: ['autodocs'],
});
let tags = $state<Tag[]>([
{ id: 'spanisch', label: 'Spanisch', color: '#FF6600' },
{ id: 'vokabular', label: 'Vokabular', color: '#DC2626' },
{ id: 'reise', label: 'Reise', color: '#16a34a' },
{ id: 'a1', label: 'A1', color: '#07D6FF' },
{ id: 'a2', label: 'A2', color: '#07D6FF' },
{ id: 'b1', label: 'B1', color: '#6366F1' },
{ id: 'kueche', label: 'Küche', color: '#F59E0B' },
{ id: 'bahnhof', label: 'Bahnhof' },
]);
let selected = $state<string[]>(['spanisch', 'a1']);
let nextId = 9;
function toggle(id: string) {
selected = selected.includes(id) ? selected.filter((s) => s !== id) : [...selected, id];
}
function create(label: string) {
const id = `new-${nextId++}`;
tags = [...tags, { id, label }];
selected = [...selected, id];
}
</script>
<Story name="Default">
{#snippet children()}
<div style="width:18rem;">
<TagSelector
{tags}
selectedIds={selected}
onToggle={toggle}
onCreate={create}
onClear={() => (selected = [])}
label="Tags"
/>
</div>
{/snippet}
</Story>
<Story name="Empty">
{#snippet children()}
<div style="width:18rem;">
<TagSelector
tags={[]}
selectedIds={[]}
onToggle={() => {}}
onCreate={() => {}}
placeholder="Erste Tag erstellen …"
createLabel="Anlegen:"
/>
</div>
{/snippet}
</Story>
<Story name="ReadOnly">
{#snippet children()}
<div style="width:18rem;">
<TagSelector {tags} selectedIds={['vokabular', 'a2']} onToggle={() => {}} label="Nur Lesen" />
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet children()}
<div style="width:18rem;">
<TagSelector
{tags}
selectedIds={['spanisch']}
onToggle={() => {}}
disabled
label="Disabled"
/>
</div>
{/snippet}
</Story>

View file

@ -0,0 +1,316 @@
<script lang="ts">
import TagChip from './TagChip.svelte';
import DynamicIcon from '../atoms/DynamicIcon.svelte';
export interface Tag {
id: string;
/** Anzeige-Name. v0.1.x-Konsumenten haben `name`, v1.0.0 nimmt `label` — beide funktionieren. */
label?: string;
/** v0.1.x-Alias für `label`. */
name?: string;
color?: string | null;
}
interface Props {
tags: Tag[];
selectedIds?: string[];
/** v0.1.x-Compat: Tag-Objekte statt Tag-IDs. Bei gesetztem `selectedTags` wird `selectedIds` daraus abgeleitet. */
selectedTags?: Tag[];
onToggle?: (id: string) => void;
/** v0.1.x-Compat: liefert die aktualisierte Tag-Liste. */
onTagsChange?: (tags: Tag[]) => void;
onCreate?: (label: string) => void;
onClear?: () => void;
label?: string;
placeholder?: string;
/** v0.1.x-Compat-Alias für placeholder. */
searchPlaceholder?: string;
/** v0.1.x-Compat: heute ignoriert. */
addTagLabel?: string;
clearLabel?: string;
createLabel?: string;
emptyLabel?: string;
disabled?: boolean;
/** v0.1.x-Compat: heute ignoriert. */
maxTags?: number;
}
let {
tags,
selectedIds,
selectedTags,
onToggle,
onTagsChange,
onCreate,
onClear,
label,
placeholder = 'Suchen oder neu …',
searchPlaceholder,
clearLabel = 'Auswahl leeren',
createLabel = 'Anlegen:',
emptyLabel = 'Keine Tags',
disabled = false,
}: Props = $props();
// v0.1.x-Compat: selectedTags → selectedIds
const effectiveSelectedIds = $derived<string[]>(
selectedIds ?? selectedTags?.map((t) => t.id) ?? []
);
const effectivePlaceholder = $derived(searchPlaceholder ?? placeholder);
function handleToggle(id: string) {
if (onToggle) {
onToggle(id);
return;
}
if (onTagsChange) {
const current = effectiveSelectedIds;
if (current.includes(id)) {
const next = tags.filter((t) => current.includes(t.id) && t.id !== id);
onTagsChange(next);
} else {
const tag = tags.find((t) => t.id === id);
if (tag) {
const next = [...tags.filter((t) => current.includes(t.id)), tag];
onTagsChange(next);
}
}
}
}
let query = $state('');
function tagLabel(t: Tag): string {
return t.label ?? t.name ?? '';
}
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return tags;
return tags.filter((t) => tagLabel(t).toLowerCase().includes(q));
});
const exactMatch = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return true;
return tags.some((t) => tagLabel(t).toLowerCase() === q);
});
const canCreate = $derived(!!onCreate && query.trim().length > 0 && !exactMatch);
function isSelected(id: string): boolean {
return effectiveSelectedIds.includes(id);
}
function handleCreate() {
if (!canCreate || !onCreate) return;
onCreate(query.trim());
query = '';
}
function handleSearchKey(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (canCreate) handleCreate();
}
}
</script>
<div class="tag-selector" class:disabled>
{#if label}<span class="header-label">{label}</span>{/if}
{#if effectiveSelectedIds.length > 0}
<div class="selected" aria-label="Ausgewählte Tags">
{#each tags.filter((t) => isSelected(t.id)) as tag (tag.id)}
<TagChip
label={tagLabel(tag)}
color={tag.color}
removable
onRemove={() => handleToggle(tag.id)}
/>
{/each}
{#if onClear}
<button type="button" class="clear" onclick={() => onClear?.()}>{clearLabel}</button>
{/if}
</div>
{/if}
<div class="search">
<DynamicIcon name="search" size="sm" />
<input type="text" {placeholder} bind:value={query} {disabled} onkeydown={handleSearchKey} />
</div>
<div class="options" role="listbox" aria-multiselectable="true">
{#if filtered.length === 0 && !canCreate}
<p class="empty">{emptyLabel}</p>
{:else}
{#each filtered as tag (tag.id)}
<button
type="button"
class="option"
class:selected={isSelected(tag.id)}
role="option"
aria-selected={isSelected(tag.id)}
onclick={() => handleToggle(tag.id)}
>
{#if tag.color}
<span class="dot" style:background={tag.color} aria-hidden="true"></span>
{/if}
<span class="opt-label">{tagLabel(tag)}</span>
{#if isSelected(tag.id)}
<DynamicIcon name="check" size="sm" />
{/if}
</button>
{/each}
{#if canCreate}
<button type="button" class="option create" onclick={handleCreate}>
<DynamicIcon name="plus" size="sm" />
<span class="opt-label">{createLabel} <strong>{query.trim()}</strong></span>
</button>
{/if}
{/if}
</div>
</div>
<style>
.tag-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.625rem;
font-family: inherit;
}
.tag-selector.disabled {
opacity: 0.6;
pointer-events: none;
}
.header-label {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.selected {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: center;
}
.clear {
margin-left: 0.25rem;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
background: transparent;
border: none;
cursor: pointer;
font-family: inherit;
}
.clear:hover {
color: hsl(var(--color-foreground));
}
.search {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
background: hsl(var(--color-muted));
border-radius: 0.375rem;
color: hsl(var(--color-muted-foreground));
}
.search:focus-within {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.search input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.8125rem;
outline: none;
}
.search input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.options {
display: flex;
flex-direction: column;
gap: 0.125rem;
max-height: 14rem;
overflow-y: auto;
}
.empty {
margin: 0.5rem 0;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
border-radius: 0.375rem;
color: hsl(var(--color-foreground));
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
text-align: left;
}
.option:hover {
background: hsl(var(--color-surface-hover));
}
.option:focus-visible {
outline: 2px solid hsl(var(--color-primary));
outline-offset: -2px;
}
.option.selected {
color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.08);
}
.option.create {
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.option.create strong {
color: hsl(var(--color-foreground));
font-style: normal;
font-weight: 500;
}
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.opt-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,56 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Textarea from './Textarea.svelte';
const { Story } = defineMeta({
title: 'Molecules/Textarea',
component: Textarea,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<div style="width: 360px;">
<Textarea label="Notiz" placeholder="Ein paar Gedanken …" rows={4} />
</div>
{/snippet}
</Story>
<Story name="WithCharCount">
{#snippet children()}
<div style="width: 360px;">
<Textarea
label="Kurzbeschreibung"
value="Vereins-Karten zur regelmäßigen Wiederholung."
maxLength={200}
hint="Wird auf der Sammlungs-Seite angezeigt."
/>
</div>
{/snippet}
</Story>
<Story name="NearLimit">
{#snippet children()}
<div style="width: 360px;">
<Textarea
label="Knapp am Limit"
value="Hier ist ein Text, der sich dem Maximum nähert. Noch wenige Zeichen frei."
maxLength={120}
/>
</div>
{/snippet}
</Story>
<Story name="WithError">
{#snippet children()}
<div style="width: 360px;">
<Textarea
label="Antwort"
value=""
error="Pflichtfeld — bitte mindestens einen Satz schreiben."
required
/>
</div>
{/snippet}
</Story>

View file

@ -1,201 +1,186 @@
<script lang="ts">
interface Props {
/** Current value */
value: string;
/** Called on input */
oninput?: (value: string) => void;
/** Called on change (blur) */
onchange?: (value: string) => void;
/** Label text */
label?: string;
/** Placeholder text */
value?: string;
placeholder?: string;
/** Number of visible rows */
rows?: number;
/** Maximum character count */
maxlength?: number;
/** Show character count */
showCount?: boolean;
/** Error message */
label?: string;
hint?: string;
error?: string;
/** Disable the textarea */
disabled?: boolean;
/** Mark as required */
readonly?: boolean;
required?: boolean;
/** Enable auto-resize based on content */
autoResize?: boolean;
/** Additional CSS classes */
class?: string;
/** Unique ID for accessibility */
rows?: number;
maxLength?: number;
id?: string;
name?: string;
ariaLabel?: string;
oninput?: (e: Event) => void;
onchange?: (e: Event) => void;
onblur?: (e: FocusEvent) => void;
}
let {
value = $bindable(),
oninput,
onchange,
label,
value = $bindable(''),
placeholder,
rows = 3,
maxlength,
showCount = false,
label,
hint,
error,
disabled = false,
readonly = false,
required = false,
autoResize = false,
class: className = '',
id = `textarea-${Math.random().toString(36).slice(2, 9)}`,
rows = 4,
maxLength,
id,
name,
ariaLabel,
oninput,
onchange,
onblur,
}: Props = $props();
let textareaElement: HTMLTextAreaElement | null = $state(null);
const charCount = $derived(value?.length ?? 0);
const isOverLimit = $derived(maxlength ? charCount > maxlength : false);
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
value = target.value;
oninput?.(target.value);
if (autoResize && textareaElement) {
textareaElement.style.height = 'auto';
textareaElement.style.height = `${textareaElement.scrollHeight}px`;
}
}
function handleChange(e: Event) {
const target = e.target as HTMLTextAreaElement;
onchange?.(target.value);
}
const inputId = $derived(id ?? `textarea-${Math.random().toString(36).slice(2, 9)}`);
const hintId = $derived(hint || error ? `${inputId}-hint` : undefined);
const remainingChars = $derived(maxLength ? maxLength - (value?.length ?? 0) : null);
</script>
<div class="textarea-wrapper {className}">
<div class="field">
{#if label}
<label for={id} class="textarea-label">
<label for={inputId}>
{label}
{#if required}
<span class="textarea-required">*</span>
{/if}
{#if required}<span class="required" aria-hidden="true">*</span>{/if}
</label>
{/if}
<textarea
{id}
bind:this={textareaElement}
{value}
{placeholder}
{rows}
{maxlength}
{disabled}
{required}
oninput={handleInput}
onchange={handleChange}
class="textarea-input {error || isOverLimit ? 'textarea-input--error' : ''} {autoResize
? 'textarea-input--auto-resize'
: ''}"
></textarea>
<div class="textarea-footer">
<div class="wrap" class:disabled class:has-error={!!error}>
<textarea
id={inputId}
{name}
{placeholder}
{disabled}
{readonly}
{required}
{rows}
maxlength={maxLength}
aria-label={ariaLabel}
aria-invalid={error ? 'true' : undefined}
aria-describedby={hintId}
bind:value
{oninput}
{onchange}
{onblur}
></textarea>
</div>
<div class="meta">
{#if error}
<p class="textarea-error">{error}</p>
<p class="hint error" id={hintId} role="alert">{error}</p>
{:else if hint}
<p class="hint" id={hintId}>{hint}</p>
{:else}
<span></span>
{/if}
{#if showCount || maxlength}
<span class="textarea-count {isOverLimit ? 'textarea-count--error' : ''}">
{charCount}{#if maxlength}/{maxlength}{/if}
{#if remainingChars !== null}
<span class="char-count" class:near-limit={remainingChars <= 20}>
{remainingChars}
</span>
{/if}
</div>
</div>
<style>
.textarea-wrapper {
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.textarea-label {
label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.textarea-required {
.required {
color: hsl(var(--color-error));
margin-left: 0.125rem;
}
.textarea-input {
width: 100%;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-family: inherit;
line-height: 1.5;
color: hsl(var(--color-foreground));
background-color: hsl(var(--color-surface));
.wrap {
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
resize: vertical;
transition: all 0.15s ease;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.textarea-input:hover:not(:disabled) {
border-color: hsl(var(--color-border-strong));
}
.textarea-input:focus {
outline: none;
.wrap:focus-within {
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.2);
}
.textarea-input:disabled {
opacity: 0.5;
cursor: not-allowed;
resize: none;
.wrap.disabled {
opacity: 0.6;
background: hsl(var(--color-muted));
}
.textarea-input--error {
.wrap.has-error {
border-color: hsl(var(--color-error));
}
.textarea-input--error:focus {
border-color: hsl(var(--color-error));
box-shadow: 0 0 0 3px hsl(var(--color-error) / 0.1);
.wrap.has-error:focus-within {
box-shadow: 0 0 0 2px hsl(var(--color-error) / 0.2);
}
.textarea-input--auto-resize {
resize: none;
overflow: hidden;
textarea {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-family: inherit;
outline: none;
resize: vertical;
min-height: 4rem;
}
.textarea-input::placeholder {
textarea::placeholder {
color: hsl(var(--color-muted-foreground));
}
.textarea-footer {
textarea:disabled {
cursor: not-allowed;
}
.meta {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 1.25rem;
align-items: baseline;
gap: 0.5rem;
}
.textarea-error {
font-size: 0.75rem;
color: hsl(var(--color-error));
.hint {
margin: 0;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
.textarea-count {
.hint.error {
color: hsl(var(--color-error));
}
.char-count {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.textarea-count--error {
color: hsl(var(--color-error));
.char-count.near-limit {
color: hsl(var(--color-warning));
}
@media (prefers-reduced-motion: reduce) {
.wrap {
transition: none;
}
}
</style>

View file

@ -0,0 +1,50 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Toggle from './Toggle.svelte';
const { Story } = defineMeta({
title: 'Molecules/Toggle',
component: Toggle,
tags: ['autodocs'],
});
</script>
<Story name="Default">
{#snippet children()}
<Toggle label="Dark-Mode" />
{/snippet}
</Story>
<Story name="On">
{#snippet children()}
<Toggle label="Cross-Device-Sync aktiv" checked={true} />
{/snippet}
</Story>
<Story name="WithHint">
{#snippet children()}
<Toggle
label="Reduzierte Bewegung"
hint="Animationen werden auf 0.01ms gekürzt."
checked={true}
/>
{/snippet}
</Story>
<Story name="SizeSweep">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:0.625rem;">
<Toggle size="sm" label="Klein" />
<Toggle size="md" label="Mittel" />
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet children()}
<div style="display:flex; flex-direction:column; gap:0.5rem;">
<Toggle label="Disabled (off)" disabled />
<Toggle label="Disabled (on)" checked={true} disabled />
</div>
{/snippet}
</Story>

View file

@ -1,39 +1,166 @@
<script lang="ts">
type Size = 'sm' | 'md';
interface Props {
isOn: boolean;
onToggle: (value: boolean) => void;
checked?: boolean;
/** v0.1.x-Compat-Alias für `checked`. */
isOn?: boolean;
label?: string;
hint?: string;
disabled?: boolean;
size?: 'sm' | 'md';
size?: Size;
id?: string;
name?: string;
ariaLabel?: string;
onchange?: (e: Event) => void;
/** v0.1.x-Compat: Toggle-Callback mit boolean-Wert. */
onToggle?: (value: boolean) => void;
}
let { isOn = false, onToggle, disabled = false, size = 'md' }: Props = $props();
let {
checked = $bindable(false),
isOn,
label,
hint,
disabled = false,
size = 'md',
id,
name,
ariaLabel,
onchange,
onToggle,
}: Props = $props();
function handleToggle() {
if (!disabled) {
onToggle(!isOn);
// v0.1.x-Compat: wenn isOn gesetzt, schiebe es auf checked
$effect(() => {
if (isOn !== undefined && isOn !== checked) {
checked = isOn;
}
}
});
const sizeClasses = {
sm: { track: 'h-6 w-10', thumb: 'h-4 w-4 top-1 left-1', translate: 'translate-x-4' },
md: { track: 'h-8 w-14', thumb: 'h-6 w-6 top-1 left-1', translate: 'translate-x-6' },
};
const inputId = $derived(id ?? `toggle-${Math.random().toString(36).slice(2, 9)}`);
const hintId = $derived(hint ? `${inputId}-hint` : undefined);
</script>
<button
onclick={handleToggle}
class="relative {sizeClasses[size].track} flex-shrink-0 rounded-full transition-colors {isOn
? 'bg-primary'
: 'bg-menu'} {disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}"
role="switch"
aria-checked={isOn}
aria-label="Toggle"
{disabled}
>
<span
class="absolute {sizeClasses[size]
.thumb} rounded-full bg-white shadow-md transition-transform {isOn
? sizeClasses[size].translate
: 'translate-x-0'}"
></span>
</button>
<label class="toggle size-{size}" class:disabled for={inputId}>
<input
type="checkbox"
role="switch"
id={inputId}
{name}
{disabled}
aria-label={ariaLabel}
aria-describedby={hintId}
aria-checked={checked}
bind:checked
onchange={(e) => {
onchange?.(e);
onToggle?.((e.currentTarget as HTMLInputElement).checked);
}}
/>
<span class="track" aria-hidden="true">
<span class="thumb"></span>
</span>
{#if label || hint}
<span class="label-text">
{#if label}{label}{/if}
{#if hint}<span class="hint" id={hintId}>{hint}</span>{/if}
</span>
{/if}
</label>
<style>
.toggle {
display: inline-flex;
align-items: center;
gap: 0.625rem;
cursor: pointer;
font: inherit;
color: hsl(var(--color-foreground));
}
.toggle.disabled {
cursor: not-allowed;
opacity: 0.6;
}
input {
position: absolute;
opacity: 0;
pointer-events: none;
width: 1px;
height: 1px;
}
.track {
position: relative;
display: inline-block;
flex-shrink: 0;
background: hsl(var(--color-muted));
border-radius: 9999px;
transition: background-color 0.2s ease;
}
.size-sm .track {
width: 1.75rem;
height: 1rem;
}
.size-md .track {
width: 2.25rem;
height: 1.25rem;
}
.thumb {
position: absolute;
top: 2px;
left: 2px;
background: hsl(var(--color-surface));
border-radius: 9999px;
box-shadow: 0 1px 2px hsl(var(--color-foreground) / 0.15);
transition: transform 0.2s ease;
}
.size-sm .thumb {
width: 0.75rem;
height: 0.75rem;
}
.size-md .thumb {
width: 1rem;
height: 1rem;
}
input:checked + .track {
background: hsl(var(--color-primary));
}
.size-sm input:checked + .track .thumb {
transform: translateX(0.75rem);
}
.size-md input:checked + .track .thumb {
transform: translateX(1rem);
}
input:focus-visible + .track {
outline: 2px solid hsl(var(--color-primary));
outline-offset: 2px;
}
.label-text {
font-size: 0.9375rem;
line-height: 1.35;
}
.hint {
display: block;
margin-top: 0.125rem;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
@media (prefers-reduced-motion: reduce) {
.track,
.thumb {
transition: none;
}
}
</style>

View file

@ -1,100 +0,0 @@
<script lang="ts">
import { User } from '@mana/shared-icons';
interface Props {
/** Photo URL */
photoUrl?: string | null;
/** Display name (for initials fallback) */
name?: string;
/** Size in pixels */
size?: 'xs' | 'sm' | 'md' | 'lg';
/** Custom class */
class?: string;
}
let { photoUrl, name = '', size = 'md', class: className = '' }: Props = $props();
const sizeClasses = {
xs: 'w-5 h-5 text-[10px]',
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm',
lg: 'w-10 h-10 text-base',
};
const iconSizes = {
xs: 10,
sm: 12,
md: 16,
lg: 20,
};
// Generate initials from name
const initials = $derived.by(() => {
if (!name) return '';
const parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
});
// Generate a consistent background color based on the name
const bgColor = $derived.by(() => {
if (!name) return 'bg-gray-400';
const colors = [
'bg-violet-500',
'bg-blue-500',
'bg-cyan-500',
'bg-teal-500',
'bg-green-500',
'bg-amber-500',
'bg-orange-500',
'bg-rose-500',
'bg-pink-500',
'bg-indigo-500',
];
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
});
</script>
{#if photoUrl}
<img
src={photoUrl}
alt={name || 'Kontakt'}
class="
{sizeClasses[size]}
rounded-full object-cover
{className}
"
/>
{:else if initials}
<div
class="
{sizeClasses[size]}
{bgColor}
rounded-full
flex items-center justify-center
text-white font-medium
{className}
"
>
{initials}
</div>
{:else}
<div
class="
{sizeClasses[size]}
bg-gray-300 dark:bg-gray-600
rounded-full
flex items-center justify-center
text-gray-500 dark:text-gray-400
{className}
"
>
<User size={iconSizes[size]} />
</div>
{/if}

View file

@ -1,185 +0,0 @@
<script lang="ts">
import { X } from '@mana/shared-icons';
import ContactAvatar from './ContactAvatar.svelte';
import type {
ContactReference,
ManualContactEntry,
ContactOrManual,
} from '@mana/shared-types';
interface Props {
/** Contact to display */
contact: ContactOrManual;
/** Show remove button */
removable?: boolean;
/** Called when remove is clicked */
onRemove?: () => void;
/** Size variant */
size?: 'sm' | 'md';
/** Show email under name */
showEmail?: boolean;
}
let { contact, removable = false, onRemove, size = 'md', showEmail = false }: Props = $props();
// Check if this is a manual entry
const isManual = $derived('isManual' in contact && contact.isManual === true);
// Get display values
const displayName = $derived(
isManual
? (contact as ManualContactEntry).name || (contact as ManualContactEntry).email
: (contact as ContactReference).displayName
);
const email = $derived(
isManual ? (contact as ManualContactEntry).email : (contact as ContactReference).email
);
const photoUrl = $derived(isManual ? undefined : (contact as ContactReference).photoUrl);
const avatarSizes = {
sm: 'xs' as const,
md: 'sm' as const,
};
</script>
<span
class="contact-badge"
class:size-sm={size === 'sm'}
class:size-md={size === 'md'}
class:manual={isManual}
>
<ContactAvatar {photoUrl} name={displayName} size={avatarSizes[size]} />
<span class="contact-info">
<span class="contact-name">{displayName}</span>
{#if showEmail && email && email !== displayName}
<span class="contact-email">{email}</span>
{/if}
</span>
{#if removable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onRemove?.();
}}
class="remove-btn"
aria-label="Entfernen"
>
<X size={12} />
</button>
{/if}
</span>
<style>
.contact-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: rgba(139, 92, 246, 0.12);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 9999px;
transition: all 0.15s;
}
:global(.dark) .contact-badge {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.25);
}
.contact-badge:hover {
background: rgba(139, 92, 246, 0.18);
border-color: rgba(139, 92, 246, 0.3);
}
:global(.dark) .contact-badge:hover {
background: rgba(139, 92, 246, 0.22);
border-color: rgba(139, 92, 246, 0.35);
}
/* Manual entry variant (dashed border) */
.contact-badge.manual {
background: rgba(107, 114, 128, 0.1);
border: 1px dashed rgba(107, 114, 128, 0.3);
}
:global(.dark) .contact-badge.manual {
background: rgba(156, 163, 175, 0.12);
border-color: rgba(156, 163, 175, 0.3);
}
/* Size variants */
.size-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.size-md {
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
}
.contact-info {
display: flex;
flex-direction: column;
line-height: 1.2;
min-width: 0;
}
.contact-name {
color: #374151;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
:global(.dark) .contact-name {
color: #f3f4f6;
}
.contact-email {
font-size: 0.625rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
:global(.dark) .contact-email {
color: #9ca3af;
}
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
margin-left: 0.125rem;
padding: 0.25rem;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
border-radius: 9999px;
transition: all 0.15s;
}
:global(.dark) .remove-btn {
color: #9ca3af;
}
.remove-btn:hover {
background: rgba(0, 0, 0, 0.08);
color: #374151;
}
:global(.dark) .remove-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e5e7eb;
}
</style>

View file

@ -1,711 +0,0 @@
<script lang="ts">
import { Plus, MagnifyingGlass, User, Envelope } from '@mana/shared-icons';
import ContactBadge from './ContactBadge.svelte';
import ContactAvatar from './ContactAvatar.svelte';
import type {
ContactReference,
ContactSummary,
ManualContactEntry,
ContactOrManual,
createContactReference,
} from '@mana/shared-types';
interface Props {
/** Currently selected contacts */
selectedContacts: ContactOrManual[];
/** Called when selection changes */
onContactsChange: (contacts: ContactOrManual[]) => void;
/** Function to search contacts (async) */
onSearch: (query: string) => Promise<ContactSummary[]>;
/** Allow manual email entry (for contacts not in system) */
allowManualEntry?: boolean;
/** Maximum contacts that can be selected */
maxContacts?: number;
/** Single select mode (only one contact allowed) */
singleSelect?: boolean;
/** Placeholder text */
placeholder?: string;
/** Add button label */
addLabel?: string;
/** Search placeholder */
searchPlaceholder?: string;
/** Loading state */
loading?: boolean;
/** Disabled state */
disabled?: boolean;
/** Show "not available" message when contacts API is down */
unavailableMessage?: string;
/** Is contacts API available */
isAvailable?: boolean;
}
let {
selectedContacts,
onContactsChange,
onSearch,
allowManualEntry = false,
maxContacts,
singleSelect = false,
placeholder = 'Kontakt hinzufügen...',
addLabel = 'Kontakt hinzufügen',
searchPlaceholder = 'Name oder E-Mail suchen...',
loading = false,
disabled = false,
unavailableMessage = 'Kontakte nicht verfügbar',
isAvailable = true,
}: Props = $props();
let isOpen = $state(false);
let searchQuery = $state('');
let searchResults = $state<ContactSummary[]>([]);
let isSearching = $state(false);
let showManualEntry = $state(false);
let manualEmail = $state('');
let manualName = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let searchInputRef = $state<HTMLInputElement | null>(null);
let highlightedIndex = $state(-1);
// Focus search input when dropdown opens
$effect(() => {
if (isOpen && searchInputRef) {
setTimeout(() => searchInputRef?.focus(), 0);
highlightedIndex = -1;
}
});
// Reset highlighted index when results change
$effect(() => {
if (searchResults.length > 0) {
highlightedIndex = -1;
}
});
const effectiveMax = $derived(singleSelect ? 1 : maxContacts);
const canAddMore = $derived(!effectiveMax || selectedContacts.length < effectiveMax);
// Check if an email looks valid
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Debounced search
async function handleSearchInput(query: string) {
searchQuery = query;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (!query.trim()) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
if (!isAvailable) return;
isSearching = true;
try {
const results = await onSearch(query);
// Filter out already selected contacts
const selectedIds = new Set(
selectedContacts
.filter((c): c is ContactReference => 'contactId' in c)
.map((c) => c.contactId)
);
searchResults = results.filter((r) => !selectedIds.has(r.id));
} catch (error) {
console.error('Contact search failed:', error);
searchResults = [];
} finally {
isSearching = false;
}
}, 300);
}
function handleSelectContact(contact: ContactSummary) {
if (!canAddMore) return;
const reference: ContactReference = {
contactId: contact.id,
displayName: contact.displayName,
email: contact.email,
photoUrl: contact.photoUrl,
company: contact.company,
fetchedAt: new Date().toISOString(),
};
if (singleSelect) {
onContactsChange([reference]);
} else {
onContactsChange([...selectedContacts, reference]);
}
searchQuery = '';
searchResults = [];
isOpen = false;
}
function handleRemoveContact(index: number) {
onContactsChange(selectedContacts.filter((_, i) => i !== index));
}
function handleAddManualEntry() {
if (!manualEmail.trim() || !isValidEmail(manualEmail)) return;
const entry: ManualContactEntry = {
email: manualEmail.trim(),
name: manualName.trim() || undefined,
isManual: true,
};
if (singleSelect) {
onContactsChange([entry]);
} else {
onContactsChange([...selectedContacts, entry]);
}
manualEmail = '';
manualName = '';
showManualEntry = false;
isOpen = false;
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.contact-selector-container')) {
isOpen = false;
showManualEntry = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isOpen = false;
showManualEntry = false;
}
}
function handleSearchKeyDown(e: KeyboardEvent) {
if (!isOpen || searchResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightedIndex = Math.min(highlightedIndex + 1, searchResults.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightedIndex = Math.max(highlightedIndex - 1, -1);
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
e.preventDefault();
handleSelectContact(searchResults[highlightedIndex]);
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
<div class="contact-selector-container">
<!-- Selected Contacts Display -->
<div class="selected-contacts">
{#each selectedContacts as contact, index (index)}
<ContactBadge {contact} removable onRemove={() => handleRemoveContact(index)} />
{/each}
{#if canAddMore && !disabled}
<button type="button" onclick={() => (isOpen = !isOpen)} class="add-button" {disabled}>
<Plus size={14} weight="bold" />
<span>{addLabel}</span>
</button>
{/if}
</div>
<!-- Dropdown -->
{#if isOpen}
<div class="dropdown">
{#if !isAvailable}
<!-- Unavailable State -->
<div class="unavailable-state">
<User size={24} />
<p>{unavailableMessage}</p>
{#if allowManualEntry}
<button type="button" onclick={() => (showManualEntry = true)} class="manual-link">
Manuell hinzufügen
</button>
{/if}
</div>
{:else}
<!-- Search Input -->
<div class="search-section">
<div class="search-input-wrapper">
<MagnifyingGlass size={16} class="search-icon" />
<input
bind:this={searchInputRef}
type="text"
value={searchQuery}
oninput={(e) => handleSearchInput(e.currentTarget.value)}
onkeydown={handleSearchKeyDown}
placeholder={searchPlaceholder}
class="search-input"
/>
</div>
</div>
<!-- Results List -->
<div class="results-list">
{#if isSearching || loading}
<div class="empty-state">Suche...</div>
{:else if searchResults.length > 0}
{#each searchResults as contact, index (contact.id)}
<button
type="button"
onclick={() => handleSelectContact(contact)}
class="result-item"
class:highlighted={index === highlightedIndex}
>
<ContactAvatar photoUrl={contact.photoUrl} name={contact.displayName} size="md" />
<div class="result-info">
<div class="result-name">{contact.displayName}</div>
{#if contact.email}
<div class="result-detail">{contact.email}</div>
{/if}
{#if contact.company}
<div class="result-detail">{contact.company}</div>
{/if}
</div>
</button>
{/each}
{:else if searchQuery.trim()}
<div class="empty-state">Kein Kontakt gefunden</div>
{:else}
<div class="empty-state">Name oder E-Mail eingeben...</div>
{/if}
</div>
<!-- Manual Entry Option -->
{#if allowManualEntry}
<div class="manual-section">
{#if showManualEntry}
<div class="manual-form">
<div class="input-with-icon">
<Envelope size={14} />
<input
type="email"
bind:value={manualEmail}
placeholder="E-Mail-Adresse *"
class="manual-input"
/>
</div>
<div class="input-with-icon">
<User size={14} />
<input
type="text"
bind:value={manualName}
placeholder="Name (optional)"
class="manual-input"
onkeydown={(e) => e.key === 'Enter' && handleAddManualEntry()}
/>
</div>
<div class="manual-actions">
<button
type="button"
onclick={() => (showManualEntry = false)}
class="btn-cancel"
>
Abbrechen
</button>
<button
type="button"
onclick={handleAddManualEntry}
disabled={!manualEmail.trim() || !isValidEmail(manualEmail)}
class="btn-add"
>
Hinzufügen
</button>
</div>
</div>
{:else}
<button type="button" onclick={() => (showManualEntry = true)} class="manual-trigger">
<Envelope size={14} />
<span>E-Mail manuell hinzufügen</span>
</button>
{/if}
</div>
{/if}
{/if}
</div>
{/if}
</div>
<style>
.contact-selector-container {
position: relative;
}
.selected-contacts {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.add-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
color: #6b7280;
background: transparent;
border: 1px dashed rgba(0, 0, 0, 0.2);
border-radius: 9999px;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .add-button {
color: #9ca3af;
border-color: rgba(255, 255, 255, 0.2);
}
.add-button:hover:not(:disabled) {
color: #374151;
border-color: rgba(0, 0, 0, 0.3);
background: rgba(0, 0, 0, 0.03);
}
:global(.dark) .add-button:hover:not(:disabled) {
color: #e5e7eb;
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.05);
}
.add-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Dropdown */
.dropdown {
position: absolute;
z-index: 50;
margin-top: 0.25rem;
width: 100%;
min-width: 320px;
background: rgba(255, 255, 255, 1);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 1rem;
box-shadow:
0 12px 28px -5px rgba(0, 0, 0, 0.2),
0 0 0 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
:global(.dark) .dropdown {
background: rgba(45, 45, 45, 1);
border-color: rgba(255, 255, 255, 0.18);
box-shadow:
0 12px 28px -5px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05);
}
/* Search Section */
.search-section {
padding: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .search-section {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.search-input-wrapper {
position: relative;
}
.search-input-wrapper :global(.search-icon) {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: #6b7280;
}
:global(.dark) .search-input-wrapper :global(.search-icon) {
color: #9ca3af;
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
font-size: 0.875rem;
color: #374151;
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.75rem;
outline: none;
transition: all 0.15s;
}
:global(.dark) .search-input {
color: #f3f4f6;
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.search-input:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
.search-input::placeholder {
color: #9ca3af;
}
/* Results List */
.results-list {
max-height: 14rem;
overflow-y: auto;
}
.empty-state {
padding: 1rem;
text-align: center;
font-size: 0.875rem;
color: #6b7280;
}
:global(.dark) .empty-state {
color: #9ca3af;
}
.result-item {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 1rem;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.result-item:hover,
.result-item.highlighted {
background: rgba(139, 92, 246, 0.08);
}
:global(.dark) .result-item:hover,
:global(.dark) .result-item.highlighted {
background: rgba(139, 92, 246, 0.15);
}
.result-info {
flex: 1;
min-width: 0;
}
.result-name {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .result-name {
color: #f3f4f6;
}
.result-detail {
font-size: 0.75rem;
color: #6b7280;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .result-detail {
color: #9ca3af;
}
/* Manual Entry Section */
.manual-section {
padding: 0.75rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .manual-section {
border-top-color: rgba(255, 255, 255, 0.08);
}
.manual-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-with-icon {
position: relative;
display: flex;
align-items: center;
}
.input-with-icon > :global(svg) {
position: absolute;
left: 0.75rem;
color: #6b7280;
}
:global(.dark) .input-with-icon > :global(svg) {
color: #9ca3af;
}
.manual-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
font-size: 0.875rem;
color: #374151;
background: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.75rem;
outline: none;
transition: all 0.15s;
}
:global(.dark) .manual-input {
color: #f3f4f6;
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.manual-input:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
}
.manual-input::placeholder {
color: #9ca3af;
}
.manual-actions {
display: flex;
gap: 0.5rem;
}
.btn-cancel {
flex: 1;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
color: #6b7280;
background: transparent;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
:global(.dark) .btn-cancel {
color: #9ca3af;
}
.btn-cancel:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .btn-cancel:hover {
background: rgba(255, 255, 255, 0.08);
}
.btn-add {
flex: 1;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: white;
background: #8b5cf6;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-add:hover:not(:disabled) {
opacity: 0.9;
}
.btn-add:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.manual-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: #6b7280;
background: transparent;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .manual-trigger {
color: #9ca3af;
}
.manual-trigger:hover {
color: #374151;
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .manual-trigger:hover {
color: #e5e7eb;
background: rgba(255, 255, 255, 0.08);
}
/* Unavailable State */
.unavailable-state {
padding: 1.5rem;
text-align: center;
color: #6b7280;
}
:global(.dark) .unavailable-state {
color: #9ca3af;
}
.unavailable-state > :global(svg) {
margin: 0 auto 0.5rem;
opacity: 0.5;
}
.unavailable-state p {
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.manual-link {
font-size: 0.875rem;
color: #8b5cf6;
background: none;
border: none;
cursor: pointer;
text-decoration: none;
}
.manual-link:hover {
text-decoration: underline;
}
</style>

View file

@ -1,4 +0,0 @@
// Contact selection and display components
export { default as ContactAvatar } from './ContactAvatar.svelte';
export { default as ContactBadge } from './ContactBadge.svelte';
export { default as ContactSelector } from './ContactSelector.svelte';

View file

@ -1,121 +0,0 @@
<script lang="ts">
/**
* EmptyState - Standardized empty state display
*
* Used when a list, search, or section has no content to display.
* Provides consistent visual feedback with optional action button.
*
* @example Basic usage
* ```svelte
* <EmptyState
* title="No memos yet"
* message="Start recording to create your first memo"
* />
* ```
*
* @example With action and icon
* ```svelte
* <EmptyState
* title="No results found"
* message="Try adjusting your search or filters"
* actionLabel="Clear filters"
* onAction={() => clearFilters()}
* >
* {#snippet icon()}
* <SearchIcon class="w-12 h-12" />
* {/snippet}
* </EmptyState>
* ```
*/
import type { Snippet } from 'svelte';
import { Text, Button } from '../../atoms';
import { Tray } from '@mana/shared-icons';
type EmptyStateVariant = 'default' | 'compact' | 'centered';
interface Props {
/** Title text */
title: string;
/** Description message */
message?: string;
/** Action button label */
actionLabel?: string;
/** Action button callback */
onAction?: () => void;
/** Secondary action label */
secondaryActionLabel?: string;
/** Secondary action callback */
onSecondaryAction?: () => void;
/** Layout variant */
variant?: EmptyStateVariant;
/** Custom icon snippet */
icon?: Snippet;
/** Additional CSS classes */
class?: string;
}
let {
title,
message,
actionLabel,
onAction,
secondaryActionLabel,
onSecondaryAction,
variant = 'default',
icon,
class: className = '',
}: Props = $props();
const variantClasses: Record<EmptyStateVariant, string> = {
default: 'py-12 px-6',
compact: 'py-6 px-4',
centered: 'py-16 px-8',
};
</script>
<div
class="empty-state flex flex-col items-center justify-center text-center {variantClasses[
variant
]} {className}"
>
<!-- Icon -->
{#if icon}
<div class="empty-state__icon mb-4 text-theme-secondary opacity-50">
{@render icon()}
</div>
{:else}
<!-- Default icon -->
<div class="empty-state__icon mb-4 text-theme-secondary opacity-50">
<Tray size={48} />
</div>
{/if}
<!-- Title -->
<Text variant="body" weight="semibold" class="mb-2">
{title}
</Text>
<!-- Message -->
{#if message}
<Text variant="muted" class="max-w-sm mb-4">
{message}
</Text>
{/if}
<!-- Actions -->
{#if actionLabel || secondaryActionLabel}
<div class="empty-state__actions flex gap-3 mt-2">
{#if secondaryActionLabel && onSecondaryAction}
<Button variant="ghost" onclick={onSecondaryAction}>
{secondaryActionLabel}
</Button>
{/if}
{#if actionLabel && onAction}
<Button variant="primary" onclick={onAction}>
{actionLabel}
</Button>
{/if}
</div>
{/if}
</div>

View file

@ -1,4 +0,0 @@
/**
* Feedback components for user states
*/
export { default as EmptyState } from './EmptyState.svelte';

View file

@ -1,64 +1,19 @@
export { default as Toggle } from './Toggle.svelte';
export { default as Input } from './Input.svelte';
export { default as Select } from './Select.svelte';
export { default as Textarea } from './Textarea.svelte';
export { default as Select } from './Select.svelte';
export { default as Checkbox } from './Checkbox.svelte';
export { default as FilterDropdown } from './FilterDropdown.svelte';
export { default as FavoriteButton } from './FavoriteButton.svelte';
export { default as ColorPicker } from './ColorPicker.svelte';
export { COLORS_12, COLORS_16, DEFAULT_COLOR, getRandomColor } from './ColorPicker.constants';
export { default as IconPicker } from './IconPicker.svelte';
export { DEFAULT_ICON } from './IconPicker.constants';
export { default as ReminderPicker } from './ReminderPicker.svelte';
export type { SelectOption } from './Select.types';
export type { FilterDropdownOption } from './FilterDropdown.types';
// Stats components
export { GlassCard, StatRow } from './stats';
// Tag components
export {
TagBadge,
TagChip,
TagColorPicker,
TagEditModal,
TagSelector,
TagField,
TagList,
TAG_COLORS,
DEFAULT_TAG_COLOR,
getRandomTagColor,
getTagColorByName,
} from './tags';
export type { Tag, TagData, TagColorName, TagColorHex } from './tags';
// Media components
export { AudioPlayer } from './media';
// Loading components
export {
SkeletonBox,
SkeletonText,
SkeletonAvatar,
SkeletonRow,
SkeletonList,
SkeletonCard,
SkeletonGrid,
AppLoadingSkeleton,
calculateFadeOpacity,
} from './loaders';
// Feedback components
export { EmptyState } from './feedback';
// Contact components
export { ContactAvatar, ContactBadge, ContactSelector } from './contacts';
// Layout components
export { default as ModalFooter } from './ModalFooter.svelte';
export { default as DataCard } from './DataCard.svelte';
export { default as Toggle } from './Toggle.svelte';
export { default as ContactAvatar } from './ContactAvatar.svelte';
export { default as TagBadge } from './TagBadge.svelte';
export { default as TagChip } from './TagChip.svelte';
export { default as TagSelector } from './TagSelector.svelte';
export type { Tag } from './TagSelector.svelte';
export { default as PageHeader } from './PageHeader.svelte';
export { default as KeyboardShortcutsPanel } from './KeyboardShortcutsPanel.svelte';
// Confirmation
export { default as ConfirmationPopover } from './ConfirmationPopover.svelte';
export { default as DataCard } from './DataCard.svelte';
export { default as FavoriteButton } from './FavoriteButton.svelte';
export { default as ReminderPicker } from './ReminderPicker.svelte';
export type { ReminderOption } from './ReminderPicker.svelte';
export { default as TagField } from './TagField.svelte';
export { default as IconPicker } from './IconPicker.svelte';
export type { IconSlot } from './IconPicker.svelte';
export { default as EmptyState } from './EmptyState.svelte';

View file

@ -1,466 +0,0 @@
<script lang="ts">
/**
* AppLoadingSkeleton - Full page loading skeleton for app initialization
*
* A flexible loading skeleton that supports different layout presets:
* - list: Default list view with search bar and item rows
* - centered: Centered content (ideal for clock, calendar previews)
* - sidebar: Sidebar + main content layout
* - tasks: Task list with quick-add bar
* - minimal: Just a centered spinner placeholder
*
* @example
* ```svelte
* <AppLoadingSkeleton />
* <AppLoadingSkeleton layout="centered" appLogo="/logo.svg" />
* <AppLoadingSkeleton layout="sidebar" />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
import type { Snippet } from 'svelte';
type LayoutPreset = 'list' | 'centered' | 'sidebar' | 'tasks' | 'minimal';
interface Props {
/** Layout preset for the content area */
layout?: LayoutPreset;
/** Show header skeleton (default: true, false for minimal) */
showHeader?: boolean;
/** Number of list items to show (for list/tasks layouts) */
listItemCount?: number;
/** App logo URL (shown in centered layout) */
appLogo?: string;
/** Loading text for screen readers */
loadingLabel?: string;
/** Custom content slot (overrides preset content) */
children?: Snippet;
}
let {
layout = 'list',
showHeader = true,
listItemCount = 5,
appLogo,
loadingLabel = 'App wird geladen...',
children,
}: Props = $props();
// Hide header in minimal layout by default
const displayHeader = $derived(layout === 'minimal' ? false : showHeader);
</script>
<div
class="app-loading-skeleton"
class:minimal-layout={layout === 'minimal'}
class:sidebar-layout={layout === 'sidebar'}
role="status"
aria-label={loadingLabel}
>
{#if displayHeader}
<!-- Header Skeleton -->
<div class="header-skeleton">
<SkeletonBox width="120px" height="32px" borderRadius="8px" />
<div class="header-nav">
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
</div>
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
</div>
{/if}
<!-- Content Area -->
{#if children}
<!-- Custom content via slot -->
<div class="content-skeleton custom-content">
{@render children()}
</div>
{:else if layout === 'minimal'}
<!-- Minimal: Centered spinner placeholder -->
<div class="minimal-content">
<SkeletonBox width="64px" height="64px" borderRadius="50%" />
<SkeletonBox width="120px" height="16px" borderRadius="4px" />
</div>
{:else if layout === 'centered'}
<!-- Centered: Logo + preview card -->
<div class="centered-content">
{#if appLogo}
<img src={appLogo} alt="" class="app-logo" />
{:else}
<SkeletonBox width="64px" height="64px" borderRadius="16px" />
{/if}
<SkeletonBox width="180px" height="24px" borderRadius="8px" />
<div class="centered-card">
<div class="card-header">
<SkeletonBox width="120px" height="20px" />
<div class="card-actions">
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
<SkeletonBox width="32px" height="32px" borderRadius="8px" />
</div>
</div>
<div class="card-content">
{#each Array(4) as _, i}
<SkeletonBox
width="100%"
height="32px"
borderRadius="8px"
class="opacity-{Math.max(30, 100 - i * 20)}"
/>
{/each}
</div>
</div>
<SkeletonBox width="140px" height="16px" borderRadius="4px" />
</div>
{:else if layout === 'sidebar'}
<!-- Sidebar: Left sidebar + main content -->
<div class="sidebar-wrapper">
<aside class="sidebar-skeleton">
<div class="sidebar-header">
<SkeletonBox width="100%" height="40px" borderRadius="8px" />
</div>
<nav class="sidebar-nav">
<SkeletonBox width="100%" height="40px" borderRadius="8px" />
<div class="sidebar-divider">
<SkeletonBox width="80px" height="12px" />
</div>
{#each Array(4) as _}
<SkeletonBox width="100%" height="40px" borderRadius="8px" />
{/each}
</nav>
</aside>
<main class="main-skeleton">
<div class="main-header">
<SkeletonBox width="200px" height="32px" />
<SkeletonBox width="120px" height="16px" />
</div>
<div class="main-toolbar">
<SkeletonBox width="100%" height="40px" borderRadius="8px" class="flex-1" />
<SkeletonBox width="120px" height="40px" borderRadius="8px" />
<SkeletonBox width="100px" height="40px" borderRadius="8px" />
</div>
<div class="main-content">
{#each Array(listItemCount) as _, i}
<div style="opacity: {Math.max(0.3, 1 - i * 0.15)}">
<SkeletonBox width="100%" height="80px" borderRadius="12px" />
</div>
{/each}
</div>
</main>
</div>
{:else if layout === 'tasks'}
<!-- Tasks: Quick-add + task sections -->
<div class="content-skeleton tasks-content">
<div class="tasks-header">
<SkeletonBox width="180px" height="28px" />
<SkeletonBox width="220px" height="16px" />
</div>
<div class="quick-add">
<SkeletonBox width="100%" height="52px" borderRadius="12px" />
</div>
<div class="task-section">
<div class="section-header">
<SkeletonBox width="100px" height="20px" />
<SkeletonBox width="28px" height="28px" borderRadius="50%" />
</div>
<div class="task-list">
{#each Array(listItemCount) as _, i}
<div class="task-item" style="opacity: {Math.max(0.3, 1 - i * 0.18)}">
<SkeletonBox width="22px" height="22px" borderRadius="6px" />
<div class="task-content">
<SkeletonBox width="{70 - i * 8}%" height="18px" />
<SkeletonBox width="{40 + i * 5}%" height="14px" />
</div>
<SkeletonBox width="24px" height="24px" borderRadius="4px" />
</div>
{/each}
</div>
</div>
</div>
{:else}
<!-- List (default): Search + item rows -->
<div class="content-skeleton list-content">
<div class="title-row">
<SkeletonBox width="200px" height="32px" />
<SkeletonBox width="120px" height="40px" borderRadius="8px" />
</div>
<SkeletonBox width="100%" height="48px" borderRadius="12px" />
<div class="list-skeleton">
{#each Array(listItemCount) as _, i}
<div class="list-item" style="opacity: {Math.max(0.3, 1 - i * 0.15)}">
<SkeletonBox width="48px" height="48px" borderRadius="50%" />
<div class="item-content">
<SkeletonBox width="60%" height="18px" />
<SkeletonBox width="40%" height="14px" />
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.app-loading-skeleton {
min-height: 100vh;
background: hsl(var(--color-background));
}
.app-loading-skeleton.sidebar-layout {
display: flex;
flex-direction: column;
}
/* Header */
.header-skeleton {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.header-nav {
display: flex;
gap: 0.5rem;
}
/* Content Areas */
.content-skeleton {
max-width: 80rem;
margin: 0 auto;
padding: 2rem;
}
.custom-content {
padding: 0;
max-width: none;
}
/* Minimal Layout */
.minimal-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 1rem;
}
/* Centered Layout */
.centered-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 80px);
padding: 2rem;
gap: 1.5rem;
}
.app-logo {
width: 64px;
height: 64px;
object-fit: contain;
}
.centered-card {
width: 100%;
max-width: 400px;
background: hsl(var(--color-card));
border-radius: 16px;
padding: 1.5rem;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-actions {
display: flex;
gap: 0.5rem;
}
.card-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Sidebar Layout */
.sidebar-wrapper {
display: flex;
flex: 1;
min-height: calc(100vh - 65px);
}
.sidebar-skeleton {
width: 16rem;
border-right: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
padding: 1rem;
}
.sidebar-header {
padding-bottom: 1rem;
border-bottom: 1px solid hsl(var(--color-border));
margin-bottom: 1rem;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sidebar-divider {
padding: 1rem 0.75rem 0.5rem;
}
.main-skeleton {
flex: 1;
padding: 1.5rem;
}
.main-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.main-toolbar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.main-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Tasks Layout */
.tasks-content {
max-width: 48rem;
}
.tasks-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.quick-add {
margin: 1.5rem 0;
}
.task-section {
margin-top: 1.5rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0;
margin-bottom: 0.5rem;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.task-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
}
.task-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
/* List Layout (Default) */
.list-content {
padding: 2rem;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.list-skeleton {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1.5rem;
}
.list-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: hsl(var(--color-card));
border: 1px solid hsl(var(--color-border));
border-radius: 12px;
}
.item-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Responsive */
@media (max-width: 768px) {
.header-nav {
display: none;
}
.header-skeleton {
padding: 1rem;
}
.content-skeleton {
padding: 1rem;
}
.sidebar-skeleton {
display: none;
}
.sidebar-wrapper {
min-height: calc(100vh - 57px);
}
.centered-content {
padding: 1rem;
}
}
</style>

View file

@ -1,24 +0,0 @@
<script lang="ts">
/**
* SkeletonAvatar - Circular skeleton for profile pictures/avatars
*
* @example
* ```svelte
* <SkeletonAvatar size="40px" />
* <SkeletonAvatar size="64px" />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
interface Props {
/** Size of the avatar (width & height) */
size?: string;
/** Additional CSS classes */
class?: string;
}
let { size = '40px', class: className = '' }: Props = $props();
</script>
<SkeletonBox width={size} height={size} circle class={className} />

View file

@ -1,79 +0,0 @@
<script lang="ts">
/**
* SkeletonBox - Base component for skeleton loading states
*
* Reusable box with shimmer animation for loading states.
* Theme-aware (light/dark mode) and fully customizable.
*
* @example
* ```svelte
* <SkeletonBox width="200px" height="24px" />
* <SkeletonBox width="100%" height="100px" borderRadius="12px" />
* ```
*/
interface Props {
/** Width of the skeleton (CSS value) */
width?: string;
/** Height of the skeleton (CSS value) */
height?: string;
/** Border radius (CSS value) */
borderRadius?: string;
/** Make it circular (overrides borderRadius) */
circle?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
width = '100%',
height = '20px',
borderRadius = '4px',
circle = false,
class: className = '',
}: Props = $props();
const computedRadius = $derived(circle ? '50%' : borderRadius);
const computedHeight = $derived(circle ? width : height);
</script>
<div
class="skeleton-box {className}"
style="width: {width}; height: {computedHeight}; border-radius: {computedRadius};"
role="status"
aria-label="Loading"
></div>
<style>
.skeleton-box {
background: linear-gradient(
90deg,
var(--skeleton-base, #e5e7eb) 0%,
var(--skeleton-highlight, #f3f4f6) 50%,
var(--skeleton-base, #e5e7eb) 100%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Light Mode - Default */
:global(:root) {
--skeleton-base: #e5e7eb;
--skeleton-highlight: #f3f4f6;
}
/* Dark Mode */
:global(.dark) {
--skeleton-base: #2a2a2a;
--skeleton-highlight: #3a3a3a;
}
</style>

View file

@ -1,69 +0,0 @@
<script lang="ts">
/**
* SkeletonCard - Configurable card skeleton with avatar, title, body, footer
*
* @example
* ```svelte
* <SkeletonCard showAvatar titleLines={1} bodyLines={2} />
* <SkeletonCard showFooter />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
import SkeletonText from './SkeletonText.svelte';
import SkeletonAvatar from './SkeletonAvatar.svelte';
interface Props {
/** Show avatar/image placeholder */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Number of title lines */
titleLines?: number;
/** Number of body text lines */
bodyLines?: number;
/** Show footer section */
showFooter?: boolean;
/** Opacity for fade effect in lists */
opacity?: number;
/** Additional CSS classes */
class?: string;
}
let {
showAvatar = false,
avatarSize = '48px',
titleLines = 1,
bodyLines = 2,
showFooter = false,
opacity = 1,
class: className = '',
}: Props = $props();
</script>
<div
class="skeleton-card rounded-lg border border-border bg-card p-4 {className}"
style="opacity: {opacity};"
>
<div class="flex gap-3">
{#if showAvatar}
<SkeletonAvatar size={avatarSize} />
{/if}
<div class="flex-1 min-w-0">
{#if titleLines > 0}
<SkeletonText lines={titleLines} lineHeight="18px" gap="6px" lastLineWidth="60%" />
{/if}
{#if bodyLines > 0}
<div class="mt-2">
<SkeletonText lines={bodyLines} lineHeight="14px" gap="6px" lastLineWidth="80%" />
</div>
{/if}
</div>
</div>
{#if showFooter}
<div class="mt-4 flex items-center justify-between border-t border-border pt-4">
<SkeletonBox width="80px" height="14px" />
<SkeletonBox width="60px" height="14px" />
</div>
{/if}
</div>

View file

@ -1,61 +0,0 @@
<script lang="ts">
/**
* SkeletonGrid - Grid of skeleton cards with fade effect
*
* @example
* ```svelte
* <SkeletonGrid count={6} columns={3} />
* <SkeletonGrid count={8} columns={4} showAvatar />
* ```
*/
import SkeletonCard from './SkeletonCard.svelte';
interface Props {
/** Number of cards to show */
count?: number;
/** Number of columns (CSS grid) */
columns?: number;
/** Show avatar in cards */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Number of body lines per card */
bodyLines?: number;
/** Apply cascading fade effect */
fadeEffect?: boolean;
/** Minimum opacity for fade effect */
minOpacity?: number;
/** Gap between cards */
gap?: string;
/** Additional CSS classes */
class?: string;
}
let {
count = 6,
columns = 3,
showAvatar = true,
avatarSize = '48px',
bodyLines = 2,
fadeEffect = true,
minOpacity = 0.4,
gap = '1rem',
class: className = '',
}: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div
class="skeleton-grid grid {className}"
style="grid-template-columns: repeat({columns}, minmax(0, 1fr)); gap: {gap};"
>
{#each Array(count) as _, i}
<SkeletonCard {showAvatar} {avatarSize} {bodyLines} opacity={calculateOpacity(i)} />
{/each}
</div>

Some files were not shown because too many files have changed in this diff Show more