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
87 lines
2.7 KiB
TypeScript
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] });
|
|
}
|
|
}
|
|
}
|