Skip to content
UI Kit
Accent color

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

4 variants

Variants

Checklist

One row per document with its own upload, the icon turns into a check and the row tints when it is there. A bar on top fills to complete, files are checked for type and size with a reason.

Open in Studio

Drop everything

One large zone for all files, it lifts and lights up while files hover over it. Each file is sorted into the list by its name, a wrong guess is fixed with a select, the list beside it ticks itself off.

Open in Studio

Request from a firm

A firm asks for documents. Beside the list stand who asks, the deadline with the days left and the promise that only they see it. Each document carries a note from the person who asks.

Open in Studio

Review status

What came back and where it stands, in a bar of states and one row per document. A document that needs a new version says why and offers the upload right there, everything else stays as it is.

Open in Studio

Details

What File request brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/file-request.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowselect
npm packages
None
Languages
English and German, 33 texts of its own

Props

The FileRequestProps interface from components/ui/file-request.tsx, with the comments from the code.

  • variantoptional
    FileRequestVariant

    checklistsingle-dropportalreview-status

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • documentsrequired
    RequestedDocument[]
  • maxMboptional
    number

    Largest 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

  • stepsoptional
    DoneStep[]
  • submitLabeloptional
    string
  • doneoptional
    { 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
uploadEnglish Add fileGerman Datei hinzufügen
replaceEnglish ReplaceGerman Ersetzen
removeEnglish Remove {f}German {f} entfernen
optionalEnglish OptionalGerman Optional
doneEnglish {n} of {total} readyGerman {n} von {total} bereit
completeEnglish Everything is hereGerman Alles da
missingEnglish Still missingGerman Fehlt noch
sendEnglish Send documentsGerman Unterlagen senden
tooBigEnglish {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.
wrongTypeEnglish {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}.
tooManyEnglish Please add at most {n} files here.German Bitte füge hier höchstens {n} Dateien hinzu.
neededEnglish Please add this document, we cannot finish without it.German Bitte füge diese Unterlage hinzu, ohne sie können wir nicht abschließen.
dropEnglish Drop all files hereGerman Alle Dateien hier ablegen
dropOrEnglish or choose them from your deviceGerman oder vom Gerät auswählen
dropHintEnglish 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.
unsortedEnglish Not sorted yetGerman Noch nicht eingeordnet
pickPlaceEnglish Choose where it belongsGerman Wähle, wohin sie gehört
unsortedNeededEnglish Please choose where {f} belongs, so we file it right.German Bitte wähle, wohin {f} gehört, damit wir sie richtig ablegen.
fromEnglish Request from {n}German Anfrage von {n}
dueEnglish Please send by {d}German Bitte bis {d} senden
daysLeftEnglish {n} days leftGerman noch {n} Tage
secureEnglish Encrypted and only visible to {n}German Verschlüsselt und nur für {n} sichtbar
noteEnglish NoteGerman Hinweis
acceptedEnglish AcceptedGerman Angenommen
reviewEnglish In reviewGerman Wird geprüft
rejectedEnglish New version neededGerman Neue Fassung nötig
notYetEnglish Not sent yetGerman Noch nicht gesendet
newVersionEnglish Send new versionGerman Neue Fassung senden
sendUpdatesEnglish Send the new versionsGerman Neue Fassungen senden
doneTitleEnglish Thank you, your documents are with usGerman Danke, deine Unterlagen sind bei uns
doneTextEnglish 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.
againEnglish Add more documentsGerman Weitere Unterlagen hinzufügen
chooseEnglish Choose fileGerman 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
components/ui/file-request.tsx
'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.

Often searched

Jump to

Copied to the clipboard