Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  ArrowDown,
  ArrowUp,
  ArrowUpDown,
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
  Download,
  GripVertical,
  Mail,
  Pin,
  PinOff,
  Rows3,
  Rows4,
  Search,
  X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Checkbox, FacetFilter, IconToggle } from "./table-parts";
import { customers as seededCustomers, type Customer } from "./data";

/* ================================================================== */
/* Types                                                               */
/* ================================================================== */

export type ColumnDef<T> = {
  id: string;
  header: string;
  /** Initial width in px. */
  width?: number;
  minWidth?: number;
  align?: "left" | "right";
  cell: (row: T) => React.ReactNode;
  /** Sort key; omit to make the column unsortable. */
  sortValue?: (row: T) => string | number;
  /** Plain text used for the global search and CSV export. */
  text?: (row: T) => string | number;
  /** Adds a multi-select facet filter for this column. */
  facet?: (row: T) => string;
};

type Sort = { id: string; dir: "asc" | "desc" } | null;
type Density = "compact" | "comfortable";

export type DataTableProps<T> = {
  rows: T[];
  columns: ColumnDef<T>[];
  getRowId: (row: T) => string;
  title?: string;
  description?: string;
  /** Rendered under a row when it is expanded. Omit to hide the expand column. */
  renderExpanded?: (row: T) => React.ReactNode;
  searchPlaceholder?: string;
  pageSizes?: number[];
  defaultPageSize?: number;
  defaultDensity?: Density;
  defaultPinned?: boolean;
  defaultSort?: Sort;
  /** Max height of the scroll area (sticky header inside). */
  maxHeight?: number;
  csvFilename?: string;
  onSelectionChange?: (ids: string[]) => void;
  className?: string;
};

/* ================================================================== */
/* Helpers                                                             */
/* ================================================================== */

