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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user