File request
Asking for documents and getting them back complete. One row per document, one drop for everything that sorts itself, a request from a firm with a deadline, and the review of what came back with the reason when a new version is needed.
One of 4 components in Request forms
Variants
Details
What File request brings along, read from the registry and the source.
- Category
- Request forms
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/file-request.tsx
- npm packages
- None
- Languages
- English and German, 33 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The FileRequestProps interface from components/ui/file-request.tsx, with the comments from the code.
variantoptionalFileRequestVariantchecklistsingle-dropportalreview-status
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodedocumentsrequiredRequestedDocument[]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
Built-in texts
File request writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 33 texts
| Key | English | German |
|---|---|---|
| upload | English Add file | German Datei hinzufügen |
| replace | English Replace | German Ersetzen |
| remove | English Remove {f} | German {f} entfernen |
| optional | English Optional | German Optional |
| done | English {n} of {total} ready | German {n} von {total} bereit |
| complete | English Everything is here | German Alles da |
| missing | English Still missing | German Fehlt noch |
| send | English Send documents | German Unterlagen senden |
| tooBig | English {f} is larger than {n} MB. Please make it smaller or send it as a PDF, so it comes through. | German {f} ist größer als {n} MB. Bitte verkleinere die Datei oder schick sie als PDF, damit sie durchkommt. |
| wrongType | English {f} is not a type we can open here. Please use {a}. | German {f} ist kein Format, das wir hier öffnen können. Bitte nutze {a}. |
| tooMany | English Please add at most {n} files here. | German Bitte füge hier höchstens {n} Dateien hinzu. |
| needed | English Please add this document, we cannot finish without it. | German Bitte füge diese Unterlage hinzu, ohne sie können wir nicht abschließen. |
| drop | English Drop all files here | German Alle Dateien hier ablegen |
| dropOr | English or choose them from your device | German oder vom Gerät auswählen |
| dropHint | English We sort them into the list by their names. Change the place if we guessed wrong. | German Wir ordnen sie anhand der Namen in die Liste ein. Ändere den Platz, wenn wir falsch geraten haben. |
| unsorted | English Not sorted yet | German Noch nicht eingeordnet |
| pickPlace | English Choose where it belongs | German Wähle, wohin sie gehört |
| unsortedNeeded | English Please choose where {f} belongs, so we file it right. | German Bitte wähle, wohin {f} gehört, damit wir sie richtig ablegen. |
| from | English Request from {n} | German Anfrage von {n} |
| due | English Please send by {d} | German Bitte bis {d} senden |
| daysLeft | English {n} days left | German noch {n} Tage |
| secure | English Encrypted and only visible to {n} | German Verschlüsselt und nur für {n} sichtbar |
| note | English Note | German Hinweis |
| accepted | English Accepted | German Angenommen |
| review | English In review | German Wird geprüft |
| rejected | English New version needed | German Neue Fassung nötig |
| notYet | English Not sent yet | German Noch nicht gesendet |
| newVersion | English Send new version | German Neue Fassung senden |
| sendUpdates | English Send the new versions | German Neue Fassungen senden |
| doneTitle | English Thank you, your documents are with us | German Danke, deine Unterlagen sind bei uns |
| doneText | English We check them within two working days and tell you if anything is missing. | German Wir prüfen sie innerhalb von zwei Werktagen und sagen Bescheid, falls etwas fehlt. |
| again | English Add more documents | German Weitere Unterlagen hinzufügen |
| choose | English Choose file | German Datei wählen |
Accessibility
Read from the source of the component.
- Sets aria-describedby, aria-invalid, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the role alert.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 685 lines
'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>
);
}Frequent questions
How do I install File request?
With aiw add @ai-website/file-request. The command puts components/ui/file-request.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow and select. It needs no further npm packages.
Which variants does File request have?
4 variants, Checklist, Drop everything, Request from a firm and Review status. You pick one with the variant prop, for example variant="single-drop".
Can I translate the texts of File request?
Yes. File request carries 33 texts of its own in English and German, such as “Add file”, “Replace” and “Optional”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with File request?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find File request with aiw_components and install it with aiw_add @ai-website/file-request. Name the variant in your prompt, for example variant="checklist", and the assistant writes your content straight into the props.
Can I adjust File request without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching FileRequest code to copy.