- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Total results4 rows
Use arrow keys to navigate, Shift and arrows to select cells, and Enter or F2 to edit. Copy and paste cells with your keyboard. Column headings sort; column separators resize with arrow keys.
1
Garden guide
Editorial
Active
28
2
Field notes
Research
Planned
16
3
Seasonal index
Editorial
Complete
42
4
Plant archive
Research
Active
35
Sum121
A1Count 1
Use arrow keys to move between cells, Shift with arrow keys to select a range, and Enter or F2 to edit. Copy and paste work with spreadsheet data.
Installation
$ pnpm dlx shadcn@latest add @cobalt/data-grid
Source
"use client";
import {
RiArrowDownLine,
RiArrowUpLine,
RiDownloadLine,
RiArrowGoForwardLine,
RiArrowGoBackLine,
} from "@remixicon/react";
import { cn } from "cn";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import {
applyGridEdits,
columnLetter,
parseGridClipboard,
parseGridValue,
serializeGridRows,
} from "@/components/lib/data-grid-utils";
import type { GridEdit } from "@/components/lib/data-grid-utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface DataGridColumn<T> {
key: Extract<keyof T, string>;
header: string;
width?: number;
type?: "text" | "number" | "select";
options?: readonly string[];
editable?: boolean;
filterable?: boolean;
min?: number;
max?: number;
align?: "start" | "end";
summary?: "sum" | "average" | "count";
format?: (value: T[keyof T]) => React.ReactNode;
formatSummary?: (value: number) => React.ReactNode;
validate?: (value: string | number, row: T) => string | undefined;
}
export interface DataGridProps<T extends object> {
"aria-label": string;
data: readonly T[];
columns: readonly DataGridColumn<T>[];
getRowId: (row: T) => string;
onDataChange?: (data: T[]) => void;
height?: number;
className?: string;
exportFileName?: string;
emptyState?: React.ReactNode;
direction?: "ltr" | "rtl";
popupClassName?: string;
}
type Transaction<T> =
| { type: "edit"; edits: GridEdit<T>[] }
| { type: "delete"; rows: { row: T; index: number }[] };
interface Cell {
rowId: string;
key: string;
}
export function DataGrid<T extends object>({
"aria-label": label,
data,
columns,
getRowId,
onDataChange,
height = 396,
className,
exportFileName = "data-grid.csv",
emptyState = "No matching rows.",
direction = "ltr",
popupClassName,
}: DataGridProps<T>) {
const id = React.useId();
const grid = React.useRef<HTMLDivElement>(null);
const body = React.useRef<HTMLDivElement>(null);
const summaryRow = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const [menu, setMenu] = React.useState<"filters" | "columns" | null>(null);
const [viewportWidth, setViewportWidth] = React.useState(0);
React.useEffect(() => {
if (!body.current) {
return;
}
const observer = new ResizeObserver(([entry]) =>
setViewportWidth(entry.contentRect.width)
);
observer.observe(body.current);
return () => observer.disconnect();
}, []);
const [query, setQuery] = React.useState("");
const [sort, setSort] = React.useState<{
key: string;
descending: boolean;
} | null>(null);
const [filters, setFilters] = React.useState<Record<string, string>>({});
const [hidden, setHidden] = React.useState<string[]>([]);
const [compact, setCompact] = React.useState(false);
const [widths, setWidths] = React.useState<Record<string, number>>({});
const [selected, setSelected] = React.useState<string[]>([]);
const [range, setRange] = React.useState<{
anchor: Cell;
focus: Cell;
} | null>(() => {
if (!data.length || !columns.length) {
return null;
}
const cell = { rowId: getRowId(data[0]), key: columns[0].key };
return { anchor: cell, focus: cell };
});
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const [error, setError] = React.useState("");
const [message, setMessage] = React.useState("");
const [history, setHistory] = React.useState<{
undo: Transaction<T>[];
redo: Transaction<T>[];
}>({ undo: [], redo: [] });
const [scrollTop, setScrollTop] = React.useState(0);
const visible = columns.filter((column) => !hidden.includes(column.key));
const rows = React.useMemo(() => {
const q = query.trim().toLocaleLowerCase();
const result = data.filter(
(row) =>
(!q ||
columns.some((c) =>
String(row[c.key] ?? "")
.toLocaleLowerCase()
.includes(q)
)) &&
columns.every(
(c) =>
filters[c.key] === undefined ||
String(row[c.key] ?? "") === filters[c.key]
)
);
if (sort) {
result.sort((a, b) => {
const x = a[sort.key as keyof T],
y = b[sort.key as keyof T];
const n =
typeof x === "number" && typeof y === "number"
? x - y
: String(x ?? "").localeCompare(String(y ?? ""), undefined, {
numeric: true,
});
return sort.descending ? -n : n;
});
}
return result;
}, [data, columns, query, filters, sort]);
const activeRow = range
? rows.findIndex((row) => getRowId(row) === range.focus.rowId)
: -1;
const activeColumn = range
? visible.findIndex((column) => column.key === range.focus.key)
: -1;
const anchorRow = range
? rows.findIndex((row) => getRowId(row) === range.anchor.rowId)
: -1;
const anchorColumn = range
? visible.findIndex((column) => column.key === range.anchor.key)
: -1;
const bounds =
activeRow < 0 || activeColumn < 0 || anchorRow < 0 || anchorColumn < 0
? null
: {
top: Math.min(activeRow, anchorRow),
bottom: Math.max(activeRow, anchorRow),
start: Math.min(activeColumn, anchorColumn),
end: Math.max(activeColumn, anchorColumn),
};
const rowHeight = compact ? 36 : 44;
const first = Math.max(0, Math.floor(scrollTop / rowHeight) - 4);
const last = Math.min(
rows.length,
Math.ceil((scrollTop + height) / rowHeight) + 4
);
const template = `48px ${visible.map((c) => `${widths[c.key] ?? c.width ?? 160}px`).join(" ")}`;
const freeze =
viewportWidth === 0 ||
viewportWidth >=
48 + (widths[visible[0]?.key] ?? visible[0]?.width ?? 160) + 96;
const totalWidth =
48 + visible.reduce((n, c) => n + (widths[c.key] ?? c.width ?? 160), 0);
const rowStyle = {
gridTemplateColumns: template,
width: totalWidth,
minWidth: "100%",
};
function select(r: number, c: number, extend = false) {
r = Math.max(0, Math.min(rows.length - 1, r));
c = Math.max(0, Math.min(visible.length - 1, c));
const row = rows[r],
column = visible[c];
if (!row || !column) {
return;
}
const cell = { rowId: getRowId(row), key: column.key };
setRange((old) => ({
anchor: extend && old && bounds ? old.anchor : cell,
focus: cell,
}));
setEditing(false);
setError("");
grid.current?.focus({ preventScroll: true });
if (body.current) {
const el = body.current;
if (r * rowHeight < el.scrollTop) {
el.scrollTop = r * rowHeight;
} else if ((r + 1) * rowHeight > el.scrollTop + el.clientHeight) {
el.scrollTop = (r + 1) * rowHeight - el.clientHeight;
}
const left =
48 +
visible
.slice(0, c)
.reduce((n, v) => n + (widths[v.key] ?? v.width ?? 160), 0);
const width = widths[column.key] ?? column.width ?? 160;
const offset = Math.abs(el.scrollLeft);
const frozenWidth =
48 +
(freeze ? (widths[visible[0]?.key] ?? visible[0]?.width ?? 160) : 0);
const sign = direction === "rtl" ? -1 : 1;
if (c > 0 || !freeze) {
if (left < offset + frozenWidth) {
el.scrollLeft = sign * Math.max(0, left - frozenWidth);
} else if (left + width > offset + el.clientWidth) {
el.scrollLeft = sign * (left + width - el.clientWidth);
}
}
}
}
function commit(edits: GridEdit<T>[]) {
if (!onDataChange || !edits.length) {
return;
}
onDataChange(applyGridEdits(data, getRowId, edits));
setHistory((h) => ({
undo: [...h.undo.slice(-99), { type: "edit", edits }],
redo: [],
}));
setError("");
setMessage(
`${edits.length} ${edits.length === 1 ? "cell updated" : "cells updated"}.`
);
}
function editValue(
row: T,
column: DataGridColumn<T>,
value: string
): GridEdit<T> {
if (!onDataChange || column.editable === false) {
throw new Error(`${column.header} is read-only.`);
}
const parsed = parseGridValue(value, column);
const problem = column.validate?.(parsed, row);
if (problem) {
throw new Error(`${column.header}: ${problem}`);
}
return {
rowId: getRowId(row),
key: column.key,
before: row[column.key],
after: parsed as T[keyof T],
};
}
function finish(move = 0) {
const row = rows[activeRow],
column = visible[activeColumn];
if (!row || !column) {
return;
}
try {
const edit = editValue(row, column, draft);
if (!Object.is(edit.before, edit.after)) {
commit([edit]);
}
setEditing(false);
if (Math.abs(move) === 2) {
const next = Math.max(
0,
Math.min(
rows.length * visible.length - 1,
activeRow * visible.length + activeColumn + Math.sign(move)
)
);
select(Math.floor(next / visible.length), next % visible.length);
} else if (move) {
select(activeRow + move, activeColumn);
} else {
grid.current?.focus();
}
} catch (error) {
setError(error instanceof Error ? error.message : "Invalid value.");
}
}
function travel(undo: boolean) {
const stack = undo ? history.undo : history.redo;
const transaction = stack.at(-1);
if (!transaction || !onDataChange) {
return;
}
const current = new Map(data.map((row) => [getRowId(row), row]));
const stale =
transaction.type === "edit"
? transaction.edits.some(
(edit) =>
!current.has(edit.rowId) ||
!Object.is(
current.get(edit.rowId)?.[edit.key],
undo ? edit.after : edit.before
)
)
: transaction.rows.some(({ row }) =>
undo
? current.has(getRowId(row))
: current.get(getRowId(row)) !== row
);
if (stale) {
setHistory({ undo: [], redo: [] });
setMessage(
"The data changed outside this grid. Change history has been cleared."
);
return;
}
if (transaction.type === "edit") {
onDataChange(applyGridEdits(data, getRowId, transaction.edits, undo));
} else if (undo) {
const next = [...data];
for (const { row, index } of transaction.rows) {
next.splice(Math.min(index, next.length), 0, row);
}
onDataChange(next);
} else {
const removed = new Set(transaction.rows.map(({ row }) => getRowId(row)));
onDataChange(data.filter((row) => !removed.has(getRowId(row))));
}
setHistory(
undo
? { undo: stack.slice(0, -1), redo: [...history.redo, transaction] }
: { undo: [...history.undo, transaction], redo: stack.slice(0, -1) }
);
setEditing(false);
setMessage(undo ? "Change undone." : "Change redone.");
setError("");
}
const selectedRows = rows.filter((row) => selected.includes(getRowId(row)));
async function copyRows() {
try {
await navigator.clipboard.writeText(
serializeGridRows(
selectedRows.map((row) => visible.map((column) => row[column.key])),
"\t"
)
);
setMessage(
`${selectedRows.length} ${selectedRows.length === 1 ? "row" : "rows"} copied.`
);
setError("");
} catch {
setError("Could not copy rows. Allow clipboard access and try again.");
}
}
function deleteRows() {
if (!onDataChange || !selectedRows.length) {
return;
}
const removed = new Set(selectedRows.map(getRowId));
const deleted = data.flatMap((row, index) =>
removed.has(getRowId(row)) ? [{ row, index }] : []
);
onDataChange(data.filter((row) => !removed.has(getRowId(row))));
setHistory((h) => ({
undo: [...h.undo.slice(-99), { type: "delete", rows: deleted }],
redo: [],
}));
setSelected((s) => s.filter((id) => !removed.has(id)));
setRange((old) =>
old && (removed.has(old.anchor.rowId) || removed.has(old.focus.rowId))
? null
: old
);
setEditing(false);
setMessage(
`${deleted.length} ${deleted.length === 1 ? "row" : "rows"} deleted.`
);
}
function exportCsv() {
const exportRows = selected.length
? rows.filter((r) => selected.includes(getRowId(r)))
: rows;
const csv = serializeGridRows([
visible.map((c) => c.header),
...exportRows.map((r) => visible.map((c) => r[c.key])),
]);
const url = URL.createObjectURL(
new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8" })
);
const link = document.createElement("a");
link.href = url;
link.download = exportFileName;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setMessage(
`${exportRows.length} ${exportRows.length === 1 ? "row" : "rows"} exported.`
);
}
const selectedValues = bounds
? rows
.slice(bounds.top, bounds.bottom + 1)
.flatMap((r) =>
visible.slice(bounds.start, bounds.end + 1).map((c) => r[c.key])
)
: [];
const numbers = (selectedValues as unknown[]).filter(
(v): v is number => typeof v === "number" && Number.isFinite(v)
);
const sum = numbers.reduce((a, b) => a + b, 0);
const surface = "bg-[var(--surface-card,var(--background))]";
const secondary = "bg-[var(--surface-sunken,var(--muted))]";
const border = "border-[var(--command-border,var(--border))]";
return (
<div
dir={direction}
data-slot="data-grid"
className={cn(
"w-full min-w-0 overflow-hidden rounded-[16px] border text-sm text-foreground",
surface,
border,
className
)}
>
<div
className={cn(
"flex flex-wrap items-center gap-2 border-b px-2 py-[7px]",
border
)}
>
<div className="me-auto flex min-h-10 flex-col justify-center">
<span className="text-muted-foreground">Total results</span>
<span className="font-medium">
{rows.length.toLocaleString()} {rows.length === 1 ? "row" : "rows"}
</span>
</div>
{selectedRows.length > 0 && (
<>
<span>{selectedRows.length} selected</span>
<Button
variant="outline"
size="sm"
disabled={!onDataChange}
onClick={deleteRows}
>
Delete
</Button>
<Button variant="outline" size="sm" onClick={copyRows}>
Copy rows
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelected([])}>
Clear
</Button>
</>
)}
<Button
variant="outline"
size="icon-sm"
aria-label="Undo edit"
disabled={!history.undo.length || !onDataChange}
onClick={() => travel(true)}
>
<RiArrowGoBackLine />
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Redo edit"
disabled={!history.redo.length || !onDataChange}
onClick={() => travel(false)}
>
<RiArrowGoForwardLine />
</Button>
<Popover
open={menu === "filters"}
onOpenChange={(open) => setMenu(open ? "filters" : null)}
>
<PopoverTrigger render={<Button variant="outline" size="sm" />}>
Filters
</PopoverTrigger>
<PopoverContent align="end" className={popupClassName}>
<div className={cn("flex flex-col gap-2", surface, border)}>
{columns
.filter((c) => c.filterable || c.type === "select")
.map((c) => (
<label key={c.key} className="flex flex-col gap-1">
{c.header}
<select
className={cn(
"rounded-[4px] border p-1",
surface,
border
)}
value={filters[c.key] ?? ""}
onChange={(e) =>
setFilters((f) => {
const next = { ...f };
if (e.target.value === "") {
delete next[c.key];
} else {
next[c.key] = e.target.value;
}
return next;
})
}
>
<option value="">All</option>
{(
c.options ?? [
...new Set(data.map((r) => String(r[c.key] ?? ""))),
]
)
.filter(Boolean)
.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
))}
<Button variant="ghost" size="sm" onClick={() => setFilters({})}>
Clear filters
</Button>
</div>
</PopoverContent>
</Popover>
<Popover
open={menu === "columns"}
onOpenChange={(open) => setMenu(open ? "columns" : null)}
>
<PopoverTrigger render={<Button variant="outline" size="sm" />}>
Columns
</PopoverTrigger>
<PopoverContent align="end" className={popupClassName}>
<div className={cn("flex flex-col gap-2", surface, border)}>
{columns.map((c) => (
<label key={c.key} className="flex items-center gap-2">
<Checkbox
checked={!hidden.includes(c.key)}
disabled={!hidden.includes(c.key) && visible.length === 1}
onCheckedChange={(checked) =>
setHidden((h) =>
checked ? h.filter((k) => k !== c.key) : [...h, c.key]
)
}
/>
{c.header}
</label>
))}
</div>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
onClick={() => {
setCompact(!compact);
setScrollTop(0);
if (body.current) {
body.current.scrollTop = 0;
}
}}
>
{compact ? "Comfortable" : "Compact"}
</Button>
<Button
variant="outline"
size="icon-sm"
aria-label="Export"
disabled={!rows.length}
onClick={exportCsv}
>
<RiDownloadLine />
</Button>
<Input
aria-label={`Search ${label}`}
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-40 rounded-full sm:w-56"
/>
</div>
<div
ref={grid}
role="grid"
aria-label={label}
aria-describedby={`${id}-instructions`}
aria-rowcount={rows.length + 1}
aria-colcount={visible.length + 1}
aria-multiselectable="true"
aria-readonly={!onDataChange}
aria-activedescendant={
activeRow >= first && activeRow < last && activeColumn >= 0
? `${id}-${activeRow}-${activeColumn}`
: undefined
}
tabIndex={0}
className="outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[pointer]:focus-visible:ring-0"
onFocus={(e) => {
if (e.target === e.currentTarget && !range) {
select(0, 0);
}
}}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
delete e.currentTarget.dataset.pointer;
}
}}
onKeyDown={(e) => {
delete e.currentTarget.dataset.pointer;
if (editing) {
return;
}
const moves: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
ArrowLeft: [0, direction === "rtl" ? 1 : -1],
ArrowRight: [0, direction === "rtl" ? -1 : 1],
};
if (moves[e.key]) {
e.preventDefault();
const [dr, dc] = moves[e.key];
select(
Math.max(0, activeRow) + dr,
Math.max(0, activeColumn) + dc,
e.shiftKey
);
} else if (
(e.key === "Enter" || e.key === "F2") &&
onDataChange &&
activeRow >= 0 &&
visible[activeColumn]?.editable !== false
) {
e.preventDefault();
setDraft(String(rows[activeRow][visible[activeColumn].key] ?? ""));
setEditing(true);
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
travel(!e.shiftKey);
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
travel(false);
} else if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === "a" &&
rows.length &&
visible.length
) {
e.preventDefault();
setRange({
anchor: { rowId: getRowId(rows[0]), key: visible[0].key },
focus: {
rowId: getRowId(rows.at(-1) as T),
key: (visible.at(-1) as DataGridColumn<T>).key,
},
});
} else if (e.key === "Escape") {
setRange(null);
} else if (["Home", "End", "PageUp", "PageDown"].includes(e.key)) {
e.preventDefault();
const page = Math.max(1, Math.floor(height / rowHeight));
const row = Math.max(0, activeRow);
const column = Math.max(0, activeColumn);
const command = e.metaKey || e.ctrlKey;
if (e.key === "Home") {
select(command ? 0 : row, 0, e.shiftKey);
} else if (e.key === "End") {
select(
command ? rows.length - 1 : row,
visible.length - 1,
e.shiftKey
);
} else {
select(
row + (e.key === "PageDown" ? page : -page),
column,
e.shiftKey
);
}
}
}}
onCopy={(e) => {
if (!bounds || editing) {
return;
}
e.preventDefault();
e.clipboardData.setData(
"text/plain",
serializeGridRows(
rows
.slice(bounds.top, bounds.bottom + 1)
.map((r) =>
visible
.slice(bounds.start, bounds.end + 1)
.map((c) => r[c.key])
),
"\t"
)
);
setMessage(
`${(bounds.bottom - bounds.top + 1) * (bounds.end - bounds.start + 1)} cells copied.`
);
}}
onPaste={(e) => {
if (!bounds || editing || !onDataChange) {
return;
}
e.preventDefault();
try {
const edits: GridEdit<T>[] = [];
parseGridClipboard(e.clipboardData.getData("text/plain")).forEach(
(line, r) =>
line.forEach((v, c) => {
const row = rows[bounds.top + r],
column = visible[bounds.start + c];
if (!row || !column) {
throw new Error("The pasted cells extend beyond the grid.");
}
edits.push(editValue(row, column, v));
})
);
commit(edits);
} catch (error) {
setError(
`Paste cancelled. ${error instanceof Error ? error.message : "Invalid value."}`
);
}
}}
>
<p id={`${id}-instructions`} className="sr-only">
Use arrow keys to navigate, Shift and arrows to select cells, and
Enter or F2 to edit. Copy and paste cells with your keyboard. Column
headings sort; column separators resize with arrow keys.
</p>
<div
// A div-based ARIA grid: the header and body scroll separately, so
// they cannot be thead/tbody.
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
role="rowgroup"
className={cn("overflow-hidden border-b", border, secondary)}
>
<div role="row" className="grid h-10" style={rowStyle}>
<div
role="columnheader"
className={cn(
"sticky start-0 z-20 flex items-center justify-center border-e",
border,
secondary
)}
>
<Checkbox
aria-label="Select all filtered rows"
checked={
!!rows.length &&
rows.every((r) => selected.includes(getRowId(r)))
}
indeterminate={
rows.some((r) => selected.includes(getRowId(r))) &&
!rows.every((r) => selected.includes(getRowId(r)))
}
onCheckedChange={(checked) =>
setSelected((s) =>
checked
? [...new Set([...s, ...rows.map(getRowId)])]
: s.filter((id) => !rows.some((r) => getRowId(r) === id))
)
}
/>
</div>
{visible.map((c, col) => (
<div
role="columnheader"
aria-sort={
sort?.key === c.key
? sort.descending
? "descending"
: "ascending"
: "none"
}
key={c.key}
className={cn(
"group relative flex border-e",
border,
col === 0 && freeze && "sticky start-12 z-10",
secondary
)}
>
<button
type="button"
className="flex h-full min-w-0 flex-1 items-center gap-2 px-3 text-start font-medium text-muted-foreground hover:text-foreground"
onClick={() =>
setSort((s) =>
s?.key === c.key
? s.descending
? null
: { key: c.key, descending: true }
: { key: c.key, descending: false }
)
}
>
<span className="truncate">{c.header}</span>
{sort?.key === c.key &&
(sort.descending ? (
<RiArrowDownLine className="size-3.5" />
) : (
<RiArrowUpLine className="size-3.5" />
))}
</button>
<div
role="separator"
aria-label={`Resize ${c.header}`}
aria-orientation="vertical"
aria-valuemin={96}
aria-valuemax={480}
aria-valuenow={widths[c.key] ?? c.width ?? 160}
tabIndex={0}
className="absolute inset-y-0 -end-1 z-10 w-2 cursor-col-resize hover:bg-primary/20 focus-visible:bg-primary/20"
onDoubleClick={() =>
setWidths((w) => ({ ...w, [c.key]: c.width ?? 160 }))
}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
e.stopPropagation();
setWidths((w) => ({
...w,
[c.key]: Math.max(
96,
Math.min(
480,
(w[c.key] ?? c.width ?? 160) +
(e.key ===
(direction === "rtl" ? "ArrowLeft" : "ArrowRight")
? 16
: -16)
)
),
}));
}
}}
onPointerDown={(e) => {
const start = e.clientX,
width = widths[c.key] ?? c.width ?? 160,
el = e.currentTarget;
el.setPointerCapture(e.pointerId);
const move = (ev: PointerEvent) =>
setWidths((w) => ({
...w,
[c.key]: Math.max(
96,
Math.min(
480,
width +
(ev.clientX - start) *
(direction === "rtl" ? -1 : 1)
)
),
}));
const stop = () => {
el.removeEventListener("pointermove", move);
el.removeEventListener("lostpointercapture", stop);
};
el.addEventListener("pointermove", move);
el.addEventListener("lostpointercapture", stop);
}}
/>
</div>
))}
</div>
</div>
<motion.div
layoutScroll
ref={body}
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
role="rowgroup"
style={{ height }}
className="overflow-auto overscroll-contain [scrollbar-width:thin]"
onScroll={(e) => {
setScrollTop(e.currentTarget.scrollTop);
const header = e.currentTarget.previousElementSibling;
if (header) {
header.scrollLeft = e.currentTarget.scrollLeft;
}
if (summaryRow.current) {
summaryRow.current.scrollLeft = e.currentTarget.scrollLeft;
}
}}
>
<div
className="relative"
style={{
height: rows.length ? rows.length * rowHeight : height,
width: totalWidth,
minWidth: "100%",
}}
>
{rows.slice(first, last).map((row, index) => {
const r = first + index,
rowId = getRowId(row);
return (
<div
role="row"
aria-rowindex={r + 2}
aria-selected={selected.includes(rowId)}
key={rowId}
className={cn(
"group/row absolute inset-x-0 grid border-b",
border
)}
style={{ ...rowStyle, top: r * rowHeight, height: rowHeight }}
>
<div
role="gridcell"
className={cn(
"sticky start-0 z-20 flex items-center justify-center border-e text-xs text-muted-foreground",
border,
surface
)}
>
<span
className={cn(
"group-hover/row:hidden group-focus-within/row:hidden",
selected.includes(rowId) && "hidden"
)}
>
{r + 1}
</span>
<Checkbox
aria-label={`Select row ${r + 1}`}
checked={selected.includes(rowId)}
onCheckedChange={(checked) =>
setSelected((s) =>
checked ? [...s, rowId] : s.filter((x) => x !== rowId)
)
}
className={cn(
"hidden group-hover/row:flex group-focus-within/row:flex",
selected.includes(rowId) && "flex"
)}
/>
</div>
{visible.map((c, col) => {
const active = activeRow === r && activeColumn === col,
inRange =
!!bounds &&
r >= bounds.top &&
r <= bounds.bottom &&
col >= bounds.start &&
col <= bounds.end;
return (
<div
role="gridcell"
id={`${id}-${r}-${col}`}
aria-colindex={col + 2}
aria-selected={inRange}
key={c.key}
className={cn(
"relative flex min-w-0 cursor-cell items-center border-e px-3 transition-colors duration-150 ease-out motion-reduce:transition-none",
border,
surface,
col === 0 && freeze && "sticky start-12 z-10",
(inRange || selected.includes(rowId)) && secondary,
(c.align === "end" ||
(!c.align && c.type === "number")) &&
"justify-end text-end tabular-nums"
)}
onPointerDown={(e) => {
if (editing || e.button !== 0) {
return;
}
if (e.pointerType === "mouse") {
e.preventDefault();
// Script focus after a prevented mousedown matches
// :focus-visible; hide the grid ring until a key press.
if (grid.current) {
grid.current.dataset.pointer = "";
}
}
select(r, col, e.shiftKey);
}}
onPointerEnter={(e) => {
if (
e.pointerType === "mouse" &&
e.buttons === 1 &&
!editing
) {
select(r, col, true);
}
}}
onDoubleClick={() => {
if (onDataChange && c.editable !== false) {
setDraft(String(row[c.key] ?? ""));
setEditing(true);
}
}}
>
{active && (
<motion.span
aria-hidden
layoutId={`${id}-highlight`}
initial={false}
transition={{
layout: reduced
? { duration: 0 }
: {
type: "spring",
stiffness: 480,
damping: 38,
mass: 0.7,
},
}}
className="pointer-events-none absolute inset-0 ring-2 ring-inset ring-[var(--foreground-faint,var(--ring))]"
/>
)}
{active && editing ? (
c.type === "select" ? (
<select
autoFocus
ref={(element) => {
if (element) {
try {
element.showPicker?.();
} catch {
/* Picker may require user activation. */
}
}
}}
aria-label={`Edit ${c.header}`}
value={draft}
className={cn(
"absolute inset-0 size-full px-2",
surface
)}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => finish()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
setEditing(false);
grid.current?.focus();
} else if (e.key === "Tab") {
e.preventDefault();
finish(e.shiftKey ? -2 : 2);
} else if (e.key === "Enter") {
e.preventDefault();
finish(e.shiftKey ? -1 : 1);
}
}}
>
{(c.options ?? []).map((option) => (
<option key={option}>{option}</option>
))}
</select>
) : (
<input
autoFocus
aria-label={`Edit ${c.header}`}
type={c.type === "number" ? "number" : "text"}
value={draft}
className={cn(
"absolute inset-0 size-full px-2 outline-none",
surface
)}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => finish()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
setEditing(false);
grid.current?.focus();
} else if (e.key === "Tab") {
e.preventDefault();
finish(e.shiftKey ? -2 : 2);
} else if (e.key === "Enter") {
e.preventDefault();
finish(e.shiftKey ? -1 : 1);
}
}}
/>
)
) : (
<span
className="truncate"
title={String(row[c.key] ?? "")}
>
{c.format
? c.format(row[c.key])
: String(row[c.key] ?? "")}
</span>
)}
</div>
);
})}
</div>
);
})}
{!rows.length && (
<div className="flex h-full items-center p-6 text-muted-foreground">
{emptyState}
</div>
)}
</div>
</motion.div>
</div>
<div ref={summaryRow} className={cn("overflow-hidden border-t", border)}>
<div className="grid h-11" style={rowStyle}>
<div className={cn("border-e", border)} />
{visible.map((c, col) => {
const values = (rows.map((r) => r[c.key]) as unknown[]).filter(
(v): v is number => typeof v === "number" && Number.isFinite(v)
);
const total = values.reduce((a, b) => a + b, 0);
const value =
c.summary === "count"
? rows.length
: c.summary === "average"
? values.length
? total / values.length
: null
: total;
return (
<div
key={c.key}
className={cn(
"flex items-center justify-end gap-2 border-e px-3 text-muted-foreground tabular-nums",
border,
surface,
col === 0 && freeze && "sticky start-12 z-10"
)}
>
{c.summary && (
<>
<span>
{c.summary === "average"
? "Avg"
: c.summary === "sum"
? "Sum"
: "Count"}
</span>
<span className="font-medium text-foreground">
{value === null
? "—"
: c.formatSummary
? c.formatSummary(value)
: value.toLocaleString(undefined, {
maximumFractionDigits: 1,
})}
</span>
</>
)}
</div>
);
})}
</div>
</div>
<div
className={cn(
"flex min-h-10 flex-wrap items-center gap-4 border-t px-4 py-2 text-muted-foreground",
border
)}
>
<span className="font-medium text-foreground">
{bounds
? `${columnLetter(bounds.start)}${bounds.top + 1}${bounds.top !== bounds.bottom || bounds.start !== bounds.end ? `:${columnLetter(bounds.end)}${bounds.bottom + 1}` : ""}`
: "—"}
</span>
<span>
Count{" "}
{
selectedValues.filter(
(v) => v !== null && v !== undefined && v !== ""
).length
}
</span>
{numbers.length > 0 && (
<>
<span>Sum {sum.toLocaleString()}</span>
<span>
Avg{" "}
{(sum / numbers.length).toLocaleString(undefined, {
maximumFractionDigits: 1,
})}
</span>
</>
)}
{!onDataChange && <span>Read-only</span>}
<span className="sr-only" role="status">
{message}
</span>
</div>
{error && (
<p
role="alert"
className={cn("border-t px-4 py-2 text-destructive", border)}
>
{error}
</p>
)}
</div>
);
} On this page