DocumentRequestList

A predefined checklist of required documents with a per-row upload control, status, and preview — for onboarding / KYC flows.

import { DocumentRequestList }from "@/components/ui"

Specification

Presents a known set of documents the user must provide (e.g. KYC/onboarding), each row with its own upload button, status (Not uploaded / Uploading / Uploaded / Needs attention), and optional preview. Rows stack responsively on mobile. Presentational — the parent owns the requests and drives per-row state via callbacks.

Anatomy

  • Header: title + uploaded/total counter
  • One row per required document: name + required badge + description
  • Uploaded file line (icon + name + size); status pill; row actions
  • Actions: Upload, or View / Replace / Remove once uploaded

States

  • Per row: missing (Not uploaded), uploading (progress), uploaded (check), rejected (red row + reason)
  • Responsive: label / status / actions stack on small screens

Accessibility

  • Each row has its own labelled <input type=file>
  • Status conveyed by icon + label, not color alone
  • Progress and rejection reasons announced via aria-live

Spec sourced from mathesis ui-component/file-upload

Props (7)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
requests*DocumentRequest[]—The predefined documents to collect; each has id, name, optional description/required/accept/maxSize, and controlled state/file/progress/error.
onUpload(requestId: string, file: File) => void—Called with a valid file for a row.
onRemove(requestId: string) => void—Called to clear an uploaded file for a row.
onView(request: DocumentRequest) => void—Called to preview an uploaded file; omit to hide the View button.
onError(requestId: string, message: string) => void—Called when a file fails validation for a row.
titlestring—Optional header title; a uploaded/total counter is always shown.
disabledboolean—Disables all row controls.

Required documents

A KYC-style checklist: uploaded, missing, and rejected rows.

Required documents

1/4 uploaded
  • National ID (front & back)

    Required

    Clear photo or scan, both sides.

    national-id.pdf· 0 B
    Uploaded
  • KRA PIN certificate

    Required

    PDF from the iTax portal.

    Not uploaded
  • Passport photo

    Required

    Recent, plain background.

    Not uploaded
  • Bank statement

    Last 3 months (optional).

    File was unreadable — please re-scan and upload again.

    Needs attention
Full source
The complete component, read from src/components/ui/document-request-list.tsx.
document-request-list.tsx
"use client";

import { useRef, type ChangeEvent } from "react";
import { UploadCloud, CheckCircle2, Clock, AlertCircle, X, Eye, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatFileSize, fileKind, fileIcon, validateFile, type FileConstraints } from "@/lib/upload";
import type { UploadStatus } from "./file-upload";

/** State of one requested document. */
export type RequestState = "missing" | "uploading" | "uploaded" | "rejected";

/** A predefined document the user is required (or asked) to provide. */
export interface DocumentRequest {
  id: string;
  /** What to upload, e.g. "National ID (front)". */
  name: string;
  /** Optional guidance shown under the name. */
  description?: string;
  required?: boolean;
  /** Accepted types for this row (extensions/MIME/wildcards). */
  accept?: string;
  /** Max size in bytes for this row. */
  maxSize?: number;
  /** Current state (controlled by the parent). */
  state?: RequestState;
  /** The uploaded file, when state is "uploading"/"uploaded". */
  file?: File | null;
  /** Upload progress 0–100 while uploading. */
  progress?: number;
  /** Error/rejection reason when state is "rejected". */
  error?: string;
}

interface DocumentRequestListProps {
  title?: string;
  requests: DocumentRequest[];
  /** Called with a valid file for a given request row. */
  onUpload?: (requestId: string, file: File) => void;
  /** Called to clear an uploaded file for a row. */
  onRemove?: (requestId: string) => void;
  /** Called to preview/view an uploaded file. Omit to hide the view button. */
  onView?: (request: DocumentRequest) => void;
  /** Called when a file fails validation for a row. */
  onError?: (requestId: string, message: string) => void;
  disabled?: boolean;
}

