feat(hosts): Seiten fuer Hosts und Hostgruppen (#008)
Ersetzt die Platzhalter: Hostliste mit Suche und Gruppenfilter per GET (teilbare Links), Formulare fuer Hosts und Gruppen, Hostdetail als Geruest und Gruppendetail mit Mitgliederauswahl. Bearbeiten-Knoepfe entscheidet der Server per darf(); jede Seite prueft die Sitzung selbst. Die Detailseite rendert HostKeyAktionen (#009) und BootstrapAktionen (#010) als feste Platzhalter mit fertigen Props, damit die Folge-Issues nur ihre eigene Datei ersetzen. Formulare senden per onSubmit, weil React ein <form action> nach jeder Antwort zuruecksetzt und Eingaben bei Feldfehlern sonst verloren gingen. Unter md zeigt die Hostliste Karten statt Tabelle, damit Status und Host-Key auf 390 px ohne Wischen sichtbar bleiben. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import type { HostKeyZustand } from "@/lib/hosts";
|
||||
|
||||
/** Kleine Anzeigebausteine fuer Hosts (#008) – reine Darstellung. */
|
||||
|
||||
const HOSTKEY: Record<HostKeyZustand, { text: string; variante: "ok" | "messing" | "neutral" }> = {
|
||||
bestaetigt: { text: "bestätigt", variante: "ok" },
|
||||
// Messing statt Koralle: eine offene Entscheidung, noch kein Fehler.
|
||||
ausstehend: { text: "Bestätigung ausstehend", variante: "messing" },
|
||||
keiner: { text: "nicht abgerufen", variante: "neutral" },
|
||||
};
|
||||
|
||||
export function HostKeyBadge({ zustand }: { zustand: HostKeyZustand }) {
|
||||
const d = HOSTKEY[zustand];
|
||||
return <Badge variante={d.variante}>{d.text}</Badge>;
|
||||
}
|
||||
|
||||
const HOSTSCHLUESSEL_STATUS: Record<
|
||||
string,
|
||||
{ text: string; variante: "ok" | "messing" | "neutral" | "gefahr" }
|
||||
> = {
|
||||
VERTRAUT: { text: "vertraut", variante: "ok" },
|
||||
AUSSTEHEND: { text: "ausstehend", variante: "messing" },
|
||||
ABGELEHNT: { text: "abgelehnt", variante: "gefahr" },
|
||||
ERSETZT: { text: "ersetzt", variante: "neutral" },
|
||||
};
|
||||
|
||||
export function HostSchluesselStatusBadge({ status }: { status: string }) {
|
||||
const d = HOSTSCHLUESSEL_STATUS[status] ?? { text: status, variante: "neutral" as const };
|
||||
return <Badge variante={d.variante}>{d.text}</Badge>;
|
||||
}
|
||||
|
||||
export function AnmeldeartBadge({ anmeldeart }: { anmeldeart: "DIENSTBENUTZER" | "ROOT" }) {
|
||||
return anmeldeart === "ROOT" ? (
|
||||
<Badge variante="messing" mono>
|
||||
root
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge mono>sshkv + sudo</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** `host:port`, IPv6 in Klammern – so, wie man es in ssh-Befehlen wiedererkennt. */
|
||||
export function adresseMitPort(adresse: string, port: number): string {
|
||||
const host = adresse.includes(":") ? `[${adresse}]` : adresse;
|
||||
return `${host}:${port}`;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Archive } from "lucide-react";
|
||||
import { useActionState, useRef } from "react";
|
||||
import { Button, Formularfehler } from "@/components/ui";
|
||||
import type { FormularZustand } from "@/server/actions/formular";
|
||||
import { hostArchivierenAktion } from "@/server/actions/hosts";
|
||||
|
||||
/**
|
||||
* Archivieren mit Rueckfrage (#008). Ein nativer `<dialog>` bringt Fokusfalle, Escape und
|
||||
* Hintergrundsperre mit – ohne eigene Dialog-Bibliothek.
|
||||
*/
|
||||
export function ArchivierenDialog({ hostId, hostName }: { hostId: string; hostName: string }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(hostArchivierenAktion, {});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Archive className="size-4" />}
|
||||
onClick={() => dialog.current?.showModal()}
|
||||
>
|
||||
Host archivieren
|
||||
</Button>
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="archivieren-titel"
|
||||
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<form action={aktion} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="id" value={hostId} />
|
||||
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
|
||||
<span className="font-mono">{hostName}</span> archivieren?
|
||||
</h2>
|
||||
<p className="text-muted text-[14.5px]">
|
||||
Der Host verschwindet aus Listen und Hostgruppen. sshkv verbindet sich nicht mehr mit ihm; auf dem
|
||||
Host selbst ändert sich nichts. Das Protokoll bleibt erhalten.
|
||||
</p>
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<div className="flex flex-wrap justify-end gap-2.5">
|
||||
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||
{laeuft ? "Archiviere …" : "Archivieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Textbereich } from "@/components/ui";
|
||||
import { hostgruppeAnlegenAktion, hostgruppeBearbeitenAktion } from "@/server/actions/hostgruppen";
|
||||
import { useFormularAktion } from "./useFormularAktion";
|
||||
|
||||
export type GruppenFormularVorgabe = {
|
||||
id?: string;
|
||||
name: string;
|
||||
beschreibung: string;
|
||||
hostIds: string[];
|
||||
};
|
||||
|
||||
/** Name, Beschreibung und Mitglieder einer Hostgruppe (#008). */
|
||||
export function GruppenFormular({
|
||||
vorgabe,
|
||||
hosts,
|
||||
abbrechenZiel,
|
||||
}: {
|
||||
vorgabe: GruppenFormularVorgabe;
|
||||
hosts: { id: string; name: string; adresse: string }[];
|
||||
abbrechenZiel?: string;
|
||||
}) {
|
||||
const bearbeiten = Boolean(vorgabe.id);
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(
|
||||
bearbeiten ? hostgruppeBearbeitenAktion : hostgruppeAnlegenAktion,
|
||||
);
|
||||
const f = zustand.feldfehler ?? {};
|
||||
|
||||
return (
|
||||
<form onSubmit={absenden} noValidate className="flex flex-col gap-5">
|
||||
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Feld
|
||||
beschriftung="Name"
|
||||
name="name"
|
||||
defaultValue={vorgabe.name}
|
||||
required
|
||||
maxLength={64}
|
||||
autoComplete="off"
|
||||
placeholder="Proxmox"
|
||||
fehler={f["name"]}
|
||||
/>
|
||||
<Textbereich
|
||||
beschriftung="Beschreibung (optional)"
|
||||
name="beschreibung"
|
||||
defaultValue={vorgabe.beschreibung}
|
||||
maxLength={500}
|
||||
fehler={f["beschreibung"]}
|
||||
/>
|
||||
<Feldgruppe
|
||||
legende="Mitglieder"
|
||||
fehler={f["hostIds"]}
|
||||
hinweis={hosts.length === 0 ? "Noch keine Hosts angelegt." : undefined}
|
||||
>
|
||||
{hosts.length > 0 && (
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
{hosts.map((h) => (
|
||||
<Ankreuzfeld
|
||||
key={h.id}
|
||||
name="hostIds"
|
||||
value={h.id}
|
||||
defaultChecked={vorgabe.hostIds.includes(h.id)}
|
||||
beschriftung={<span className="font-mono text-[13.5px]">{h.name}</span>}
|
||||
erklaerung={<span className="font-mono text-[12.5px] break-all">{h.adresse}</span>}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Feldgruppe>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Gruppe anlegen"}
|
||||
</Button>
|
||||
{abbrechenZiel && <Button href={abbrechenZiel}>Abbrechen</Button>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||||
import { hostAnlegenAktion, hostBearbeitenAktion } from "@/server/actions/hosts";
|
||||
import { useFormularAktion } from "./useFormularAktion";
|
||||
|
||||
export type HostFormularVorgabe = {
|
||||
id?: string;
|
||||
name: string;
|
||||
adresse: string;
|
||||
port: number;
|
||||
tags: string[];
|
||||
beschreibung: string;
|
||||
kundeName: string;
|
||||
anmeldeart: "DIENSTBENUTZER" | "ROOT";
|
||||
gruppenIds: string[];
|
||||
};
|
||||
|
||||
export const LEERER_HOST: HostFormularVorgabe = {
|
||||
name: "",
|
||||
adresse: "",
|
||||
port: 22,
|
||||
tags: [],
|
||||
beschreibung: "",
|
||||
kundeName: "",
|
||||
anmeldeart: "DIENSTBENUTZER",
|
||||
gruppenIds: [],
|
||||
};
|
||||
|
||||
/** Formular fuer neue und bestehende Hosts (#008). Regeln prueft der Server. */
|
||||
export function HostFormular({
|
||||
vorgabe,
|
||||
gruppen,
|
||||
kunden,
|
||||
abbrechenZiel,
|
||||
}: {
|
||||
vorgabe: HostFormularVorgabe;
|
||||
gruppen: { id: string; name: string }[];
|
||||
kunden: string[];
|
||||
abbrechenZiel: string;
|
||||
}) {
|
||||
const bearbeiten = Boolean(vorgabe.id);
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(
|
||||
bearbeiten ? hostBearbeitenAktion : hostAnlegenAktion,
|
||||
);
|
||||
const f = zustand.feldfehler ?? {};
|
||||
|
||||
return (
|
||||
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
|
||||
<Karte titel="Verbindung">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Name"
|
||||
name="name"
|
||||
defaultValue={vorgabe.name}
|
||||
mono
|
||||
required
|
||||
maxLength={64}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="srv-pve01"
|
||||
hinweis="Buchstaben, Ziffern, Punkt, Unterstrich, Bindestrich."
|
||||
fehler={f["name"]}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_6.5rem] gap-3 sm:col-span-2">
|
||||
<Feld
|
||||
beschriftung="Adresse"
|
||||
name="adresse"
|
||||
defaultValue={vorgabe.adresse}
|
||||
mono
|
||||
required
|
||||
maxLength={253}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="10.0.0.11 oder pve01.intern"
|
||||
fehler={f["adresse"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Port"
|
||||
name="port"
|
||||
defaultValue={vorgabe.port}
|
||||
mono
|
||||
inputMode="numeric"
|
||||
required
|
||||
fehler={f["port"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Anmeldung" untertitel="Wie sich sshkv auf diesem Host anmeldet">
|
||||
<Feldgruppe legende="Anmeldeart" fehler={f["anmeldeart"]}>
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="anmeldeart"
|
||||
value="DIENSTBENUTZER"
|
||||
defaultChecked={vorgabe.anmeldeart === "DIENSTBENUTZER"}
|
||||
beschriftung={
|
||||
<>
|
||||
Dienstbenutzer <code className="font-mono text-[13.5px]">sshkv</code> mit sudo – empfohlen
|
||||
</>
|
||||
}
|
||||
erklaerung="Ein eigener Benutzer mit /etc/sudoers.d/sshkv; root-Login per SSH bleibt aus."
|
||||
/>
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="anmeldeart"
|
||||
value="ROOT"
|
||||
defaultChecked={vorgabe.anmeldeart === "ROOT"}
|
||||
beschriftung="Direkt als root"
|
||||
erklaerung="Nur wenn kein Dienstbenutzer möglich ist (z. B. Appliances). Verlangt PermitRootLogin."
|
||||
/>
|
||||
</Feldgruppe>
|
||||
</Karte>
|
||||
|
||||
<Karte titel="Einordnung">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Feldgruppe
|
||||
legende="Hostgruppen"
|
||||
fehler={f["gruppenIds"]}
|
||||
hinweis={
|
||||
gruppen.length === 0 ? (
|
||||
<>
|
||||
Noch keine Hostgruppen –{" "}
|
||||
<Link href="/hostgruppen/neu" className="text-accent hover:underline">
|
||||
Gruppe anlegen
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{gruppen.length > 0 && (
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
{gruppen.map((g) => (
|
||||
<Ankreuzfeld
|
||||
key={g.id}
|
||||
name="gruppenIds"
|
||||
value={g.id}
|
||||
defaultChecked={vorgabe.gruppenIds.includes(g.id)}
|
||||
beschriftung={g.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Feldgruppe>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Tags"
|
||||
name="tags"
|
||||
defaultValue={vorgabe.tags.join(", ")}
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="pve, produktion"
|
||||
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||
fehler={f["tags"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Kunde (optional)"
|
||||
name="kundeName"
|
||||
defaultValue={vorgabe.kundeName}
|
||||
list="kunden-vorschlaege"
|
||||
autoComplete="off"
|
||||
hinweis="Ein neuer Name legt den Kunden an."
|
||||
fehler={f["kundeName"]}
|
||||
/>
|
||||
<datalist id="kunden-vorschlaege">
|
||||
{kunden.map((k) => (
|
||||
<option key={k} value={k} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<Textbereich
|
||||
beschriftung="Beschreibung (optional)"
|
||||
name="beschreibung"
|
||||
defaultValue={vorgabe.beschreibung}
|
||||
maxLength={500}
|
||||
fehler={f["beschreibung"]}
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Host anlegen"}
|
||||
</Button>
|
||||
<Button href={abbrechenZiel}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { startTransition, useActionState, type FormEvent } from "react";
|
||||
import type { FormularZustand } from "@/server/actions/formular";
|
||||
|
||||
type Aktion = (vorher: FormularZustand, formular: FormData) => Promise<FormularZustand>;
|
||||
|
||||
/**
|
||||
* `useActionState` mit eigenem Absenden (#008). Ueber `<form action>` setzte React das
|
||||
* Formular nach jeder Antwort zurueck – bei einer Feldmeldung waeren alle Eingaben weg.
|
||||
* Mit `onSubmit` und `startTransition` bleiben sie stehen.
|
||||
*/
|
||||
export function useFormularAktion(aktion: Aktion) {
|
||||
const [zustand, ausfuehren, laeuft] = useActionState<FormularZustand, FormData>(aktion, {});
|
||||
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault();
|
||||
const daten = new FormData(ereignis.currentTarget);
|
||||
startTransition(() => ausfuehren(daten));
|
||||
}
|
||||
return { zustand, absenden, laeuft } as const;
|
||||
}
|
||||
+148
-9
@@ -1,37 +1,176 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
const EINGABE_KLASSEN = clsx(
|
||||
"bg-bg border-border-strong text-text rounded-feld min-h-11 w-full min-w-0 border px-3 text-[15px]",
|
||||
"placeholder:text-muted/70 focus-visible:border-accent",
|
||||
"aria-[invalid=true]:border-danger-border",
|
||||
);
|
||||
|
||||
/** Hinweis und Fehlermeldung unter einem Feld; die Ids verknuepft `aria-describedby` (#008). */
|
||||
function FeldFuss({ feldId, hinweis, fehler }: { feldId: string; hinweis?: ReactNode; fehler?: string }) {
|
||||
return (
|
||||
<>
|
||||
{/* Bei einem Fehler ersetzt die Meldung den Hinweis – meist sagt sie dasselbe genauer. */}
|
||||
{hinweis && !fehler && (
|
||||
<p id={`${feldId}-hinweis`} className="text-muted text-[12.5px]">
|
||||
{hinweis}
|
||||
</p>
|
||||
)}
|
||||
{fehler && (
|
||||
<p id={`${feldId}-fehler`} className="text-danger text-[13px]">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function beschreibtDurch(feldId: string, hinweis?: ReactNode, fehler?: string): string | undefined {
|
||||
if (fehler) return `${feldId}-fehler`;
|
||||
return hinweis ? `${feldId}-hinweis` : undefined;
|
||||
}
|
||||
|
||||
/** Eingabefeld mit Beschriftung (#004). Mindesthoehe 44 px wie alle Klickflaechen. */
|
||||
export function Feld({
|
||||
beschriftung,
|
||||
hinweis,
|
||||
fehler,
|
||||
mono = false,
|
||||
className,
|
||||
id,
|
||||
name,
|
||||
...rest
|
||||
}: ComponentProps<"input"> & { beschriftung: string; hinweis?: string; mono?: boolean }) {
|
||||
}: ComponentProps<"input"> & { beschriftung: string; hinweis?: ReactNode; fehler?: string; mono?: boolean }) {
|
||||
const feldId = id ?? `feld-${name}`;
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-1.5", className)}>
|
||||
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<input
|
||||
id={feldId}
|
||||
name={name}
|
||||
aria-invalid={fehler ? true : undefined}
|
||||
aria-describedby={beschreibtDurch(feldId, hinweis, fehler)}
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"bg-bg border-border-strong text-text rounded-feld min-h-11 border px-3 text-[15px]",
|
||||
"placeholder:text-muted/70 focus-visible:border-accent",
|
||||
mono && "font-mono tracking-wide",
|
||||
)}
|
||||
className={clsx(EINGABE_KLASSEN, mono && "font-mono tracking-wide")}
|
||||
/>
|
||||
{hinweis && <p className="text-muted text-[12.5px]">{hinweis}</p>}
|
||||
<FeldFuss feldId={feldId} hinweis={hinweis} fehler={fehler} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mehrzeiliges Textfeld, z. B. fuer Beschreibungen (#008). */
|
||||
export function Textbereich({
|
||||
beschriftung,
|
||||
hinweis,
|
||||
fehler,
|
||||
className,
|
||||
id,
|
||||
name,
|
||||
...rest
|
||||
}: ComponentProps<"textarea"> & { beschriftung: string; hinweis?: ReactNode; fehler?: string }) {
|
||||
const feldId = id ?? `feld-${name}`;
|
||||
return (
|
||||
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<textarea
|
||||
id={feldId}
|
||||
name={name}
|
||||
rows={3}
|
||||
aria-invalid={fehler ? true : undefined}
|
||||
aria-describedby={beschreibtDurch(feldId, hinweis, fehler)}
|
||||
{...rest}
|
||||
className={clsx(EINGABE_KLASSEN, "py-2.5 leading-normal")}
|
||||
/>
|
||||
<FeldFuss feldId={feldId} hinweis={hinweis} fehler={fehler} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Auswahlliste im Stil der Eingabefelder (#008). */
|
||||
export function Auswahlliste({
|
||||
beschriftung,
|
||||
className,
|
||||
id,
|
||||
name,
|
||||
children,
|
||||
...rest
|
||||
}: ComponentProps<"select"> & { beschriftung: string }) {
|
||||
const feldId = id ?? `feld-${name}`;
|
||||
return (
|
||||
<div className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<label htmlFor={feldId} className="text-muted text-[13px] font-medium">
|
||||
{beschriftung}
|
||||
</label>
|
||||
<select id={feldId} name={name} {...rest} className={EINGABE_KLASSEN}>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkbox oder Radio mit Beschriftung; die ganze Zeile ist Klickflaeche (44 px), weil das
|
||||
* Kaestchen allein auf dem Telefon zu klein ist (#008).
|
||||
*/
|
||||
export function Ankreuzfeld({
|
||||
beschriftung,
|
||||
erklaerung,
|
||||
className,
|
||||
...rest
|
||||
}: Omit<ComponentProps<"input">, "type"> & {
|
||||
type?: "checkbox" | "radio";
|
||||
beschriftung: ReactNode;
|
||||
erklaerung?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
"hover:bg-raised/60 rounded-box flex min-h-11 min-w-0 cursor-pointer items-start gap-3 px-2 py-2.5 transition-colors",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input type="checkbox" {...rest} className="accent-accent mt-0.5 size-[18px] shrink-0 cursor-pointer" />
|
||||
<span className="min-w-0 text-[14.5px] leading-snug">
|
||||
<span className="text-text block break-words">{beschriftung}</span>
|
||||
{erklaerung && <span className="text-muted mt-0.5 block text-[13px]">{erklaerung}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gruppe von Ankreuzfeldern mit Legende und Fehler (#008). */
|
||||
export function Feldgruppe({
|
||||
legende,
|
||||
hinweis,
|
||||
fehler,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
legende: string;
|
||||
hinweis?: ReactNode;
|
||||
fehler?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className={clsx("flex min-w-0 flex-col gap-1.5", className)}>
|
||||
<legend className="text-muted mb-1.5 text-[13px] font-medium">{legende}</legend>
|
||||
{hinweis && <p className="text-muted -mt-1 text-[12.5px]">{hinweis}</p>}
|
||||
{children}
|
||||
{fehler && (
|
||||
<p role="alert" className="text-danger text-[13px]">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fehlermeldung eines Formulars – mit role=alert, damit Screenreader sie vorlesen. */
|
||||
export function Formularfehler({ text }: { text?: string }) {
|
||||
if (!text) return null;
|
||||
|
||||
@@ -9,4 +9,4 @@ export { MonoKopieren } from "./MonoKopieren";
|
||||
export { Seitenkopf } from "./Seitenkopf";
|
||||
export { Statuspunkt, type HostStatus } from "./Statuspunkt";
|
||||
export { Tabelle, TabellenZeile, TabellenZelle, type Spalte } from "./Tabelle";
|
||||
export { Feld, Formularfehler } from "./Feld";
|
||||
export { Ankreuzfeld, Auswahlliste, Feld, Feldgruppe, Formularfehler, Textbereich } from "./Feld";
|
||||
|
||||
Reference in New Issue
Block a user