Data Table

by Dax · tsx · 69 lines

A sortable, paginated data table following Material Design 3 patterns. Click any column header to sort. Clean and minimal with built-in pagination controls.

tabledatasortingpagination
69 lines
"use client";
import { useState, useMemo } from "react";
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";

type Column<T> = { key: keyof T; label: string; sortable?: boolean };

export function DataTable<T extends Record<string, any>>({
  columns, data, pageSize = 10,
}: {
  columns: Column<T>[]; data: T[]; pageSize?: number;
}) {
  const [sort, setSort] = useState<{ key: keyof T; asc: boolean } | null>(null);
  const [page, setPage] = useState(0);

  const sorted = useMemo(() => {
    if (!sort) return data;
    return [...data].sort((a, b) => {
      const v = a[sort.key] > b[sort.key] ? 1 : a[sort.key] < b[sort.key] ? -1 : 0;
      return sort.asc ? v : -v;
    });
  }, [data, sort]);

  const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
  const pages = Math.ceil(data.length / pageSize);

  return (
    <div className="rounded-xl border border-neutral-200 overflow-hidden">
      <table className="w-full text-sm">
        <thead className="bg-neutral-50 text-left text-xs font-medium text-neutral-500 uppercase tracking-wide">
          <tr>
            {columns.map((col) => (
              <th
                key={String(col.key)}
                onClick={() => col.sortable !== false && setSort(s =>
                  s?.key === col.key ? { key: col.key, asc: !s.asc } : { key: col.key, asc: true }
                )}
                className={`px-4 py-3 ${col.sortable !== false ? "cursor-pointer select-none hover:text-neutral-900" : ""}`}
              >
                <span className="inline-flex items-center gap-1">
                  {col.label}
                  {sort?.key === col.key && (sort.asc ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
                </span>
              </th>
            ))}
          </tr>
        </thead>
        <tbody className="divide-y divide-neutral-100">
          {paged.map((row, i) => (
            <tr key={i} className="hover:bg-neutral-50 transition-colors">
              {columns.map((col) => (
                <td key={String(col.key)} className="px-4 py-3 text-neutral-700">{String(row[col.key])}</td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      {pages > 1 && (
        <div className="flex items-center justify-between border-t border-neutral-100 px-4 py-2 text-xs text-neutral-500">
          <span>{page * pageSize + 1}–{Math.min((page + 1) * pageSize, data.length)} of {data.length}</span>
          <div className="flex gap-1">
            <button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0} className="p-1 rounded hover:bg-neutral-100 disabled:opacity-30"><ChevronLeft size={16} /></button>
            <button onClick={() => setPage(p => Math.min(pages - 1, p + 1))} disabled={page >= pages - 1} className="p-1 rounded hover:bg-neutral-100 disabled:opacity-30"><ChevronRight size={16} /></button>
          </div>
        </div>
      )}
    </div>
  );
}