const STATE_META: Record<RequestState, { label: string; icon: typeof CheckCircle2; className: string; spin?: boolean }> = {
  missing: { label: "Not uploaded", icon: Clock, className: "text-text-tertiary" },
  uploading: { label: "Uploading", icon: Loader2, className: "text-text-secondary", spin: true },
  uploaded: { label: "Uploaded", icon: CheckCircle2, className: "text-accent" },
  rejected: { label: "Needs attention", icon: AlertCircle, className: "text-error" },
};

/**
 * DocumentRequestList — a predefined checklist of documents to provide, each
 * row with its own upload control, status, and preview. Use it for onboarding /
 * KYC / application flows where the set of required files is known upfront.
 * Rows stack responsively on small screens. Presentational: the parent owns the
 * requests and drives per-row state/progress via callbacks.
 */
export function DocumentRequestList({
  title,
  requests,
  onUpload,
  onRemove,
  onView,
  onError,
  disabled,
}: DocumentRequestListProps) {
  const uploaded = requests.filter((r) => r.state === "uploaded").length;
  const requiredCount = requests.filter((r) => r.required).length;

  return (
    <div className="overflow-hidden rounded-xl border border-border bg-surface">
      {(title || requiredCount > 0) && (
        <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
          {title && <h3 className="text-sm font-semibold text-text">{title}</h3>}
          <span className="text-xs tabular-nums text-text-secondary">
            {uploaded}/{requests.length} uploaded
          </span>
        </div>
      )}

      <ul className="divide-y divide-border">
        {requests.map((req) => (
          <li key={req.id}>
            <RequestRow
              request={req}
              disabled={disabled}
              onUpload={onUpload}
              onRemove={onRemove}
              onView={onView}
              onError={onError}
            />
          </li>
        ))}
      </ul>
    </div>
  );
}

function RequestRow({
  request,
  disabled,
  onUpload,
  onRemove,
  onView,
  onError,
}: {
  request: DocumentRequest;
  disabled?: boolean;
  onUpload?: (id: string, file: File) => void;
  onRemove?: (id: string) => void;
  onView?: (r: DocumentRequest) => void;
  onError?: (id: string, message: string) => void;
}) {
  const inputRef = useRef<HTMLInputElement>(null);
  const state: RequestState = request.state ?? (request.file ? "uploaded" : "missing");
  const meta = STATE_META[state];
  const StatusIcon = meta.icon;

  const onPick = (e: ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    const constraints: FileConstraints = { accept: request.accept, maxSize: request.maxSize };
    const message = validateFile(file, constraints);
    if (message) {
      onError?.(request.id, message);
      return;
    }
    onUpload?.(request.id, file);
  };

  const open = () => !disabled && inputRef.current?.click();

  return (
    <div
      className={cn(
        "flex flex-col gap-3 p-4 sm:flex-row sm:items-center",
        state === "rejected" && "bg-error-light"
      )}
    >
      {/* Requirement label */}
      <div className="min-w-0 flex-1">
        <div className="flex items-center gap-2">
          <p className="truncate text-sm font-medium text-text">{request.name}</p>
          {request.required && (
            <span className="shrink-0 rounded bg-bg-secondary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-secondary">
              Required
            </span>
          )}
        </div>
        {request.description && <p className="mt-0.5 text-xs text-text-secondary">{request.description}</p>}

        {/* Uploaded file line */}
        {request.file && (
          <div className="mt-1.5 flex items-center gap-1.5 text-xs text-text-tertiary">
            <FileGlyph name={request.file.name} type={request.file.type} />
            <span className="truncate">{request.file.name}</span>
            <span className="tabular-nums">· {formatFileSize(request.file.size)}</span>
          </div>
        )}
        {state === "rejected" && request.error && (
          <p className="mt-1 text-xs text-error" aria-live="polite">{request.error}</p>
        )}
        {state === "uploading" && (
          <p className="mt-1 text-xs tabular-nums text-text-secondary" aria-live="polite">
            Uploading… {Math.round(request.progress ?? 0)}%
          </p>
        )}
      </div>

      {/* Status */}
      <span className={cn("flex shrink-0 items-center gap-1.5 text-xs font-medium sm:w-36", meta.className)}>
        <StatusIcon size={14} className={cn(meta.spin && "animate-spin")} aria-hidden />
        {meta.label}
      </span>

      {/* Actions */}
      <div className="flex shrink-0 items-center gap-2">
        <input
          ref={inputRef}
          type="file"
          accept={request.accept}
          disabled={disabled || state === "uploading"}
          onChange={onPick}
          className="sr-only"
          aria-label={`Upload ${request.name}`}
        />
        {state === "uploaded" || state === "rejected" ? (
          <>
            {onView && request.file && state === "uploaded" && (
              <button
                type="button"
                onClick={() => onView(request)}
                aria-label={`View ${request.name}`}
                className="inline-flex items-center gap-1 rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-text transition-colors hover:bg-bg-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
              >
                <Eye size={14} aria-hidden />
                View
              </button>
            )}
            <button
              type="button"
              onClick={open}
              disabled={disabled}
              className="rounded-lg border border-border bg-surface px-2.5 py-1.5 text-xs font-medium text-text transition-colors hover:bg-bg-hover disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
            >
              Replace
            </button>
            {onRemove && (
              <button
                type="button"
                onClick={() => onRemove(request.id)}
                disabled={disabled}
                aria-label={`Remove ${request.name}`}
                className="rounded-md p-1.5 text-text-tertiary transition-colors hover:bg-bg-hover hover:text-error disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
              >
                <X size={15} aria-hidden />
              </button>
            )}
          </>
        ) : (
          <button
            type="button"
            onClick={open}
            disabled={disabled || state === "uploading"}
            className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-3 py-1.5 text-xs font-medium text-text transition-colors hover:border-border-hover hover:bg-bg-hover disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
          >
            {state === "uploading" ? (
              <Loader2 size={14} className="animate-spin" aria-hidden />
            ) : (
              <UploadCloud size={14} aria-hidden />
            )}
            Upload
          </button>
        )}
      </div>
    </div>
  );
}

