Drei Zustaende in der Kopfzeile — System, Hell, Dunkel. Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick fuer immer eine feste Wahl bedeutet; wer "System" waehlt, bekommt abends dunkel und morgens hell. Umgesetzt ueber `light-dark()` im CSS, nicht ueber `dark:`-Klassen. Die Oberflaeche benutzt an rund 440 Stellen feste Farbklassen; jede davon zu verdoppeln waere eine riesige Fleissarbeit, und jede kuenftige Zeile muesste daran denken. Stattdessen entscheiden die Farbvariablen selbst, welchen Wert sie tragen — gesteuert ueber `color-scheme`. Deshalb genuegt fuer den Umschalter ein Attribut am html-Element, und die Systemwahl funktioniert sogar ohne JavaScript. Das Skript im Kopf setzt nur eine ausdrueckliche Wahl, damit die helle Fassung nicht kurz aufblitzt. Zwei Faelle liessen sich nicht ueber Variablen loesen: `bg-white` muss im Dunkeln eine dunkle Flaeche sein, `text-white` dagegen weiss bleiben — beide teilen sich aber dieselbe Variable. Fuer Flaechen gibt es deshalb jetzt `bg-surface`. Ebenso brauchen Akzent-Links im Dunkeln eine hellere Stufe als Akzent-Knoepfe: dafuer `text-accent-text` und `bg-accent-soft`. Bewusst unveraendert bleiben die QR-Codes selbst — deren Farben legt die Gestaltung fest. Die dunklen Werte sind keine Spiegelung: Reines Schwarz unter weisser Schrift flimmert. Alle Textkontraste sind gemessen und liegen ueber 4,5:1, der niedrigste bei 5,9:1.
286 lines
7.2 KiB
TypeScript
286 lines
7.2 KiB
TypeScript
import type {
|
|
ButtonHTMLAttributes,
|
|
InputHTMLAttributes,
|
|
ReactNode,
|
|
SelectHTMLAttributes,
|
|
TextareaHTMLAttributes,
|
|
} from "react";
|
|
|
|
export function cx(...classes: Array<string | false | null | undefined>): string {
|
|
return classes.filter(Boolean).join(" ");
|
|
}
|
|
|
|
/* -------------------------------------------------------------------- Card */
|
|
|
|
export function Card({
|
|
children,
|
|
className,
|
|
}: {
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<section
|
|
className={cx(
|
|
"rounded-2xl border border-slate-200 bg-surface p-5 shadow-sm sm:p-6",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function CardTitle({
|
|
children,
|
|
hint,
|
|
}: {
|
|
children: ReactNode;
|
|
hint?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="mb-4">
|
|
<h2 className="text-base font-semibold text-slate-900">{children}</h2>
|
|
{hint ? <p className="mt-1 text-sm text-slate-500">{hint}</p> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ Button */
|
|
|
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
variant?: "primary" | "secondary" | "ghost" | "danger";
|
|
size?: "md" | "sm";
|
|
};
|
|
|
|
const BUTTON_VARIANTS: Record<NonNullable<ButtonProps["variant"]>, string> = {
|
|
primary:
|
|
"bg-indigo-600 text-white hover:bg-indigo-700 disabled:hover:bg-indigo-600",
|
|
secondary:
|
|
"border border-slate-300 bg-surface text-slate-700 hover:bg-slate-50 disabled:hover:bg-surface",
|
|
ghost: "text-slate-600 hover:bg-slate-100 disabled:hover:bg-transparent",
|
|
danger: "bg-red-600 text-white hover:bg-red-700 disabled:hover:bg-red-600",
|
|
};
|
|
|
|
export function Button({
|
|
variant = "primary",
|
|
size = "md",
|
|
className,
|
|
...props
|
|
}: ButtonProps) {
|
|
return (
|
|
<button
|
|
className={cx(
|
|
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
|
size === "md" ? "px-4 py-2.5 text-sm" : "px-3 py-1.5 text-sm",
|
|
BUTTON_VARIANTS[variant],
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- Field */
|
|
|
|
export function Field({
|
|
label,
|
|
hint,
|
|
error,
|
|
htmlFor,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
hint?: ReactNode;
|
|
error?: string | null;
|
|
htmlFor?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label
|
|
htmlFor={htmlFor}
|
|
className="mb-1.5 block text-sm font-medium text-slate-700"
|
|
>
|
|
{label}
|
|
</label>
|
|
{children}
|
|
{error ? (
|
|
<p role="alert" className="mt-1.5 text-sm text-red-600">
|
|
{error}
|
|
</p>
|
|
) : hint ? (
|
|
<p className="mt-1.5 text-sm text-slate-500">{hint}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TextInput({
|
|
invalid,
|
|
className,
|
|
...props
|
|
}: InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }) {
|
|
return (
|
|
<input
|
|
className={cx(
|
|
"w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400",
|
|
invalid
|
|
? "border-red-400 focus-visible:outline-red-500"
|
|
: "border-slate-300",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** Gemeinsame Grundgestaltung aller Eingabefelder. */
|
|
export const CONTROL_BASE =
|
|
"w-full rounded-lg border bg-surface px-3 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 disabled:cursor-not-allowed disabled:bg-slate-50 disabled:text-slate-500";
|
|
|
|
export function TextArea({
|
|
invalid,
|
|
className,
|
|
...props
|
|
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { invalid?: boolean }) {
|
|
return (
|
|
<textarea
|
|
rows={3}
|
|
className={cx(
|
|
CONTROL_BASE,
|
|
"resize-y",
|
|
invalid ? "border-red-400" : "border-slate-300",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function Select({
|
|
invalid,
|
|
className,
|
|
children,
|
|
...props
|
|
}: SelectHTMLAttributes<HTMLSelectElement> & { invalid?: boolean }) {
|
|
return (
|
|
<select
|
|
className={cx(
|
|
CONTROL_BASE,
|
|
"cursor-pointer appearance-none bg-[length:1.1rem] bg-[right_0.7rem_center] bg-no-repeat pr-9",
|
|
invalid ? "border-red-400" : "border-slate-300",
|
|
className,
|
|
)}
|
|
style={{
|
|
backgroundImage:
|
|
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E\")",
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
export function Checkbox({
|
|
label,
|
|
hint,
|
|
id,
|
|
...props
|
|
}: InputHTMLAttributes<HTMLInputElement> & { label: string; hint?: string }) {
|
|
return (
|
|
<div className="flex items-start gap-2.5">
|
|
<input
|
|
id={id}
|
|
type="checkbox"
|
|
className="mt-0.5 size-4 shrink-0 cursor-pointer rounded border-slate-300 accent-indigo-600"
|
|
{...props}
|
|
/>
|
|
<label htmlFor={id} className="cursor-pointer text-sm text-slate-700">
|
|
{label}
|
|
{hint ? <span className="block text-slate-500">{hint}</span> : null}
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Zwei Felder nebeneinander, ab „sm“ — für Paare wie Vor- und Nachname. */
|
|
export function FieldRow({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div>
|
|
);
|
|
}
|
|
|
|
/** Abschnitt innerhalb eines Formulars mit eigener Überschrift. */
|
|
export function FieldSet({
|
|
legend,
|
|
children,
|
|
}: {
|
|
legend: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<fieldset className="border-t border-slate-200 pt-4">
|
|
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
|
{legend}
|
|
</legend>
|
|
<div className="mt-3 flex flex-col gap-4">{children}</div>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- Badge */
|
|
|
|
export function Badge({
|
|
tone = "neutral",
|
|
children,
|
|
}: {
|
|
tone?: "neutral" | "accent" | "soon" | "success";
|
|
children: ReactNode;
|
|
}) {
|
|
const tones: Record<string, string> = {
|
|
neutral: "bg-slate-100 text-slate-600",
|
|
// Hervorhebung — hieß früher "popular", als sie nur für das Abzeichen
|
|
// „Beliebt" auf der Startseite gedacht war.
|
|
accent: "bg-accent-soft text-accent-soft-fg",
|
|
soon: "bg-slate-100 text-slate-500",
|
|
success: "bg-emerald-100 text-emerald-700",
|
|
};
|
|
return (
|
|
<span
|
|
className={cx(
|
|
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
|
tones[tone],
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ Notice */
|
|
|
|
export function Notice({
|
|
tone,
|
|
children,
|
|
}: {
|
|
tone: "warning" | "error" | "info" | "success";
|
|
children: ReactNode;
|
|
}) {
|
|
const tones: Record<string, string> = {
|
|
warning: "border-amber-300 bg-amber-50 text-amber-900",
|
|
error: "border-red-300 bg-red-50 text-red-800",
|
|
info: "border-sky-300 bg-sky-50 text-sky-900",
|
|
success: "border-emerald-300 bg-emerald-50 text-emerald-900",
|
|
};
|
|
return (
|
|
<div
|
|
role={tone === "error" ? "alert" : "status"}
|
|
className={cx("rounded-lg border px-3.5 py-3 text-sm", tones[tone])}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|