function toCsv<T>(rows: T[], columns: ColumnDef<T>[]) {
  const esc = (v: string | number) => {
    const s = String(v);
    return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  const cols = columns.filter((c) => c.text);
  const lines = [cols.map((c) => esc(c.header)).join(",")];
  for (const r of rows) lines.push(cols.map((c) => esc(c.text!(r))).join(","));
  return lines.join("\n");
}

function download(filename: string, content: string) {
  const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

const SELECT_W = 44;
const EXPAND_W = 36;

/* ================================================================== */
/* Generic table                                                       */
/* ================================================================== */

export function DataTable<T>({
  rows,
  columns,
  getRowId,
  title = "Records",
  description,
  renderExpanded,
  searchPlaceholder = "Search…",
  pageSizes = [10, 20, 50],
  defaultPageSize = 10,
  defaultDensity = "comfortable",
  defaultPinned = true,
  defaultSort = null,
  maxHeight = 540,
  csvFilename = "export.csv",
  onSelectionChange,
  className,
}: DataTableProps<T>) {
  const reduce = useReducedMotion();
  const [query, setQuery] = React.useState("");
  const [filters, setFilters] = React.useState<Record<string, Set<string>>>({});
  const [sort, setSort] = React.useState<Sort>(defaultSort);
  const [order, setOrder] = React.useState(() => columns.map((c) => c.id));
  const [widths, setWidths] = React.useState<Record<string, number>>(() => Object.fromEntries(columns.map((c) => [c.id, c.width ?? 150])));
  const [pinned, setPinned] = React.useState(defaultPinned);
  const [density, setDensity] = React.useState<Density>(defaultDensity);
  const [selected, setSelected] = React.useState<Set<string>>(new Set());
  const [expanded, setExpanded] = React.useState<Set<string>>(new Set());
  const [page, setPage] = React.useState(0);
  const [pageSize, setPageSize] = React.useState(defaultPageSize);
  const [scrolled, setScrolled] = React.useState(false);
  const [dragId, setDragId] = React.useState<string | null>(null);
  const [drop, setDrop] = React.useState<{ id: string; side: "before" | "after" } | null>(null);
  const [resizing, setResizing] = React.useState<string | null>(null);
  const [status, setStatus] = React.useState("");
  const searchId = React.useId();
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const [boxW, setBoxW] = React.useState(0);
  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const ro = new ResizeObserver((e) => setBoxW(Math.floor(e[0].contentRect.width)));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const byId = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
  const ordered = order.map((id) => byId.get(id)).filter((c): c is ColumnDef<T> => !!c);
  const facetCols = columns.filter((c) => c.facet);
  const hasExpand = !!renderExpanded;

  /* ---------- filtering ---------- */
  const matchesQuery = React.useCallback(
    (r: T) => {
      const q = query.trim().toLowerCase();
      if (!q) return true;
      return columns.some((c) => c.text && String(c.text(r)).toLowerCase().includes(q));
    },
    [columns, query],
  );
  const matchesFacets = React.useCallback(
    (r: T, except?: string) =>
      facetCols.every((c) => {
        if (c.id === except) return true;
        const set = filters[c.id];
        return !set || set.size === 0 || set.has(c.facet!(r));
      }),
    [facetCols, filters],
  );

  const filtered = React.useMemo(() => rows.filter((r) => matchesQuery(r) && matchesFacets(r)), [rows, matchesQuery, matchesFacets]);

  const facetOptions = (col: ColumnDef<T>) => {
    const counts = new Map<string, number>();
    for (const r of rows) counts.set(col.facet!(r), 0);
    for (const r of rows) if (matchesQuery(r) && matchesFacets(r, col.id)) counts.set(col.facet!(r), (counts.get(col.facet!(r)) ?? 0) + 1);
    return [...counts.entries()].map(([value, count]) => ({ value, count })).sort((a, b) => a.value.localeCompare(b.value));
  };

  /* ---------- sorting ---------- */
  const sorted = React.useMemo(() => {
    if (!sort) return filtered;
    const col = byId.get(sort.id);
    if (!col?.sortValue) return filtered;
    const sv = col.sortValue;
    const dir = sort.dir === "asc" ? 1 : -1;
    return [...filtered].sort((a, b) => {
      const x = sv(a);
      const y = sv(b);
      return (typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y))) * dir;
    });
  }, [filtered, sort, byId]);

  /* ---------- pagination ---------- */
  const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
  const current = Math.min(page, pageCount - 1);
  const pageRows = sorted.slice(current * pageSize, current * pageSize + pageSize);
  const from = sorted.length ? current * pageSize + 1 : 0;
  const to = Math.min(sorted.length, (current + 1) * pageSize);

  /* ---------- selection ---------- */
  const updateSelection = (next: Set<string>) => {
    setSelected(next);
    onSelectionChange?.([...next]);
  };
  const pageIds = pageRows.map(getRowId);
  const pageSelected = pageIds.filter((id) => selected.has(id)).length;
  const allOnPage = pageIds.length > 0 && pageSelected === pageIds.length;

  const activeFilterCount = Object.values(filters).reduce((a, s) => a + s.size, 0);
  const resetAll = () => {
    setQuery("");
    setFilters({});
    setPage(0);
  };

  const cycleSort = (id: string) => {
    setSort((s) => (!s || s.id !== id ? { id, dir: "asc" } : s.dir === "asc" ? { id, dir: "desc" } : null));
    setPage(0);
  };

  const moveColumn = (id: string, targetId: string, side: "before" | "after") => {
    if (id === targetId) return;
    setOrder((o) => {
      const without = o.filter((x) => x !== id);
      const idx = without.indexOf(targetId);
      without.splice(side === "before" ? idx : idx + 1, 0, id);
      return without;
    });
  };

  const exportCsv = (onlySelected: boolean) => {
    const data = onlySelected ? sorted.filter((r) => selected.has(getRowId(r))) : sorted;
    download(csvFilename, toCsv(data, ordered));
    setStatus(`Exported ${data.length} row${data.length === 1 ? "" : "s"} to ${csvFilename}`);
  };
  React.useEffect(() => {
    if (!status) return;
    const t = setTimeout(() => setStatus(""), 3200);
    return () => clearTimeout(t);
  }, [status]);

  /* ---------- resize ---------- */
  const startResize = (e: React.PointerEvent, col: ColumnDef<T>) => {
    e.preventDefault();
    e.stopPropagation();
    const startX = e.clientX;
    const startW = widths[col.id];
    const min = col.minWidth ?? 80;
    setResizing(col.id);
    const move = (ev: PointerEvent) => setWidths((w) => ({ ...w, [col.id]: Math.max(min, Math.round(startW + ev.clientX - startX)) }));
    const up = () => {
      setResizing(null);
      window.removeEventListener("pointermove", move);
      window.removeEventListener("pointerup", up);
    };
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
  };

  /* ---------- layout ---------- */
  const pad = density === "compact" ? "py-1.5" : "py-2.5";
  const firstId = ordered[0]?.id;
  const leftFor = (id: "select" | "expand" | "first") => (id === "select" ? 0 : id === "expand" ? SELECT_W : SELECT_W + (hasExpand ? EXPAND_W : 0));
  const pinCls = (edge = false) =>
    pinned ? cn("sticky", edge && scrolled && "shadow-[6px_0_8px_-6px_rgb(0_0_0/0.25)] dark:shadow-[6px_0_10px_-6px_rgb(0_0_0/0.8)]") : "";
  const pinStyle = (id: "select" | "expand" | "first"): React.CSSProperties => (pinned ? { left: leftFor(id) } : {});
  // On narrow screens a pinned first column is capped so the rest of the table stays scrollable.
  const colW = (id: string) =>
    pinned && id === firstId && boxW > 0 && boxW < 640 ? Math.min(widths[id], Math.max(120, Math.round(boxW * 0.42))) : widths[id];
  const tableWidth = SELECT_W + (hasExpand ? EXPAND_W : 0) + ordered.reduce((a, c) => a + colW(c.id), 0);
  const colCount = ordered.length + 1 + (hasExpand ? 1 : 0);

  const cellBg =
    "bg-card group-hover:bg-[color-mix(in_oklch,var(--muted)_70%,var(--card))] group-data-[selected=true]:bg-[color-mix(in_oklch,var(--primary)_7%,var(--card))]";

  return (
    <section aria-label={title} className={cn("w-full bg-background p-4 text-foreground sm:p-6", className)}>
      {/* header */}
      <div className="mb-4 flex flex-wrap items-end justify-between gap-3">
        <div>
          <h2 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
            {title}
            <span className="rounded-full border bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground tabular-nums">{rows.length}</span>
          </h2>
          {description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
        </div>
      </div>

      {/* toolbar */}
      <div className="mb-3 flex flex-wrap items-center gap-2">
        <div className="relative w-full sm:w-72">
          <Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
          <label htmlFor={searchId} className="sr-only">
            Search {title}
          </label>
          <input
            id={searchId}
            type="search"
            value={query}
            onChange={(e) => {
              setQuery(e.target.value);
              setPage(0);
            }}
            placeholder={searchPlaceholder}
            className="h-8 w-full rounded-md border bg-background pr-8 pl-8 text-sm shadow-xs outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-search-cancel-button]:hidden"
          />
          {query && (
            <button
              type="button"
              aria-label="Clear search"
              onClick={() => setQuery("")}
              className="absolute top-1/2 right-1.5 grid size-5 -translate-y-1/2 place-items-center rounded text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
            >
              <X className="size-3.5" />
            </button>
          )}
        </div>
        {facetCols.map((c) => (
          <FacetFilter
            key={c.id}
            label={c.header}
            options={facetOptions(c)}
            selected={filters[c.id] ?? new Set()}
            onChange={(next) => {
              setFilters((f) => ({ ...f, [c.id]: next }));
              setPage(0);
            }}
          />
        ))}
        {(activeFilterCount > 0 || query) && (
          <button
            type="button"
            onClick={resetAll}
            className="inline-flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
          >
            Reset <X className="size-3.5" aria-hidden />
          </button>
        )}
        <div className="ml-auto flex items-center gap-1.5">
          <IconToggle pressed={density === "compact"} onClick={() => setDensity((d) => (d === "compact" ? "comfortable" : "compact"))} label={density === "compact" ? "Comfortable rows" : "Compact rows"}>
            {density === "compact" ? <Rows4 /> : <Rows3 />}
          </IconToggle>
          <IconToggle pressed={pinned} onClick={() => setPinned((p) => !p)} label={pinned ? "Unpin first column" : "Pin first column"}>
            {pinned ? <Pin /> : <PinOff />}
          </IconToggle>
          <button
            type="button"
            onClick={() => exportCsv(false)}
            className="inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2.5 text-xs font-medium shadow-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
          >
            <Download className="size-3.5" aria-hidden />
            Export CSV
          </button>
        </div>
      </div>

      {/* bulk bar */}
      <AnimatePresence initial={false}>
        {selected.size > 0 && (
          <motion.div
            initial={reduce ? false : { height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.18 }}
            className="overflow-hidden"
          >
            <div className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
              <span className="font-medium tabular-nums">{selected.size} selected</span>
              {selected.size < sorted.length && (
                <button type="button" onClick={() => updateSelection(new Set(sorted.map(getRowId)))} className="text-xs font-medium text-primary underline-offset-2 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring">
                  Select all {sorted.length} results
                </button>
              )}
              <span className="ml-auto flex items-center gap-1.5">
                <button type="button" onClick={() => exportCsv(true)} className="inline-flex h-7 items-center gap-1.5 rounded-md border bg-background px-2 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring">
                  <Download className="size-3.5" aria-hidden /> Export selected
                </button>
                <button type="button" onClick={() => updateSelection(new Set())} className="inline-flex h-7 items-center rounded-md px-2 text-xs font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
                  Clear
                </button>
              </span>
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* table */}
      <div className="overflow-hidden rounded-xl border bg-card shadow-xs">
        <div
          ref={scrollRef}
          className={cn("relative overflow-auto overscroll-x-contain", resizing && "cursor-col-resize select-none")}
          style={{ maxHeight }}
          onScroll={(e) => setScrolled(e.currentTarget.scrollLeft > 2)}
        >
          <table className="table-fixed border-separate border-spacing-0 text-sm" style={{ width: tableWidth, minWidth: "100%" }} aria-rowcount={sorted.length + 1}>
            <caption className="sr-only">
              {title}, {sorted.length} results, sorted {sort ? `by ${byId.get(sort.id)?.header} ${sort.dir === "asc" ? "ascending" : "descending"}` : "in default order"}. Page {current + 1} of {pageCount}.
            </caption>
            <colgroup>
              <col style={{ width: SELECT_W }} />
              {hasExpand && <col style={{ width: EXPAND_W }} />}
              {ordered.map((c) => (
                <col key={c.id} style={{ width: colW(c.id) }} />
              ))}
            </colgroup>
            <thead>
              <tr>
                <th
                  scope="col"
                  className={cn("sticky top-0 z-30 border-b bg-muted/95 px-3 text-left backdrop-blur-sm", pinCls())}
                  style={pinStyle("select")}
                >
                  <Checkbox
                    checked={allOnPage}
                    indeterminate={pageSelected > 0 && !allOnPage}
                    label="Select all rows on this page"
                    onChange={(c) => {
                      const next = new Set(selected);
                      pageIds.forEach((id) => (c ? next.add(id) : next.delete(id)));
                      updateSelection(next);
                    }}
                  />
                </th>
                {hasExpand && (
                  <th scope="col" className={cn("sticky top-0 z-30 border-b bg-muted/95 backdrop-blur-sm", pinCls())} style={pinStyle("expand")}>
                    <span className="sr-only">Expand</span>
                  </th>
                )}
                {ordered.map((c) => {
                  const isFirst = pinned && c.id === firstId;
                  const s = sort?.id === c.id ? sort.dir : null;
                  const SortIcon = s === "asc" ? ArrowUp : s === "desc" ? ArrowDown : ArrowUpDown;
                  return (
                    <th
                      key={c.id}
                      scope="col"
                      aria-sort={s === "asc" ? "ascending" : s === "desc" ? "descending" : c.sortValue ? "none" : undefined}
                      onDragOver={(e) => {
                        if (!dragId || dragId === c.id) return;
                        e.preventDefault();
                        const r = e.currentTarget.getBoundingClientRect();
                        const side = e.clientX < r.left + r.width / 2 ? "before" : "after";
                        if (drop?.id !== c.id || drop.side !== side) setDrop({ id: c.id, side });
                      }}
                      onDrop={(e) => {
                        e.preventDefault();
                        if (dragId && drop) moveColumn(dragId, drop.id, drop.side);
                        setDragId(null);
                        setDrop(null);
                      }}
                      className={cn(
                        "group/th sticky top-0 border-b bg-muted/95 px-3 py-2 text-xs font-medium whitespace-nowrap text-muted-foreground backdrop-blur-sm",
                        isFirst ? cn("z-30", pinCls(true)) : "z-20",
                        c.align === "right" ? "text-right" : "text-left",
                        dragId === c.id && "opacity-50",
                      )}
                      style={isFirst ? pinStyle("first") : undefined}
                    >
                      <div className={cn("flex items-center gap-1", c.align === "right" && "flex-row-reverse")}>
                        <span
                          draggable
                          onDragStart={(e) => {
                            e.dataTransfer.effectAllowed = "move";
                            e.dataTransfer.setData("text/plain", c.id);
                            setDragId(c.id);
                          }}
                          onDragEnd={() => {
                            setDragId(null);
                            setDrop(null);
                          }}
                          title="Drag to reorder"
                          aria-hidden
                          className="-ml-1.5 cursor-grab text-muted-foreground/40 opacity-0 transition-opacity group-hover/th:opacity-100 active:cursor-grabbing"
                        >
                          <GripVertical className="size-3.5" />
                        </span>
                        {c.sortValue ? (
                          <button
                            type="button"
                            onClick={() => cycleSort(c.id)}
                            onKeyDown={(e) => {
                              if (!e.altKey || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
                              e.preventDefault();
                              const i = order.indexOf(c.id);
                              const j = e.key === "ArrowLeft" ? i - 1 : i + 1;
                              if (j < 0 || j >= order.length) return;
                              moveColumn(c.id, order[j], e.key === "ArrowLeft" ? "before" : "after");
                              setStatus(`Moved ${c.header} ${e.key === "ArrowLeft" ? "left" : "right"}`);
                            }}
                            aria-label={`${c.header}, sort. Alt plus arrow keys move the column.`}
                            className={cn(
                              "-mx-1 inline-flex min-w-0 items-center gap-1 rounded px-1 py-0.5 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
                              s && "text-foreground",
                            )}
                          >
                            <span className="truncate">{c.header}</span>
                            <SortIcon className={cn("size-3 shrink-0", !s && "opacity-40")} aria-hidden />
                          </button>
                        ) : (
                          <span className="truncate">{c.header}</span>
                        )}
                      </div>
                      {drop?.id === c.id && (
                        <span aria-hidden className={cn("absolute top-1 bottom-1 w-0.5 rounded bg-primary", drop.side === "before" ? "left-0" : "right-0")} />
                      )}
                      <span
                        role="separator"
                        aria-orientation="vertical"
                        aria-label={`Resize ${c.header} column`}
                        aria-valuenow={widths[c.id]}
                        aria-valuemin={c.minWidth ?? 80}
                        tabIndex={0}
                        onPointerDown={(e) => startResize(e, c)}
                        onDoubleClick={() => setWidths((w) => ({ ...w, [c.id]: c.width ?? 150 }))}
                        onKeyDown={(e) => {
                          if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
                          e.preventDefault();
                          const d = e.key === "ArrowLeft" ? -16 : 16;
                          setWidths((w) => ({ ...w, [c.id]: Math.max(c.minWidth ?? 80, w[c.id] + d) }));
                        }}
                        data-resize-handle={c.id}
                        className="absolute top-0 -right-1 z-10 flex h-full w-2.5 cursor-col-resize touch-none justify-center outline-none after:h-full after:w-px after:bg-transparent after:transition-colors hover:after:bg-primary focus-visible:after:w-0.5 focus-visible:after:bg-ring data-[active=true]:after:bg-primary"
                        data-active={resizing === c.id}
                      />
                    </th>
                  );
                })}
              </tr>
            </thead>
            <tbody>
              {pageRows.map((r) => {
                const id = getRowId(r);
                const isSel = selected.has(id);
                const isExp = expanded.has(id);
                return (
                  <React.Fragment key={id}>
                    <tr className="group" data-selected={isSel} aria-selected={isSel}>
                      <td className={cn("border-b px-3", pad, cellBg, pinCls(), pinned && "z-10")} style={pinStyle("select")}>
                        <Checkbox
                          checked={isSel}
                          label={`Select row ${id}`}
                          onChange={(c) => {
                            const next = new Set(selected);
                            if (c) next.add(id);
                            else next.delete(id);
                            updateSelection(next);
                          }}
                        />
                      </td>
                      {hasExpand && (
                        <td className={cn("border-b pr-1", pad, cellBg, pinCls(), pinned && "z-10")} style={pinStyle("expand")}>
                          <button
                            type="button"
                            aria-expanded={isExp}
                            aria-label={isExp ? `Collapse row ${id}` : `Expand row ${id}`}
                            onClick={() =>
                              setExpanded((s) => {
                                const next = new Set(s);
                                if (next.has(id)) next.delete(id);
                                else next.add(id);
                                return next;
                              })
                            }
                            className="grid size-6 place-items-center rounded text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                          >
                            <ChevronRight className={cn("size-3.5 transition-transform", isExp && "rotate-90")} />
                          </button>
                        </td>
                      )}
                      {ordered.map((c) => {
                        const isFirst = pinned && c.id === firstId;
                        return (
                          <td
                            key={c.id}
                            className={cn(
                              "overflow-hidden border-b px-3 text-ellipsis whitespace-nowrap",
                              pad,
                              cellBg,
                              c.align === "right" && "text-right tabular-nums",
                              isFirst && cn("z-10", pinCls(true)),
                            )}
                            style={isFirst ? pinStyle("first") : undefined}
                          >
                            {c.cell(r)}
                          </td>
                        );
                      })}
                    </tr>
                    {hasExpand && isExp && (
                      <tr>
                        <td colSpan={colCount} className="border-b bg-muted/30 p-0">
                          <motion.div
                            initial={reduce ? false : { height: 0, opacity: 0 }}
                            animate={{ height: "auto", opacity: 1 }}
                            transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                            className="overflow-hidden"
                          >
                            <div className={cn(pinned && "sticky left-0")} style={{ maxWidth: "min(100vw - 2rem, 900px)" }}>
                              {renderExpanded!(r)}
                            </div>
                          </motion.div>
                        </td>
                      </tr>
                    )}
                  </React.Fragment>
                );
              })}
              {pageRows.length === 0 && (
                <tr>
                  <td colSpan={colCount} className="px-4 py-16 text-center">
                    <div className="sticky left-0 mx-auto max-w-[calc(100vw-4rem)]">
                      <p className="font-medium">No results</p>
                      <p className="mt-1 text-sm text-muted-foreground">Nothing matches your search and filters.</p>
                      <button
                        type="button"
                        onClick={resetAll}
                        className="mt-3 inline-flex h-8 items-center rounded-md border bg-background px-3 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
                      >
                        Clear filters
                      </button>
                    </div>
                  </td>
                </tr>
              )}
            </tbody>
          </table>
        </div>

        {/* pagination */}
        <div className="flex flex-wrap items-center justify-between gap-3 border-t px-3 py-2.5 text-xs text-muted-foreground">
          <p className="tabular-nums" aria-live="polite">
            {from}–{to} of {sorted.length}
            {sorted.length !== rows.length && <span> (filtered from {rows.length})</span>}
          </p>
          <div className="flex items-center gap-3">
            <label className="flex items-center gap-1.5">
              <span className="hidden sm:inline">Rows per page</span>
              <span className="sm:hidden">Rows</span>
              <select
                value={pageSize}
                onChange={(e) => {
                  setPageSize(Number(e.target.value));
                  setPage(0);
                }}
                className="h-7 rounded-md border bg-background px-1.5 text-xs text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
              >
                {pageSizes.map((s) => (
                  <option key={s} value={s}>
                    {s}
                  </option>
                ))}
              </select>
            </label>
            <nav aria-label="Pagination" className="flex items-center gap-1">
              {(
                [
                  ["First page", ChevronsLeft, 0, current === 0],
                  ["Previous page", ChevronLeft, current - 1, current === 0],
                ] as const
              ).map(([label, Icon, p, dis]) => (
                <button key={label} type="button" aria-label={label} disabled={dis} onClick={() => setPage(p)} className="grid size-7 place-items-center rounded-md border bg-background text-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40">
                  <Icon className="size-3.5" />
                </button>
              ))}
              <span className="px-1.5 text-foreground tabular-nums">
                {current + 1} / {pageCount}
              </span>
              {(
                [
                  ["Next page", ChevronRight, current + 1, current >= pageCount - 1],
                  ["Last page", ChevronsRight, pageCount - 1, current >= pageCount - 1],
                ] as const
              ).map(([label, Icon, p, dis]) => (
                <button key={label} type="button" aria-label={label} disabled={dis} onClick={() => setPage(p)} className="grid size-7 place-items-center rounded-md border bg-background text-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40">
                  <Icon className="size-3.5" />
                </button>
              ))}
            </nav>
          </div>
        </div>
      </div>

      {/* export / status toast */}
      <div aria-live="polite" className="pointer-events-none fixed right-4 bottom-4 z-50">
        <AnimatePresence>
          {status && (
            <motion.p
              key={status}
              initial={{ opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: 8 }}
              className="rounded-lg border bg-popover px-3 py-2 text-xs font-medium text-popover-foreground shadow-lg"
            >
              {status}
            </motion.p>
          )}
        </AnimatePresence>
      </div>
    </section>
  );
}

/* ================================================================== */
/* Customers preset (works with zero props)                            */
/* ================================================================== */

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const dateFmt = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
const AVATAR_HUES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#4a3aa7"];

const STATUS_STYLE: Record<Customer["status"], string> = {
  Active: "bg-emerald-500",
  Trialing: "bg-sky-500",
  "Past due": "bg-amber-500",
  Canceled: "bg-zinc-400 dark:bg-zinc-500",
};

function Avatar({ name }: { name: string }) {
  const initials = name
    .split(" ")
    .map((p) => p[0])
    .join("")
    .slice(0, 2);
  let h = 0;
  for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
  const hue = AVATAR_HUES[h % AVATAR_HUES.length];
  return (
    <span
      aria-hidden
      className="grid size-7 shrink-0 place-items-center rounded-full text-[10px] font-semibold text-foreground"
      style={{ background: `color-mix(in oklch, ${hue} 26%, var(--card))`, boxShadow: `inset 0 0 0 1px color-mix(in oklch, ${hue} 35%, transparent)` }}
    >
      {initials}
    </span>
  );
}

const lastSeen = (d: number) => (d === 0 ? "Today" : d === 1 ? "Yesterday" : d < 30 ? `${d}d ago` : `${Math.floor(d / 30)}mo ago`);

export const customerColumns: ColumnDef<Customer>[] = [
  {
    id: "customer",
    header: "Customer",
    width: 250,
    minWidth: 160,
    sortValue: (r) => r.name,
    text: (r) => r.name,
    cell: (r) => (
      <div className="flex min-w-0 items-center gap-2.5">
        <Avatar name={r.name} />
        <div className="min-w-0">
          <p className="truncate font-medium">{r.name}</p>
          <p className="truncate text-xs text-muted-foreground">{r.email}</p>
        </div>
      </div>
    ),
  },
  {
    id: "status",
    header: "Status",
    width: 128,
    sortValue: (r) => r.status,
    text: (r) => r.status,
    facet: (r) => r.status,
    cell: (r) => (
      <span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium">
        <span aria-hidden className={cn("size-1.5 rounded-full", STATUS_STYLE[r.status])} />
        {r.status}
      </span>
    ),
  },
  { id: "plan", header: "Plan", width: 118, sortValue: (r) => ["Starter", "Pro", "Business", "Enterprise"].indexOf(r.plan), text: (r) => r.plan, facet: (r) => r.plan, cell: (r) => r.plan },
  { id: "company", header: "Company", width: 150, sortValue: (r) => r.company, text: (r) => r.company, cell: (r) => r.company },
  { id: "country", header: "Country", width: 150, sortValue: (r) => r.country, text: (r) => r.country, facet: (r) => r.country, cell: (r) => <span className="text-muted-foreground">{r.country}</span> },
  { id: "mrr", header: "MRR", width: 112, align: "right", sortValue: (r) => r.mrr, text: (r) => r.mrr, cell: (r) => <span className={cn("font-medium", r.mrr === 0 && "text-muted-foreground")}>{usd.format(r.mrr)}</span> },
  { id: "seats", header: "Seats", width: 90, align: "right", sortValue: (r) => r.seats, text: (r) => r.seats, cell: (r) => r.seats },
  { id: "created", header: "Signed up", width: 132, sortValue: (r) => r.createdAt, text: (r) => r.createdAt, cell: (r) => <span className="text-muted-foreground">{dateFmt.format(new Date(`${r.createdAt}T00:00:00Z`))}</span> },
  { id: "lastSeen", header: "Last active", width: 118, sortValue: (r) => r.lastSeenDays, text: (r) => lastSeen(r.lastSeenDays), cell: (r) => <span className="text-muted-foreground">{lastSeen(r.lastSeenDays)}</span> },
];

const MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep"];

function CustomerDetail({ row }: { row: Customer }) {
  const max = Math.max(...row.usage, 1);
  return (
    <div className="grid gap-5 px-4 py-4 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto] sm:pl-[92px]">
      <div className="min-w-0">
        <p className="text-xs font-medium text-muted-foreground">API calls · last 6 months (k)</p>
        <div className="mt-2 flex h-20 items-end gap-2" role="img" aria-label={`Usage: ${row.usage.map((u, i) => `${MONTHS[i]} ${u}k`).join(", ")}`}>
          {row.usage.map((u, i) => (
            <div key={i} className="flex h-full flex-1 flex-col items-center justify-end gap-1">
              <span className="text-[10px] text-muted-foreground tabular-nums">{u}</span>
              <motion.div
                initial={{ height: 0 }}
                animate={{ height: `${Math.max(6, (u / max) * 100)}%` }}
                transition={{ duration: 0.5, delay: i * 0.04, ease: [0.22, 1, 0.36, 1] }}
                className={cn("w-full max-w-7 rounded-t-[4px]", i === row.usage.length - 1 ? "bg-primary" : "bg-primary/35")}
              />
              <span className="text-[10px] text-muted-foreground">{MONTHS[i]}</span>
            </div>
          ))}
        </div>
      </div>
      <dl className="grid grid-cols-[auto_1fr] content-start gap-x-4 gap-y-1.5 text-xs">
        <dt className="text-muted-foreground">Customer ID</dt>
        <dd className="font-mono">{row.id}</dd>
        <dt className="text-muted-foreground">Account owner</dt>
        <dd>{row.owner}</dd>
        <dt className="text-muted-foreground">Email</dt>
        <dd className="truncate">{row.email}</dd>
        <dt className="text-muted-foreground">ARR</dt>
        <dd className="font-medium tabular-nums">{usd.format(row.mrr * 12)}</dd>
      </dl>
      <div className="flex items-start gap-2 sm:flex-col">
        <button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md border bg-background px-2.5 text-xs font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring">
          <Mail className="size-3.5" aria-hidden /> Email
        </button>
        <button type="button" className="inline-flex h-8 items-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
          Open profile
        </button>
      </div>
    </div>
  );
}

export type DataTableAdvancedProps = Partial<Omit<DataTableProps<Customer>, "getRowId">> & { getRowId?: (row: Customer) => string };

/** Customers table with 200 seeded rows. Every prop of `DataTable` can be overridden. */
export function DataTableAdvanced(props: DataTableAdvancedProps) {
  return (
    <DataTable<Customer>
      rows={seededCustomers}
      columns={customerColumns}
      getRowId={(r) => r.id}
      title="Customers"
      description="Search, filter, sort, resize and drag columns to reorder. Expand a row for usage."
      searchPlaceholder="Search name, company, country…"
      csvFilename="customers.csv"
      renderExpanded={(r) => <CustomerDetail row={r} />}
      defaultSort={{ id: "mrr", dir: "desc" }}
      {...props}
    />
  );
}

More in Dashboards

View all →