DocumentsTable
A DataTable preset for stored files: type icon + name, type, size, uploader/date, an accessible status badge, and row actions.
import { DocumentsTable }from "@/components/ui"
Specification
Displays a list of documents with sensible defaults for files: a type icon + name, extension, human-readable size, uploader and date, and a status shown as icon + label (never color alone). Presentational — pass documents and wire the row callbacks.
Anatomy
- Columns: Name (icon + filename), Type, Size, Uploaded (date + by), Status, Actions
- Status: icon + label (Ready / Processing / Uploading / Failed)
- Row actions: download and a more-actions button
States
- Loading (skeleton rows), empty (blank state)
- Per-row status conveyed by icon + text + color
Accessibility
- Status uses icon + label, not color alone
- Action buttons have descriptive aria-labels and stop row-click propagation
- Table caption for screen readers
Spec sourced from mathesis ui-component/data-table
Props (10)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| documents* | DocumentRow[] | — | The files to display; each has id, name, size, optional mimeType/uploadedBy/uploadedAt/status. |
| onOpen | (doc: DocumentRow) => void | — | Row click, e.g. to open/preview the document. |
| onDownload | (doc: DocumentRow) => void | — | Download action per row; omit to hide the download button. |
| onActions | (doc: DocumentRow) => void | — | Row actions trigger; omit to hide the actions button. |
| showStatus | boolean | true | Show the status column. |
| loading | boolean | — | Shows skeleton rows. |
| title | string | — | Table title. |
| subtitle | string | — | Table subtitle (e.g. a count). |
| pagination | PaginationProps | — | Pagination config, forwarded to DataTable. |
| empty | { title: string; body: string } | — | Empty-state copy when there are no documents. |
Documents list
Type icons, sizes, dates, status, and per-row actions.
Documents
4 files
| Name | Type | Size | Uploaded | Status | |
|---|---|---|---|---|---|
Loan-agreement.pdf | 242.2 KB | Feb 12, 2026Amina Wanjiru | Ready | ||
Member-roster.xlsx | xlsx | 1.2 MB | Feb 10, 2026Brian Otieno | Processing | |
ID-scan.jpg | jpg | 800.8 KB | Feb 9, 2026Faith Kamau | Ready | |
Statement-Q1.csv | csv | 43.0 KB | Feb 8, 2026Dennis Ndung'u | Failed |
Full source
The complete component, read from
src/components/ui/documents-table.tsx.documents-table.tsx
"use client";
import { type ReactNode } from "react";
import { Eye, Download, MoreHorizontal, CheckCircle2, Clock, AlertCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatFileSize, fileKind, fileIcon } from "@/lib/upload";
import { DataTable, type DataTableColumn } from "./data-table";
import type { PaginationProps } from "./pagination";
/** Status of a stored document. Rendered with an icon + label (never color alone). */
export type DocumentStatus = "ready" | "processing" | "uploading" | "failed";
export interface DocumentRow {
id: string;
/** File name including extension (drives the type icon). */
name: string;
/** Optional explicit MIME type (otherwise inferred from the name). */
mimeType?: string;
/** Size in bytes. */
size: number;
/** Who uploaded it. */
uploadedBy?: string;
/** Upload date (Date or preformatted string). */
uploadedAt?: Date | string;
status?: DocumentStatus;
}
interface DocumentsTableProps {
documents: DocumentRow[];
loading?: boolean;
title?: string;
subtitle?: string;
/** Row click (e.g. open/preview). */
onOpen?: (doc: DocumentRow) => void;
/** View action button per row. Omit to hide the view button. */
onView?: (doc: DocumentRow) => void;
/** Download action per row. Omit to hide the download button. */
onDownload?: (doc: DocumentRow) => void;
/** Row actions menu trigger. Omit to hide the actions button. */
onActions?: (doc: DocumentRow) => void;
pagination?: PaginationProps;
empty?: { title: string; body: string };
/** Hide the status column when documents have no lifecycle. */
showStatus?: boolean;
}
const STATUS: Record<DocumentStatus, { label: string; icon: typeof CheckCircle2; className: string; spin?: boolean }> = {
ready: { label: "Ready", icon: CheckCircle2, className: "text-accent" },
processing: { label: "Processing", icon: Clock, className: "text-warning" },
uploading: { label: "Uploading", icon: Loader2, className: "text-text-secondary", spin: true },
failed: { label: "Failed", icon: AlertCircle, className: "text-error" },
};
function formatDate(value: Date | string | undefined): string {
if (!value) return "—";
if (typeof value === "string") return value;
return value.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
/**
* DocumentsTable — a DataTable preset for lists of stored files. Renders a
* type icon + name, file type, size, uploader/date, an accessible status badge
* (icon + label), and optional download / actions controls. Presentational:
* pass `documents` and wire the row callbacks.
*/
export function DocumentsTable({
documents,
loading,
title,
subtitle,
onOpen,
onView,
onDownload,
onActions,
pagination,
empty,
showStatus = true,
}: DocumentsTableProps) {
const columns: DataTableColumn<DocumentRow>[] = [
{
key: "name",
header: "Name",
cell: (d) => {
const Icon = fileIcon(fileKind(d.name, d.mimeType));
return (
<div className="flex items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-bg-secondary text-text-secondary">
<Icon size={16} aria-hidden />
</span>
<span className="truncate font-medium text-text">{d.name}</span>
</div>
);
},
},
{
key: "type",
header: "Type",
cell: (d) => <span className="uppercase text-text-secondary">{extension(d.name)}</span>,
width: "90px",
},
{
key: "size",
header: "Size",
numeric: true,
cell: (d) => formatFileSize(d.size),
width: "100px",
},
{
key: "uploaded",
header: "Uploaded",
cell: (d) => (
<div className="leading-tight">
<span className="block text-text">{formatDate(d.uploadedAt)}</span>
{d.uploadedBy && <span className="block text-xs text-text-tertiary">{d.uploadedBy}</span>}
</div>
),
width: "160px",
},
...(showStatus
? [
{
key: "status",
header: "Status",
width: "130px",
cell: (d: DocumentRow) => <StatusBadge status={d.status ?? "ready"} />,
} satisfies DataTableColumn<DocumentRow>,
]
: []),
...(onView || onDownload || onActions
? [
{
key: "actions",
header: "",
align: "right" as const,
width: "120px",
cell: (d: DocumentRow): ReactNode => (
<div className="flex items-center justify-end gap-1">
{onView && (
<IconButton label={`View ${d.name}`} onClick={() => onView(d)}>
<Eye size={15} aria-hidden />
</IconButton>
)}
{onDownload && (
<IconButton label={`Download ${d.name}`} onClick={() => onDownload(d)}>
<Download size={15} aria-hidden />
</IconButton>
)}
{onActions && (
<IconButton label={`More actions for ${d.name}`} onClick={() => onActions(d)}>
<MoreHorizontal size={15} aria-hidden />
</IconButton>
)}
</div>
),
} satisfies DataTableColumn<DocumentRow>,
]
: []),
];
return (
<DataTable
title={title}
subtitle={subtitle}
columns={columns}
rows={documents}
rowKey={(d) => d.id}
loading={loading}
onRowClick={onOpen}
pagination={pagination}
empty={empty ?? { title: "No documents", body: "Uploaded files will appear here." }}
caption="Documents"
/>
);
}
function StatusBadge({ status }: { status: DocumentStatus }) {
const cfg = STATUS[status];
const Icon = cfg.icon;
return (
<span className={cn("inline-flex items-center gap-1.5 text-xs font-medium", cfg.className)}>
<Icon size={13} className={cn(cfg.spin && "animate-spin")} aria-hidden />
{cfg.label}
</span>
);
}
function IconButton({ label, onClick, children }: { label: string; onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="rounded-md p-1.5 text-text-tertiary transition-colors hover:bg-bg-hover hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{children}
</button>
);
}
function extension(name: string): string {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(dot + 1) : "file";
}Dependencies
To use DocumentsTable you also need the following. Copy these into your project alongside it.
Related components
Recommended folder structure
project structure
src/components/ui/ ├─ data-table.tsx └─ documents-table.tsx src/lib/ ├─ upload.ts └─ utils.ts
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
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 };
}