Validation
Per-field and cross-field validation. Zod or any resolve. Errors inline on the cell.
The grid is validation-agnostic. Your resolve returns status + optional error. Invalid cells show a tooltip. No summary block.
Hover a red cell for the reason. Try a bad date (2026-13-02), a 12-hour time, or an end time before the start.
| Date * | Start Time * | End Time * | Venue |
|---|
1"use client"2
3/**4 * niko-table/grid — validation with Zod.5 *6 * The grid is validation-agnostic: every cell carries a `status`7 * ("valid" | "invalid" | "empty") and an optional `error` string, and YOU decide8 * them in one `resolve(columnId, raw)` function. That's the seam where Zod runs.9 *10 * Two layers, both inline (no summary block):11 * 1. Per-cell (field) — a Zod schema per column inside `resolve`. A bad value12 * goes red and shows its message in a tooltip on hover/focus.13 * 2. Cross-field (row) — a rule spanning cells (here: end time > start time),14 * recomputed after every commit and folded onto the offending cell.15 *16 * On Save, the same schema that powers the cells validates the change-set — so17 * the grid can't submit anything your backend would reject (see the Persistence18 * example for the save half).19 */20import { Badge } from "@/components/ui/badge"21import { DataTable } from "@/components/niko-table/core/data-table"22import { DataTableColumnResize } from "@/components/niko-table/components/data-table-column-resize"23import { DataTableRoot } from "@/components/niko-table/core/data-table-root"24import {25 DataTableVirtualizedBody,26 DataTableVirtualizedHeader,27} from "@/components/niko-table/core/data-table-virtualized-structure"28import type { CellEditorProps } from "@/components/niko-table/grid/cells/cell-props"29import { GridTextCell } from "@/components/niko-table/grid/cells/grid-text-cell"30import { DataGridClipboard } from "@/components/niko-table/grid/components/grid-clipboard"31import { DataGridFillHandle } from "@/components/niko-table/grid/components/grid-fill-handle"32import {33 DataGridAddRows,34 DataGridPasteHint,35 DataGridToolbar,36} from "@/components/niko-table/grid/components/grid-toolbar"37import { DataGrid } from "@/components/niko-table/grid/core/data-grid"38import { DataGridCell } from "@/components/niko-table/grid/core/data-grid-context"39import { useDataGrid } from "@/components/niko-table/grid/hooks/use-data-grid"40import type {41 CellState,42 GridRow,43} from "@/components/niko-table/grid/types/grid-cell"44import type { DataTableColumnDef } from "@/components/niko-table/types"45import * as React from "react"46import { z } from "zod"47
48const COLUMN_IDS = ["date", "start", "end", "venue"] as const49type ColumnId = (typeof COLUMN_IDS)[number]50
51const COLUMNS: {52 id: ColumnId53 label: string54 placeholder: string55 required: boolean56}[] = [57 { id: "date", label: "Date", placeholder: "YYYY-MM-DD", required: true },58 { id: "start", label: "Start Time", placeholder: "HH:MM", required: true },59 { id: "end", label: "End Time", placeholder: "HH:MM", required: true },60 { id: "venue", label: "Venue", placeholder: "Optional", required: false },61]62const COLUMN_BY_ID = Object.fromEntries(COLUMNS.map(c => [c.id, c])) as Record<63 ColumnId,64 (typeof COLUMNS)[number]65>66
67// --- Layer 1: a Zod field schema per column ----------------------------------68const isRealDate = (s: string) => {69 const [y, m, d] = s.split("-").map(Number)70 const dt = new Date(Date.UTC(y!, m! - 1, d!))71 return (72 dt.getUTCFullYear() === y &&73 dt.getUTCMonth() === m! - 1 &&74 dt.getUTCDate() === d75 )76}77const FIELD_SCHEMAS: Record<ColumnId, z.ZodType<string>> = {78 date: z79 .string()80 .regex(/^\d{4}-\d{2}-\d{2}$/, "Use the format YYYY-MM-DD")81 .refine(isRealDate, "That date doesn't exist on the calendar"),82 start: z83 .string()84 .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour time, e.g. 09:30"),85 end: z86 .string()87 .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour time, e.g. 17:00"),88 venue: z.string(),89}90
91// The one function the grid asks you for. Empty required cells read as "empty"92// (neutral until you try to save); a filled-but-bad value is "invalid" + reason.93function resolveCell(columnId: string, raw: string): CellState<string> {94 const trimmed = raw.trim()95 const col = COLUMN_BY_ID[columnId as ColumnId]96 if (trimmed === "") {97 return { raw, value: null, status: col?.required ? "empty" : "valid" }98 }99 const result = FIELD_SCHEMAS[columnId as ColumnId].safeParse(trimmed)100 return result.success101 ? { raw, value: result.data, status: "valid" }102 : {103 raw,104 value: null,105 status: "invalid",106 error: result.error.issues[0]!.message,107 }108}109
110// --- Layer 2: a cross-field rule (end must be after start) --------------------111// Runs against a row's already-resolved start/end cells; returns the message to112// pin on the `end` cell, or null when the pair is fine or not yet both valid.113function crossFieldEndError(row: GridRow): string | null {114 const start = row.start115 const end = row.end116 if (typeof start !== "object" || typeof end !== "object") return null117 if (start?.status !== "valid" || end?.status !== "valid") return null118 return (end.value ?? "") > (start.value ?? "")119 ? null120 : "End time must be after the start time"121}122
123const cell = (columnId: ColumnId, raw: string) => resolveCell(columnId, raw)124const INITIAL_ROWS: GridRow[] = [125 {126 id: "r1",127 date: cell("date", "2026-07-20"),128 start: cell("start", "09:00"),129 end: cell("end", "10:30"),130 venue: cell("venue", "Rink A"),131 },132 // Seeds that arrive invalid, so the red state + tooltips show immediately:133 {134 id: "r2",135 date: cell("date", "2026-13-02"),136 start: cell("start", "18:00"),137 end: cell("end", "19:00"),138 venue: cell("venue", ""),139 },140 {141 id: "r3",142 date: cell("date", "2026-07-21"),143 start: cell("start", "20:00"),144 end: cell("end", "19:15"),145 venue: cell("venue", "Rink B"),146 },147]148
149const createEmptyRow = (id: string): GridRow => ({ id })150const gridCellClassName = () =>151 "border-border border-r p-0 align-middle last:border-r-0"152
153const rawOf = (row: GridRow, id: ColumnId) => {154 const v = row[id]155 return typeof v === "object" && v != null ? v.raw : ""156}157
158export function GridValidation() {159 const grid = useDataGrid<GridRow>({160 columnIds: COLUMN_IDS,161 createEmptyRow,162 initialRows: INITIAL_ROWS,163 })164
165 // Re-run the cross-field rule after every commit and fold its result onto the166 // `end` cell. Field-level resolution wins (a malformed time stays malformed);167 // otherwise the cross-field message is overlaid.168 //169 // Two things keep this correct:170 // - Return the SAME row refs when nothing changed. The engine treats a171 // new array of identical element refs as a no-op (no `lastCommit` bump),172 // so the settle converges and never loops.173 // - `{ history: false }` — re-settling derived state must NOT push an undo174 // entry, or Ctrl+Z would step through the settle instead of the user's175 // actual edit (and a settle right after an undo could drop the redo stack).176 React.useEffect(() => {177 if (!grid.lastCommit) return178 grid.updateRows(179 rows =>180 rows.map(row => {181 const end = row.end182 if (typeof end !== "object" || end == null) return row183 const base = resolveCell("end", end.raw)184 const crossErr = crossFieldEndError(row)185 const next =186 crossErr && base.status !== "invalid"187 ? { ...base, status: "invalid" as const, error: crossErr }188 : base189 if (next.status === end.status && next.error === end.error) return row190 return { ...row, end: next }191 }),192 { history: false },193 )194 }, [grid.lastCommit, grid])195
196 const invalidCount = React.useMemo(197 () =>198 grid.rows.filter(row =>199 COLUMN_IDS.some(id => {200 const c = row[id]201 return typeof c === "object" && c?.status === "invalid"202 }),203 ).length,204 [grid.rows],205 )206
207 const columns = React.useMemo<DataTableColumnDef<GridRow>[]>(208 () =>209 COLUMNS.map(col => ({210 id: col.id,211 accessorFn: (row: GridRow) => rawOf(row, col.id),212 header: () => (213 <span>214 {col.label}215 {col.required && (216 <span aria-hidden className="font-semibold text-destructive">217 {" "}218 *219 </span>220 )}221 </span>222 ),223 size: col.id === "venue" ? 200 : 170,224 meta: { label: col.label, required: col.required },225 cell: ctx => (226 <DataGridCell row={ctx.row.original} columnId={col.id}>227 {(p: CellEditorProps) => (228 <GridTextCell229 {...p}230 resolve={raw => resolveCell(col.id, raw)}231 placeholder={col.placeholder}232 />233 )}234 </DataGridCell>235 ),236 })),237 [],238 )239
240 return (241 <DataTableRoot data={grid.rows} columns={columns} getRowId={r => r.id}>242 <DataGrid grid={grid} className="space-y-2 outline-none">243 {/* Flex-fill + drag-resizable columns */}244 <DataTableColumnResize />245 <DataGridClipboard resolveCell={resolveCell} />246 <DataGridFillHandle />247 <DataGridToolbar>248 <DataGridAddRows count={1} />249 <DataGridPasteHint />250 <span className="ms-auto">251 {invalidCount > 0 ? (252 <Badge variant="destructive">253 {invalidCount} row{invalidCount === 1 ? "" : "s"} need fixing254 </Badge>255 ) : (256 <Badge variant="secondary">All rows valid</Badge>257 )}258 </span>259 </DataGridToolbar>260 <p className="px-1 text-sm text-muted-foreground">261 Hover a red cell for the reason. Try a bad date (2026-13-02), a262 12-hour time, or an end time before the start.263 </p>264 <DataTable maxHeight={320}>265 <DataTableVirtualizedHeader />266 <DataTableVirtualizedBody<GridRow>267 estimateSize={37}268 fixedRowHeight269 getCellClassName={gridCellClassName}270 />271 </DataTable>272 </DataGrid>273 </DataTableRoot>274 )275}Preview with Controlled State
Hover a red cell for the reason. Try a bad date (2026-13-02), a 12-hour time, or an end time before the start.
| Date * | Start Time * | End Time * | Venue |
|---|
1"use client"2
3/**4 * niko-table/grid — Zod validation with a live state panel for docs.5 */6import { Badge } from "@/components/ui/badge"7import { Button } from "@/components/ui/button"8import {9 Card,10 CardAction,11 CardContent,12 CardDescription,13 CardHeader,14 CardTitle,15} from "@/components/ui/card"16import {17 Collapsible,18 CollapsibleContent,19 CollapsibleTrigger,20} from "@/components/ui/collapsible"21import { DataTable } from "@/components/niko-table/core/data-table"22import { DataTableColumnResize } from "@/components/niko-table/components/data-table-column-resize"23import { DataTableRoot } from "@/components/niko-table/core/data-table-root"24import {25 DataTableVirtualizedBody,26 DataTableVirtualizedHeader,27} from "@/components/niko-table/core/data-table-virtualized-structure"28import type { CellEditorProps } from "@/components/niko-table/grid/cells/cell-props"29import { GridTextCell } from "@/components/niko-table/grid/cells/grid-text-cell"30import { DataGridClipboard } from "@/components/niko-table/grid/components/grid-clipboard"31import { DataGridFillHandle } from "@/components/niko-table/grid/components/grid-fill-handle"32import {33 DataGridAddRows,34 DataGridPasteHint,35 DataGridToolbar,36} from "@/components/niko-table/grid/components/grid-toolbar"37import { DataGrid } from "@/components/niko-table/grid/core/data-grid"38import { DataGridCell } from "@/components/niko-table/grid/core/data-grid-context"39import { useDataGrid } from "@/components/niko-table/grid/hooks/use-data-grid"40import type {41 CellState,42 GridRow,43} from "@/components/niko-table/grid/types/grid-cell"44import type { DataTableColumnDef } from "@/components/niko-table/types"45import { ChevronRight } from "lucide-react"46import * as React from "react"47import { z } from "zod"48
49const COLUMN_IDS = ["date", "start", "end", "venue"] as const50type ColumnId = (typeof COLUMN_IDS)[number]51
52const COLUMNS: {53 id: ColumnId54 label: string55 placeholder: string56 required: boolean57}[] = [58 { id: "date", label: "Date", placeholder: "YYYY-MM-DD", required: true },59 { id: "start", label: "Start Time", placeholder: "HH:MM", required: true },60 { id: "end", label: "End Time", placeholder: "HH:MM", required: true },61 { id: "venue", label: "Venue", placeholder: "Optional", required: false },62]63const COLUMN_BY_ID = Object.fromEntries(COLUMNS.map(c => [c.id, c])) as Record<64 ColumnId,65 (typeof COLUMNS)[number]66>67
68const isRealDate = (s: string) => {69 const [y, m, d] = s.split("-").map(Number)70 const dt = new Date(Date.UTC(y!, m! - 1, d!))71 return (72 dt.getUTCFullYear() === y &&73 dt.getUTCMonth() === m! - 1 &&74 dt.getUTCDate() === d75 )76}77const FIELD_SCHEMAS: Record<ColumnId, z.ZodType<string>> = {78 date: z79 .string()80 .regex(/^\d{4}-\d{2}-\d{2}$/, "Use the format YYYY-MM-DD")81 .refine(isRealDate, "That date doesn't exist on the calendar"),82 start: z83 .string()84 .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour time, e.g. 09:30"),85 end: z86 .string()87 .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour time, e.g. 17:00"),88 venue: z.string(),89}90
91function resolveCell(columnId: string, raw: string): CellState<string> {92 const trimmed = raw.trim()93 const col = COLUMN_BY_ID[columnId as ColumnId]94 if (trimmed === "") {95 return { raw, value: null, status: col?.required ? "empty" : "valid" }96 }97 const result = FIELD_SCHEMAS[columnId as ColumnId].safeParse(trimmed)98 return result.success99 ? { raw, value: result.data, status: "valid" }100 : {101 raw,102 value: null,103 status: "invalid",104 error: result.error.issues[0]!.message,105 }106}107
108function crossFieldEndError(row: GridRow): string | null {109 const start = row.start110 const end = row.end111 if (typeof start !== "object" || typeof end !== "object") return null112 if (start?.status !== "valid" || end?.status !== "valid") return null113 return (end.value ?? "") > (start.value ?? "")114 ? null115 : "End time must be after the start time"116}117
118const cell = (columnId: ColumnId, raw: string) => resolveCell(columnId, raw)119const INITIAL_ROWS: GridRow[] = [120 {121 id: "r1",122 date: cell("date", "2026-07-20"),123 start: cell("start", "09:00"),124 end: cell("end", "10:30"),125 venue: cell("venue", "Rink A"),126 },127 {128 id: "r2",129 date: cell("date", "2026-13-02"),130 start: cell("start", "18:00"),131 end: cell("end", "19:00"),132 venue: cell("venue", ""),133 },134 {135 id: "r3",136 date: cell("date", "2026-07-21"),137 start: cell("start", "20:00"),138 end: cell("end", "19:15"),139 venue: cell("venue", "Rink B"),140 },141]142
143const createEmptyRow = (id: string): GridRow => ({ id })144const gridCellClassName = () =>145 "border-border border-r p-0 align-middle last:border-r-0"146
147const rawOf = (row: GridRow, id: ColumnId) => {148 const v = row[id]149 return typeof v === "object" && v != null ? v.raw : ""150}151
152function formatPos(153 pos: { rowId: string; columnId: string } | null | undefined,154): string {155 if (!pos) return "None"156 return `${pos.rowId} / ${pos.columnId}`157}158
159export function GridValidationState() {160 const [resetKey, setResetKey] = React.useState(0)161 return (162 <GridValidationStateInner163 key={resetKey}164 onReset={() => setResetKey(k => k + 1)}165 />166 )167}168
169function GridValidationStateInner({ onReset }: { onReset: () => void }) {170 const grid = useDataGrid<GridRow>({171 columnIds: COLUMN_IDS,172 createEmptyRow,173 initialRows: INITIAL_ROWS,174 })175
176 React.useEffect(() => {177 if (!grid.lastCommit) return178 grid.updateRows(179 rows =>180 rows.map(row => {181 const end = row.end182 if (typeof end !== "object" || end == null) return row183 const base = resolveCell("end", end.raw)184 const crossErr = crossFieldEndError(row)185 const next =186 crossErr && base.status !== "invalid"187 ? { ...base, status: "invalid" as const, error: crossErr }188 : base189 if (next.status === end.status && next.error === end.error) return row190 return { ...row, end: next }191 }),192 { history: false },193 )194 }, [grid.lastCommit, grid])195
196 const invalidCount = React.useMemo(197 () =>198 grid.rows.filter(row =>199 COLUMN_IDS.some(id => {200 const c = row[id]201 return typeof c === "object" && c?.status === "invalid"202 }),203 ).length,204 [grid.rows],205 )206
207 const columns = React.useMemo<DataTableColumnDef<GridRow>[]>(208 () =>209 COLUMNS.map(col => ({210 id: col.id,211 accessorFn: (row: GridRow) => rawOf(row, col.id),212 header: () => (213 <span>214 {col.label}215 {col.required && (216 <span aria-hidden className="font-semibold text-destructive">217 {" "}218 *219 </span>220 )}221 </span>222 ),223 size: col.id === "venue" ? 200 : 170,224 meta: { label: col.label, required: col.required },225 cell: ctx => (226 <DataGridCell row={ctx.row.original} columnId={col.id}>227 {(p: CellEditorProps) => (228 <GridTextCell229 {...p}230 resolve={raw => resolveCell(col.id, raw)}231 placeholder={col.placeholder}232 />233 )}234 </DataGridCell>235 ),236 })),237 [],238 )239
240 return (241 <div className="w-full space-y-4">242 <DataTableRoot data={grid.rows} columns={columns} getRowId={r => r.id}>243 <DataGrid grid={grid} className="space-y-2 outline-none">244 {/* Flex-fill + drag-resizable columns */}245 <DataTableColumnResize />246 <DataGridClipboard resolveCell={resolveCell} />247 <DataGridFillHandle />248 <DataGridToolbar>249 <DataGridAddRows count={1} />250 <DataGridPasteHint />251 <span className="ms-auto">252 {invalidCount > 0 ? (253 <Badge variant="destructive">254 {invalidCount} row{invalidCount === 1 ? "" : "s"} need fixing255 </Badge>256 ) : (257 <Badge variant="secondary">All rows valid</Badge>258 )}259 </span>260 </DataGridToolbar>261 <p className="px-1 text-sm text-muted-foreground">262 Hover a red cell for the reason. Try a bad date (2026-13-02), a263 12-hour time, or an end time before the start.264 </p>265 <DataTable maxHeight={320}>266 <DataTableVirtualizedHeader />267 <DataTableVirtualizedBody<GridRow>268 estimateSize={37}269 fixedRowHeight270 getCellClassName={gridCellClassName}271 />272 </DataTable>273 </DataGrid>274 </DataTableRoot>275
276 <Card>277 <CardHeader>278 <CardTitle>Current Grid State</CardTitle>279 <CardDescription>280 Live view of the grid engine state for demonstration281 </CardDescription>282 <CardAction>283 <Button variant="outline" size="sm" onClick={onReset}>284 Reset All State285 </Button>286 </CardAction>287 </CardHeader>288 <CardContent className="space-y-4">289 <div className="grid gap-2 text-xs text-muted-foreground">290 <div className="flex justify-between">291 <span className="font-medium">Focused Cell:</span>292 <span className="text-foreground">293 {formatPos(grid.focusedCell)}294 </span>295 </div>296 <div className="flex justify-between">297 <span className="font-medium">Editing Cell:</span>298 <span className="text-foreground">299 {formatPos(grid.editingCell)}300 </span>301 </div>302 <div className="flex justify-between">303 <span className="font-medium">Selection Anchor:</span>304 <span className="text-foreground">305 {formatPos(grid.selectionAnchor)}306 </span>307 </div>308 <div className="flex justify-between">309 <span className="font-medium">Total Rows:</span>310 <span className="text-foreground">{grid.rows.length}</span>311 </div>312 <div className="flex justify-between">313 <span className="font-medium">Invalid Rows:</span>314 <span className="text-foreground">{invalidCount}</span>315 </div>316 <div className="flex justify-between">317 <span className="font-medium">Can Undo / Redo:</span>318 <span className="text-foreground">319 {grid.canUndo ? "Yes" : "No"} / {grid.canRedo ? "Yes" : "No"}320 </span>321 </div>322 <div className="flex justify-between">323 <span className="font-medium">Last Commit:</span>324 <span className="text-foreground">325 {grid.lastCommit326 ? `${grid.lastCommit.kind} (seq ${grid.lastCommit.seq})`327 : "None"}328 </span>329 </div>330 </div>331
332 <Collapsible>333 <CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground [&[data-state=open]>svg]:rotate-90">334 <ChevronRight className="size-3.5 transition-transform" />335 View Full State Object336 </CollapsibleTrigger>337 <CollapsibleContent>338 <pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-[10px] leading-relaxed">339 {JSON.stringify(340 {341 focusedCell: grid.focusedCell,342 editingCell: grid.editingCell,343 selectionAnchor: grid.selectionAnchor,344 canUndo: grid.canUndo,345 canRedo: grid.canRedo,346 lastCommit: grid.lastCommit,347 rowCount: grid.rows.length,348 invalidCount,349 rows: grid.rows,350 },351 null,352 2,353 )}354 </pre>355 </CollapsibleContent>356 </Collapsible>357 </CardContent>358 </Card>359 </div>360 )361}Installation
Section titled “Installation”pnpm dlx shadcn@latest add @niko-table/data-table-gridnpm install zodCellState
Section titled “CellState”1interface CellState<T> {2 raw: string3 value: T | null4 status: "valid" | "invalid" | "empty"5 error?: string // tooltip when invalid6}Use "empty" for untouched required fields so they stay neutral until save.
Per-field (Zod)
Section titled “Per-field (Zod)”1const FIELD_SCHEMAS: Record<string, z.ZodType<string>> = {2 date: z3 .string()4 .regex(/^\d{4}-\d{2}-\d{2}$/, "Use the format YYYY-MM-DD")5 .refine(isRealDate, "That date doesn't exist on the calendar"),6 start: z7 .string()8 .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use 24-hour time, e.g. 09:30"),9}10
11function resolveCell(columnId: string, raw: string): CellState<string> {12 const trimmed = raw.trim()13 if (trimmed === "") {14 return {15 raw,16 value: null,17 status: REQUIRED.has(columnId) ? "empty" : "valid",18 }19 }20 const schema = FIELD_SCHEMAS[columnId]21 if (!schema) return { raw, value: trimmed, status: "valid" }22 const result = schema.safeParse(trimmed)23 return result.success24 ? { raw, value: result.data, status: "valid" }25 : {26 raw,27 value: null,28 status: "invalid",29 error: result.error.issues[0]?.message ?? "Invalid value",30 }31}Reuse the same resolve for cells and clipboard.
Cross-field
Section titled “Cross-field”resolve sees one cell. Re-settle after commits with updateRows(..., { history: false }) so undo stays clean. Return the same row ref when nothing changes: the engine treats that as a no-op and will not loop.
1React.useEffect(() => {2 if (!grid.lastCommit) return3 grid.updateRows(4 (rows) =>5 rows.map((row) => {6 const end = row.end7 if (typeof end !== "object" || end == null) return row8 const base = resolveCell("end", end.raw)9 const crossErr = endAfterStart(row)10 ? null11 : "End must be after the start time"12 const next =13 crossErr && base.status !== "invalid"14 ? { ...base, status: "invalid" as const, error: crossErr }15 : base16 if (next.status === end.status && next.error === end.error) return row17 return { ...row, end: next }18 }),19 { history: false },20 )21}, [grid.lastCommit, grid])Required fields
Section titled “Required fields”Asterisk in the header; keep blanks "empty" until save. Enforce at the save boundary.