UI-Gesamtumbau: Du-Form, Inspector-Panel, Onboarding-Assistent, Tour, Palette
Deploy App / deploy (push) Successful in 1m1s
Deploy App / deploy (push) Successful in 1m1s
Rein an der Oberflaeche -- Berechnung, Datenmodell und API-Semantik unveraendert (103 Tests unveraendert gruen). Paket A (Fundament): - durchgehend Du-Form und echte Umlaute in allen sichtbaren Texten, inkl. API-Fehlermeldungen (vorher Mix aus Sie/Du und ae/oe/ue) - neue UI-Primitiven (ui.tsx): Button, Modal mit ESC/Fokus-Falle/Animation, Bestaetigungs-Dialog statt window.confirm, Toasts statt alert, Skeleton-Loader, EmptyState - eigene Attention-Farbe (Amber) fuer offene Entscheide, getrennt vom Akzent - Micro-Interactions mit prefers-reduced-motion-Fallback Paket B (Onboarding, Roadmap Nr. 10): - gefuehrter Plan-Assistent in 5 Schritten; Einkommen bewusst pro Person (raeumt die 9.9-AHV-Falle aus); reine Orchestrierung bestehender Endpunkte - Beispielplan mit einem Klick; Uebergaenge absichtlich offen - interaktive Tour ueber die Planansicht (localStorage, jederzeit neu startbar) - abgeleitete "Naechste Schritte"-Karte (offene Entscheide, fehlende Elemente, fehlende Pensionsphase, Ruin -> Einflussfaktoren) Paket C (Struktur): - Inspector-Panel rechts statt Modals fuer alle Einzel-Bearbeitungen; Matrix bleibt sichtbar, Zellklick wechselt den Inhalt - Phasenkopf auf vier Kern-Infos entschlackt (Rest in der 0.11-Detailansicht) - Matrix mit eigenem Scrollbereich, Koepfe beidachsig fixiert - Sidebar-Gruppen "Meine Plaene" / "Wissen"; "So rechnet FPT" statt SPEZIFIKATION; "Szenario-Profil" statt "Plan-Einstellungen" - Aktions-Icons ohne Hover sichtbar (Touch) Paket D (Extras): - Sparklines je Element-Zeile aus den 0.11-Verlaufswerten - Befehls-Palette (Ctrl/Cmd+K) - Ruin-Banner verlinkt auf die Einflussfaktoren Nebenbei: der ProfileMenu-Lint-Fehler und der Selection-Rest (9.17) sind behoben -- npm run lint laeuft erstmals fehlerfrei. SPEZIFIKATION auf 0.13: neue Kapitel 3.2.8, 3.7.6-3.7.9, 9.23, 9.24; 3.6.3 und 3.7.1 ueberarbeitet, 9.17 bereinigt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts
|
||||
// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall
|
||||
// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden.
|
||||
//
|
||||
// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der
|
||||
// drei Farbschemata und wirken wie ein Fremdkörper.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
|
||||
|
||||
// --- ESC-Verwaltung ----------------------------------------------------------------------
|
||||
// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die
|
||||
// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte.
|
||||
const escStack: (() => void)[] = [];
|
||||
let escListenerAttached = false;
|
||||
|
||||
function ensureEscListener() {
|
||||
if (escListenerAttached || typeof window === "undefined") return;
|
||||
escListenerAttached = true;
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape" || escStack.length === 0) return;
|
||||
e.stopPropagation();
|
||||
escStack[escStack.length - 1]();
|
||||
});
|
||||
}
|
||||
|
||||
function useEscClose(onClose: () => void) {
|
||||
const closeRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
closeRef.current = onClose;
|
||||
}, [onClose]);
|
||||
useEffect(() => {
|
||||
ensureEscListener();
|
||||
const handler = () => closeRef.current();
|
||||
escStack.push(handler);
|
||||
return () => {
|
||||
const i = escStack.indexOf(handler);
|
||||
if (i >= 0) escStack.splice(i, 1);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren,
|
||||
// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück.
|
||||
function useFocusTrap(ref: React.RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const focusables = () =>
|
||||
[...el.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)].filter((x) => !x.hasAttribute("disabled"));
|
||||
(focusables()[0] ?? el).focus({ preventScroll: true });
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const f = focusables();
|
||||
if (f.length === 0) return;
|
||||
const first = f[0];
|
||||
const last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
el.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
el.removeEventListener("keydown", onKey);
|
||||
previous?.focus?.({ preventScroll: true });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
|
||||
// --- Button ------------------------------------------------------------------------------
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
|
||||
const BUTTON_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]",
|
||||
secondary:
|
||||
"border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]",
|
||||
danger:
|
||||
"border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]",
|
||||
ghost: "text-muted hover:bg-surface-2 hover:text-fg",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 ${BUTTON_CLASSES[variant]} ${sizeClass} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Modal -------------------------------------------------------------------------------
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscClose(onClose);
|
||||
useFocusTrap(panelRef);
|
||||
return (
|
||||
<div
|
||||
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`ui-pop flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-fg">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schliessen"
|
||||
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inspector ---------------------------------------------------------------------------
|
||||
// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil).
|
||||
// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine
|
||||
// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen.
|
||||
// Auf schmalen Screens deckt das Panel die volle Breite ab.
|
||||
|
||||
export function InspectorShell({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscClose(onClose);
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
role="complementary"
|
||||
aria-label={title}
|
||||
className="ui-slide-in fixed inset-y-0 right-0 z-30 flex w-full flex-col border-l border-border bg-surface shadow-2xl sm:w-[26rem]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Panel schliessen"
|
||||
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bestätigungs-Dialog (ersetzt window.confirm) ---------------------------------------
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise<boolean>) | null>(null);
|
||||
|
||||
export function useConfirm() {
|
||||
const fn = useContext(ConfirmContext);
|
||||
if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders");
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pending, setPending] = useState<{
|
||||
opts: ConfirmOptions;
|
||||
resolve: (ok: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
const confirm = useCallback(
|
||||
(opts: ConfirmOptions) =>
|
||||
new Promise<boolean>((resolve) => setPending({ opts, resolve })),
|
||||
[]
|
||||
);
|
||||
|
||||
function settle(ok: boolean) {
|
||||
pending?.resolve(ok);
|
||||
setPending(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
{pending && (
|
||||
<Modal title={pending.opts.title} onClose={() => settle(false)}>
|
||||
<p className="text-sm text-muted">{pending.opts.message}</p>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant={pending.opts.danger ? "danger" : "primary"}
|
||||
autoFocus
|
||||
onClick={() => settle(true)}
|
||||
className={pending.opts.danger ? "border-danger bg-danger-soft text-danger" : ""}
|
||||
>
|
||||
{pending.opts.confirmLabel ?? "Bestätigen"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => settle(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Toasts (ersetzt alert und stumme Erfolge) -------------------------------------------
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
kind: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const fn = useContext(ToastContext);
|
||||
if (!fn) throw new Error("useToast ausserhalb des ToastProviders");
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const nextId = useRef(1);
|
||||
|
||||
const push = useCallback((kind: ToastItem["kind"], text: string) => {
|
||||
const id = nextId.current++;
|
||||
setToasts((prev) => [...prev, { id, kind, text }]);
|
||||
// Fehler bleiben länger stehen als Erfolge.
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={push}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`ui-pop pointer-events-auto flex items-start gap-2 rounded-xl border px-3.5 py-2.5 text-sm shadow-lg ${
|
||||
t.kind === "error"
|
||||
? "border-danger bg-danger-soft text-danger"
|
||||
: "border-border bg-surface text-fg"
|
||||
}`}
|
||||
>
|
||||
{t.kind === "error" ? (
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">{t.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Meldung schliessen"
|
||||
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
|
||||
className="rounded p-0.5 text-faint hover:text-fg"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ladezustände und leere Zustände ---------------------------------------------------
|
||||
|
||||
export function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-lg bg-surface-2 ${className}`} />;
|
||||
}
|
||||
|
||||
// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur
|
||||
// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit).
|
||||
export function PlanSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5" aria-busy="true" aria-label="Lädt">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
text: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-surface p-10 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent-soft-fg">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-fg">{title}</h3>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted">{text}</p>
|
||||
</div>
|
||||
{children && <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user