function FileGlyph({ name, type }: { name: string; type?: string }) {
  const Icon = fileIcon(fileKind(name, type));
  return <Icon size={13} className="shrink-0" aria-hidden />;
}

Dependencies

To use DocumentRequestList you also need the following. Copy these into your project alongside it.

Related components

Recommended folder structure

project structure
src/components/ui/
  ├─ document-request-list.tsx
  ├─ field.tsx
  └─ file-upload.tsx
src/lib/
  ├─ upload.ts
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/components/ui/field.tsx
field.tsx
"use client";

import { type ReactNode } from "react";
import { cn } from "@/lib/utils";

interface FieldCounter {
  text: string;
  warn?: boolean;
}

interface FieldProps {
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  htmlFor?: string;
  counter?: FieldCounter;

  messageId?: string;
  children: ReactNode;
}

export function Field({
  label,
  hint,
  error,
  required,
  htmlFor,
  counter,
  messageId,
  children,
}: FieldProps) {
  const message = error || hint;
  return (
    <div className="flex w-full flex-col gap-1.5">
      {label && (
        <label
          htmlFor={htmlFor}
          className="flex items-center gap-1 text-[13px] font-medium text-text"
        >
          {label}
          {required && (
            <span className="text-error" aria-hidden>
              *
            </span>
          )}
        </label>
      )}
      {children}
      <div className="flex items-start justify-between gap-3">
        <p
          id={messageId}
          className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
        >
          {message || "\u00A0"}
        </p>
        {counter && (
          <span
            className={cn(
              "shrink-0 text-xs tabular-nums",
              counter.warn ? "text-error" : "text-text-tertiary"
            )}
          >
            {counter.text}
          </span>
        )}
      </div>
    </div>
  );
}

export const controlBase = cn(
  "w-full rounded-lg border text-sm bg-surface text-text transition-colors",
  "placeholder:text-text-tertiary",
  "focus:outline-none focus-visible:outline-none focus-visible:border-accent",
  "disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
  "read-only:bg-bg-secondary"
);

export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
  return cn(
    error ? "border-error focus-visible:border-error" : "border-border",
    readOnly && "read-only:bg-bg-secondary",
    disabled && "text-text-tertiary"
  );
}
src/lib/upload.ts
upload.ts
import {
  FileText,
  FileSpreadsheet,
  FileImage,
  FileArchive,
  FileVideo,
  FileAudio,
  FileCode,
  File as FileIcon,
  type LucideIcon,
} from "lucide-react";

