Persist values, a11y errors, and aria-invalid on /register

- Server action now echoes firstName, lastName, email, and the privacy
  checkbox state on every error path so failed submits don't wipe the form.
- New `attempt` counter on RegisterState drives a remount key per field,
  bypassing React's default <form action> reset semantics.
- Error <p> elements gain role="alert" + aria-live="polite" so screen
  readers announce validation failures live.
- Privacy checkbox now flags aria-invalid when not accepted and points to
  the error message via aria-describedby.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
t.indorf
2026-05-16 11:55:28 +02:00
parent 0b80c62beb
commit 8640001f6f
2 changed files with 97 additions and 13 deletions
+31 -3
View File
@@ -32,6 +32,16 @@ const registerSchema = z.object({
const PRIVACY_POLICY_VERSION = "2026-05-01";
export type RegisterState = {
// Bumped after every action call so the client can key uncontrolled inputs
// and force a remount with the echoed values from a failed submit.
attempt: number;
// Echoed inputs (sans password) so failed submits don't wipe the form.
values?: {
firstName?: string;
lastName?: string;
email?: string;
acceptPrivacyPolicy?: boolean;
};
errors?: {
email?: string[];
password?: string[];
@@ -42,13 +52,29 @@ export type RegisterState = {
};
};
export const initialRegisterState: RegisterState = { attempt: 0 };
function echoValues(formData: FormData): RegisterState["values"] {
return {
firstName: String(formData.get("firstName") ?? ""),
lastName: String(formData.get("lastName") ?? ""),
email: String(formData.get("email") ?? ""),
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
};
}
export async function registerAction(
_prevState: RegisterState,
prevState: RegisterState,
formData: FormData,
): Promise<RegisterState> {
const attempt = prevState.attempt + 1;
const parsed = registerSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
}
const { email, password, firstName, lastName } = parsed.data;
@@ -77,6 +103,8 @@ export async function registerAction(
err.code === "P2002"
) {
return {
attempt,
values: echoValues(formData),
errors: {
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
},
@@ -93,5 +121,5 @@ export async function registerAction(
});
// Unreachable signIn redirected.
return {};
return { attempt };
}