"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}
/>
);
}