93 lines
2.3 KiB
TypeScript
93 lines
2.3 KiB
TypeScript
import { useId, type FormHTMLAttributes, type ReactNode } from "react";
|
|
|
|
import { TextField } from "../components/ui/text-field.jsx";
|
|
import { useLocale } from "../i18n/index.js";
|
|
import type {
|
|
FieldErrors,
|
|
FieldName,
|
|
FormValues,
|
|
} from "./form-contracts.js";
|
|
|
|
export function Form(
|
|
props: FormHTMLAttributes<HTMLFormElement> & Readonly<{ pending?: boolean }>,
|
|
) {
|
|
const { pending = false, children, ...formProps } = props;
|
|
return (
|
|
<form {...formProps} noValidate aria-busy={pending || undefined}>
|
|
{children}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function FormField(
|
|
props: React.ComponentProps<typeof TextField>,
|
|
) {
|
|
return <TextField {...props} />;
|
|
}
|
|
|
|
export function ErrorSummary<Values extends FormValues>(props: Readonly<{
|
|
fieldErrors: FieldErrors<Values>;
|
|
formErrors?: readonly string[];
|
|
fieldLabels: Readonly<Record<FieldName<Values>, string>>;
|
|
fieldId(name: FieldName<Values>): string;
|
|
onFocusField?(name: FieldName<Values>): void;
|
|
}>) {
|
|
const { message } = useLocale();
|
|
const {
|
|
fieldErrors,
|
|
formErrors = [],
|
|
fieldLabels,
|
|
fieldId,
|
|
onFocusField,
|
|
} = props;
|
|
const headingId = useId();
|
|
const entries = Object.entries(fieldErrors) as [
|
|
FieldName<Values>,
|
|
string,
|
|
][];
|
|
if (entries.length === 0 && formErrors.length === 0) return null;
|
|
return (
|
|
<section
|
|
className="form-error-summary"
|
|
role="alert"
|
|
aria-labelledby={headingId}
|
|
>
|
|
<h2 id={headingId}>{message("form.errorSummary")}</h2>
|
|
{entries.length > 0 ? (
|
|
<ul>
|
|
{entries.map(([name, message]) => (
|
|
<li key={name}>
|
|
<a
|
|
href={`#${fieldId(name)}`}
|
|
onClick={(event) => {
|
|
if (!onFocusField) return;
|
|
event.preventDefault();
|
|
onFocusField(name);
|
|
}}
|
|
>
|
|
{fieldLabels[name]}: {message}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{formErrors.map((message) => (
|
|
<p key={message}>{message}</p>
|
|
))}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function FormActions(props: Readonly<{
|
|
children: ReactNode;
|
|
sticky?: boolean;
|
|
}>) {
|
|
return (
|
|
<div
|
|
className={`form-actions${props.sticky ? " form-actions--sticky" : ""}`}
|
|
>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
}
|