mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 01:26:41 +02:00
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:
parent
3b61ab64a4
commit
ce923bbdc7
213 changed files with 6712 additions and 20619 deletions
|
|
@ -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
10
packages/shared-ui/.gitignore
vendored
Normal 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/
|
||||
75
packages/shared-ui/README.md
Normal file
75
packages/shared-ui/README.md
Normal 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).
|
||||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
160
packages/shared-ui/scripts/validate-disziplin.mjs
Normal file
160
packages/shared-ui/scripts/validate-disziplin.mjs
Normal 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)'
|
||||
);
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { focusTrap } from './focusTrap';
|
||||
47
packages/shared-ui/src/atoms/Badge.stories.svelte
Normal file
47
packages/shared-ui/src/atoms/Badge.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
77
packages/shared-ui/src/atoms/Button.stories.svelte
Normal file
77
packages/shared-ui/src/atoms/Button.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
94
packages/shared-ui/src/atoms/Card.stories.svelte
Normal file
94
packages/shared-ui/src/atoms/Card.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
112
packages/shared-ui/src/atoms/DynamicIcon.stories.svelte
Normal file
112
packages/shared-ui/src/atoms/DynamicIcon.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
58
packages/shared-ui/src/atoms/Skeleton.stories.svelte
Normal file
58
packages/shared-ui/src/atoms/Skeleton.stories.svelte
Normal 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>
|
||||
111
packages/shared-ui/src/atoms/Skeleton.svelte
Normal file
111
packages/shared-ui/src/atoms/Skeleton.svelte
Normal 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>
|
||||
49
packages/shared-ui/src/atoms/Spinner.stories.svelte
Normal file
49
packages/shared-ui/src/atoms/Spinner.stories.svelte
Normal 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>
|
||||
93
packages/shared-ui/src/atoms/Spinner.svelte
Normal file
93
packages/shared-ui/src/atoms/Spinner.svelte
Normal 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>
|
||||
53
packages/shared-ui/src/atoms/Text.stories.svelte
Normal file
53
packages/shared-ui/src/atoms/Text.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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} · {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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { default as ContextMenu } from './ContextMenu.svelte';
|
||||
export type { ContextMenuItem, ContextMenuState } from './types';
|
||||
export { createContextMenuState } from './types';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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' },
|
||||
],
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
52
packages/shared-ui/src/molecules/Checkbox.stories.svelte
Normal file
52
packages/shared-ui/src/molecules/Checkbox.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
133
packages/shared-ui/src/molecules/ContactAvatar.svelte
Normal file
133
packages/shared-ui/src/molecules/ContactAvatar.svelte
Normal 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>
|
||||
53
packages/shared-ui/src/molecules/DataCard.stories.svelte
Normal file
53
packages/shared-ui/src/molecules/DataCard.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
99
packages/shared-ui/src/molecules/EmptyState.svelte
Normal file
99
packages/shared-ui/src/molecules/EmptyState.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export interface FilterDropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
group?: string;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { ICON_CATEGORIES, getAllIconNames } from '@mana/shared-icons';
|
||||
|
||||
export const DEFAULT_ICON = 'star';
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
57
packages/shared-ui/src/molecules/Input.stories.svelte
Normal file
57
packages/shared-ui/src/molecules/Input.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
72
packages/shared-ui/src/molecules/PageHeader.stories.svelte
Normal file
72
packages/shared-ui/src/molecules/PageHeader.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
57
packages/shared-ui/src/molecules/Select.stories.svelte
Normal file
57
packages/shared-ui/src/molecules/Select.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
36
packages/shared-ui/src/molecules/TagBadge.stories.svelte
Normal file
36
packages/shared-ui/src/molecules/TagBadge.stories.svelte
Normal 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>
|
||||
64
packages/shared-ui/src/molecules/TagBadge.svelte
Normal file
64
packages/shared-ui/src/molecules/TagBadge.svelte
Normal 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>
|
||||
56
packages/shared-ui/src/molecules/TagChip.stories.svelte
Normal file
56
packages/shared-ui/src/molecules/TagChip.stories.svelte
Normal 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>
|
||||
169
packages/shared-ui/src/molecules/TagChip.svelte
Normal file
169
packages/shared-ui/src/molecules/TagChip.svelte
Normal 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>
|
||||
56
packages/shared-ui/src/molecules/TagField.svelte
Normal file
56
packages/shared-ui/src/molecules/TagField.svelte
Normal 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}
|
||||
/>
|
||||
86
packages/shared-ui/src/molecules/TagSelector.stories.svelte
Normal file
86
packages/shared-ui/src/molecules/TagSelector.stories.svelte
Normal 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>
|
||||
316
packages/shared-ui/src/molecules/TagSelector.svelte
Normal file
316
packages/shared-ui/src/molecules/TagSelector.svelte
Normal 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>
|
||||
56
packages/shared-ui/src/molecules/Textarea.stories.svelte
Normal file
56
packages/shared-ui/src/molecules/Textarea.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
50
packages/shared-ui/src/molecules/Toggle.stories.svelte
Normal file
50
packages/shared-ui/src/molecules/Toggle.stories.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/**
|
||||
* Feedback components for user states
|
||||
*/
|
||||
export { default as EmptyState } from './EmptyState.svelte';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue