dorfteich/apps/web/src/components/forms.tsx
Claude Fable 5 8719b0ee1e #168: Formulare — Fehler-Verdrahtung und Namenslücken
Der Field-Baustein verdrahtet Hinweis/Fehler jetzt per aria-describedby
und aria-invalid mit dem Eingabefeld (cloneElement auf das einzelne
Kind; Fragmente bleiben unangetastet) — Screenreader nennen den Fehler
damit auch beim Feld-Fokus. Quota-Typ-Select mit Namen; die leeren
Aktions-/Erledigt-Spaltenköpfe in API-Tokens, Feed-Tokens, Sitzungen
und der Aufgabenübersicht (NodeView UND Server-Renderpfad) tragen
visually-hidden-Beschriftungen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:26:54 +02:00

87 lines
2.7 KiB
TypeScript

import { cloneElement, isValidElement, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api';
/**
* Small form building blocks shared by the auth/settings pages. Error
* values are i18n keys from the shared Zod schemas or the api's
* field-level details; they resolve through the errors namespace.
*/
export function Field({
label,
error,
hint,
children,
}: {
label: string;
error?: string;
hint?: string;
children: React.ReactNode;
}): React.JSX.Element {
const { t } = useTranslation();
const noteId = useId();
// Tie the hint/error text to the control itself (#168, WCAG 3.3.1):
// screen readers then repeat it when the field receives focus. Only a
// single element child can be wired; fragments render unchanged.
const wired =
isValidElement(children) && (error || hint)
? cloneElement(children as React.ReactElement<Record<string, unknown>>, {
'aria-describedby': noteId,
...(error ? { 'aria-invalid': true } : {}),
})
: children;
return (
<label className="field">
<span className="field__label">{label}</span>
{wired}
{hint && !error && (
<span className="field__hint" id={noteId}>
{hint}
</span>
)}
{error && (
<span className="field__error" role="alert" id={noteId}>
{t(`errors:${error}`, t('errors:bad_request'))}
</span>
)}
</label>
);
}
/** Top-of-form banner for non-field errors (wrong password, closed registration…). */
export function FormError({ error }: { error: unknown }): React.JSX.Element | null {
const { t } = useTranslation();
if (!error) return null;
const code = error instanceof ApiError ? error.body.code : 'internal_error';
// Field-level details render at their fields; suppress the banner then.
if (error instanceof ApiError && error.body.details && code === 'bad_request') return null;
return (
<p className="form-banner form-banner--error" role="alert">
{t(`errors:${code}`, t('errors:internal_error'))}
</p>
);
}
export function FormSuccess({ message }: { message: string | null }): React.JSX.Element | null {
if (!message) return null;
return (
<p className="form-banner form-banner--ok" role="status">
{message}
</p>
);
}
/** Maps api field details onto react-hook-form's setError. */
export function applyFieldErrors(
error: unknown,
setError: (name: string, error: { message: string }) => void,
): void {
if (error instanceof ApiError && error.body.details) {
for (const [field, keys] of Object.entries(error.body.details)) {
if (keys[0]) setError(field, { message: keys[0] });
}
}
}