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>, { 'aria-describedby': noteId, ...(error ? { 'aria-invalid': true } : {}), }) : children; return ( ); } /** 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 (

{t(`errors:${code}`, t('errors:internal_error'))}

); } export function FormSuccess({ message }: { message: string | null }): React.JSX.Element | null { if (!message) return null; return (

{message}

); } /** 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] }); } } }