shadimage

Data Table

src/components/thegridcn/data-table.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

interface DataTableColumn<T> {
  align?: "left" | "center" | "right";
  key: keyof T & string;
  label: string;
  render?: (value: T[keyof T], row: T) => React.ReactNode;
  sortable?: boolean;
}

interface DataTableProps<T extends Record<string, unknown>>
  extends React.HTMLAttributes<HTMLDivElement> {
  columns: DataTableColumn<T>[];
  data: T[];
  label?: string;
  striped?: boolean;
}

export function DataTable<T extends Record<string, unknown>>({
  columns,
  data,
  label,
  striped = true,
  className,
  ...props
}: DataTableProps<T>) {
  const [sortKey, setSortKey] = React.useState<string | null>(null);
  const [sortDir, setSortDir] = React.useState<"asc" | "desc">("asc");

  function handleSort(key: string) {
    if (sortKey === key) {
      setSortDir((d) => (d === "asc" ? "desc" : "asc"));
    } else {
      setSortKey(key);
      setSortDir("asc");
    }
  }

  const sortedData = React.useMemo(() => {
    if (!sortKey) {
      return data;
    }
    return [...data].sort((a, b) => {
      const aVal = a[sortKey];
      const bVal = b[sortKey];
      if (
        aVal === null ||
        aVal === undefined ||
        bVal === null ||
        bVal === undefined
      ) {
        return 0;
      }
      const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
      return sortDir === "asc" ? cmp : -cmp;
    });
  }, [data, sortKey, sortDir]);

  // Stagger row reveal
  const [revealedRow, setRevealedRow] = React.useState(-1);
  React.useEffect(() => {
    let row = 0;
    const interval = setInterval(() => {
      setRevealedRow(row);
      row++;
      if (row >= sortedData.length) {
        clearInterval(interval);
      }
    }, 40);
    return () => clearInterval(interval);
  }, [sortedData.length]);

  const alignClass = (align?: string) =>
    align === "center"
      ? "text-center"
      : align === "right"
        ? "text-right"
        : "text-left";

  return (
    <div
      data-slot="tron-data-table"
      className={cn(
        "relative overflow-hidden rounded border border-primary/30 bg-card/80 backdrop-blur-sm",
        className
      )}
      {...props}
    >
      <div className="pointer-events-none absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(0,0,0,0.03)_2px,rgba(0,0,0,0.03)_4px)]" />

      {label ? (
        <div className="border-primary/20 border-b px-4 py-2 text-[10px] text-foreground/50 uppercase tracking-widest">
          {label}
        </div>
      ) : null}

      <div className="relative overflow-x-auto">
        <table className="w-full border-collapse">
          <thead>
            <tr>
              {columns.map((col) => (
                <th
                  key={col.key}
                  className={cn(
                    "border-primary/20 border-b px-4 py-2.5 font-mono text-[10px] text-foreground/40 uppercase tracking-widest",
                    alignClass(col.align),
                    col.sortable &&
                      "cursor-pointer select-none transition-colors hover:text-primary"
                  )}
                  onClick={col.sortable ? () => handleSort(col.key) : undefined}
                >
                  <span className="inline-flex items-center gap-1">
                    {col.label}
                    {col.sortable && sortKey === col.key && (
                      <svg
                        aria-hidden="true"
                        width="8"
                        height="8"
                        viewBox="0 0 8 8"
                        fill="none"
                        className="text-primary"
                      >
                        {sortDir === "asc" ? (
                          <path d="M4 1l3 5H1l3-5z" fill="currentColor" />
                        ) : (
                          <path d="M4 7L1 2h6L4 7z" fill="currentColor" />
                        )}
                      </svg>
                    )}
                  </span>
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {sortedData.map((row, ri) => (
              <tr
                key={ri}
                className={cn(
                  "transition-all duration-200",
                  ri <= revealedRow ? "opacity-100" : "translate-y-1 opacity-0",
                  striped && ri % 2 === 1 && "bg-foreground/[0.02]",
                  "hover:bg-primary/5"
                )}
              >
                {columns.map((col) => (
                  <td
                    key={col.key}
                    className={cn(
                      "border-primary/10 border-b px-4 py-2.5 font-mono text-foreground/70 text-xs",
                      alignClass(col.align)
                    )}
                  >
                    {col.render
                      ? col.render(row[col.key], row)
                      : String(row[col.key] ?? "")}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <div className="pointer-events-none absolute top-0 left-0 h-3 w-3 border-primary/50 border-t-2 border-l-2" />
      <div className="pointer-events-none absolute top-0 right-0 h-3 w-3 border-primary/50 border-t-2 border-r-2" />
      <div className="pointer-events-none absolute bottom-0 left-0 h-3 w-3 border-primary/50 border-b-2 border-l-2" />
      <div className="pointer-events-none absolute right-0 bottom-0 h-3 w-3 border-primary/50 border-r-2 border-b-2" />
    </div>
  );
}
Where it goes
data-table
Use cases
dashboard