Docs
Composed Grid
Composed Grid
Full composable Data Grid with clipboard, fill, move, filters, dynamic columns, and typed editors.
Demo from the Introduction. Mount only the features you need; unmounted children attach no listeners.
Stress test:
Paste a spreadsheet (Ctrl/Cmd+V) to fill rows
Current Table State
Live view of table + grid state for demonstration
Search Query:None
Total Rows:500
Selected Rows:0
Active Filters:0
Sorting:None
Hidden Columns:0
Pinned Columns:1 Left, 0 Right
Dynamic Columns:10
Focused Cell:None
Editing Cell:None
Selection Anchor:None
Can Undo / Redo:No / No
Last Commit:None
1"use client"2
3/**4 * niko-table/grid — the full-featured editable DataGrid.5 *6 * Every capability, composed as opt-in children of <DataGrid>: clipboard,7 * fill handle, drag-to-move, drag-to-reorder rows, cross-highlight, dynamic8 * columns, column resize, undo/redo, CSV export, a selection status bar, and9 * six cell types (text / number / currency / checkbox / date / select). Remove10 * any child and the grid drops that capability — and its listeners — entirely.11 */12import { Button } from "@/components/ui/button"13import {14 Card,15 CardAction,16 CardContent,17 CardDescription,18 CardHeader,19 CardTitle,20} from "@/components/ui/card"21import { Checkbox } from "@/components/ui/checkbox"22import {23 Collapsible,24 CollapsibleContent,25 CollapsibleTrigger,26} from "@/components/ui/collapsible"27import { DataTableClearFilter } from "@/components/niko-table/components/data-table-clear-filter"28import { DataTableColumnActions } from "@/components/niko-table/components/data-table-column-actions"29import { DataTableColumnHeader } from "@/components/niko-table/components/data-table-column-header"30import { DataTableColumnHideOptions } from "@/components/niko-table/components/data-table-column-hide"31import { DataTableColumnPinOptions } from "@/components/niko-table/components/data-table-column-pin"32import { DataTableColumnSortOptions } from "@/components/niko-table/components/data-table-column-sort"33import { DataTableColumnTitle } from "@/components/niko-table/components/data-table-column-title"34import { DataTableExportButton } from "@/components/niko-table/components/data-table-export-button"35import { DataTableFacetedFilter } from "@/components/niko-table/components/data-table-faceted-filter"36import { DataTableFilterMenu } from "@/components/niko-table/components/data-table-filter-menu"37import { DataTableRowContextMenuSlot } from "@/components/niko-table/components/data-table-row-context-menu-slot"38import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"39import { DataTableSelectionBar } from "@/components/niko-table/components/data-table-selection-bar"40import { DataTableSortMenu } from "@/components/niko-table/components/data-table-sort-menu"41import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"42import { DataTableViewDndMenu } from "@/components/niko-table/components/data-table-view-dnd-menu"43import { DataTable } from "@/components/niko-table/core/data-table"44import { useDataTable } from "@/components/niko-table/core/data-table-context"45import { DataTableColumnResize } from "@/components/niko-table/components/data-table-column-resize"46import { DataTableRoot } from "@/components/niko-table/core/data-table-root"47import {48 DataTableVirtualizedBody,49 DataTableVirtualizedHeader,50} from "@/components/niko-table/core/data-table-virtualized-structure"51import { useColumnHeaderContext } from "@/components/niko-table/components/data-table-column-header"52import type { CellEditorProps } from "@/components/niko-table/grid/cells/cell-props"53import { GridCheckboxCell } from "@/components/niko-table/grid/cells/grid-checkbox-cell"54import { GridDateCell } from "@/components/niko-table/grid/cells/grid-date-cell"55import { GridSelectCell } from "@/components/niko-table/grid/cells/grid-select-cell"56import {57 GridNumberCell,58 GridTextCell,59} from "@/components/niko-table/grid/cells/grid-text-cell"60import { DataGridAddColumnButton } from "@/components/niko-table/grid/components/grid-add-column"61import { DataGridClipboard } from "@/components/niko-table/grid/components/grid-clipboard"62import { GridColumnMenuOptions } from "@/components/niko-table/grid/components/grid-column-menu"63import { DataGridCrossHighlight } from "@/components/niko-table/grid/components/grid-cross-highlight"64import { DataGridFillHandle } from "@/components/niko-table/grid/components/grid-fill-handle"65import { DataGridMove } from "@/components/niko-table/grid/components/grid-move"66import { GridRowMenu } from "@/components/niko-table/grid/components/grid-row-menu"67import {68 DataGridRowReorder,69 useDataGridRowReorder,70} from "@/components/niko-table/grid/components/grid-row-reorder"71import { DataGridShortcutsButton } from "@/components/niko-table/grid/components/grid-shortcuts-dialog"72import { DataGridStatusBar } from "@/components/niko-table/grid/components/grid-status-bar"73import {74 DataGridAddRows,75 DataGridClearAll,76 DataGridPasteHint,77 DataGridRedo,78 DataGridToolbar,79 DataGridUndo,80} from "@/components/niko-table/grid/components/grid-toolbar"81import { DataGrid } from "@/components/niko-table/grid/core/data-grid"82import { DataGridColumns } from "@/components/niko-table/grid/core/data-grid-columns-context"83import {84 DataGridCell,85 useDataGridContext,86} from "@/components/niko-table/grid/core/data-grid-context"87import { useDataGrid } from "@/components/niko-table/grid/hooks/use-data-grid"88import {89 useGridColumns,90 type GridColumnSpec,91} from "@/components/niko-table/grid/hooks/use-grid-columns"92import type {93 CellState,94 GridRow,95} from "@/components/niko-table/grid/types/grid-cell"96import {97 FILTER_VARIANTS,98 SYSTEM_COLUMN_IDS,99} from "@/components/niko-table/lib/constants"100import type { DataTableColumnDef } from "@/components/niko-table/types"101import { cn } from "@/lib/utils"102import { ChevronRight, GripVertical, Plus, Trash2 } from "lucide-react"103import * as React from "react"104
105function formatPos(106 pos: { rowId: string; columnId: string } | null | undefined,107): string {108 if (!pos) return "None"109 return `${pos.rowId} / ${pos.columnId}`110}111
112function formatSearch(globalFilter: unknown): string {113 if (typeof globalFilter === "string") return globalFilter || "None"114 if (115 typeof globalFilter === "object" &&116 globalFilter &&117 "filters" in globalFilter118 ) {119 const filterObj = globalFilter as {120 filters: unknown[]121 joinOperator: string122 }123 return `OR Filter (${filterObj.filters?.length || 0} conditions)`124 }125 return "None"126}127
128// ---------------------------------------------------------------------------129// Deterministic sample data (no Math.random → no hydration mismatch).130// ---------------------------------------------------------------------------131const FIRST = ["Bailey", "Olivia", "Noah", "Mia", "Liam", "Emma", "Ava", "Kai"]132const LAST = ["Kohler", "Hansen", "Reilly", "Nolan", "Abbott", "Lynch"]133const TEAMS = ["Falcons", "Titans", "Rovers", "Wolves", "Hawks", "Comets"]134const VENUES = ["Central Arena", "Riverside Hall", "North Gym", "Summit Center"]135const STATUSES = ["Draft", "Ready", "Needs review", "Conflict"]136
137const TEAM_OPTIONS = TEAMS.map(t => ({ label: t, value: t }))138const STATUS_OPTIONS = STATUSES.map(s => ({ label: s, value: s }))139
140const pad = (n: number, w = 2) => String(n).padStart(w, "0")141const pick = <T,>(arr: readonly T[], i: number) => arr[i % arr.length]!142
143interface Column {144 id: string145 header: string146 width: number147 gen: (i: number) => string148 type?: "text" | "select" | "number" | "date" | "checkbox"149 options?: readonly string[]150}151
152const COLUMNS: Column[] = [153 {154 id: "firstName",155 header: "First Name",156 width: 140,157 gen: i => pick(FIRST, i),158 },159 { id: "lastName", header: "Last Name", width: 140, gen: i => pick(LAST, i) },160 {161 id: "email",162 header: "Email",163 width: 240,164 gen: i =>165 `${pick(FIRST, i).toLowerCase()}.${pick(LAST, i).toLowerCase()}${i}@example.com`,166 },167 { id: "homeTeam", header: "Home Team", width: 140, gen: i => pick(TEAMS, i) },168 {169 id: "awayTeam",170 header: "Away Team",171 width: 140,172 gen: i => pick(TEAMS, i + 3),173 },174 { id: "venue", header: "Venue", width: 160, gen: i => pick(VENUES, i) },175 {176 id: "date",177 header: "Date",178 width: 150,179 type: "date",180 gen: i => `2026-${pad(1 + (i % 12))}-${pad(1 + (i % 28))}`,181 },182 {183 id: "fee",184 header: "Fee",185 width: 110,186 type: "number",187 gen: i => String(25 + (i % 40)),188 },189 {190 id: "confirmed",191 header: "Confirmed",192 width: 120,193 type: "checkbox",194 gen: i => (i % 3 === 0 ? "true" : "false"),195 },196 {197 id: "status",198 header: "Status",199 width: 160,200 type: "select",201 options: STATUSES,202 gen: i => pick(STATUSES, i),203 },204]205
206const INITIAL_COLUMN_SPECS: GridColumnSpec[] = COLUMNS.map(c => ({207 id: c.id,208 label: c.header,209 type: c.type ?? "text",210 width: c.width,211 ...(c.options ? { options: c.options } : {}),212}))213
214const DEFAULT_ROW_COUNT = 500215const MAX_ROWS = 500_000216const ROW_COUNT_OPTIONS = [500, 10_000, 100_000, 500_000] as const217const ROW_HEIGHT = 37218
219/**220 * Tight cell matrix. Skip `border-r` on the pinned select gutter — the pin221 * separator already draws the edge; a second rule misaligns vs the header.222 */223const gridCellClassName = (_row: GridRow, columnId: string) =>224 cn(225 "p-0 align-middle",226 columnId !== SYSTEM_COLUMN_IDS.SELECT &&227 "border-border border-r last:border-r-0",228 )229
230/** Gutter width — grip (14) + checkbox (16) + padding (8) + breathing room. */231const GUTTER_SIZE = 52232
233/**234 * Exact same chrome for header + body: fill the sticky cell (ignores TableHead235 * `px-2`), reserved grip column, then a fixed-size checkbox slot so select-all236 * and per-row checkboxes share one x-coordinate. Pin separator alone draws the237 * right edge (no `border-r` on this column).238 */239function GutterFrame({ children }: { children: React.ReactNode }) {240 return (241 <div className="absolute inset-0 flex items-center gap-0.5 px-1">242 {children}243 </div>244 )245}246
247const TRUTHY = ["true", "1", "yes"]248
249/** Resolve a raw string into a CellState against a column's type. */250function resolveCellFor(251 col: { type?: string; options?: readonly string[] } | undefined,252 raw: string,253): CellState<string> {254 const trimmed = raw.trim()255 switch (col?.type) {256 case "select": {257 const match = col.options?.includes(raw) ?? false258 return {259 raw,260 value: match ? raw : null,261 status: raw === "" ? "empty" : match ? "valid" : "invalid",262 }263 }264 case "number": {265 if (trimmed === "") return { raw, value: null, status: "empty" }266 const ok = Number.isFinite(Number(trimmed))267 return {268 raw,269 value: ok ? trimmed : null,270 status: ok ? "valid" : "invalid",271 }272 }273 case "date": {274 if (trimmed === "") return { raw, value: null, status: "empty" }275 const ok = /^\d{4}-\d{2}-\d{2}$/.test(trimmed)276 return {277 raw,278 value: ok ? trimmed : null,279 status: ok ? "valid" : "invalid",280 }281 }282 case "checkbox": {283 const isTrue = TRUTHY.includes(trimmed.toLowerCase())284 return { raw: String(isTrue), value: String(isTrue), status: "valid" }285 }286 default:287 return { raw, value: raw || null, status: raw === "" ? "empty" : "valid" }288 }289}290
291const usd = new Intl.NumberFormat("en-US", {292 style: "currency",293 currency: "USD",294})295const COLUMN_FORMATTERS: Record<string, (raw: string) => string> = {296 fee: raw => {297 const n = Number(raw)298 return Number.isFinite(n) ? usd.format(n) : raw299 },300}301
302// New/empty rows carry no cell keys — a missing value renders empty.303const createEmptyRow = (id: string): GridRow => ({ id })304
305function makeRows(count: number): GridRow[] {306 return Array.from({ length: count }, (_, i) => {307 const row: GridRow = { id: `row-${i}` }308 for (const col of COLUMNS) row[col.id] = resolveCellFor(col, col.gen(i))309 return row310 })311}312
313const INITIAL_ROWS = makeRows(DEFAULT_ROW_COUNT)314
315/** Gutter cell: click row number to select; hover shows checkbox; grip for reorder. */316function GutterCell({317 rowId,318 isSelected,319}: {320 rowId: string321 isSelected: boolean322}) {323 const { selectRow, displayIndexOf } = useDataGridContext<GridRow>()324 const { toggleRowSelection } = useDataTable<GridRow>()325 const rowReorder = useDataGridRowReorder()326 const shiftRef = React.useRef(false)327 const isDragging = rowReorder?.draggingRowId === rowId328 // Display order (post sort/filter) — never TanStack source `row.index`.329 const displayIndex = displayIndexOf(rowId) ?? 0330 return (331 <div332 onClick={() => selectRow(rowId)}333 onKeyDown={e => {334 if (e.target !== e.currentTarget) return335 if (e.key === "Enter" || e.key === " ") {336 e.preventDefault()337 selectRow(rowId)338 }339 }}340 role="button"341 tabIndex={0}342 aria-label={`Select row ${displayIndex + 1}`}343 // No positioned wrapper — GutterFrame anchors to the sticky td padding344 // box so header + body share the same 52px coordinate space.345 className={cn(346 "block h-9 w-full cursor-pointer group-data-[active-row=true]:bg-primary/15",347 isDragging && "opacity-50",348 )}349 >350 <GutterFrame>351 <div className="flex size-3.5 shrink-0 items-center justify-center">352 {rowReorder ? (353 <button354 type="button"355 title="Drag to reorder"356 aria-label={`Drag to reorder row ${displayIndex + 1}`}357 onClick={e => e.stopPropagation()}358 onMouseDown={e => rowReorder.onRowReorderMouseDown(e, rowId)}359 className="flex size-3.5 cursor-grab items-center justify-center text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground focus-visible:opacity-100 active:cursor-grabbing"360 >361 <GripVertical className="size-3.5" />362 </button>363 ) : null}364 </div>365 {/* Fixed w-4 — same slot as header select-all checkbox. */}366 <div className="relative flex size-4 shrink-0 items-center justify-center">367 <span className="text-xs text-muted-foreground tabular-nums group-hover:invisible group-data-[active-row=true]:font-semibold group-data-[active-row=true]:text-foreground group-data-[state=selected]:invisible">368 {displayIndex + 1}369 </span>370 <span371 onPointerDown={e => {372 shiftRef.current = e.shiftKey373 }}374 onClick={e => e.stopPropagation()}375 className="absolute inset-0 hidden items-center justify-center group-hover:flex group-data-[state=selected]:flex"376 >377 <Checkbox378 checked={isSelected}379 onCheckedChange={() =>380 toggleRowSelection(rowId, shiftRef.current)381 }382 aria-label={`Toggle row ${displayIndex + 1}`}383 />384 </span>385 </div>386 </GutterFrame>387 </div>388 )389}390
391/** Search + faceted + advanced filters — same pattern as the homepage live demo. */392function FilterToolbar() {393 return (394 <DataTableToolbarSection className="w-full flex-col justify-between gap-2 px-0">395 <DataTableToolbarSection className="px-0">396 <DataTableSearchFilter placeholder="Search rows..." />397 </DataTableToolbarSection>398 <DataTableToolbarSection className="flex-wrap px-0">399 <DataTableFacetedFilter400 accessorKey="homeTeam"401 title="Home Team"402 options={TEAM_OPTIONS}403 multiple404 />405 <DataTableFacetedFilter406 accessorKey="status"407 title="Status"408 options={STATUS_OPTIONS}409 multiple410 />411 <DataTableSortMenu />412 <DataTableFilterMenu />413 <DataTableClearFilter />414 </DataTableToolbarSection>415 </DataTableToolbarSection>416 )417}418
419/** Row-selection bar: appears when checkboxes are ticked. */420function RowSelectionBar() {421 const { table } = useDataTable<GridRow>()422 const { grid } = useDataGridContext<GridRow>()423 const selected = table.getSelectedRowModel().rows424 return (425 <DataTableSelectionBar426 selectedCount={selected.length}427 onClear={() => table.resetRowSelection()}428 >429 <Button430 variant="destructive"431 size="sm"432 onClick={() => {433 grid.removeRows(selected.map(r => r.id))434 table.resetRowSelection()435 }}436 >437 <Trash2 className="size-4" />438 Delete rows439 </Button>440 </DataTableSelectionBar>441 )442}443
444/** Header title that selects the whole column on click (sort stays in the ⋯ menu). */445function SelectableColumnTitle() {446 const { column } = useColumnHeaderContext(true)447 const { selectColumn } = useDataGridContext<GridRow>()448 return (449 <button450 type="button"451 onClick={() => selectColumn(column.id)}452 title="Select column"453 className="-mx-1 block min-w-0 max-w-full cursor-pointer rounded px-1 text-left transition-colors hover:bg-muted"454 >455 <DataTableColumnTitle />456 </button>457 )458}459
460/** Cell dispatcher: picks the editor by the column's type. The grid stays461 * type-agnostic — this palette is consumer-owned. */462function GridCellByType({463 col,464 resolveCell,465 ...p466}: CellEditorProps & {467 col: GridColumnSpec468 resolveCell: (columnId: string, raw: string) => CellState<string>469}) {470 switch (col.type) {471 case "select":472 return (473 <GridSelectCell474 {...p}475 options={(col.options ?? []).map(o => ({ label: o, value: o }))}476 placeholder="Select…"477 displayLabel={p.cell.value ?? undefined}478 />479 )480 case "number":481 return (482 <GridNumberCell483 {...p}484 resolve={raw => resolveCell(col.id, raw)}485 placeholder={col.label}486 format={COLUMN_FORMATTERS[col.id]}487 />488 )489 case "date":490 return <GridDateCell {...p} placeholder={col.label} />491 case "checkbox":492 return <GridCheckboxCell {...p} aria-label={col.label} />493 default:494 return (495 <GridTextCell496 {...p}497 resolve={raw => resolveCell(col.id, raw)}498 placeholder={col.label}499 />500 )501 }502}503
504export function GridAllFeatures() {505 const [resetKey, setResetKey] = React.useState(0)506 return (507 <GridAllFeaturesInner508 key={resetKey}509 onReset={() => setResetKey(k => k + 1)}510 />511 )512}513
514/** Live state card — table chrome + grid engine. Must render inside DataTableRoot. */515function CurrentTableStatePanel({516 grid,517 columnCount,518 onReset,519}: {520 grid: ReturnType<typeof useDataGrid<GridRow>>521 columnCount: number522 onReset: () => void523}) {524 const { table } = useDataTable<GridRow>()525 const state = table.getState()526 const sorting = state.sorting527 const columnFilters = state.columnFilters528 const columnVisibility = state.columnVisibility529 const columnPinning = state.columnPinning530 const rowSelection = state.rowSelection531 const globalFilter = state.globalFilter532 const selectedCount = Object.values(rowSelection).filter(Boolean).length533 const hiddenCount = Object.values(columnVisibility).filter(534 v => v === false,535 ).length536
537 return (538 <Card>539 <CardHeader>540 <CardTitle>Current Table State</CardTitle>541 <CardDescription>542 Live view of table + grid state for demonstration543 </CardDescription>544 <CardAction>545 <Button variant="outline" size="sm" onClick={onReset}>546 Reset All State547 </Button>548 </CardAction>549 </CardHeader>550 <CardContent className="space-y-4">551 <div className="grid gap-2 text-xs text-muted-foreground">552 <div className="flex justify-between">553 <span className="font-medium">Search Query:</span>554 <span className="text-foreground">555 {formatSearch(globalFilter)}556 </span>557 </div>558 <div className="flex justify-between">559 <span className="font-medium">Total Rows:</span>560 <span className="text-foreground">{grid.rows.length}</span>561 </div>562 <div className="flex justify-between">563 <span className="font-medium">Selected Rows:</span>564 <span className="text-foreground">{selectedCount}</span>565 </div>566 <div className="flex justify-between">567 <span className="font-medium">Active Filters:</span>568 <span className="text-foreground">{columnFilters.length}</span>569 </div>570 <div className="flex justify-between">571 <span className="font-medium">Sorting:</span>572 <span className="text-foreground">573 {sorting.length > 0574 ? sorting575 .map(s => `${s.id} ${s.desc ? "desc" : "asc"}`)576 .join(", ")577 : "None"}578 </span>579 </div>580 <div className="flex justify-between">581 <span className="font-medium">Hidden Columns:</span>582 <span className="text-foreground">{hiddenCount}</span>583 </div>584 <div className="flex justify-between">585 <span className="font-medium">Pinned Columns:</span>586 <span className="text-foreground">587 {columnPinning.left?.length || 0} Left,{" "}588 {columnPinning.right?.length || 0} Right589 </span>590 </div>591 <div className="flex justify-between">592 <span className="font-medium">Dynamic Columns:</span>593 <span className="text-foreground">{columnCount}</span>594 </div>595 <div className="flex justify-between">596 <span className="font-medium">Focused Cell:</span>597 <span className="text-foreground">598 {formatPos(grid.focusedCell)}599 </span>600 </div>601 <div className="flex justify-between">602 <span className="font-medium">Editing Cell:</span>603 <span className="text-foreground">604 {formatPos(grid.editingCell)}605 </span>606 </div>607 <div className="flex justify-between">608 <span className="font-medium">Selection Anchor:</span>609 <span className="text-foreground">610 {formatPos(grid.selectionAnchor)}611 </span>612 </div>613 <div className="flex justify-between">614 <span className="font-medium">Can Undo / Redo:</span>615 <span className="text-foreground">616 {grid.canUndo ? "Yes" : "No"} / {grid.canRedo ? "Yes" : "No"}617 </span>618 </div>619 <div className="flex justify-between">620 <span className="font-medium">Last Commit:</span>621 <span className="text-foreground">622 {grid.lastCommit623 ? `${grid.lastCommit.kind} (seq ${grid.lastCommit.seq})`624 : "None"}625 </span>626 </div>627 </div>628
629 <Collapsible>630 <CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground [&[data-state=open]>svg]:rotate-90">631 <ChevronRight className="size-3.5 transition-transform" />632 View Full State Object633 </CollapsibleTrigger>634 <CollapsibleContent>635 <pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-[10px] leading-relaxed">636 {JSON.stringify(637 {638 focusedCell: grid.focusedCell,639 editingCell: grid.editingCell,640 selectionAnchor: grid.selectionAnchor,641 canUndo: grid.canUndo,642 canRedo: grid.canRedo,643 lastCommit: grid.lastCommit,644 rowCount: grid.rows.length,645 columnCount,646 globalFilter,647 sorting,648 columnFilters,649 columnVisibility,650 columnPinning,651 rowSelection,652 },653 null,654 2,655 )}656 </pre>657 </CollapsibleContent>658 </Collapsible>659 </CardContent>660 </Card>661 )662}663
664function GridAllFeaturesInner({ onReset }: { onReset: () => void }) {665 const cols = useGridColumns({ initialColumns: INITIAL_COLUMN_SPECS })666 const columnById = React.useMemo(667 () => Object.fromEntries(cols.columns.map(c => [c.id, c])),668 [cols.columns],669 )670 const resolveCell = React.useCallback(671 (columnId: string, raw: string) =>672 resolveCellFor(columnById[columnId], raw),673 [columnById],674 )675
676 const grid = useDataGrid<GridRow>({677 columnIds: cols.columnIds,678 createEmptyRow,679 maxRows: MAX_ROWS,680 initialRows: INITIAL_ROWS,681 })682 const [shortcutsOpen, setShortcutsOpen] = React.useState(false)683
684 // Stress test — regenerate the whole dataset to feel the grid at scale.685 const [genMs, setGenMs] = React.useState<number | null>(null)686 const rowCount = grid.rows.length687 const regenerate = React.useCallback(688 (count: number) => {689 const t0 = performance.now()690 const rows = makeRows(count)691 setGenMs(Math.round(performance.now() - t0))692 grid.updateRows(() => rows)693 if (rows[0]) {694 grid.selectCell({ rowId: rows[0].id, columnId: cols.columnIds[0]! })695 }696 },697 [grid, cols.columnIds],698 )699
700 const columns = React.useMemo<DataTableColumnDef<GridRow>[]>(() => {701 // Leading gutter — uses the system SELECT id so it auto-enables row702 // selection AND pins left (stays put on horizontal scroll).703 const gutter: DataTableColumnDef<GridRow> = {704 id: SYSTEM_COLUMN_IDS.SELECT,705 size: GUTTER_SIZE,706 minSize: GUTTER_SIZE,707 maxSize: GUTTER_SIZE,708 enableSorting: false,709 enableHiding: false,710 enableResizing: false,711 header: ({ table }) => (712 // Direct child of sticky th — `absolute inset-0` fills the padding box713 // (full column width), not the inner content box after TableHead `pl-2`.714 <GutterFrame>715 <div className="size-3.5 shrink-0" aria-hidden />716 <div className="flex size-4 shrink-0 items-center justify-center">717 <Checkbox718 checked={719 table.getIsAllRowsSelected()720 ? true721 : table.getIsSomeRowsSelected()722 ? "indeterminate"723 : false724 }725 onCheckedChange={v => table.toggleAllRowsSelected(!!v)}726 aria-label="Select all rows"727 />728 </div>729 </GutterFrame>730 ),731 cell: ctx => (732 <GutterCell rowId={ctx.row.id} isSelected={ctx.row.getIsSelected()} />733 ),734 }735
736 const dataColumns = cols.columns.map(737 (col): DataTableColumnDef<GridRow> => ({738 id: col.id,739 accessorFn: (row: GridRow) => {740 const v = row[col.id]741 return typeof v === "object" && v != null ? v.raw : ""742 },743 header: () => (744 <DataTableColumnHeader745 onContextMenu={e => {746 // Right-click the header → open the same ⋯ column menu.747 e.preventDefault()748 e.currentTarget749 .closest('[data-slot="table-head"]')750 ?.querySelector<HTMLElement>(751 '[data-slot="dropdown-menu-trigger"]',752 )753 ?.click()754 }}755 >756 <SelectableColumnTitle />757 <DataTableColumnActions>758 <DataTableColumnSortOptions />759 <DataTableColumnPinOptions />760 <DataTableColumnHideOptions />761 <GridColumnMenuOptions />762 </DataTableColumnActions>763 </DataTableColumnHeader>764 ),765 size: col.width,766 enableSorting: true,767 enableColumnFilter: true,768 meta: {769 variant:770 col.id === "homeTeam" || col.id === "status"771 ? FILTER_VARIANTS.MULTI_SELECT772 : col.type === "select"773 ? FILTER_VARIANTS.SELECT774 : col.type === "number"775 ? FILTER_VARIANTS.NUMBER776 : col.type === "date"777 ? FILTER_VARIANTS.DATE778 : col.type === "checkbox"779 ? FILTER_VARIANTS.BOOLEAN780 : FILTER_VARIANTS.TEXT,781 label: col.label,782 ...(col.id === "homeTeam"783 ? { options: TEAM_OPTIONS }784 : col.id === "status"785 ? { options: STATUS_OPTIONS }786 : col.options787 ? {788 options: col.options.map(o => ({ label: o, value: o })),789 }790 : {}),791 },792 cell: ctx => (793 <DataGridCell row={ctx.row.original} columnId={col.id}>794 {(p: CellEditorProps) => (795 <GridCellByType col={col} resolveCell={resolveCell} {...p} />796 )}797 </DataGridCell>798 ),799 }),800 )801
802 return [gutter, ...dataColumns]803 }, [cols.columns, resolveCell])804
805 return (806 <div className="w-full min-w-0 space-y-4">807 <div className="flex flex-wrap items-center gap-2">808 <span className="text-sm text-muted-foreground">Stress test:</span>809 {ROW_COUNT_OPTIONS.map(count => (810 <button811 key={count}812 type="button"813 onClick={() => regenerate(count)}814 className={cn(815 "rounded-md border border-border px-3 py-1.5 text-sm tabular-nums transition-colors",816 count === rowCount817 ? "border-primary bg-primary text-primary-foreground"818 : "bg-background hover:bg-muted",819 )}820 >821 {count.toLocaleString()} rows822 </button>823 ))}824 {genMs != null && (825 <span className="text-xs text-muted-foreground tabular-nums">826 built {rowCount.toLocaleString()} rows in {genMs}ms827 </span>828 )}829 </div>830 <DataTableRoot831 data={grid.rows}832 columns={columns}833 getRowId={r => r.id}834 initialState={{ columnPinning: { left: [SYSTEM_COLUMN_IDS.SELECT] } }}835 >836 <DataGrid grid={grid} onRequestShortcuts={() => setShortcutsOpen(true)}>837 {/* Opt-in features — mix and match. */}838 <DataGridClipboard resolveCell={resolveCell} />839 <DataGridFillHandle />840 <DataGridMove />841 <DataGridCrossHighlight />842 <DataGridRowReorder />843 <DataTableColumnResize />844 <DataGridColumns value={cols}>845 <div className="space-y-3">846 <FilterToolbar />847 <DataGridToolbar className="justify-between gap-x-2 gap-y-3">848 <div className="flex flex-wrap items-center gap-2">849 <DataGridUndo />850 <DataGridRedo />851 <DataGridAddRows count={5} />852 <DataGridAddColumnButton />853 <DataTableViewDndMenu854 columnOrder={cols.columnIds}855 onColumnOrderChange={cols.reorderColumns}856 />857 <DataGridClearAll />858 </div>859 <div className="flex flex-wrap items-center gap-2">860 <DataTableExportButton861 filename="grid-export"862 useHeaderLabels863 excludeColumns={[SYSTEM_COLUMN_IDS.SELECT]}864 />865 <DataGridShortcutsButton866 open={shortcutsOpen}867 onOpenChange={setShortcutsOpen}868 />869 <DataGridPasteHint />870 </div>871 </DataGridToolbar>872 <div>873 <DataTable maxHeight={520}>874 <DataTableVirtualizedHeader />875 <DataTableVirtualizedBody<GridRow>876 estimateSize={ROW_HEIGHT}877 fixedRowHeight878 getCellClassName={gridCellClassName}879 >880 <DataTableRowContextMenuSlot>881 <GridRowMenu />882 </DataTableRowContextMenuSlot>883 </DataTableVirtualizedBody>884 </DataTable>885 <button886 type="button"887 onClick={() => {888 const created = grid.addRows(1)889 // Focus the new row so the grid scrolls to it (scroll follows890 // focus) and it's ready for typing.891 const first = created[0]892 if (first && cols.columnIds[0]) {893 grid.selectCell({894 rowId: first.id,895 columnId: cols.columnIds[0],896 })897 }898 }}899 disabled={grid.rows.length >= grid.maxRows}900 className="flex w-full items-center gap-1.5 border border-t-0 border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"901 >902 <Plus className="size-4" />903 Add row904 </button>905 </div>906 <RowSelectionBar />907 <DataGridStatusBar />908 </div>909 </DataGridColumns>910 </DataGrid>911 <CurrentTableStatePanel912 grid={grid}913 columnCount={cols.columns.length}914 onReset={onReset}915 />916 </DataTableRoot>917 </div>918 )919}Installation
Section titled “Installation”pnpm dlx shadcn@latest add @niko-table/data-table-grid @niko-table/data-table-grid-changes @niko-table/data-table-view-dnd-menu @niko-table/data-table-faceted-filter @niko-table/data-table-search-filter @niko-table/data-table-sort-menuAlso needs shadcn primitives used by editors and menus:
pnpm dlx shadcn@latest add calendar popover command button checkbox dropdown-menuFirst time using
@niko-table? See the Installation Guide.
What’s included
Section titled “What’s included”| Area | Pieces |
|---|---|
| Core | useDataGrid, <DataGrid>, <DataGridCell> |
| Editors | text, number, checkbox, date, select |
| Features | clipboard, fill, move, cross-highlight, status bar, row reorder |
| Table overlays | search, faceted filter, sort, view menu, column resize |
| Dynamic columns | useGridColumns + add/menu |
| Persistence | useGridChanges + Current Table State panel |
- Basic Grid: minimal starting point (reference example shape)
- Cell Types · Validation
- Dynamic Columns · Persistence
- Components · API Reference
- Documentation Guidelines