syncova-backup/apps/web/src/components/ui/Button.tsx
Jerrit Fritzsche 77e61dc2b3
Some checks failed
CI / Backend (Go) (push) Failing after 30s
CI / Frontend (React/TypeScript) (push) Successful in 44s
CI / Sicherheitsprüfungen (push) Successful in 26s
Blackscreen auf /retention behoben, Aussehen ueberarbeitet
**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>
2026-08-18 14:56:51 +02:00

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 };