**Der schwarze Bildschirm ist gefunden.** `GET /retention-policies` liefert ein
Objekt `{ policies, predefined }` — als einziger von zehn geprueften
Listenendpunkten. Die Oberflaeche behandelte es als Array; `map` gibt es auf
einem Objekt nicht, React haengte den ganzen Baum aus, und uebrig blieb eine
leere Seite. Im dunklen Thema schwarz.
Der Regressionstest fuettert die **echte** Antwortform. Ein Test mit einem Array
haette den Fehler nie gefunden — und genau das war passiert. Durch Mutation als
fangend bestaetigt ("repositories.map is not a function").
Nebenbei ergab sich ein Modul: Der Endpunkt liefert **sechs mitgelieferte
Vorlagen** mit. Sie erscheinen jetzt als Kacheln, ein Klick legt daraus eine
eigene Regel an.
**Aussehen.** Kraeftigerer Akzent (Indigo statt gedecktem Blau), waermere
Neutraltoene, groessere Radien, Schatten statt Rahmen, wo Tiefe gemeint ist:
Ein Rahmen trennt, ein Schatten hebt — Karten sollen heben, Tabellenzeilen
trennen. Kompaktere Statusabzeichen und Tabellenkoepfe.
**Texte gekuerzt.** 33 Erlaeuterungen von Absaetzen auf einen Satz. Der Inhalt
bleibt richtig; er steht nur nicht mehr zwischen den Bedienelementen. Was
ausfuehrlich erklaert werden muss, gehoert in docs/.
**Schnellzugriffe auf der Uebersicht.** Acht Kacheln, die die Handlung nennen
statt des Bereichs ("Auftrag anlegen" statt "Sicherungsauftraege"). Gezeigt wird
nur, was die Rolle darf — eine Kachel, die in eine Fehlermeldung fuehrt, ist
schlimmer als keine. Als Schaltflaechen umgesetzt, nicht als div mit
Klick-Handler: Der Unterschied ist Tastaturbedienbarkeit.
84 Tests gruen, tsc sauber, eslint ohne Warnung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
2.7 KiB
TypeScript
80 lines
2.7 KiB
TypeScript
/**
|
|
* Schaltfläche.
|
|
*
|
|
* Die Ausprägung "destructive" ist bewusst eigenständig und nicht nur eine
|
|
* rote Farbe: Löschende Handlungen sollen sich auch dann unterscheiden, wenn
|
|
* jemand die Oberfläche in Graustufen betrachtet oder farbfehlsichtig ist.
|
|
* Deshalb trägt sie zusätzlich einen kräftigen Rand.
|
|
*/
|
|
|
|
import { Slot } from '@radix-ui/react-slot';
|
|
import { cva, type VariantProps } from 'class-variance-authority';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const buttonVariants = cva(
|
|
// Gemeinsame Grundlage aller Ausprägungen.
|
|
[
|
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg',
|
|
'text-sm font-medium transition-colors',
|
|
'disabled:pointer-events-none disabled:opacity-50',
|
|
'[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
|
].join(' '),
|
|
{
|
|
variants: {
|
|
variant: {
|
|
primary: 'bg-accent text-accent-fg shadow-[var(--shadow-card)] hover:bg-accent-hover',
|
|
secondary: 'border border-line-strong bg-card text-fg hover:bg-hover',
|
|
ghost: 'text-fg-muted hover:bg-hover hover:text-fg',
|
|
destructive:
|
|
'border border-critical bg-critical text-white hover:brightness-110 dark:text-fg-inverted',
|
|
link: 'text-accent underline-offset-4 hover:underline',
|
|
},
|
|
size: {
|
|
sm: 'h-8 px-3 text-xs',
|
|
md: 'h-9 px-4',
|
|
lg: 'h-10 px-6',
|
|
icon: 'size-9',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
variant: 'secondary',
|
|
size: 'md',
|
|
},
|
|
},
|
|
);
|
|
|
|
export interface ButtonProps
|
|
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
|
VariantProps<typeof buttonVariants> {
|
|
/** Rendert die Kindkomponente statt eines <button> (z. B. einen Link). */
|
|
readonly asChild?: boolean | undefined;
|
|
/** Zeigt einen Ladeanzeiger und sperrt die Schaltfläche. */
|
|
readonly isLoading?: boolean | undefined;
|
|
}
|
|
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
{ className, variant, size, asChild = false, isLoading = false, children, disabled, ...restProps },
|
|
forwardedReference,
|
|
) {
|
|
const RenderedComponent = asChild ? Slot : 'button';
|
|
|
|
return (
|
|
<RenderedComponent
|
|
ref={forwardedReference}
|
|
className={cn(buttonVariants({ variant, size }), className)}
|
|
// Während eine Handlung läuft, darf sie nicht ein zweites Mal ausgelöst
|
|
// werden. Bei einem Sicherungslauf wäre der zweite Klick ein 409, bei
|
|
// einer Löschung schlimmstenfalls mehr als beabsichtigt.
|
|
disabled={disabled || isLoading}
|
|
{...restProps}
|
|
>
|
|
{isLoading ? <Loader2 className="animate-spin" aria-hidden /> : null}
|
|
{children}
|
|
</RenderedComponent>
|
|
);
|
|
});
|
|
|
|
export { buttonVariants };
|