Damit gibt es nur noch ein Stylesheet. Uebersicht, Meldungen, Kennzahlen, Berichte, Security Center, Wiederherstellungspunkte, Anmeldung, Backup-Assistent und Gesundheitsanzeige nutzten noch das alte — sie funktionierten, sahen aber anders aus als der Rest. Der Backup-Assistent wurde als **Klassenabbildung** umgestellt, nicht neu geschrieben: Seine Logik ist geprueft und richtig; eine Neufassung haette 500 Zeilen Verhalten ohne Not angefasst. Die elf Tests des Assistenten pruefen Verhalten und blieben unveraendert gueltig. Entfernt, weil ersetzt und nirgends mehr verwendet: JobsPanel, StatusIndicator, PageState, App.css, tokens.css und vier weitere Stylesheets. Das ausgelieferte CSS faellt von 54 auf 30 KB. Drei Funde beim Umbau, alle von Tests aufgedeckt: - **`describeApiError` warf die genauere Servermeldung weg.** Sie ersetzte sie durch den allgemeinen Satz aus der Codetabelle. Ein `SERVICE_UNAVAILABLE` mit der Meldung "Fuer diesen Bericht ist keine Sicherheitspruefung eingerichtet." wurde zu "Der Dienst ist derzeit nicht vollstaendig verfuegbar" — der Betreiber haette den Fehler bei seiner Anlage gesucht statt bei der Einrichtung dieses einen Berichts. Jetzt hat die Servermeldung Vorrang; die Tabelle springt nur ein, wenn keine mitkommt. - **Einem Fehler nach einer Handlung fehlte `role="alert"`.** Ein Screenreader haette ihn nicht angesagt. `Callout` nimmt jetzt eine Rolle entgegen; Standard bleibt `note`, weil die meisten Hinweise schon beim Oeffnen dastehen. - **Zwei Statusbeschriftungen wichen von den etablierten ab** (`Gesund` statt `Fehlerfrei`, `Nicht verbunden` statt `Nicht erreichbar`). Die etablierten gewinnen — sie stehen in Tests fest und sind treffender. Dazu zwei kleinere Korrekturen: Die Gesundheitsanzeige hing kurzzeitig in der Uebersicht und verband damit zwei Ladewege, die nichts miteinander zu tun haben; sie steht jetzt wieder daneben. Und ein frueherer Regex hatte `(row) => void | undefined` erzeugt — gemeint war eine optionale Eigenschaft, geschrieben stand "gibt void oder undefined zurueck". Nachgewiesen gegen Debian 12 mit nginx: alle 18 Seiten liefern 200, das Design-System steckt im ausgelieferten CSS samt Dark-Mode-Regeln, und vom alten Stylesheet ist kein Klassenname mehr darin. 76 Tests gruen, tsc sauber, eslint ohne Warnung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
5.1 KiB
TypeScript
167 lines
5.1 KiB
TypeScript
/**
|
|
* Zustandsanzeigen: Laden, Fehler, Leere, Hinweise.
|
|
*
|
|
* `ErrorState` zeigt immer die Vorgangsnummer. `NotMeasured` ist die Antwort
|
|
* auf einen Grundsatz, der sich durch die ganze Anlage zieht: Unbekannt ist
|
|
* nicht null. Eine Kennzahl ohne Datengrundlage erscheint als Satz mit
|
|
* Begruendung, nie als "0" — eine Null liest sich wie "geprueft und in
|
|
* Ordnung", und genau das waere falsch.
|
|
*/
|
|
|
|
import { AlertTriangle, Info, Loader2, TriangleAlert } from 'lucide-react';
|
|
import type { ReactNode } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from './Button';
|
|
|
|
export function LoadingState({ label = 'Wird geladen …' }: { readonly label?: string }) {
|
|
return (
|
|
<div className="flex items-center justify-center gap-2 px-6 py-14 text-sm text-fg-muted">
|
|
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
{label}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ErrorState({
|
|
title = 'Die Anfrage ist fehlgeschlagen',
|
|
message,
|
|
requestId,
|
|
onRetry,
|
|
className,
|
|
}: {
|
|
readonly title?: string | undefined;
|
|
readonly message?: string | undefined;
|
|
readonly requestId?: string | undefined;
|
|
readonly onRetry?: (() => void) | undefined;
|
|
readonly className?: string | undefined;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col items-center justify-center px-6 py-12 text-center',
|
|
className,
|
|
)}
|
|
role="alert"
|
|
>
|
|
<TriangleAlert className="size-6 text-critical" aria-hidden />
|
|
<p className="mt-3 text-sm font-medium text-fg">{title}</p>
|
|
{message ? <p className="mt-1 max-w-lg text-sm text-fg-muted">{message}</p> : null}
|
|
{requestId ? (
|
|
<p className="mt-3 font-mono text-xs text-fg-subtle">
|
|
Vorgangsnummer: {requestId}
|
|
</p>
|
|
) : null}
|
|
{onRetry ? (
|
|
<Button variant="secondary" size="sm" className="mt-4" onClick={onRetry}>
|
|
Erneut versuchen
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EmptyState({
|
|
title,
|
|
description,
|
|
action,
|
|
icon,
|
|
}: {
|
|
readonly title: ReactNode;
|
|
readonly description?: ReactNode | undefined;
|
|
readonly action?: ReactNode | undefined;
|
|
readonly icon?: ReactNode | undefined;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center px-6 py-14 text-center">
|
|
{icon ? <div className="mb-3 text-fg-subtle">{icon}</div> : null}
|
|
<p className="text-sm font-medium text-fg">{title}</p>
|
|
{description ? (
|
|
<p className="mt-1 max-w-md text-sm text-fg-muted">{description}</p>
|
|
) : null}
|
|
{action ? <div className="mt-4">{action}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Hinweis fuer eine Kennzahl ohne Datengrundlage.
|
|
*
|
|
* Der Grund gehoert dazu. "—" allein sieht nach einem Fehler der Oberflaeche
|
|
* aus; "nicht gemessen, weil kein Wiederherstellungstest lief" ist eine
|
|
* Handlungsanweisung.
|
|
*/
|
|
export function NotMeasured({ reason }: { readonly reason: ReactNode }) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 text-sm text-fg-muted">
|
|
<Info className="size-3.5 shrink-0" aria-hidden />
|
|
{reason}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Auffaelliger Hinweiskasten. */
|
|
export function Callout({
|
|
tone = 'info',
|
|
title,
|
|
children,
|
|
className,
|
|
role = 'note',
|
|
}: {
|
|
readonly tone?: 'info' | 'warning' | 'critical' | 'healthy' | undefined;
|
|
readonly title?: ReactNode | undefined;
|
|
readonly children?: ReactNode | undefined;
|
|
readonly className?: string | undefined;
|
|
/**
|
|
* ARIA-Rolle.
|
|
*
|
|
* Standard ist `note` — die meisten Hinweise erklaeren etwas und stehen schon
|
|
* beim Oeffnen der Maske da. Ein Hinweis, der **als Folge einer Handlung**
|
|
* erscheint, gehoert dagegen auf `alert`: Ein Screenreader sagt ihn dann an,
|
|
* statt ihn im Seiteninhalt untergehen zu lassen.
|
|
*/
|
|
readonly role?: 'note' | 'alert' | 'status' | undefined;
|
|
}) {
|
|
const toneClasses = {
|
|
info: 'border-info/30 bg-info-bg text-info',
|
|
warning: 'border-warning/30 bg-warning-bg text-warning',
|
|
critical: 'border-critical/30 bg-critical-bg text-critical',
|
|
healthy: 'border-healthy/30 bg-healthy-bg text-healthy',
|
|
} as const;
|
|
|
|
const IconComponent = tone === 'info' ? Info : tone === 'healthy' ? Info : AlertTriangle;
|
|
|
|
return (
|
|
<div className={cn('rounded-md border p-3', toneClasses[tone], className)} role={role}>
|
|
<div className="flex gap-2.5">
|
|
<IconComponent className="mt-0.5 size-4 shrink-0" aria-hidden />
|
|
<div className="min-w-0 text-sm">
|
|
{title ? <p className="font-medium">{title}</p> : null}
|
|
{children ? (
|
|
<div className={cn('text-fg', title && 'mt-1')}>{children}</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Beschriftetes Wertepaar fuer Detailseiten. */
|
|
export function DetailItem({
|
|
label,
|
|
children,
|
|
className,
|
|
}: {
|
|
readonly label: ReactNode;
|
|
readonly children: ReactNode;
|
|
readonly className?: string | undefined;
|
|
}) {
|
|
return (
|
|
<div className={cn('min-w-0', className)}>
|
|
<dt className="text-xs font-medium uppercase tracking-wide text-fg-subtle">
|
|
{label}
|
|
</dt>
|
|
<dd className="mt-1 text-sm text-fg">{children}</dd>
|
|
</div>
|
|
);
|
|
}
|