/**
 * Shared, framework-agnostic helpers for the upload components
 * (FileUpload, FileUploadMultiple, PhotoUpload) and DocumentsTable.
 * Pure functions — safe to call during render.
 */

/** Broad file kind used for preview + icon selection. */
export type FileKind = "image" | "document" | "spreadsheet" | "archive" | "video" | "audio" | "code" | "other";

/** Format a byte count as a human-readable size, e.g. `2.4 MB`. */
export function formatFileSize(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
  const units = ["B", "KB", "MB", "GB", "TB"];
  const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
  const value = bytes / 1024 ** i;
  // No decimals for bytes; one decimal otherwise.
  return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
}

/** Lowercased extension without the dot (e.g. `pdf`), or `""` when none. */
export function fileExtension(name: string): string {
  const dot = name.lastIndexOf(".");
  return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
}

const EXT_KIND: Record<string, FileKind> = {
  // documents
  pdf: "document", doc: "document", docx: "document", txt: "document", rtf: "document", md: "document",
  // spreadsheets
  xls: "spreadsheet", xlsx: "spreadsheet", csv: "spreadsheet",
  // images
  png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", svg: "image", avif: "image", heic: "image",
  // archives
  zip: "archive", rar: "archive", "7z": "archive", tar: "archive", gz: "archive",
  // media
  mp4: "video", mov: "video", webm: "video", avi: "video", mkv: "video",
  mp3: "audio", wav: "audio", ogg: "audio", m4a: "audio",
  // code
  js: "code", ts: "code", tsx: "code", jsx: "code", json: "code", html: "code", css: "code",
};

/** Classify a file by MIME type first, falling back to its extension. */
export function fileKind(nameOrType: string, mime?: string): FileKind {
  if (mime) {
    if (mime.startsWith("image/")) return "image";
    if (mime.startsWith("video/")) return "video";
    if (mime.startsWith("audio/")) return "audio";
    if (mime === "application/pdf") return "document";
    if (mime.includes("spreadsheet") || mime.includes("excel") || mime === "text/csv") return "spreadsheet";
    if (mime.includes("zip") || mime.includes("compressed") || mime.includes("tar")) return "archive";
  }
  return EXT_KIND[fileExtension(nameOrType)] ?? "other";
}

const KIND_ICON: Record<FileKind, LucideIcon> = {
  image: FileImage,
  document: FileText,
  spreadsheet: FileSpreadsheet,
  archive: FileArchive,
  video: FileVideo,
  audio: FileAudio,
  code: FileCode,
  other: FileIcon,
};

/** The lucide icon that represents a file kind. */
export function fileIcon(kind: FileKind): LucideIcon {
  return KIND_ICON[kind];
}

export interface FileConstraints {
  /** Max size per file, in bytes. */
  maxSize?: number;
  /**
   * Accepted types, in the same syntax as the input `accept` attribute:
   * extensions (".pdf"), MIME types ("image/png"), or wildcards ("image/*").
   */
  accept?: string;
}

/**
 * Validate a file against constraints. Returns a human-readable error string
 * ("what + why"), or `null` when the file is acceptable.
 */
export function validateFile(file: File, constraints: FileConstraints = {}): string | null {
  const { maxSize, accept } = constraints;
  if (typeof maxSize === "number" && file.size > maxSize) {
    return `File is too large — max ${formatFileSize(maxSize)}.`;
  }
  if (accept && !matchesAccept(file, accept)) {
    return `File type not allowed — accepted: ${accept}.`;
  }
  return null;
}

/** Whether a file satisfies an `accept` list (extensions, MIME, wildcards). */
export function matchesAccept(file: File, accept: string): boolean {
  const patterns = accept.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
  if (patterns.length === 0) return true;
  const type = file.type.toLowerCase();
  const ext = `.${fileExtension(file.name)}`;
  return patterns.some((p) => {
    if (p.startsWith(".")) return ext === p;
    if (p.endsWith("/*")) return type.startsWith(p.slice(0, -1)); // e.g. "image/"
    return type === p;
  });
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}