Zum Inhalt springen
UI Kit
Accent

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

4 Variants

Variants

Übersicht oben

Alle Probleme in einem Kasten über dem Formular, jedes ein Link zu seinem Feld. Ein behobenes Feld verschwindet sofort von der Liste und zeichnet einen Haken, mit dem letzten wird der Kasten zur grünen Entwarnung.

Im Studio öffnen

Prüfen beim Tippen

Prüfungen nach dem Verlassen eines Felds, nie mitten im Tippen. Ein vergebener Benutzername bietet einen freien an, eine vertippte Domain wird mit einem Tipp korrigiert, Passwortregeln haken sich nacheinander ab.

Im Studio öffnen

Senden fehlgeschlagen

Das Senden ging nicht durch, und das Formular sagt es klar, jedes Wort ist noch da. Ein Countdown läuft im Knopf für den neuen Versuch ab und versucht es selbst, die Nachricht lässt sich kopieren, ein anderer Weg zum Schreiben bleibt offen.

Im Studio öffnen

Sitzung abgelaufen

Die Anmeldung ist mitten in einem langen Text abgelaufen. Das Formular bleibt hinter einem weichen Schleier sichtbar, ein kleiner Dialog fragt nach dem Passwort, und danach stehen die Worte genau dort, wo sie waren.

Im Studio öffnen

Details

Was Fehler und Wiederaufnahme mitbringt, aus Registry und Quellcode gelesen.

Bereich
Content
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/error-recovery.tsx
Bringt mit
utilskitbuttoniconrevealsectionmotionform-flowinputtextarea
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 44 eigene Texte

Props

Die Schnittstelle ErrorRecoveryProps aus components/ui/error-recovery.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    ErrorRecoveryVariant

    summary-topinline-livesubmit-failedsession-expired

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • showErrorsoptional
    boolean

    Start 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

  • takenNamesoptional
    string[]

    inline-live: names that are taken

  • domainsoptional
    string[]

    inline-live: domains people often mistype, the right one first

  • fallbackEmailoptional
    string

    submit-failed: another way to reach you

  • accountoptional
    string

    session-expired: the account that signs back in

  • submitLabeloptional
    string
  • onSubmitoptional
    (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
nameEnglisch NameDeutsch Name
emailEnglisch EmailDeutsch E-Mail
phoneEnglisch PhoneDeutsch Telefon
messageEnglisch MessageDeutsch Nachricht
usernameEnglisch UsernameDeutsch Benutzername
passwordEnglisch PasswordDeutsch Passwort
nameNeededEnglisch Please add your name, so we know who we answer.Deutsch Bitte gib deinen Namen an, damit wir wissen, wem wir antworten.
emailNeededEnglisch 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.
phoneNeededEnglisch 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.
messageNeededEnglisch Please write a few words, so we know what it is about.Deutsch Bitte schreib ein paar Worte, damit wir wissen, worum es geht.
fixedAllEnglisch All fixed, you can send nowDeutsch Alles behoben, du kannst jetzt senden
leftEnglisch {n} fields still to fixDeutsch Noch {n} Felder offen
leftOneEnglisch One field still to fixDeutsch Noch ein Feld offen
sendEnglisch SendDeutsch Senden
sentEnglisch Sent. Thank you.Deutsch Gesendet. Danke.
checkingEnglisch Checking {u}Deutsch Prüfe {u}
takenEnglisch {u} is taken. How about {s}?Deutsch {u} ist vergeben. Wie wäre es mit {s}?
freeEnglisch {u} is freeDeutsch {u} ist frei
didYouMeanEnglisch Did you mean {e}?Deutsch Meintest du {e}?
useItEnglisch Use {e}Deutsch {e} übernehmen
rulesEnglisch Your password needsDeutsch Dein Passwort braucht
rule8Englisch At least 10 charactersDeutsch Mindestens 10 Zeichen
ruleNumEnglisch A numberDeutsch Eine Ziffer
ruleCaseEnglisch Upper and lower case lettersDeutsch Groß- und Kleinbuchstaben
ruleNameEnglisch Not your usernameDeutsch Nicht deinen Benutzernamen
createEnglisch Create accountDeutsch Konto anlegen
createdEnglisch Account createdDeutsch Konto angelegt
failTitleEnglisch We could not send it, but nothing is lostDeutsch Wir konnten es nicht senden, aber nichts ist verloren
failTextEnglisch 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.
retryInEnglisch Trying again in {n} secondsDeutsch Neuer Versuch in {n} Sekunden
retryingEnglisch Trying againDeutsch Neuer Versuch läuft
retryNowEnglisch Try again nowDeutsch Jetzt erneut versuchen
copyEnglisch Copy my messageDeutsch Nachricht kopieren
copiedEnglisch Copied to the clipboardDeutsch In die Zwischenablage kopiert
otherEnglisch Or write to {e}Deutsch Oder schreib an {e}
expiredTitleEnglisch Your session ran out, your form did notDeutsch Deine Sitzung ist abgelaufen, dein Formular nicht
expiredTextEnglisch 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.
continueEnglisch Sign in and continueDeutsch Anmelden und weitermachen
pwNeededEnglisch Please enter your password to continue.Deutsch Bitte gib dein Passwort ein, um weiterzumachen.
pwWrongEnglisch 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.
resetEnglisch Reset passwordDeutsch Passwort zurücksetzen
keptEnglisch Kept for youDeutsch Für dich aufbewahrt
signedInEnglisch Welcome back, you can send now.Deutsch Willkommen zurück, du kannst jetzt senden.
signedAsEnglisch 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
components/ui/error-recovery.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert