Fehler und Wiederaufnahme
Wenn etwas schiefgeht, übernimmt das Formular die Verantwortung, behält jede Eingabe und zeigt den Ausweg. Probleme über dem Formular, Prüfungen beim Tippen mit Vorschlag für Tippfehler, ein fehlgeschlagenes Senden, das sich selbst wiederholt, und eine abgelaufene Sitzung, die kein Wort verliert.
Eine von 3 Components in Formularzustände
Variants
Details
Was Fehler und Wiederaufnahme mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Formularzustände
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/error-recovery.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 44 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ErrorRecoveryProps aus components/ui/error-recovery.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalErrorRecoveryVariantsummary-topinline-livesubmit-failedsession-expired
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeshowErrorsoptionalbooleanStart with the errors shown, for previews of the error state
valuesoptional{ name?: string; email?: string; phone?: string; message?: string; username?: string }Start values of the fields
takenNamesoptionalstring[]inline-live: names that are taken
domainsoptionalstring[]inline-live: domains people often mistype, the right one first
fallbackEmailoptionalstringsubmit-failed: another way to reach you
accountoptionalstringsession-expired: the account that signs back in
submitLabeloptionalstringonSubmitoptional(data: Record<string, string>) => boolean | void | Promise<boolean | void>Your sender. Return false or throw to show the failure state.
linksoptional{ reset?: string }session-expired: where "Reset password" goes, #reset by default
Eingebaute Texte
Diese Texte schreibt Fehler und Wiederaufnahme selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 44 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| name | Englisch Name | Deutsch Name |
| Englisch Email | Deutsch E-Mail | |
| phone | Englisch Phone | Deutsch Telefon |
| message | Englisch Message | Deutsch Nachricht |
| username | Englisch Username | Deutsch Benutzername |
| password | Englisch Password | Deutsch Passwort |
| nameNeeded | Englisch Please add your name, so we know who we answer. | Deutsch Bitte gib deinen Namen an, damit wir wissen, wem wir antworten. |
| emailNeeded | Englisch This address is missing the part after the @. Please use a form like name@example.com. | Deutsch Dieser Adresse fehlt der Teil nach dem @. Bitte nutze die Form name@beispiel.de. |
| phoneNeeded | Englisch This number has too few digits. Please add the area code, so we can call back. | Deutsch Diese Nummer hat zu wenige Ziffern. Bitte ergänze die Vorwahl, damit wir zurückrufen können. |
| messageNeeded | Englisch Please write a few words, so we know what it is about. | Deutsch Bitte schreib ein paar Worte, damit wir wissen, worum es geht. |
| fixedAll | Englisch All fixed, you can send now | Deutsch Alles behoben, du kannst jetzt senden |
| left | Englisch {n} fields still to fix | Deutsch Noch {n} Felder offen |
| leftOne | Englisch One field still to fix | Deutsch Noch ein Feld offen |
| send | Englisch Send | Deutsch Senden |
| sent | Englisch Sent. Thank you. | Deutsch Gesendet. Danke. |
| checking | Englisch Checking {u} | Deutsch Prüfe {u} |
| taken | Englisch {u} is taken. How about {s}? | Deutsch {u} ist vergeben. Wie wäre es mit {s}? |
| free | Englisch {u} is free | Deutsch {u} ist frei |
| didYouMean | Englisch Did you mean {e}? | Deutsch Meintest du {e}? |
| useIt | Englisch Use {e} | Deutsch {e} übernehmen |
| rules | Englisch Your password needs | Deutsch Dein Passwort braucht |
| rule8 | Englisch At least 10 characters | Deutsch Mindestens 10 Zeichen |
| ruleNum | Englisch A number | Deutsch Eine Ziffer |
| ruleCase | Englisch Upper and lower case letters | Deutsch Groß- und Kleinbuchstaben |
| ruleName | Englisch Not your username | Deutsch Nicht deinen Benutzernamen |
| create | Englisch Create account | Deutsch Konto anlegen |
| created | Englisch Account created | Deutsch Konto angelegt |
| failTitle | Englisch We could not send it, but nothing is lost | Deutsch Wir konnten es nicht senden, aber nichts ist verloren |
| failText | Englisch Our server did not answer. Everything you entered is still here. | Deutsch Unser Server hat nicht geantwortet. Alles, was du eingegeben hast, ist noch da. |
| retryIn | Englisch Trying again in {n} seconds | Deutsch Neuer Versuch in {n} Sekunden |
| retrying | Englisch Trying again | Deutsch Neuer Versuch läuft |
| retryNow | Englisch Try again now | Deutsch Jetzt erneut versuchen |
| copy | Englisch Copy my message | Deutsch Nachricht kopieren |
| copied | Englisch Copied to the clipboard | Deutsch In die Zwischenablage kopiert |
| other | Englisch Or write to {e} | Deutsch Oder schreib an {e} |
| expiredTitle | Englisch Your session ran out, your form did not | Deutsch Deine Sitzung ist abgelaufen, dein Formular nicht |
| expiredText | Englisch For your safety we sign you out after 30 minutes. Enter your password and continue where you left off. | Deutsch Zu deiner Sicherheit melden wir dich nach 30 Minuten ab. Gib dein Passwort ein und mach dort weiter, wo du warst. |
| continue | Englisch Sign in and continue | Deutsch Anmelden und weitermachen |
| pwNeeded | Englisch Please enter your password to continue. | Deutsch Bitte gib dein Passwort ein, um weiterzumachen. |
| pwWrong | Englisch This password does not match. Check caps lock, or reset it, your form waits. | Deutsch Dieses Passwort passt nicht. Prüf die Feststelltaste oder setz es zurück, dein Formular wartet. |
| reset | Englisch Reset password | Deutsch Passwort zurücksetzen |
| kept | Englisch Kept for you | Deutsch Für dich aufbewahrt |
| signedIn | Englisch Welcome back, you can send now. | Deutsch Willkommen zurück, du kannst jetzt senden. |
| signedAs | Englisch Signed in as {a} | Deutsch Angemeldet als {a} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-describedby, aria-labelledby, aria-live und aria-modal, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, dialog und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 622 Zeilen
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input, PasswordInput } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { DoneMark, ErrorSummary, FlowHead, isEmail, jumpTo, Unfold, useFocusFirstError, type FlowError } from '@/components/ui/form-flow';
/*
* When something goes wrong in a form, the form takes the blame, keeps every
* entry and shows the way out. Four moments, each a section to drop into a
* page or to copy the pattern from:
*
* summary-top all problems above the form, each a link, the box counts down as they are fixed
* inline-live checks after leaving a field, rules that tick, a typo in the address offered as a fix
* submit-failed the send did not go through, nothing is lost, retry runs by itself, other ways stay open
* session-expired the sign-in ran out mid form, a password brings it back without losing a word
*/
export type ErrorRecoveryVariant = 'summary-top' | 'inline-live' | 'submit-failed' | 'session-expired';
export interface ErrorRecoveryProps {
variant?: ErrorRecoveryVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** Start with the errors shown, for previews of the error state */
showErrors?: boolean;
/** Start values of the fields */
values?: { name?: string; email?: string; phone?: string; message?: string; username?: string };
/** inline-live: names that are taken */
takenNames?: string[];
/** inline-live: domains people often mistype, the right one first */
domains?: string[];
/** submit-failed: another way to reach you */
fallbackEmail?: string;
/** session-expired: the account that signs back in */
account?: string;
submitLabel?: string;
/** Your sender. Return false or throw to show the failure state. */
onSubmit?: (data: Record<string, string>) => boolean | void | Promise<boolean | void>;
/** session-expired: where "Reset password" goes, #reset by default */
links?: { reset?: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
name: 'Name',
email: 'Email',
phone: 'Phone',
message: 'Message',
username: 'Username',
password: 'Password',
nameNeeded: 'Please add your name, so we know who we answer.',
emailNeeded: 'This address is missing the part after the @. Please use a form like name@example.com.',
phoneNeeded: 'This number has too few digits. Please add the area code, so we can call back.',
messageNeeded: 'Please write a few words, so we know what it is about.',
fixedAll: 'All fixed, you can send now',
left: '{n} fields still to fix',
leftOne: 'One field still to fix',
send: 'Send',
sent: 'Sent. Thank you.',
checking: 'Checking {u}',
taken: '{u} is taken. How about {s}?',
free: '{u} is free',
didYouMean: 'Did you mean {e}?',
useIt: 'Use {e}',
rules: 'Your password needs',
rule8: 'At least 10 characters',
ruleNum: 'A number',
ruleCase: 'Upper and lower case letters',
ruleName: 'Not your username',
create: 'Create account',
created: 'Account created',
failTitle: 'We could not send it, but nothing is lost',
failText: 'Our server did not answer. Everything you entered is still here.',
retryIn: 'Trying again in {n} seconds',
retrying: 'Trying again',
retryNow: 'Try again now',
copy: 'Copy my message',
copied: 'Copied to the clipboard',
other: 'Or write to {e}',
expiredTitle: 'Your session ran out, your form did not',
expiredText: 'For your safety we sign you out after 30 minutes. Enter your password and continue where you left off.',
continue: 'Sign in and continue',
pwNeeded: 'Please enter your password to continue.',
pwWrong: 'This password does not match. Check caps lock, or reset it, your form waits.',
reset: 'Reset password',
kept: 'Kept for you',
signedIn: 'Welcome back, you can send now.',
signedAs: 'Signed in as {a}',
},
de: {
name: 'Name',
email: 'E-Mail',
phone: 'Telefon',
message: 'Nachricht',
username: 'Benutzername',
password: 'Passwort',
nameNeeded: 'Bitte gib deinen Namen an, damit wir wissen, wem wir antworten.',
emailNeeded: 'Dieser Adresse fehlt der Teil nach dem @. Bitte nutze die Form name@beispiel.de.',
phoneNeeded: 'Diese Nummer hat zu wenige Ziffern. Bitte ergänze die Vorwahl, damit wir zurückrufen können.',
messageNeeded: 'Bitte schreib ein paar Worte, damit wir wissen, worum es geht.',
fixedAll: 'Alles behoben, du kannst jetzt senden',
left: 'Noch {n} Felder offen',
leftOne: 'Noch ein Feld offen',
send: 'Senden',
sent: 'Gesendet. Danke.',
checking: 'Prüfe {u}',
taken: '{u} ist vergeben. Wie wäre es mit {s}?',
free: '{u} ist frei',
didYouMean: 'Meintest du {e}?',
useIt: '{e} übernehmen',
rules: 'Dein Passwort braucht',
rule8: 'Mindestens 10 Zeichen',
ruleNum: 'Eine Ziffer',
ruleCase: 'Groß- und Kleinbuchstaben',
ruleName: 'Nicht deinen Benutzernamen',
create: 'Konto anlegen',
created: 'Konto angelegt',
failTitle: 'Wir konnten es nicht senden, aber nichts ist verloren',
failText: 'Unser Server hat nicht geantwortet. Alles, was du eingegeben hast, ist noch da.',
retryIn: 'Neuer Versuch in {n} Sekunden',
retrying: 'Neuer Versuch läuft',
retryNow: 'Jetzt erneut versuchen',
copy: 'Nachricht kopieren',
copied: 'In die Zwischenablage kopiert',
other: 'Oder schreib an {e}',
expiredTitle: 'Deine Sitzung ist abgelaufen, dein Formular nicht',
expiredText: 'Zu deiner Sicherheit melden wir dich nach 30 Minuten ab. Gib dein Passwort ein und mach dort weiter, wo du warst.',
continue: 'Anmelden und weitermachen',
pwNeeded: 'Bitte gib dein Passwort ein, um weiterzumachen.',
pwWrong: 'Dieses Passwort passt nicht. Prüf die Feststelltaste oder setz es zurück, dein Formular wartet.',
reset: 'Passwort zurücksetzen',
kept: 'Für dich aufbewahrt',
signedIn: 'Willkommen zurück, du kannst jetzt senden.',
signedAs: 'Angemeldet als {a}',
},
};
type Texts = (typeof TEXTS)['en'];
const digits = (v: string) => v.replace(/\D/g, '').length;
/* ------------------------------------------------------------------ */
/* summary-top */
/* ------------------------------------------------------------------ */
function SummaryTop({ p, t }: { p: ErrorRecoveryProps; t: Texts }) {
const id = useId();
const [v, setV] = useState({ name: p.values?.name ?? '', email: p.values?.email ?? '', phone: p.values?.phone ?? '', message: p.values?.message ?? '' });
const check = (x: typeof v) => {
const e: Record<string, string> = {};
if (!x.name.trim()) e.name = t.nameNeeded;
if (!isEmail(x.email)) e.email = t.emailNeeded;
if (x.phone && digits(x.phone) < 6) e.phone = t.phoneNeeded;
if (!x.message.trim()) e.message = t.messageNeeded;
return e;
};
const [errors, setErrors] = useState<Record<string, string>>(p.showErrors ? check(v) : {});
const [tried, setTried] = useState(Boolean(p.showErrors));
const [sent, setSent] = useState(false);
const summaryRef = useRef<HTMLDivElement>(null);
const set = (k: keyof typeof v) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const next = { ...v, [k]: e.target.value };
setV(next);
// After the first try, fixing a field takes it off the list right away
if (tried) setErrors(check(next));
};
const labels: Record<string, string> = { name: t.name, email: t.email, phone: t.phone, message: t.message };
const summary: FlowError[] = Object.keys(errors).map((k) => ({ field: `${id}-${k}`, message: labels[k] }));
const allFixed = tried && summary.length === 0 && !sent;
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
noValidate
className="kit-card grid gap-5 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
const found = check(v);
setErrors(found);
setTried(true);
if (Object.keys(found).length) {
requestAnimationFrame(() => summaryRef.current?.focus());
return;
}
p.onSubmit?.(v);
setSent(true);
}}
>
<ErrorSummary ref={summaryRef} errors={summary} />
{allFixed && (
<p role="status" className="kit-rise flex items-center gap-2.5 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_7%,var(--card))] p-4 text-[14.5px] font-medium text-success-foreground">
<Icon name="check-circle" size={18} />
{t.fixedAll}
</p>
)}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={set('name')} error={errors.name} success={tried && !errors.name && Boolean(v.name)} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={set('email')} error={errors.email} success={tried && !errors.email} />
<Input id={`${id}-phone`} type="tel" label={t.phone} optional autoComplete="tel" value={v.phone} onChange={set('phone')} error={errors.phone} className="sm:col-span-2" />
<Textarea id={`${id}-message`} label={t.message} required value={v.message} onChange={set('message')} error={errors.message} minRows={3} className="sm:col-span-2" />
</div>
<div className="flex flex-col gap-3 border-t border-border pt-5 sm:flex-row sm:items-center sm:justify-between">
<p aria-live="polite" className="text-[13px] tabular-nums text-muted-foreground">
{sent ? <span className="text-success-foreground">{t.sent}</span> : summary.length === 1 ? t.leftOne : summary.length ? t.left.replace('{n}', String(summary.length)) : ''}
</p>
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* inline-live */
/* ------------------------------------------------------------------ */
/** Closest known domain by edit distance, for "did you mean" */
function closest(domain: string, list: string[]) {
const dist = (a: string, b: string) => {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
return dp[a.length][b.length];
};
let best: string | null = null;
let score = 3;
for (const d of list) {
const s = dist(domain, d);
if (s > 0 && s < score) {
score = s;
best = d;
}
}
return best;
}
function InlineLive({ p, t }: { p: ErrorRecoveryProps; t: Texts }) {
const id = useId();
const [username, setUsername] = useState(p.values?.username ?? '');
const [email, setEmail] = useState(p.values?.email ?? '');
const [pw, setPw] = useState('');
const [touched, setTouched] = useState<Record<string, boolean>>({ username: Boolean(p.values?.username), email: Boolean(p.values?.email) });
const [checkingName, setCheckingName] = useState(false);
const [nameState, setNameState] = useState<'free' | 'taken' | null>(null);
const [done, setDone] = useState(false);
const taken = (p.takenNames ?? []).map((x) => x.toLowerCase());
// A short pause, as if a server answered, then free or taken
useEffect(() => {
if (!touched.username || username.trim().length < 3) {
setNameState(null);
return;
}
setCheckingName(true);
const timer = window.setTimeout(() => {
setCheckingName(false);
setNameState(taken.includes(username.trim().toLowerCase()) ? 'taken' : 'free');
}, 650);
return () => window.clearTimeout(timer);
}, [username, touched.username]); // eslint-disable-line react-hooks/exhaustive-deps
const suggestion = `${username.trim().toLowerCase()}2`;
const at = email.lastIndexOf('@');
const domain = at > 0 ? email.slice(at + 1).toLowerCase() : '';
const fix = domain && p.domains ? closest(domain, p.domains) : null;
const fixed = fix ? `${email.slice(0, at + 1)}${fix}` : '';
const emailError = touched.email && email && !isEmail(email) ? t.emailNeeded : undefined;
const rules = [
{ ok: pw.length >= 10, label: t.rule8 },
{ ok: /\d/.test(pw), label: t.ruleNum },
{ ok: /[a-z]/.test(pw) && /[A-Z]/.test(pw), label: t.ruleCase },
{ ok: pw.length > 0 && !(username && pw.toLowerCase().includes(username.toLowerCase())), label: t.ruleName },
];
const ready = nameState === 'free' && isEmail(email) && rules.every((r) => r.ok);
if (done)
return (
<div className="kit-card mx-auto flex max-w-[34rem] flex-col items-center p-8 text-center" role="status">
<DoneMark size={56} />
<p className="mt-4 text-[19px] font-semibold text-foreground">{t.created}</p>
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[34rem]">
<form
noValidate
className="kit-card grid gap-5 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
setTouched({ username: true, email: true, pw: true });
if (!ready) {
const first = nameState !== 'free' ? `${id}-user` : !isEmail(email) ? `${id}-email` : `${id}-pw`;
jumpTo(first);
return;
}
p.onSubmit?.({ username, email });
setDone(true);
}}
>
<div>
<Input
id={`${id}-user`}
label={t.username}
required
autoComplete="username"
prefix="@"
value={username}
onChange={(e) => setUsername(e.target.value.replace(/\s/g, ''))}
onBlur={() => setTouched((x) => ({ ...x, username: true }))}
success={nameState === 'free'}
invalid={nameState === 'taken'}
aria-describedby={`${id}-user-msg`}
trailing={checkingName ? <Icon name="loader" size={16} className="me-2 animate-spin text-muted-foreground motion-reduce:animate-none" /> : undefined}
/>
<p id={`${id}-user-msg`} aria-live="polite" className="mt-2 min-h-5 text-[13px]">
{checkingName ? (
<span className="text-muted-foreground">{t.checking.replace('{u}', `@${username}`)}</span>
) : nameState === 'taken' ? (
<span className="kit-rise flex flex-wrap items-center gap-x-2 text-destructive">
{t.taken.replace('{u}', `@${username}`).replace('{s}', `@${suggestion}`)}
<button type="button" onClick={() => setUsername(suggestion)} className="min-h-9 font-medium text-foreground underline underline-offset-4 max-sm:min-h-11 focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
{t.useIt.replace('{e}', `@${suggestion}`)}
</button>
</span>
) : nameState === 'free' ? (
<span className="text-success-foreground">{t.free.replace('{u}', `@${username}`)}</span>
) : null}
</p>
</div>
<div>
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} onBlur={() => setTouched((x) => ({ ...x, email: true }))} error={emailError} success={touched.email && isEmail(email) && !fix} />
{/* A likely typo in the domain, offered as a one tap fix */}
<Unfold open={Boolean(fix) && touched.email}>
<p className="mt-2 flex flex-wrap items-center gap-x-2 rounded-lg bg-surface px-3 py-2 text-[13px] text-muted-foreground">
<Icon name="sparkle" size={14} className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
{t.didYouMean.replace('{e}', fixed)}
<button type="button" onClick={() => setEmail(fixed)} className="min-h-9 font-medium text-foreground underline underline-offset-4 max-sm:min-h-11 focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
{t.useIt.replace('{e}', fix ?? '')}
</button>
</p>
</Unfold>
</div>
<div>
<PasswordInput id={`${id}-pw`} label={t.password} required autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} aria-describedby={`${id}-rules`} invalid={Boolean(touched.pw) && !rules.every((r) => r.ok)} />
{/* Rules tick off one by one as the password grows */}
<div id={`${id}-rules`} className="mt-3">
<p className="text-[12.5px] text-muted-foreground">{t.rules}</p>
<ul className="mt-2 grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{rules.map((r) => (
<li key={r.label} className={cn('flex items-center gap-2 text-[13px] transition-colors duration-300', r.ok ? 'text-foreground' : 'text-muted-foreground')}>
<span className={cn('grid size-4 shrink-0 place-items-center rounded-full transition-[background-color,transform] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', r.ok ? 'scale-110 bg-success text-background' : 'border border-border-strong')}>{r.ok && <Icon name="check" size={10} strokeWidth={3} />}</span>
{r.label}
<span className="sr-only">{r.ok ? '✓' : ''}</span>
</li>
))}
</ul>
</div>
</div>
<Button type="submit" icon="arrow-right" className="mt-1 w-full">
{p.submitLabel ?? t.create}
</Button>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* submit-failed */
/* ------------------------------------------------------------------ */
function SubmitFailed({ p, t }: { p: ErrorRecoveryProps; t: Texts }) {
const id = useId();
const [v, setV] = useState({ name: p.values?.name ?? '', email: p.values?.email ?? '', message: p.values?.message ?? '' });
const [failed, setFailed] = useState(Boolean(p.showErrors));
const [count, setCount] = useState(15);
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const sentRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (sent) sentRef.current?.focus({ preventScroll: true });
}, [sent]);
const [copied, setCopied] = useState(false);
const attempts = useRef(0);
const banner = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
useFocusFirstError(formRef, attempt);
const check = (x: typeof v) => {
const e: Record<string, string> = {};
if (!x.name.trim()) e.name = t.nameNeeded;
if (!isEmail(x.email)) e.email = t.emailNeeded;
if (!x.message.trim()) e.message = t.messageNeeded;
return e;
};
const change = (k: keyof typeof v, value: string) => {
const next = { ...v, [k]: value };
setV(next);
if (errors[k]) setErrors(check(next));
};
const send = async () => {
setBusy(true);
attempts.current += 1;
let ok: boolean | void = undefined;
try {
ok = await p.onSubmit?.(v);
} catch {
ok = false;
}
// Without a sender of your own the first attempt shows the failure, so the state can be seen
if (!p.onSubmit) ok = attempts.current > 1 || p.showErrors ? true : false;
window.setTimeout(() => {
setBusy(false);
if (ok === false) {
setFailed(true);
setCount(15);
requestAnimationFrame(() => banner.current?.focus());
} else {
setFailed(false);
setSent(true);
}
}, 700);
};
// While failed, a gentle countdown retries by itself
useEffect(() => {
if (!failed || busy || sent) return;
if (count <= 0) {
send();
return;
}
const timer = window.setTimeout(() => setCount((c) => c - 1), 1000);
return () => window.clearTimeout(timer);
}, [failed, count, busy, sent]); // eslint-disable-line react-hooks/exhaustive-deps
if (sent)
return (
<div className="kit-card mx-auto flex max-w-[40rem] flex-col items-center p-8 text-center" role="status">
<DoneMark size={56} />
<p ref={sentRef} tabIndex={-1} className="mt-4 text-[19px] font-semibold text-foreground outline-none">{t.sent}</p>
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
noValidate
className="kit-card overflow-hidden"
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
const found = check(v);
setErrors(found);
if (Object.keys(found).length) {
setAttempt((a) => a + 1);
return;
}
send();
}}
>
<Unfold open={failed}>
<div ref={banner} tabIndex={-1} role="alert" className="border-b border-[color-mix(in_oklab,var(--destructive)_35%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))] p-5 outline-none sm:p-6">
<p className="flex items-start gap-3 text-[15.5px] font-semibold text-foreground">
<Icon name="wifi-off" size={19} className="mt-0.5 shrink-0 text-destructive" />
{t.failTitle}
</p>
<p className="mt-1 ps-[1.9rem] text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{t.failText}</p>
<div className="mt-4 flex flex-col gap-3 ps-[1.9rem] sm:flex-row sm:items-center">
{/* The countdown as a draining bar inside the button */}
<Button type="button" variant="outline" size="sm" iconLeft="rotate-cw" onClick={send} disabled={busy} className="relative overflow-hidden">
<span aria-hidden className="absolute inset-y-0 start-0 bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] transition-[width] duration-1000 ease-linear" style={{ width: `${Math.round((count / 15) * 100)}%` }} />
<span className="relative">{t.retryNow}</span>
</Button>
<span aria-live="polite" className="text-[13px] tabular-nums text-muted-foreground">
{busy ? t.retrying : t.retryIn.replace('{n}', String(count))}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-x-5 gap-y-2 ps-[1.9rem] text-[13px]">
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(`${v.name}\n${v.email}\n\n${v.message}`).catch(() => {});
setCopied(true);
}}
className="inline-flex min-h-9 items-center gap-1.5 font-medium text-foreground underline-offset-4 hover:underline max-sm:min-h-11 focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none"
>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? t.copied : t.copy}
</button>
{p.fallbackEmail && (
<a href={`mailto:${p.fallbackEmail}`} className="inline-flex min-h-9 items-center gap-1.5 font-medium text-foreground underline-offset-4 hover:underline max-sm:min-h-11">
<Icon name="mail" size={14} />
{t.other.replace('{e}', p.fallbackEmail)}
</a>
)}
</div>
</div>
</Unfold>
<div className="grid gap-5 p-6 sm:p-8">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={(e) => change('name', e.target.value)} error={errors.name} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={(e) => change('email', e.target.value)} error={errors.email} />
</div>
<Textarea id={`${id}-message`} label={t.message} required value={v.message} onChange={(e) => change('message', e.target.value)} error={errors.message} minRows={4} labelAction={failed ? <span className="inline-flex items-center gap-1 text-[12.5px] text-success-foreground"><Icon name="shield" size={13} />{t.kept}</span> : undefined} />
<div className="flex justify-end border-t border-border pt-5">
<Button type="submit" icon={busy ? undefined : 'send'} disabled={busy} className="max-sm:w-full">
{busy && <Icon name="loader" size={16} className="animate-spin motion-reduce:animate-none" />}
{busy ? t.retrying : (p.submitLabel ?? t.send)}
</Button>
</div>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* session-expired */
/* ------------------------------------------------------------------ */
function SessionExpired({ p, t }: { p: ErrorRecoveryProps; t: Texts }) {
const id = useId();
const [expired, setExpired] = useState(p.showErrors !== false);
const [pw, setPw] = useState('');
const [error, setError] = useState('');
const [tries, setTries] = useState(0);
const [v, setV] = useState({ message: p.values?.message ?? '' });
const pwRef = useRef<HTMLInputElement>(null);
const done = useRef<HTMLParagraphElement>(null);
return (
<div className="relative mx-auto max-w-[44rem]">
{/* The form stays visible behind a calm veil, so people see their words are safe */}
<Reveal effect="rise">
<div aria-hidden={expired || undefined} inert={expired || undefined} className={cn('kit-card p-6 transition-[filter,opacity] duration-500 sm:p-8', expired && 'min-h-[38rem] opacity-60 blur-[1.5px] sm:min-h-[33rem]')}>
{!expired && (
<p ref={done} tabIndex={-1} role="status" className="kit-rise mb-5 flex items-center gap-2.5 text-[14px] font-medium text-success-foreground outline-none">
<Icon name="check-circle" size={17} />
{t.signedIn}
</p>
)}
<Textarea label={t.message} value={v.message} onChange={(e) => setV({ message: e.target.value })} minRows={7} labelAction={<span className="inline-flex items-center gap-1 text-[12.5px] text-muted-foreground"><Icon name="shield" size={13} />{t.kept}</span>} />
<div className="mt-5 flex justify-end">
<Button icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</div>
</Reveal>
{expired && (
<div className="absolute inset-0 z-10 grid place-items-center p-3 sm:p-6">
<form
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-t`}
noValidate
className="kit-rise w-full max-w-[26rem] rounded-2xl border border-border bg-card p-6 shadow-[var(--shadow-lg)]"
onSubmit={(e) => {
e.preventDefault();
if (!pw) {
setError(t.pwNeeded);
pwRef.current?.focus();
return;
}
// Without a sender of your own the first password counts as wrong, so the message can be seen
if (!p.onSubmit && tries === 0) {
setTries(1);
setError(t.pwWrong);
pwRef.current?.focus();
return;
}
setExpired(false);
requestAnimationFrame(() => done.current?.focus());
}}
>
<span className="grid size-11 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<Icon name="lock" size={20} />
</span>
<h3 id={`${id}-t`} className="mt-4 text-balance text-[18px] font-semibold leading-snug text-foreground">
{t.expiredTitle}
</h3>
<p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{t.expiredText}</p>
{p.account && <p className="mt-4 rounded-lg bg-surface px-3 py-2 text-[13px] text-foreground">{t.signedAs.replace('{a}', p.account)}</p>}
<PasswordInput ref={pwRef} id={`${id}-pw`} label={t.password} autoComplete="current-password" value={pw} onChange={(e) => (setPw(e.target.value), setError(''))} error={error || undefined} className="mt-4" />
<Button type="submit" icon="arrow-right" className="mt-5 w-full">
{t.continue}
</Button>
<a href={p.links?.reset ?? '#reset'} className="mt-3 flex min-h-11 items-center justify-center text-[13px] font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
{t.reset}
</a>
</form>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* Forms that recover well.
* <ErrorRecovery variant="summary-top" showErrors values={{ email: 'lena@' }} />
* <ErrorRecovery variant="submit-failed" onSubmit={send} fallbackEmail="hello@example.com" />
*/
export function ErrorRecovery(props: ErrorRecoveryProps) {
const { variant = 'summary-top', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
{variant === 'summary-top' && <SummaryTop p={props} t={t} />}
{variant === 'inline-live' && <InlineLive p={props} t={t} />}
{variant === 'submit-failed' && <SubmitFailed p={props} t={t} />}
{variant === 'session-expired' && <SessionExpired p={props} t={t} />}
</Section>
);
}Häufige Fragen
Wie installiere ich Fehler und Wiederaufnahme?
Mit aiw add @ai-website/error-recovery. Der Befehl legt die Datei components/ui/error-recovery.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow, input und textarea. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Fehler und Wiederaufnahme?
4 Varianten, Übersicht oben, Prüfen beim Tippen, Senden fehlgeschlagen und Sitzung abgelaufen. Du wählst sie mit der Prop variant, zum Beispiel variant="inline-live".
Kann ich die Texte von Fehler und Wiederaufnahme übersetzen?
Ja. Fehler und Wiederaufnahme bringt 44 eigene Texte auf Deutsch und Englisch mit, etwa „Name“, „E-Mail“ und „Telefon“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Fehler und Wiederaufnahme einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Fehler und Wiederaufnahme mit aiw_components und installieren sie mit aiw_add @ai-website/error-recovery. Nenn im Prompt die Variante, etwa variant="summary-top", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Fehler und Wiederaufnahme ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ErrorRecovery-Code zum Kopieren.