Unterlagen anfordern
Unterlagen anfordern und vollständig zurückbekommen. Eine Zeile je Unterlage, ein Ablegen für alles, das sich selbst einsortiert, eine Anfrage einer Kanzlei mit Frist und die Prüfung des Zurückgeschickten mit Grund, wenn eine neue Fassung nötig ist.
Eine von 4 Components in Anfrageformulare
Variants
Details
Was Unterlagen anfordern mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Anfrageformulare
- Bereich
- Content
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/file-request.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 33 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle FileRequestProps aus components/ui/file-request.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalFileRequestVariantchecklistsingle-dropportalreview-status
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodedocumentsPflichtRequestedDocument[]maxMboptionalnumberLargest file in megabytes, default 20
requesteroptional{ name: string; person?: string; initials?: string; message?: string; deadline?: string; daysLeft?: number }portal: who asks, until when, and a short message
stepsoptionalDoneStep[]submitLabeloptionalstringdoneoptional{ title: string; text?: string }onSubmitoptional(files: Record<string, File[]>) => void
Eingebaute Texte
Diese Texte schreibt Unterlagen anfordern selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 33 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| upload | Englisch Add file | Deutsch Datei hinzufügen |
| replace | Englisch Replace | Deutsch Ersetzen |
| remove | Englisch Remove {f} | Deutsch {f} entfernen |
| optional | Englisch Optional | Deutsch Optional |
| done | Englisch {n} of {total} ready | Deutsch {n} von {total} bereit |
| complete | Englisch Everything is here | Deutsch Alles da |
| missing | Englisch Still missing | Deutsch Fehlt noch |
| send | Englisch Send documents | Deutsch Unterlagen senden |
| tooBig | Englisch {f} is larger than {n} MB. Please make it smaller or send it as a PDF, so it comes through. | Deutsch {f} ist größer als {n} MB. Bitte verkleinere die Datei oder schick sie als PDF, damit sie durchkommt. |
| wrongType | Englisch {f} is not a type we can open here. Please use {a}. | Deutsch {f} ist kein Format, das wir hier öffnen können. Bitte nutze {a}. |
| tooMany | Englisch Please add at most {n} files here. | Deutsch Bitte füge hier höchstens {n} Dateien hinzu. |
| needed | Englisch Please add this document, we cannot finish without it. | Deutsch Bitte füge diese Unterlage hinzu, ohne sie können wir nicht abschließen. |
| drop | Englisch Drop all files here | Deutsch Alle Dateien hier ablegen |
| dropOr | Englisch or choose them from your device | Deutsch oder vom Gerät auswählen |
| dropHint | Englisch We sort them into the list by their names. Change the place if we guessed wrong. | Deutsch Wir ordnen sie anhand der Namen in die Liste ein. Ändere den Platz, wenn wir falsch geraten haben. |
| unsorted | Englisch Not sorted yet | Deutsch Noch nicht eingeordnet |
| pickPlace | Englisch Choose where it belongs | Deutsch Wähle, wohin sie gehört |
| unsortedNeeded | Englisch Please choose where {f} belongs, so we file it right. | Deutsch Bitte wähle, wohin {f} gehört, damit wir sie richtig ablegen. |
| from | Englisch Request from {n} | Deutsch Anfrage von {n} |
| due | Englisch Please send by {d} | Deutsch Bitte bis {d} senden |
| daysLeft | Englisch {n} days left | Deutsch noch {n} Tage |
| secure | Englisch Encrypted and only visible to {n} | Deutsch Verschlüsselt und nur für {n} sichtbar |
| note | Englisch Note | Deutsch Hinweis |
| accepted | Englisch Accepted | Deutsch Angenommen |
| review | Englisch In review | Deutsch Wird geprüft |
| rejected | Englisch New version needed | Deutsch Neue Fassung nötig |
| notYet | Englisch Not sent yet | Deutsch Noch nicht gesendet |
| newVersion | Englisch Send new version | Deutsch Neue Fassung senden |
| sendUpdates | Englisch Send the new versions | Deutsch Neue Fassungen senden |
| doneTitle | Englisch Thank you, your documents are with us | Deutsch Danke, deine Unterlagen sind bei uns |
| doneText | Englisch We check them within two working days and tell you if anything is missing. | Deutsch Wir prüfen sie innerhalb von zwei Werktagen und sagen Bescheid, falls etwas fehlt. |
| again | Englisch Add more documents | Deutsch Weitere Unterlagen hinzufügen |
| choose | Englisch Choose file | Deutsch Datei wählen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle alert.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- 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. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 685 Zeilen
'use client';
import { useId, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { NativeSelect } from '@/components/ui/select';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { Stagger } from '@/components/ui/motion';
import { DonePanel, ErrorSummary, FlowHead, type DoneStep, type FlowError } from '@/components/ui/form-flow';
/*
* Asking for documents and getting them back complete. Every document has
* its own place, the list says what is still missing, and files are checked
* for type and size on the way in, with a reason when they are refused.
*
* checklist one row per document with its own upload, a bar fills to complete
* single-drop drop everything at once, files sort themselves into the list
* portal a request from a firm with a deadline, a note per document and a lock
* review-status what came back: accepted, in review, or a new version needed with the reason
*
* Sends { [documentId]: File[] } to `onSubmit`.
*/
export type FileRequestVariant = 'checklist' | 'single-drop' | 'portal' | 'review-status';
export interface RequestedDocument {
id: string;
title: string;
hint?: string;
icon?: IconName;
optional?: boolean;
/** Accepted types, e.g. ".pdf,image/*" */
accept?: string;
/** Most files for this document */
max?: number;
/** single-drop: words in a file name that belong to this document */
keywords?: string[];
/** portal: a note from the person who asks */
note?: string;
/** review-status: where it stands */
status?: 'accepted' | 'review' | 'rejected' | 'missing';
/** review-status: why a new version is needed */
reason?: string;
/** review-status: the file already sent */
file?: string;
}
export interface FileRequestProps {
variant?: FileRequestVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
documents: RequestedDocument[];
/** Largest file in megabytes, default 20 */
maxMb?: number;
/** portal: who asks, until when, and a short message */
requester?: { name: string; person?: string; initials?: string; message?: string; deadline?: string; daysLeft?: number };
steps?: DoneStep[];
submitLabel?: string;
done?: { title: string; text?: string };
onSubmit?: (files: Record<string, File[]>) => void;
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
upload: 'Add file',
replace: 'Replace',
remove: 'Remove {f}',
optional: 'Optional',
done: '{n} of {total} ready',
complete: 'Everything is here',
missing: 'Still missing',
send: 'Send documents',
tooBig: '{f} is larger than {n} MB. Please make it smaller or send it as a PDF, so it comes through.',
wrongType: '{f} is not a type we can open here. Please use {a}.',
tooMany: 'Please add at most {n} files here.',
needed: 'Please add this document, we cannot finish without it.',
drop: 'Drop all files here',
dropOr: 'or choose them from your device',
dropHint: 'We sort them into the list by their names. Change the place if we guessed wrong.',
unsorted: 'Not sorted yet',
pickPlace: 'Choose where it belongs',
unsortedNeeded: 'Please choose where {f} belongs, so we file it right.',
from: 'Request from {n}',
due: 'Please send by {d}',
daysLeft: '{n} days left',
secure: 'Encrypted and only visible to {n}',
note: 'Note',
accepted: 'Accepted',
review: 'In review',
rejected: 'New version needed',
notYet: 'Not sent yet',
newVersion: 'Send new version',
sendUpdates: 'Send the new versions',
doneTitle: 'Thank you, your documents are with us',
doneText: 'We check them within two working days and tell you if anything is missing.',
again: 'Add more documents',
choose: 'Choose file',
},
de: {
upload: 'Datei hinzufügen',
replace: 'Ersetzen',
remove: '{f} entfernen',
optional: 'Optional',
done: '{n} von {total} bereit',
complete: 'Alles da',
missing: 'Fehlt noch',
send: 'Unterlagen senden',
tooBig: '{f} ist größer als {n} MB. Bitte verkleinere die Datei oder schick sie als PDF, damit sie durchkommt.',
wrongType: '{f} ist kein Format, das wir hier öffnen können. Bitte nutze {a}.',
tooMany: 'Bitte füge hier höchstens {n} Dateien hinzu.',
needed: 'Bitte füge diese Unterlage hinzu, ohne sie können wir nicht abschließen.',
drop: 'Alle Dateien hier ablegen',
dropOr: 'oder vom Gerät auswählen',
dropHint: 'Wir ordnen sie anhand der Namen in die Liste ein. Ändere den Platz, wenn wir falsch geraten haben.',
unsorted: 'Noch nicht eingeordnet',
pickPlace: 'Wähle, wohin sie gehört',
unsortedNeeded: 'Bitte wähle, wohin {f} gehört, damit wir sie richtig ablegen.',
from: 'Anfrage von {n}',
due: 'Bitte bis {d} senden',
daysLeft: 'noch {n} Tage',
secure: 'Verschlüsselt und nur für {n} sichtbar',
note: 'Hinweis',
accepted: 'Angenommen',
review: 'Wird geprüft',
rejected: 'Neue Fassung nötig',
notYet: 'Noch nicht gesendet',
newVersion: 'Neue Fassung senden',
sendUpdates: 'Neue Fassungen senden',
doneTitle: 'Danke, deine Unterlagen sind bei uns',
doneText: 'Wir prüfen sie innerhalb von zwei Werktagen und sagen Bescheid, falls etwas fehlt.',
again: 'Weitere Unterlagen hinzufügen',
choose: 'Datei wählen',
},
};
type Texts = (typeof TEXTS)['en'];
const fmtSize = (b: number, locale: 'en' | 'de') => {
const mb = b / 1e6;
const n = mb >= 1 ? round(mb, 1) : round(b / 1e3, 0);
return `${n.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')} ${mb >= 1 ? 'MB' : 'KB'}`;
};
/** Does the file match an accept string like ".pdf,image/*"? */
function accepts(file: File, accept?: string) {
if (!accept) return true;
const name = file.name.toLowerCase();
return accept.split(',').some((a) => {
const x = a.trim().toLowerCase();
if (x.startsWith('.')) return name.endsWith(x);
if (x.endsWith('/*')) return file.type.startsWith(x.slice(0, -1));
return file.type === x;
});
}
const readable = (accept?: string) => (accept ?? '').split(',').map((a) => a.trim().replace(/^\./, '').replace('image/*', 'JPG, PNG').toUpperCase()).join(', ');
/** Files per document with the checks every variant shares */
function useFiles(p: FileRequestProps, t: Texts) {
const [files, setFiles] = useState<Record<string, File[]>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const max = (p.maxMb ?? 20) * 1e6;
const add = (doc: RequestedDocument, list: File[]) => {
const current = files[doc.id] ?? [];
for (const f of list) {
if (!accepts(f, doc.accept)) return setErrors((e) => ({ ...e, [doc.id]: t.wrongType.replace('{f}', f.name).replace('{a}', readable(doc.accept)) }));
if (f.size > max) return setErrors((e) => ({ ...e, [doc.id]: t.tooBig.replace('{f}', f.name).replace('{n}', String(p.maxMb ?? 20)) }));
}
if (current.length + list.length > (doc.max ?? 1) && (doc.max ?? 1) > 1) return setErrors((e) => ({ ...e, [doc.id]: t.tooMany.replace('{n}', String(doc.max)) }));
setFiles((x) => ({ ...x, [doc.id]: (doc.max ?? 1) > 1 ? [...current, ...list] : list.slice(0, 1) }));
setErrors((e) => Object.fromEntries(Object.entries(e).filter(([k]) => k !== doc.id)));
};
const remove = (doc: RequestedDocument, i: number) => setFiles((x) => ({ ...x, [doc.id]: (x[doc.id] ?? []).filter((_, j) => j !== i) }));
return { files, setFiles, errors, setErrors, add, remove };
}
/** A real file input behind a button-like label, keyboard and screen readers included */
function PickButton({ id, doc, label, onFiles, variant = 'outline', describedBy }: { id: string; doc: RequestedDocument; label: string; onFiles: (f: File[]) => void; variant?: 'outline' | 'primary'; describedBy?: string }) {
return (
<label
htmlFor={id}
className={cn(
'inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg px-4 text-[13.5px] font-medium transition-[background-color,border-color,box-shadow] duration-200 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] sm:h-9',
variant === 'primary' ? 'bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]' : 'border border-border-strong bg-background text-foreground hover:bg-accent',
)}
>
<input
id={id}
type="file"
accept={doc.accept}
multiple={(doc.max ?? 1) > 1}
aria-describedby={describedBy}
aria-invalid={describedBy ? true : undefined}
onChange={(e) => {
const list = Array.from(e.target.files ?? []);
e.target.value = '';
if (list.length) onFiles(list);
}}
className="sr-only"
/>
<Icon name="upload" size={15} />
{label}
</label>
);
}
function FileChip({ f, onRemove, t }: { f: File; onRemove: () => void; t: Texts }) {
const locale = useLocale();
return (
<li className="kit-rise flex min-w-0 items-center gap-2.5 rounded-lg border border-border bg-background py-1.5 ps-3 pe-1">
<Icon name="file-check" size={16} className="shrink-0 text-success" />
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">{f.name}</span>
<span className="shrink-0 text-[12px] tabular-nums text-muted-foreground">{fmtSize(f.size, locale)}</span>
<button type="button" aria-label={t.remove.replace('{f}', f.name)} onClick={onRemove} className="grid size-9 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none max-sm:size-11">
<Icon name="x" size={15} />
</button>
</li>
);
}
function Progress({ n, total, t }: { n: number; total: number; t: Texts }) {
const share = total ? n / total : 0;
const complete = n >= total;
return (
<div>
<p aria-live="polite" className="flex items-baseline justify-between gap-3 text-[13px]">
<span className={cn('font-medium', complete ? 'text-success-foreground' : 'text-foreground')}>{complete ? t.complete : t.done.replace('{n}', String(n)).replace('{total}', String(total))}</span>
</p>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]">
<div className={cn('h-full origin-left rounded-full transition-[transform,background-color] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', complete ? 'bg-success' : 'bg-primary')} style={{ transform: `scaleX(${round(share, 3)})` }} />
</div>
</div>
);
}
function Done({ p, t, onAgain, className }: { p: FileRequestProps; t: Texts; onAgain: () => void; className?: string }) {
return (
<div className={cn('kit-card mx-auto p-6 sm:p-10', className)}>
<DonePanel
title={p.done?.title ?? t.doneTitle}
text={p.done?.text ?? t.doneText}
steps={p.steps}
actions={
<Button variant="outline" iconLeft="plus" onClick={onAgain}>
{t.again}
</Button>
}
/>
</div>
);
}
/** Checks required documents, returns the summary lines and focuses the first */
function useSubmit(p: FileRequestProps, t: Texts, fs: ReturnType<typeof useFiles>, prefix: string) {
const [done, setDone] = useState(false);
const summaryRef = useRef<HTMLDivElement>(null);
const [summary, setSummary] = useState<FlowError[]>([]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const missing = p.documents.filter((d) => !d.optional && !(fs.files[d.id]?.length) && d.status !== 'accepted' && d.status !== 'review');
if (missing.length) {
fs.setErrors((x) => ({ ...x, ...Object.fromEntries(missing.map((d) => [d.id, t.needed])) }));
setSummary(missing.map((d) => ({ field: `${prefix}-${d.id}`, message: d.title })));
requestAnimationFrame(() => summaryRef.current?.focus());
return;
}
setSummary([]);
p.onSubmit?.(fs.files);
setDone(true);
};
return { done, setDone, summaryRef, summary, submit };
}
/* ------------------------------------------------------------------ */
/* checklist and portal rows */
/* ------------------------------------------------------------------ */
function DocRow({ d, id, t, fs, showNote }: { d: RequestedDocument; id: string; t: Texts; fs: ReturnType<typeof useFiles>; showNote?: boolean }) {
const list = fs.files[d.id] ?? [];
const ready = list.length > 0;
const err = fs.errors[d.id];
return (
<li className={cn('px-5 py-4 transition-colors duration-500 sm:px-6', ready && 'bg-[color-mix(in_oklab,var(--success)_4%,transparent)]')}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className={cn('grid size-10 shrink-0 place-items-center rounded-xl border transition-[background-color,border-color,color] duration-500 max-sm:hidden', ready ? 'border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_12%,var(--card))] text-success' : 'border-border bg-surface text-muted-foreground')}>
<Icon name={ready ? 'check' : (d.icon ?? 'file')} size={18} strokeWidth={ready ? 2.2 : 1.6} />
</span>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-x-2 text-[15px] font-medium text-foreground">
{d.title}
{d.optional ? <span className="text-[12px] font-normal text-subtle-foreground">{t.optional}</span> : !ready && <span className="rounded-full bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] px-2 py-px text-[11.5px] font-normal text-muted-foreground">{t.missing}</span>}
</p>
{d.hint && <p className="mt-0.5 text-[13px] text-muted-foreground">{d.hint}</p>}
</div>
<PickButton id={`${id}-${d.id}`} doc={d} label={ready && (d.max ?? 1) === 1 ? t.replace : t.upload} onFiles={(f) => fs.add(d, f)} describedBy={err ? `${id}-${d.id}-err` : undefined} />
</div>
{showNote && d.note && (
<p className="mt-3 flex gap-2.5 rounded-lg bg-surface px-3.5 py-2.5 text-[13px] leading-snug text-muted-foreground sm:ms-14">
<Icon name="message-circle" size={15} className="mt-0.5 shrink-0" />
<span>
<span className="sr-only">{t.note}: </span>
{d.note}
</span>
</p>
)}
{list.length > 0 && (
<ul className="mt-3 grid gap-1.5 sm:ms-14">
{list.map((f, i) => (
<FileChip key={`${f.name}-${i}`} f={f} t={t} onRemove={() => fs.remove(d, i)} />
))}
</ul>
)}
{err && (
<p id={`${id}-${d.id}-err`} className="kit-rise mt-2.5 flex items-start gap-1.5 text-[13px] font-medium text-destructive sm:ms-14">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{err}
</p>
)}
</li>
);
}
function Checklist({ p, t }: { p: FileRequestProps; t: Texts }) {
const id = useId();
const fs = useFiles(p, t);
const s = useSubmit(p, t, fs, id);
const required = p.documents.filter((d) => !d.optional);
const ready = required.filter((d) => fs.files[d.id]?.length).length;
if (s.done) return <Done p={p} t={t} onAgain={() => (fs.setFiles({}), s.setDone(false))} className="max-w-[46rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[46rem]">
<form noValidate onSubmit={s.submit} className="kit-card overflow-hidden">
<div className="grid gap-5 border-b border-border px-5 py-5 sm:px-6">
<Progress n={ready} total={required.length} t={t} />
<ErrorSummary ref={s.summaryRef} errors={s.summary} />
</div>
<Stagger step={70} as="ul" className="divide-y divide-border">
{p.documents.map((d) => (
<DocRow key={d.id} d={d} id={id} t={t} fs={fs} />
))}
</Stagger>
<div className="flex flex-col gap-3 border-t border-border bg-surface px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<p className="text-[13px] text-muted-foreground">{readable([...new Set(p.documents.flatMap((d) => (d.accept ?? '').split(',').map((x) => x.trim())).filter(Boolean))].join(','))} · {p.maxMb ?? 20} MB</p>
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
);
}
function Portal({ p, t }: { p: FileRequestProps; t: Texts }) {
const id = useId();
const fs = useFiles(p, t);
const s = useSubmit(p, t, fs, id);
const r = p.requester ?? { name: '' };
const required = p.documents.filter((d) => !d.optional);
const ready = required.filter((d) => fs.files[d.id]?.length).length;
const initials = r.initials ?? r.name.split(' ').map((x) => x[0]).join('').slice(0, 2);
if (s.done) return <Done p={p} t={t} onAgain={() => (fs.setFiles({}), s.setDone(false))} className="max-w-[48rem]" />;
return (
<div className="mx-auto grid max-w-[64rem] grid-cols-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:gap-8">
{/* Who asks, until when, how it is protected. A bar on phones, a column on desktop. */}
<Reveal effect="rise" className="lg:sticky lg:top-24 lg:self-start">
<aside className="rounded-2xl border border-border bg-surface p-5 sm:p-6">
<div className="flex items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_16%,var(--card))] text-[14px] font-semibold text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">{initials}</span>
<span className="min-w-0">
<span className="block text-[12.5px] text-muted-foreground">{t.from.replace('{n}', r.name)}</span>
{r.person && <span className="block truncate text-[15px] font-semibold text-foreground">{r.person}</span>}
</span>
</div>
{r.message && <p className="mt-4 text-pretty text-[14px] leading-relaxed text-foreground">{r.message}</p>}
<div className="mt-5 grid gap-3 border-t border-border pt-5 sm:grid-cols-2 lg:grid-cols-1">
{r.deadline && (
<p className="flex items-center gap-2.5 text-[13.5px] text-foreground">
<Icon name="calendar" size={16} className="shrink-0 text-muted-foreground" />
<span>
{t.due.replace('{d}', r.deadline)}
{r.daysLeft !== undefined && <span className={cn('ms-2 rounded-full px-2 py-px text-[12px] font-medium', r.daysLeft <= 3 ? 'bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] text-warning-foreground' : 'bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)] text-muted-foreground')}>{t.daysLeft.replace('{n}', String(r.daysLeft))}</span>}
</span>
</p>
)}
<p className="flex items-center gap-2.5 text-[13.5px] text-muted-foreground">
<Icon name="lock" size={16} className="shrink-0" />
{t.secure.replace('{n}', r.name)}
</p>
</div>
<div className="mt-5">
<Progress n={ready} total={required.length} t={t} />
</div>
</aside>
</Reveal>
<Reveal effect="rise" delay={100} className="min-w-0">
<form noValidate onSubmit={s.submit} className="kit-card overflow-hidden">
{s.summary.length > 0 && (
<div className="border-b border-border p-5 sm:p-6">
<ErrorSummary ref={s.summaryRef} errors={s.summary} />
</div>
)}
<Stagger step={70} as="ul" className="divide-y divide-border">
{p.documents.map((d) => (
<DocRow key={d.id} d={d} id={id} t={t} fs={fs} showNote />
))}
</Stagger>
<div className="flex justify-end border-t border-border bg-surface px-5 py-4 sm:px-6">
<Button type="submit" icon="send" magnetic className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
</div>
);
}
/* ------------------------------------------------------------------ */
/* single-drop */
/* ------------------------------------------------------------------ */
function guess(f: File, docs: RequestedDocument[]) {
const name = f.name.toLowerCase();
return docs.find((d) => d.keywords?.some((k) => name.includes(k.toLowerCase())))?.id ?? '';
}
function SingleDrop({ p, t }: { p: FileRequestProps; t: Texts }) {
const id = useId();
const locale = useLocale();
const [items, setItems] = useState<{ file: File; doc: string }[]>([]);
const [over, setOver] = useState(false);
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const [summary, setSummary] = useState<FlowError[]>([]);
const summaryRef = useRef<HTMLDivElement>(null);
const max = (p.maxMb ?? 20) * 1e6;
const required = p.documents.filter((d) => !d.optional);
const ready = required.filter((d) => items.some((x) => x.doc === d.id)).length;
const take = (list: File[]) => {
const big = list.find((f) => f.size > max);
if (big) setError(t.tooBig.replace('{f}', big.name).replace('{n}', String(p.maxMb ?? 20)));
else setError('');
setItems((x) => [...x, ...list.filter((f) => f.size <= max).map((file) => ({ file, doc: guess(file, p.documents) }))]);
};
if (done) return <Done p={p} t={t} onAgain={() => (setItems([]), setDone(false))} className="max-w-[48rem]" />;
return (
<form
noValidate
className="mx-auto grid max-w-[64rem] grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-8"
onSubmit={(e) => {
e.preventDefault();
const unsorted = items.map((x, i) => ({ ...x, i })).filter((x) => !x.doc);
const missing = required.filter((d) => !items.some((x) => x.doc === d.id));
const lines: FlowError[] = [...unsorted.map((x) => ({ field: `${id}-f${x.i}`, message: t.unsortedNeeded.replace('{f}', x.file.name) })), ...missing.map((d) => ({ field: `${id}-drop`, message: d.title }))];
setSummary(lines);
if (lines.length) {
requestAnimationFrame(() => summaryRef.current?.focus());
return;
}
const out: Record<string, File[]> = {};
for (const x of items) (out[x.doc] ??= []).push(x.file);
p.onSubmit?.(out);
setDone(true);
}}
>
<div className="grid min-w-0 gap-5">
<ErrorSummary ref={summaryRef} errors={summary} />
{/* One zone for everything, it lifts and lights up while files hover over it */}
<Reveal effect="scale">
<label
htmlFor={`${id}-drop`}
onDragOver={(e) => {
e.preventDefault();
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(e) => {
e.preventDefault();
setOver(false);
take(Array.from(e.dataTransfer.files));
}}
className={cn(
'flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed px-6 py-12 text-center transition-[border-color,background-color,transform] duration-300 has-[:focus-visible]:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] sm:py-16',
over ? 'scale-[1.01] border-primary bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))]' : 'border-border-strong bg-card hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))]',
)}
>
<input
id={`${id}-drop`}
type="file"
multiple
aria-invalid={summary.some((x) => x.field === `${id}-drop`) || undefined}
onChange={(e) => {
const list = Array.from(e.target.files ?? []);
e.target.value = '';
if (list.length) take(list);
}}
className="sr-only"
/>
<span className={cn('grid size-14 place-items-center rounded-2xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))] transition-transform duration-500 ease-[cubic-bezier(0.34,1.6,0.64,1)]', over && '-translate-y-1 scale-110')}>
<Icon name="cloud-upload" size={26} />
</span>
<span className="mt-5 text-[17px] font-semibold text-foreground">{t.drop}</span>
<span className="mt-1 text-[14px] text-muted-foreground">{t.dropOr}</span>
<span className="mt-4 max-w-[26rem] text-pretty text-[12.5px] leading-snug text-subtle-foreground">{t.dropHint}</span>
</label>
</Reveal>
{error && (
<p role="alert" className="kit-rise flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{error}
</p>
)}
{items.length > 0 && (
<ul className="grid gap-2">
{items.map((x, i) => (
<li key={`${x.file.name}-${i}`} className="kit-rise grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-xl border border-border bg-card p-3 sm:grid-cols-[auto_minmax(0,1fr)_14rem_auto]">
<Icon name={x.doc ? 'file-check' : 'file'} size={18} className={x.doc ? 'text-success' : 'text-muted-foreground'} />
<span className="min-w-0">
<span className="block truncate text-[14px] text-foreground">{x.file.name}</span>
<span className="block text-[12px] tabular-nums text-muted-foreground">{fmtSize(x.file.size, locale)}</span>
</span>
<button type="button" aria-label={t.remove.replace('{f}', x.file.name)} onClick={() => setItems((y) => y.filter((_, j) => j !== i))} className="grid size-11 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] hover:text-foreground focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_28%,transparent)] focus-visible:outline-none sm:order-last sm:size-9">
<Icon name="x" size={16} />
</button>
<NativeSelect
id={`${id}-f${i}`}
aria-label={`${x.file.name}, ${t.pickPlace}`}
size="sm"
placeholder={t.unsorted}
invalid={!x.doc && summary.length > 0}
options={p.documents.map((d) => ({ value: d.id, label: d.title }))}
value={x.doc}
onChange={(e) => setItems((y) => y.map((z, j) => (j === i ? { ...z, doc: e.target.value } : z)))}
className="col-span-3 sm:col-span-1"
/>
</li>
))}
</ul>
)}
</div>
{/* The list fills itself as files are sorted */}
<Reveal effect="rise" delay={120} className="lg:sticky lg:top-24 lg:self-start">
<aside className="kit-card p-5 sm:p-6">
<Progress n={ready} total={required.length} t={t} />
<ul className="mt-5 grid gap-3">
{p.documents.map((d) => {
const n = items.filter((x) => x.doc === d.id).length;
return (
<li key={d.id} className="flex items-center gap-3 text-[14px]">
<span className={cn('grid size-6 shrink-0 place-items-center rounded-full border transition-[background-color,border-color,transform] duration-500 ease-[cubic-bezier(0.34,1.6,0.64,1)]', n ? 'scale-110 border-success bg-success text-background' : 'border-border-strong')}>
{n > 0 && <Icon name="check" size={13} strokeWidth={2.6} />}
</span>
<span className={cn('min-w-0 flex-1', n ? 'text-foreground' : 'text-muted-foreground')}>{d.title}</span>
{d.optional ? <span className="text-[12px] text-subtle-foreground">{t.optional}</span> : n > 1 ? <span className="text-[12px] tabular-nums text-muted-foreground">{n}</span> : null}
</li>
);
})}
</ul>
<Button type="submit" icon="send" className="mt-6 w-full">
{p.submitLabel ?? t.send}
</Button>
</aside>
</Reveal>
</form>
);
}
/* ------------------------------------------------------------------ */
/* review-status */
/* ------------------------------------------------------------------ */
const STATUS: Record<NonNullable<RequestedDocument['status']>, { icon: IconName; tone: string; label: keyof Texts }> = {
accepted: { icon: 'check-circle', tone: 'text-success-foreground bg-[color-mix(in_oklab,var(--success)_12%,var(--card))]', label: 'accepted' },
review: { icon: 'hourglass', tone: 'text-foreground bg-[color-mix(in_oklab,var(--foreground)_7%,transparent)]', label: 'review' },
rejected: { icon: 'alert-circle', tone: 'text-warning-foreground bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))]', label: 'rejected' },
missing: { icon: 'circle', tone: 'text-muted-foreground bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)]', label: 'notYet' },
};
function ReviewStatus({ p, t }: { p: FileRequestProps; t: Texts }) {
const id = useId();
const fs = useFiles(p, t);
const s = useSubmit(p, t, fs, id);
const open = p.documents.filter((d) => d.status === 'rejected' || d.status === 'missing');
const counts = (['accepted', 'review', 'rejected'] as const).map((k) => ({ k, n: p.documents.filter((d) => d.status === k).length }));
if (s.done) return <Done p={p} t={t} onAgain={() => (fs.setFiles({}), s.setDone(false))} className="max-w-[48rem]" />;
return (
<Reveal effect="rise" className="mx-auto max-w-[50rem]">
<form noValidate onSubmit={s.submit} className="kit-card overflow-hidden">
{/* A stacked bar of the states, then the counts */}
<div className="border-b border-border px-5 py-5 sm:px-6">
<div aria-hidden className="flex h-2 gap-1 overflow-hidden rounded-full">
{p.documents.map((d) => (
<span key={d.id} className={cn('h-full flex-1 rounded-full transition-colors duration-500', d.status === 'accepted' ? 'bg-success' : d.status === 'review' ? 'bg-[color-mix(in_oklab,var(--foreground)_30%,transparent)]' : d.status === 'rejected' ? 'bg-warning' : 'bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)]', fs.files[d.id]?.length && 'bg-primary')} />
))}
</div>
<p className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-[13px] text-muted-foreground">
{counts
.filter((c) => c.n)
.map((c) => (
<span key={c.k} className="tabular-nums">
<span className="font-semibold text-foreground">{c.n}</span> {t[STATUS[c.k].label]}
</span>
))}
</p>
{s.summary.length > 0 && <ErrorSummary ref={s.summaryRef} errors={s.summary} className="mt-4" />}
</div>
<Stagger step={70} as="ul" className="divide-y divide-border">
{p.documents.map((d) => {
const st = STATUS[d.status ?? 'missing'];
const fresh = fs.files[d.id] ?? [];
const canSend = d.status === 'rejected' || d.status === 'missing';
return (
<li key={d.id} className="px-5 py-4 sm:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<p className="text-[15px] font-medium text-foreground">{d.title}</p>
{d.file && <p className="mt-0.5 truncate text-[13px] text-muted-foreground">{d.file}</p>}
</div>
<span className={cn('inline-flex h-7 shrink-0 items-center gap-1.5 self-start rounded-full px-2.5 text-[12.5px] font-medium sm:self-center', fresh.length ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-foreground' : st.tone)}>
<Icon name={fresh.length ? 'upload' : st.icon} size={14} />
{fresh.length ? t.review : t[st.label]}
</span>
{canSend && <PickButton id={`${id}-${d.id}`} doc={d} label={d.status === 'rejected' ? t.newVersion : t.choose} onFiles={(f) => fs.add(d, f)} variant={d.status === 'rejected' && !fresh.length ? 'primary' : 'outline'} describedBy={d.reason ? `${id}-${d.id}-why` : undefined} />}
</div>
{d.status === 'rejected' && d.reason && !fresh.length && (
<p id={`${id}-${d.id}-why`} className="kit-rise mt-3 flex gap-2.5 rounded-lg bg-[color-mix(in_oklab,var(--warning)_16%,var(--card))] px-3.5 py-2.5 text-[13.5px] leading-snug text-warning-foreground">
<Icon name="info" size={16} className="mt-px shrink-0" />
{d.reason}
</p>
)}
{fresh.length > 0 && (
<ul className="mt-3 grid gap-1.5">
{fresh.map((f, i) => (
<FileChip key={`${f.name}-${i}`} f={f} t={t} onRemove={() => fs.remove(d, i)} />
))}
</ul>
)}
{fs.errors[d.id] && (
<p className="kit-rise mt-2.5 flex items-start gap-1.5 text-[13px] font-medium text-destructive">
<Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px] shrink-0" />
{fs.errors[d.id]}
</p>
)}
</li>
);
})}
</Stagger>
{open.length > 0 && (
<div className="flex justify-end border-t border-border bg-surface px-5 py-4 sm:px-6">
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.sendUpdates}
</Button>
</div>
)}
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* Collect documents.
* <FileRequest variant="checklist" documents={[{ id, title, hint, accept: '.pdf', max: 1 }]} onSubmit={(files) => …} />
*/
export function FileRequest(props: FileRequestProps) {
const { variant = 'checklist', 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 === 'checklist' && <Checklist p={props} t={t} />}
{variant === 'single-drop' && <SingleDrop p={props} t={t} />}
{variant === 'portal' && <Portal p={props} t={t} />}
{variant === 'review-status' && <ReviewStatus p={props} t={t} />}
</Section>
);
}Häufige Fragen
Wie installiere ich Unterlagen anfordern?
Mit aiw add @ai-website/file-request. Der Befehl legt die Datei components/ui/file-request.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, reveal, section, motion, form-flow und select. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Unterlagen anfordern?
4 Varianten, Checkliste, Alles auf einmal, Anfrage einer Kanzlei und Prüfstand. Du wählst sie mit der Prop variant, zum Beispiel variant="single-drop".
Kann ich die Texte von Unterlagen anfordern übersetzen?
Ja. Unterlagen anfordern bringt 33 eigene Texte auf Deutsch und Englisch mit, etwa „Datei hinzufügen“, „Ersetzen“ und „Optional“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Unterlagen anfordern einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Unterlagen anfordern mit aiw_components und installieren sie mit aiw_add @ai-website/file-request. Nenn im Prompt die Variante, etwa variant="checklist", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Unterlagen anfordern ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden FileRequest-Code zum Kopieren.