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)
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| title | string | — | Optional header title; a uploaded/total counter is always shown. |
| disabled | boolean | — | Disables all row controls. |
Required documents
A KYC-style checklist: uploaded, missing, and rejected rows.
Required documents
1/4 uploaded- Uploaded
National ID (front & back)
RequiredClear photo or scan, both sides.
national-id.pdf· 0 B - Not uploaded
KRA PIN certificate
RequiredPDF from the iTax portal.
- Not uploaded
Passport photo
RequiredRecent, plain background.
- Needs attention
Bank statement
Last 3 months (optional).
File was unreadable — please re-scan and upload again.
Full source
src/components/ui/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
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
"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
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
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 };
}