Persist values, tighten validation, and a11y on /onboarding

- Server action echoes kitaName, the three module booleans, and the
  Mindest-Verfügbarkeiten value on every error path so failed submits no
  longer wipe what the user typed; OnboardingState gains an `attempt`
  counter that drives a per-field remount key on the client.
- Schema now rejects min=0 when the Notdienst module is active (via
  superRefine) — previously a kita could be created with 0 minimum days,
  which is fachlich sinnlos. When the module is off, 0 stays valid.
- Validation error messages reworded from terse fragments ("Nicht
  negativ.", "Bitte ganze Zahl.") to full sentences.
- Field error <p> elements gain role="alert" + aria-live="polite" so
  screen readers announce them on submit.
- CardTitle now accepts as="h1" and the onboarding page uses it for
  "Willkommen, …" — the page now has a real top-level heading.
- ModuleCheckbox uses aria-labelledby + aria-describedby so the
  accessible name is just the module title; the longer description is
  exposed as secondary info instead of being concatenated into the AN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
t.indorf
2026-05-17 20:46:31 +02:00
parent 380c67963b
commit af2c3b245a
4 changed files with 156 additions and 44 deletions
+63 -17
View File
@@ -16,21 +16,35 @@ import { prisma } from "@/lib/prisma";
// — kein "halb-eingerichteter" Zwischenzustand. // — kein "halb-eingerichteter" Zwischenzustand.
// ===================================================================== // =====================================================================
const onboardingSchema = z.object({ const onboardingSchema = z
kitaName: z .object({
.string() kitaName: z
.min(2, "Mindestens 2 Zeichen.") .string()
.max(120, "Maximal 120 Zeichen.") .trim()
.trim(), .min(2, "Bitte mindestens 2 Zeichen angeben.")
notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), .max(120, "Maximal 120 Zeichen."),
terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), notdienstModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()), terminModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
notdienstMinPerChildPerMonth: z.coerce adressbuchModuleEnabled: z.preprocess(checkboxToBool, z.boolean()),
.number() notdienstMinPerChildPerMonth: z.coerce
.int("Bitte ganze Zahl.") .number()
.min(0, "Nicht negativ.") .int("Bitte eine ganze Zahl angeben.")
.max(31, "Maximal 31 Tage pro Monat."), .min(0, "Bitte einen Wert von 0 oder mehr angeben.")
}); .max(31, "Maximal 31 Tage pro Monat."),
})
.superRefine((data, ctx) => {
// Mindest-Verfügbarkeit nur relevant, wenn das Notdienst-Modul aktiv ist.
// Bei aktivem Modul muss mindestens 1 Tag eingetragen sein — 0 wäre
// fachlich sinnlos und würde den Planer leerlaufen lassen.
if (data.notdienstModuleEnabled && data.notdienstMinPerChildPerMonth < 1) {
ctx.addIssue({
path: ["notdienstMinPerChildPerMonth"],
code: "custom",
message:
"Bei aktivem Notdienst-Modul: mindestens 1 Tag pro Monat angeben.",
});
}
});
function checkboxToBool(value: unknown): boolean { function checkboxToBool(value: unknown): boolean {
// HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData. // HTML-Checkbox: "on" wenn aktiviert, sonst nicht im FormData.
@@ -38,6 +52,17 @@ function checkboxToBool(value: unknown): boolean {
} }
export type OnboardingState = { export type OnboardingState = {
// 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 so a failed submit doesn't wipe the form.
values?: {
kitaName?: string;
notdienstModuleEnabled?: boolean;
terminModuleEnabled?: boolean;
adressbuchModuleEnabled?: boolean;
notdienstMinPerChildPerMonth?: number;
};
errors?: { errors?: {
kitaName?: string[]; kitaName?: string[];
notdienstMinPerChildPerMonth?: string[]; notdienstMinPerChildPerMonth?: string[];
@@ -45,10 +70,25 @@ export type OnboardingState = {
}; };
}; };
export const initialOnboardingState: OnboardingState = { attempt: 0 };
function echoValues(formData: FormData): OnboardingState["values"] {
const min = formData.get("notdienstMinPerChildPerMonth");
return {
kitaName: String(formData.get("kitaName") ?? ""),
notdienstModuleEnabled: formData.get("notdienstModuleEnabled") === "on",
terminModuleEnabled: formData.get("terminModuleEnabled") === "on",
adressbuchModuleEnabled: formData.get("adressbuchModuleEnabled") === "on",
notdienstMinPerChildPerMonth:
min === null || min === "" ? undefined : Number(min),
};
}
export async function completeOnboardingAction( export async function completeOnboardingAction(
_prev: OnboardingState, prev: OnboardingState,
formData: FormData, formData: FormData,
): Promise<OnboardingState> { ): Promise<OnboardingState> {
const attempt = prev.attempt + 1;
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
redirect("/login"); redirect("/login");
@@ -61,7 +101,11 @@ export async function completeOnboardingAction(
const parsed = onboardingSchema.safeParse(Object.fromEntries(formData)); const parsed = onboardingSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) { if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }; return {
attempt,
values: echoValues(formData),
errors: parsed.error.flatten().fieldErrors,
};
} }
const data = parsed.data; const data = parsed.data;
@@ -99,6 +143,8 @@ export async function completeOnboardingAction(
} }
if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err instanceof Prisma.PrismaClientKnownRequestError) {
return { return {
attempt,
values: echoValues(formData),
errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] }, errors: { _form: ["Datenbankfehler — bitte erneut versuchen."] },
}; };
} }
+89 -25
View File
@@ -1,22 +1,31 @@
"use client"; "use client";
import { useActionState } from "react"; import { useActionState, useId } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { completeOnboardingAction, type OnboardingState } from "./actions"; import {
completeOnboardingAction,
const initialState: OnboardingState = {}; initialOnboardingState,
} from "./actions";
export function OnboardingForm() { export function OnboardingForm() {
const [state, formAction, pending] = useActionState( const [state, formAction, pending] = useActionState(
completeOnboardingAction, completeOnboardingAction,
initialState, initialOnboardingState,
); );
// Bumping the key on each attempt forces uncontrolled inputs to remount with
// the echoed defaultValue from the server — otherwise React's <form action>
// semantics would wipe them after a failed submit.
const attemptKey = state.attempt;
const kitaError = state.errors?.kitaName?.[0];
const minError = state.errors?.notdienstMinPerChildPerMonth?.[0];
return ( return (
<form action={formAction} className="space-y-8"> <form action={formAction} className="space-y-8">
{/* ----- Schritt 1: Name ----- */} {/* ----- Schritt 1: Name ----- */}
@@ -27,14 +36,24 @@ export function OnboardingForm() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label> <Label htmlFor="kitaName">Name des Elternvereins / der Kita</Label>
<Input <Input
key={`kitaName-${attemptKey}`}
id="kitaName" id="kitaName"
name="kitaName" name="kitaName"
required required
placeholder="z.B. Waldameisen e.V." placeholder="z.B. Waldameisen e.V."
aria-invalid={!!state.errors?.kitaName} defaultValue={state.values?.kitaName ?? ""}
aria-invalid={!!kitaError}
aria-describedby={kitaError ? "kitaName-error" : undefined}
/> />
{state.errors?.kitaName?.[0] && ( {kitaError && (
<p className="text-xs text-destructive">{state.errors.kitaName[0]}</p> <p
id="kitaName-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{kitaError}
</p>
)} )}
</div> </div>
</fieldset> </fieldset>
@@ -45,22 +64,25 @@ export function OnboardingForm() {
Schritt 2 · Module aktivieren Schritt 2 · Module aktivieren
</legend> </legend>
<ModuleCheckbox <ModuleCheckbox
key={`notdienstModuleEnabled-${attemptKey}`}
name="notdienstModuleEnabled" name="notdienstModuleEnabled"
label="Notdienst-Planung" label="Notdienst-Planung"
description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren." description="Verfügbarkeiten erfassen, Plan generieren, bei Krankheitsausfall alarmieren."
defaultChecked defaultChecked={state.values?.notdienstModuleEnabled ?? true}
/> />
<ModuleCheckbox <ModuleCheckbox
key={`terminModuleEnabled-${attemptKey}`}
name="terminModuleEnabled" name="terminModuleEnabled"
label="Terminkalender" label="Terminkalender"
description="Kita-Feste, Schließtage und private Anfragen koordinieren." description="Kita-Feste, Schließtage und private Anfragen koordinieren."
defaultChecked defaultChecked={state.values?.terminModuleEnabled ?? true}
/> />
<ModuleCheckbox <ModuleCheckbox
key={`adressbuchModuleEnabled-${attemptKey}`}
name="adressbuchModuleEnabled" name="adressbuchModuleEnabled"
label="Eltern-Adressbuch" label="Eltern-Adressbuch"
description="Eltern können sich auf Opt-In-Basis untereinander finden." description="Eltern können sich auf Opt-In-Basis untereinander finden."
defaultChecked defaultChecked={state.values?.adressbuchModuleEnabled ?? true}
/> />
</fieldset> </fieldset>
@@ -74,29 +96,49 @@ export function OnboardingForm() {
Mindest-Verfügbarkeiten pro Kind und Monat Mindest-Verfügbarkeiten pro Kind und Monat
</Label> </Label>
<Input <Input
key={`notdienstMinPerChildPerMonth-${attemptKey}`}
id="notdienstMinPerChildPerMonth" id="notdienstMinPerChildPerMonth"
name="notdienstMinPerChildPerMonth" name="notdienstMinPerChildPerMonth"
type="number" type="number"
min={0} min={0}
max={31} max={31}
defaultValue={2} defaultValue={state.values?.notdienstMinPerChildPerMonth ?? 2}
required required
aria-invalid={!!state.errors?.notdienstMinPerChildPerMonth} aria-invalid={!!minError}
aria-describedby={
minError
? "notdienstMinPerChildPerMonth-error"
: "notdienstMinPerChildPerMonth-helper"
}
/> />
<p className="text-xs text-muted-foreground"> {!minError && (
Wie viele Tage müssen Eltern pro Monat als verfügbar markieren? <p
Diesen Wert kannst du später jederzeit ändern. id="notdienstMinPerChildPerMonth-helper"
</p> className="text-xs text-muted-foreground"
{state.errors?.notdienstMinPerChildPerMonth?.[0] && ( >
<p className="text-xs text-destructive"> Wie viele Tage müssen Eltern pro Monat als verfügbar markieren?
{state.errors.notdienstMinPerChildPerMonth[0]} Diesen Wert kannst du später jederzeit ändern.
</p>
)}
{minError && (
<p
id="notdienstMinPerChildPerMonth-error"
role="alert"
aria-live="polite"
className="text-xs text-destructive"
>
{minError}
</p> </p>
)} )}
</div> </div>
</fieldset> </fieldset>
{state.errors?._form?.[0] && ( {state.errors?._form?.[0] && (
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> <p
role="alert"
aria-live="polite"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{state.errors._form[0]} {state.errors._form[0]}
</p> </p>
)} )}
@@ -119,12 +161,34 @@ function ModuleCheckbox({
description: string; description: string;
defaultChecked?: boolean; defaultChecked?: boolean;
}) { }) {
const id = useId();
const titleId = `${id}-title`;
const descId = `${id}-desc`;
return ( return (
<label className="flex items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"> // The wrapping <label htmlFor> keeps the whole card clickable to toggle
<Checkbox name={name} defaultChecked={defaultChecked} className="mt-0.5" /> // the checkbox. aria-labelledby narrows the accessible name to just the
// module title, with the description exposed via aria-describedby so
// screen readers can present it as secondary info rather than as part of
// the primary label.
<label
htmlFor={id}
className="flex cursor-pointer items-start gap-3 rounded-md border p-4 transition-colors hover:bg-muted/40"
>
<Checkbox
id={id}
name={name}
defaultChecked={defaultChecked}
aria-labelledby={titleId}
aria-describedby={descId}
className="mt-0.5"
/>
<div className="grid gap-1 leading-none"> <div className="grid gap-1 leading-none">
<span className="text-sm font-medium">{label}</span> <span id={titleId} className="text-sm font-medium">
<span className="text-xs text-muted-foreground">{description}</span> {label}
</span>
<span id={descId} className="text-xs text-muted-foreground">
{description}
</span>
</div> </div>
</label> </label>
); );
+3 -1
View File
@@ -30,7 +30,9 @@ export default async function OnboardingPage() {
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12"> <div className="flex min-h-screen items-center justify-center bg-muted/30 px-4 py-12">
<Card className="w-full max-w-2xl"> <Card className="w-full max-w-2xl">
<CardHeader className="space-y-2"> <CardHeader className="space-y-2">
<CardTitle>Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!</CardTitle> <CardTitle as="h1">
Willkommen, {session.user.name?.split(" ")[0] ?? "Gründer:in"}!
</CardTitle>
<CardDescription> <CardDescription>
Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle Lass uns deine Kita in 3 kurzen Schritten einrichten. Du kannst alle
Einstellungen später noch anpassen. Einstellungen später noch anpassen.
+1 -1
View File
@@ -24,7 +24,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
CardHeader.displayName = "CardHeader"; CardHeader.displayName = "CardHeader";
type CardTitleProps = React.HTMLAttributes<HTMLElement> & { type CardTitleProps = React.HTMLAttributes<HTMLElement> & {
as?: "div" | "h2" | "h3" | "h4" | "h5" | "h6"; as?: "div" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
}; };
const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>( const CardTitle = React.forwardRef<HTMLElement, CardTitleProps>(