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.
PropTypeDefaultDescription
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.
showStatusbooleantrueShow the status column.
loadingboolean—Shows skeleton rows.
titlestring—Table title.
subtitlestring—Table subtitle (e.g. a count).
paginationPaginationProps—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

Documents
NameTypeSizeUploadedStatus
Loan-agreement.pdf
pdf242.2 KB
Feb 12, 2026Amina Wanjiru
Ready
Member-roster.xlsx
xlsx1.2 MB
Feb 10, 2026Brian Otieno
Processing
ID-scan.jpg
jpg800.8 KB
Feb 9, 2026Faith Kamau
Ready
Statement-Q1.csv
csv43.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 };
}