Docs
Cell Types
Cell Types
Built-in editors (text, number, currency, checkbox, date, select) and how to write your own.
Cheap display shell for every visible cell. Heavy editor mounts only while editing. Validity comes from your resolve. The grid stays type-agnostic.
| Name | Hours | Rate | Billable | Due | Priority |
|---|
1"use client"2
3/**4 * niko-table/grid — the built-in cell editors.5 *6 * Six cell types, each following the display/editor split (the heavy editor7 * mounts only for the cell being edited): text, number, currency (a number cell8 * with a display `format`), checkbox, date (popover calendar), and select9 * (searchable dropdown). A consumer dispatches the editor per column — the grid10 * itself stays type-agnostic; validity comes from the injected `resolve`.11 */12import { DataTableRowContextMenuSlot } from "@/components/niko-table/components/data-table-row-context-menu-slot"13import { DataTable } from "@/components/niko-table/core/data-table"14import { DataTableColumnResize } from "@/components/niko-table/components/data-table-column-resize"15import { DataTableRoot } from "@/components/niko-table/core/data-table-root"16import {17 DataTableVirtualizedBody,18 DataTableVirtualizedHeader,19} from "@/components/niko-table/core/data-table-virtualized-structure"20import type { CellEditorProps } from "@/components/niko-table/grid/cells/cell-props"21import { GridCheckboxCell } from "@/components/niko-table/grid/cells/grid-checkbox-cell"22import { GridDateCell } from "@/components/niko-table/grid/cells/grid-date-cell"23import { GridSelectCell } from "@/components/niko-table/grid/cells/grid-select-cell"24import {25 GridNumberCell,26 GridTextCell,27} from "@/components/niko-table/grid/cells/grid-text-cell"28import { DataGridClipboard } from "@/components/niko-table/grid/components/grid-clipboard"29import { GridRowMenu } from "@/components/niko-table/grid/components/grid-row-menu"30import { DataGrid } from "@/components/niko-table/grid/core/data-grid"31import { DataGridCell } from "@/components/niko-table/grid/core/data-grid-context"32import { useDataGrid } from "@/components/niko-table/grid/hooks/use-data-grid"33import type {34 CellState,35 GridRow,36} from "@/components/niko-table/grid/types/grid-cell"37import type { DataTableColumnDef } from "@/components/niko-table/types"38import * as React from "react"39
40type CellType = "text" | "number" | "currency" | "checkbox" | "date" | "select"41
42interface ColumnSpec {43 id: string44 label: string45 type: CellType46 width: number47 options?: readonly string[]48}49
50const PRIORITIES = ["Low", "Medium", "High"] as const51
52const SPECS: ColumnSpec[] = [53 { id: "name", label: "Name", type: "text", width: 180 },54 { id: "hours", label: "Hours", type: "number", width: 110 },55 { id: "rate", label: "Rate", type: "currency", width: 120 },56 { id: "billable", label: "Billable", type: "checkbox", width: 100 },57 { id: "due", label: "Due", type: "date", width: 150 },58 {59 id: "priority",60 label: "Priority",61 type: "select",62 width: 140,63 options: PRIORITIES,64 },65]66
67const TRUTHY = ["true", "1", "yes"]68
69function resolveFor(70 spec: ColumnSpec | undefined,71 raw: string,72): CellState<string> {73 const trimmed = raw.trim()74 switch (spec?.type) {75 case "number":76 case "currency": {77 if (trimmed === "") return { raw, value: null, status: "empty" }78 const ok = Number.isFinite(Number(trimmed))79 return {80 raw,81 value: ok ? trimmed : null,82 status: ok ? "valid" : "invalid",83 }84 }85 case "date": {86 if (trimmed === "") return { raw, value: null, status: "empty" }87 const ok = /^\d{4}-\d{2}-\d{2}$/.test(trimmed)88 return {89 raw,90 value: ok ? trimmed : null,91 status: ok ? "valid" : "invalid",92 }93 }94 case "checkbox": {95 const isTrue = TRUTHY.includes(trimmed.toLowerCase())96 return { raw: String(isTrue), value: String(isTrue), status: "valid" }97 }98 case "select": {99 const match = spec.options?.includes(raw) ?? false100 return {101 raw,102 value: match ? raw : null,103 status: raw === "" ? "empty" : match ? "valid" : "invalid",104 }105 }106 default:107 return { raw, value: raw || null, status: raw === "" ? "empty" : "valid" }108 }109}110
111const SPEC_BY_ID = Object.fromEntries(SPECS.map(s => [s.id, s]))112const resolveCell = (columnId: string, raw: string) =>113 resolveFor(SPEC_BY_ID[columnId], raw)114
115const usd = new Intl.NumberFormat("en-US", {116 style: "currency",117 currency: "USD",118})119
120const c = (id: string, raw: string) => resolveCell(id, raw)121const INITIAL_ROWS: GridRow[] = [122 {123 id: "r1",124 name: c("name", "Design review"),125 hours: c("hours", "3"),126 rate: c("rate", "120"),127 billable: c("billable", "true"),128 due: c("due", "2026-02-14"),129 priority: c("priority", "High"),130 },131 {132 id: "r2",133 name: c("name", "Standup"),134 hours: c("hours", "1"),135 rate: c("rate", "0"),136 billable: c("billable", "false"),137 due: c("due", "2026-02-10"),138 priority: c("priority", "Low"),139 },140]141
142const createEmptyRow = (id: string): GridRow => ({ id })143const gridCellClassName = () =>144 "border-border border-r p-0 align-middle last:border-r-0"145
146function CellByType({ spec, ...p }: CellEditorProps & { spec: ColumnSpec }) {147 switch (spec.type) {148 case "number":149 return (150 <GridNumberCell151 {...p}152 resolve={raw => resolveCell(spec.id, raw)}153 placeholder={spec.label}154 />155 )156 case "currency":157 return (158 <GridNumberCell159 {...p}160 resolve={raw => resolveCell(spec.id, raw)}161 placeholder={spec.label}162 format={raw => {163 const n = Number(raw)164 return Number.isFinite(n) ? usd.format(n) : raw165 }}166 />167 )168 case "checkbox":169 return <GridCheckboxCell {...p} aria-label={spec.label} />170 case "date":171 return <GridDateCell {...p} placeholder={spec.label} />172 case "select":173 return (174 <GridSelectCell175 {...p}176 options={(spec.options ?? []).map(o => ({ label: o, value: o }))}177 placeholder="Select…"178 displayLabel={p.cell.value ?? undefined}179 />180 )181 default:182 return (183 <GridTextCell184 {...p}185 resolve={raw => resolveCell(spec.id, raw)}186 placeholder={spec.label}187 />188 )189 }190}191
192export function GridCellTypes() {193 const grid = useDataGrid<GridRow>({194 columnIds: SPECS.map(s => s.id),195 createEmptyRow,196 initialRows: INITIAL_ROWS,197 })198
199 const columns = React.useMemo<DataTableColumnDef<GridRow>[]>(200 () =>201 SPECS.map(spec => ({202 id: spec.id,203 accessorFn: (row: GridRow) => {204 const v = row[spec.id]205 return typeof v === "object" && v != null ? v.raw : ""206 },207 header: spec.label,208 size: spec.width,209 meta: { label: spec.label },210 cell: ctx => (211 <DataGridCell row={ctx.row.original} columnId={spec.id}>212 {(p: CellEditorProps) => <CellByType spec={spec} {...p} />}213 </DataGridCell>214 ),215 })),216 [],217 )218
219 return (220 <DataTableRoot data={grid.rows} columns={columns} getRowId={r => r.id}>221 <DataGrid grid={grid} className="space-y-2 outline-none">222 {/* Flex-fill + drag-resizable columns */}223 <DataTableColumnResize />224 <DataGridClipboard resolveCell={resolveCell} />225 <DataTable maxHeight={360}>226 <DataTableVirtualizedHeader />227 <DataTableVirtualizedBody<GridRow>228 estimateSize={37}229 fixedRowHeight230 getCellClassName={gridCellClassName}231 >232 <DataTableRowContextMenuSlot>233 <GridRowMenu />234 </DataTableRowContextMenuSlot>235 </DataTableVirtualizedBody>236 </DataTable>237 </DataGrid>238 </DataTableRoot>239 )240}Preview with Controlled State
| Name | Hours | Rate | Billable | Due | Priority |
|---|
Current Grid State
Live view of the grid engine state for demonstration
Focused Cell:None
Editing Cell:None
Selection Anchor:None
Total Rows:2
Can Undo / Redo:No / No
Last Commit:None
1"use client"2
3/**4 * niko-table/grid — cell types with a live state panel for docs.5 */6import { DataTableRowContextMenuSlot } from "@/components/niko-table/components/data-table-row-context-menu-slot"7import { DataTable } from "@/components/niko-table/core/data-table"8import { DataTableColumnResize } from "@/components/niko-table/components/data-table-column-resize"9import { DataTableRoot } from "@/components/niko-table/core/data-table-root"10import {11 DataTableVirtualizedBody,12 DataTableVirtualizedHeader,13} from "@/components/niko-table/core/data-table-virtualized-structure"14import type { CellEditorProps } from "@/components/niko-table/grid/cells/cell-props"15import { GridCheckboxCell } from "@/components/niko-table/grid/cells/grid-checkbox-cell"16import { GridDateCell } from "@/components/niko-table/grid/cells/grid-date-cell"17import { GridSelectCell } from "@/components/niko-table/grid/cells/grid-select-cell"18import {19 GridNumberCell,20 GridTextCell,21} from "@/components/niko-table/grid/cells/grid-text-cell"22import { DataGridClipboard } from "@/components/niko-table/grid/components/grid-clipboard"23import { GridRowMenu } from "@/components/niko-table/grid/components/grid-row-menu"24import { DataGrid } from "@/components/niko-table/grid/core/data-grid"25import { DataGridCell } from "@/components/niko-table/grid/core/data-grid-context"26import { useDataGrid } from "@/components/niko-table/grid/hooks/use-data-grid"27import type {28 CellState,29 GridRow,30} from "@/components/niko-table/grid/types/grid-cell"31import type { DataTableColumnDef } from "@/components/niko-table/types"32import { Button } from "@/components/ui/button"33import {34 Card,35 CardAction,36 CardContent,37 CardDescription,38 CardHeader,39 CardTitle,40} from "@/components/ui/card"41import {42 Collapsible,43 CollapsibleContent,44 CollapsibleTrigger,45} from "@/components/ui/collapsible"46import { ChevronRight } from "lucide-react"47import * as React from "react"48
49type CellType = "text" | "number" | "currency" | "checkbox" | "date" | "select"50
51interface ColumnSpec {52 id: string53 label: string54 type: CellType55 width: number56 options?: readonly string[]57}58
59const PRIORITIES = ["Low", "Medium", "High"] as const60
61const SPECS: ColumnSpec[] = [62 { id: "name", label: "Name", type: "text", width: 180 },63 { id: "hours", label: "Hours", type: "number", width: 110 },64 { id: "rate", label: "Rate", type: "currency", width: 120 },65 { id: "billable", label: "Billable", type: "checkbox", width: 100 },66 { id: "due", label: "Due", type: "date", width: 150 },67 {68 id: "priority",69 label: "Priority",70 type: "select",71 width: 140,72 options: PRIORITIES,73 },74]75
76const TRUTHY = ["true", "1", "yes"]77
78function resolveFor(79 spec: ColumnSpec | undefined,80 raw: string,81): CellState<string> {82 const trimmed = raw.trim()83 switch (spec?.type) {84 case "number":85 case "currency": {86 if (trimmed === "") return { raw, value: null, status: "empty" }87 const ok = Number.isFinite(Number(trimmed))88 return {89 raw,90 value: ok ? trimmed : null,91 status: ok ? "valid" : "invalid",92 }93 }94 case "date": {95 if (trimmed === "") return { raw, value: null, status: "empty" }96 const ok = /^\d{4}-\d{2}-\d{2}$/.test(trimmed)97 return {98 raw,99 value: ok ? trimmed : null,100 status: ok ? "valid" : "invalid",101 }102 }103 case "checkbox": {104 const isTrue = TRUTHY.includes(trimmed.toLowerCase())105 return { raw: String(isTrue), value: String(isTrue), status: "valid" }106 }107 case "select": {108 const match = spec.options?.includes(raw) ?? false109 return {110 raw,111 value: match ? raw : null,112 status: raw === "" ? "empty" : match ? "valid" : "invalid",113 }114 }115 default:116 return { raw, value: raw || null, status: raw === "" ? "empty" : "valid" }117 }118}119
120const SPEC_BY_ID = Object.fromEntries(SPECS.map(s => [s.id, s]))121const resolveCell = (columnId: string, raw: string) =>122 resolveFor(SPEC_BY_ID[columnId], raw)123
124const usd = new Intl.NumberFormat("en-US", {125 style: "currency",126 currency: "USD",127})128
129const c = (id: string, raw: string) => resolveCell(id, raw)130const INITIAL_ROWS: GridRow[] = [131 {132 id: "r1",133 name: c("name", "Design review"),134 hours: c("hours", "3"),135 rate: c("rate", "120"),136 billable: c("billable", "true"),137 due: c("due", "2026-02-14"),138 priority: c("priority", "High"),139 },140 {141 id: "r2",142 name: c("name", "Standup"),143 hours: c("hours", "1"),144 rate: c("rate", "0"),145 billable: c("billable", "false"),146 due: c("due", "2026-02-10"),147 priority: c("priority", "Low"),148 },149]150
151const createEmptyRow = (id: string): GridRow => ({ id })152const gridCellClassName = () =>153 "border-border border-r p-0 align-middle last:border-r-0"154
155function formatPos(156 pos: { rowId: string; columnId: string } | null | undefined,157): string {158 if (!pos) return "None"159 return `${pos.rowId} / ${pos.columnId}`160}161
162function CellByType({ spec, ...p }: CellEditorProps & { spec: ColumnSpec }) {163 switch (spec.type) {164 case "number":165 return (166 <GridNumberCell167 {...p}168 resolve={raw => resolveCell(spec.id, raw)}169 placeholder={spec.label}170 />171 )172 case "currency":173 return (174 <GridNumberCell175 {...p}176 resolve={raw => resolveCell(spec.id, raw)}177 placeholder={spec.label}178 format={raw => {179 const n = Number(raw)180 return Number.isFinite(n) ? usd.format(n) : raw181 }}182 />183 )184 case "checkbox":185 return <GridCheckboxCell {...p} aria-label={spec.label} />186 case "date":187 return <GridDateCell {...p} placeholder={spec.label} />188 case "select":189 return (190 <GridSelectCell191 {...p}192 options={(spec.options ?? []).map(o => ({ label: o, value: o }))}193 placeholder="Select…"194 displayLabel={p.cell.value ?? undefined}195 />196 )197 default:198 return (199 <GridTextCell200 {...p}201 resolve={raw => resolveCell(spec.id, raw)}202 placeholder={spec.label}203 />204 )205 }206}207
208export function GridCellTypesState() {209 const [resetKey, setResetKey] = React.useState(0)210 return (211 <GridCellTypesStateInner212 key={resetKey}213 onReset={() => setResetKey(k => k + 1)}214 />215 )216}217
218function GridCellTypesStateInner({ onReset }: { onReset: () => void }) {219 const grid = useDataGrid<GridRow>({220 columnIds: SPECS.map(s => s.id),221 createEmptyRow,222 initialRows: INITIAL_ROWS,223 })224
225 const columns = React.useMemo<DataTableColumnDef<GridRow>[]>(226 () =>227 SPECS.map(spec => ({228 id: spec.id,229 accessorFn: (row: GridRow) => {230 const v = row[spec.id]231 return typeof v === "object" && v != null ? v.raw : ""232 },233 header: spec.label,234 size: spec.width,235 meta: { label: spec.label },236 cell: ctx => (237 <DataGridCell row={ctx.row.original} columnId={spec.id}>238 {(p: CellEditorProps) => <CellByType spec={spec} {...p} />}239 </DataGridCell>240 ),241 })),242 [],243 )244
245 return (246 <div className="w-full space-y-4">247 <DataTableRoot data={grid.rows} columns={columns} getRowId={r => r.id}>248 <DataGrid grid={grid} className="space-y-2 outline-none">249 {/* Flex-fill + drag-resizable columns */}250 <DataTableColumnResize />251 <DataGridClipboard resolveCell={resolveCell} />252 <DataTable maxHeight={360}>253 <DataTableVirtualizedHeader />254 <DataTableVirtualizedBody<GridRow>255 estimateSize={37}256 fixedRowHeight257 getCellClassName={gridCellClassName}258 >259 <DataTableRowContextMenuSlot>260 <GridRowMenu />261 </DataTableRowContextMenuSlot>262 </DataTableVirtualizedBody>263 </DataTable>264 </DataGrid>265 </DataTableRoot>266
267 <Card>268 <CardHeader>269 <CardTitle>Current Grid State</CardTitle>270 <CardDescription>271 Live view of the grid engine state for demonstration272 </CardDescription>273 <CardAction>274 <Button variant="outline" size="sm" onClick={onReset}>275 Reset All State276 </Button>277 </CardAction>278 </CardHeader>279 <CardContent className="space-y-4">280 <div className="grid gap-2 text-xs text-muted-foreground">281 <div className="flex justify-between">282 <span className="font-medium">Focused Cell:</span>283 <span className="text-foreground">284 {formatPos(grid.focusedCell)}285 </span>286 </div>287 <div className="flex justify-between">288 <span className="font-medium">Editing Cell:</span>289 <span className="text-foreground">290 {formatPos(grid.editingCell)}291 </span>292 </div>293 <div className="flex justify-between">294 <span className="font-medium">Selection Anchor:</span>295 <span className="text-foreground">296 {formatPos(grid.selectionAnchor)}297 </span>298 </div>299 <div className="flex justify-between">300 <span className="font-medium">Total Rows:</span>301 <span className="text-foreground">{grid.rows.length}</span>302 </div>303 <div className="flex justify-between">304 <span className="font-medium">Can Undo / Redo:</span>305 <span className="text-foreground">306 {grid.canUndo ? "Yes" : "No"} / {grid.canRedo ? "Yes" : "No"}307 </span>308 </div>309 <div className="flex justify-between">310 <span className="font-medium">Last Commit:</span>311 <span className="text-foreground">312 {grid.lastCommit313 ? `${grid.lastCommit.kind} (seq ${grid.lastCommit.seq})`314 : "None"}315 </span>316 </div>317 </div>318
319 <Collapsible>320 <CollapsibleTrigger className="flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground [&[data-state=open]>svg]:rotate-90">321 <ChevronRight className="size-3.5 transition-transform" />322 View Full State Object323 </CollapsibleTrigger>324 <CollapsibleContent>325 <pre className="mt-2 max-h-64 overflow-auto rounded-md bg-muted p-3 text-[10px] leading-relaxed">326 {JSON.stringify(327 {328 focusedCell: grid.focusedCell,329 editingCell: grid.editingCell,330 selectionAnchor: grid.selectionAnchor,331 canUndo: grid.canUndo,332 canRedo: grid.canRedo,333 lastCommit: grid.lastCommit,334 rowCount: grid.rows.length,335 rows: grid.rows,336 },337 null,338 2,339 )}340 </pre>341 </CollapsibleContent>342 </Collapsible>343 </CardContent>344 </Card>345 </div>346 )347}Installation
Section titled “Installation”pnpm dlx shadcn@latest add @niko-table/data-table-gridDate / select also need:
pnpm dlx shadcn@latest add calendar popover command buttonEditors
Section titled “Editors”| Cell | Component | Notes |
|---|---|---|
| Text | GridTextCell |
Commits on blur / Enter / Tab |
| Number | GridNumberCell |
Right-aligned; resolve owns validity |
| Currency | GridNumberCell + format |
Display via Intl.NumberFormat; edit shows raw |
| Checkbox | GridCheckboxCell |
Always interactive |
| Date | GridDateCell |
Stores YYYY-MM-DD |
| Select | GridSelectCell / GridComboboxCell |
Combobox adds search |
Dispatch by column
Section titled “Dispatch by column”1function CellByType({2 spec,3 ...p4}: CellEditorProps & { spec: ColumnSpec }) {5 switch (spec.type) {6 case "number":7 return <GridNumberCell {...p} resolve={(raw) => resolveCell(spec.id, raw)} />8 case "currency":9 return (10 <GridNumberCell11 {...p}12 resolve={(raw) => resolveCell(spec.id, raw)}13 format={(raw) => usd.format(Number(raw))}14 />15 )16 case "checkbox":17 return <GridCheckboxCell {...p} aria-label={spec.label} />18 case "date":19 return <GridDateCell {...p} placeholder={spec.label} />20 case "select":21 return (22 <GridSelectCell23 {...p}24 options={spec.options.map((o) => ({ label: o, value: o }))}25 placeholder="Select…"26 displayLabel={p.cell.value ?? undefined}27 />28 )29 default:30 return <GridTextCell {...p} resolve={(raw) => resolveCell(spec.id, raw)} />31 }32}Validity via resolve
Section titled “Validity via resolve”1function resolveCell(columnId: string, raw: string): CellState<string> {2 const spec = SPEC_BY_ID[columnId]3 const trimmed = raw.trim()4 switch (spec?.type) {5 case "number":6 case "currency": {7 if (trimmed === "") return { raw, value: null, status: "empty" }8 const ok = Number.isFinite(Number(trimmed))9 return { raw, value: ok ? trimmed : null, status: ok ? "valid" : "invalid" }10 }11 case "select": {12 const match = spec.options.includes(raw)13 return {14 raw,15 value: match ? raw : null,16 status: raw === "" ? "empty" : match ? "valid" : "invalid",17 }18 }19 default:20 return { raw, value: raw || null, status: raw === "" ? "empty" : "valid" }21 }22}Pass the same resolve to <DataGridClipboard resolveCell={…} /> so bad pastes stay visible (red) instead of dropping.
Custom cells
Section titled “Custom cells”Satisfy CellEditorProps. Render GridCellDisplay when idle; mount your editor only when isEditing.
1export function MyCell(props: CellEditorProps) {2 const { cell, isFocused, isSelected, isEditing } = props3 if (!isEditing) {4 return (5 <GridCellDisplay status={cell.status} isFocused={isFocused} isSelected={isSelected}>6 {cell.raw}7 </GridCellDisplay>8 )9 }10 return <MyEditor {...props} />11}Portaled editors
Section titled “Portaled editors”If the interactive surface lives in a portal (Popover, calendar, combobox list), tag it with data-grid-cell-editor so mousedown inside it does not clear editingCell before commit:
1<PopoverContent data-grid-cell-editor="">2 <Calendar onSelect={…} />3</PopoverContent>Built-in date/combobox cells already do this. Full example:
Double-click a Color cell to open the palette.
| Label | Color |
|---|
1"use client"2
3/**4 * niko-table/grid — writing your own PORTALED cell editor.5 *6 * A custom "color swatch" cell that opens a Popover palette. It shows the two7 * things every portaled editor needs:8 *9 * 1. The display / editor split — a cheap `GridCellDisplay` shell renders for10 * every visible cell; the Popover mounts ONLY for the cell being edited.11 * 2. `data-grid-cell-editor` on the portaled surface — the palette renders12 * outside the cell's DOM (in a portal), but React events still bubble13 * through the React tree to the grid. Tagging the `PopoverContent` tells14 * the grid's click-away / cell-mousedown guards to leave the editor15 * mounted while a swatch is clicked. Without it, the mousedown would16 * reselect the cell and unmount the editor before the click commits, so17 * picking a color would silently no-op.18 *19 * The same contract (`CellEditorProps`) powers the built-in date / combobox20 * cells — a rating stepper, an emoji picker, or a multi-select drops in the21 * same way.22 */23import * as React from "react"24
25import { Button } from "@/components/ui/button"26import {27 Popover,28 PopoverContent,29 PopoverTrigger,30} from "@/components/ui/popover"31import { DataTable } from "@/components/niko-table/core/data-table"32import { DataTableColumnResize } from "@/components/niko-table/components/data-table-column-resize"33import { DataTableRoot } from "@/components/niko-table/core/data-table-root"34import {35 DataTableVirtualizedBody,36 DataTableVirtualizedHeader,37} from "@/components/niko-table/core/data-table-virtualized-structure"38import type { CellEditorProps } from "@/components/niko-table/grid/cells/cell-props"39import { cellTriggerClass } from "@/components/niko-table/grid/cells/cell-styles"40import { GridCellDisplay } from "@/components/niko-table/grid/cells/grid-cell-display"41import { GridTextCell } from "@/components/niko-table/grid/cells/grid-text-cell"42import {43 DataGridAddRows,44 DataGridToolbar,45} from "@/components/niko-table/grid/components/grid-toolbar"46import { DataGrid } from "@/components/niko-table/grid/core/data-grid"47import { DataGridCell } from "@/components/niko-table/grid/core/data-grid-context"48import { useDataGrid } from "@/components/niko-table/grid/hooks/use-data-grid"49import type {50 CellState,51 GridRow,52} from "@/components/niko-table/grid/types/grid-cell"53import type { DataTableColumnDef } from "@/components/niko-table/types"54import { cn } from "@/lib/utils"55
56// --- the palette -------------------------------------------------------------57const PALETTE = [58 { name: "Red", hex: "#ef4444" },59 { name: "Orange", hex: "#f97316" },60 { name: "Amber", hex: "#f59e0b" },61 { name: "Green", hex: "#22c55e" },62 { name: "Teal", hex: "#14b8a6" },63 { name: "Blue", hex: "#3b82f6" },64 { name: "Indigo", hex: "#6366f1" },65 { name: "Violet", hex: "#8b5cf6" },66 { name: "Pink", hex: "#ec4899" },67 { name: "Slate", hex: "#64748b" },68] as const69const HEX_BY_NAME = new Map<string, string>(PALETTE.map(c => [c.name, c.hex]))70
71// A picked color is stored by name; a value not in the palette reads invalid,72// so a bad paste stays visible (red) instead of being silently accepted.73function resolveColor(raw: string): CellState<string> {74 const trimmed = raw.trim()75 if (trimmed === "") return { raw, value: null, status: "empty" }76 return HEX_BY_NAME.has(trimmed)77 ? { raw, value: trimmed, status: "valid" }78 : {79 raw,80 value: null,81 status: "invalid",82 error: `Unknown color "${trimmed}"`,83 }84}85
86const resolveText = (raw: string): CellState<string> => {87 const v = raw.trim()88 return { raw, value: v || null, status: v ? "valid" : "empty" }89}90
91// --- the custom portaled cell ------------------------------------------------92function ColorDot({ name }: { name: string }) {93 return (94 <span95 className="size-3 shrink-0 rounded-full border"96 style={{ backgroundColor: HEX_BY_NAME.get(name) }}97 />98 )99}100
101function SwatchCell(props: CellEditorProps) {102 const { cell, isFocused, isSelected, isEditing } = props103
104 // Display shell — renders for every visible cell (cheap, no popover).105 if (!isEditing) {106 return (107 <GridCellDisplay108 status={cell.status}109 error={cell.error}110 isFocused={isFocused}111 isSelected={isSelected}112 >113 {cell.value ? (114 <span className="flex items-center gap-2">115 <ColorDot name={cell.value} />116 {cell.value}117 </span>118 ) : (119 <span className="text-muted-foreground">120 {cell.raw || "Pick a color"}121 </span>122 )}123 </GridCellDisplay>124 )125 }126 return <SwatchEditor {...props} />127}128
129// The open palette — mounted ONLY while this one cell is being edited.130function SwatchEditor({131 cell,132 isSelected,133 onCommit,134 onEditingChange,135}: CellEditorProps) {136 return (137 <Popover138 open139 onOpenChange={open => {140 // Click-away / Escape closes the popover → leave edit mode.141 if (!open) onEditingChange(false)142 }}143 >144 <PopoverTrigger asChild>145 <Button146 type="button"147 variant="ghost"148 aria-invalid={cell.status === "invalid"}149 className={cn(150 cellTriggerClass({151 status: cell.status,152 isFocused: true,153 isSelected,154 isEmpty: cell.status === "empty",155 }),156 "gap-2",157 )}158 >159 {cell.value ? (160 <>161 <ColorDot name={cell.value} />162 <span className="flex-1 truncate text-left">{cell.value}</span>163 </>164 ) : (165 <span className="flex-1 truncate text-left text-muted-foreground">166 Pick a color167 </span>168 )}169 </Button>170 </PopoverTrigger>171
172 {/*173 THE KEY LINE. The palette portals out of the cell's DOM; `data-grid-cell-editor`174 tells the grid to ignore pointer events inside it, so clicking a swatch175 commits instead of unmounting the editor first.176 */}177 <PopoverContent178 data-grid-cell-editor=""179 align="start"180 className="w-auto p-2"181 >182 <div className="grid grid-cols-5 gap-1">183 {PALETTE.map(c => {184 const active = c.name === cell.value185 return (186 <button187 key={c.name}188 type="button"189 title={c.name}190 aria-label={c.name}191 aria-pressed={active}192 onClick={() => {193 onCommit({ raw: c.name, value: c.name, status: "valid" })194 onEditingChange(false)195 }}196 className={cn(197 "size-7 rounded-md border transition outline-none focus-visible:ring-2 focus-visible:ring-ring",198 active199 ? "ring-2 ring-ring ring-offset-1 ring-offset-background"200 : "hover:scale-110",201 )}202 style={{ backgroundColor: c.hex }}203 />204 )205 })}206 </div>207 </PopoverContent>208 </Popover>209 )210}211
212// --- the grid ----------------------------------------------------------------213const COLUMN_IDS = ["label", "color"] as const214const createEmptyRow = (id: string): GridRow => ({ id })215const gridCellClassName = () =>216 "border-border border-r p-0 align-middle last:border-r-0"217
218const cell = (resolve: (raw: string) => CellState<string>, raw: string) =>219 resolve(raw)220const INITIAL_ROWS: GridRow[] = [221 {222 id: "r1",223 label: cell(resolveText, "Backlog"),224 color: cell(resolveColor, "Slate"),225 },226 {227 id: "r2",228 label: cell(resolveText, "In progress"),229 color: cell(resolveColor, "Blue"),230 },231 {232 id: "r3",233 label: cell(resolveText, "Blocked"),234 color: cell(resolveColor, "Red"),235 },236 {237 id: "r4",238 label: cell(resolveText, "Done"),239 color: cell(resolveColor, "Green"),240 },241]242
243const rawOf = (row: GridRow, id: string) => {244 const v = row[id]245 return typeof v === "object" && v != null ? v.raw : ""246}247
248export function GridPortaledCell() {249 const grid = useDataGrid<GridRow>({250 columnIds: COLUMN_IDS,251 createEmptyRow,252 initialRows: INITIAL_ROWS,253 })254
255 const columns = React.useMemo<DataTableColumnDef<GridRow>[]>(256 () => [257 {258 id: "label",259 accessorFn: (row: GridRow) => rawOf(row, "label"),260 header: "Label",261 size: 220,262 meta: { label: "Label" },263 cell: ctx => (264 <DataGridCell row={ctx.row.original} columnId="label">265 {(p: CellEditorProps) => (266 <GridTextCell267 {...p}268 resolve={resolveText}269 placeholder="Status name"270 />271 )}272 </DataGridCell>273 ),274 },275 {276 id: "color",277 accessorFn: (row: GridRow) => rawOf(row, "color"),278 header: "Color",279 size: 200,280 meta: { label: "Color" },281 // The custom portaled cell — double-click or press Enter to open it.282 cell: ctx => (283 <DataGridCell row={ctx.row.original} columnId="color">284 {(p: CellEditorProps) => <SwatchCell {...p} />}285 </DataGridCell>286 ),287 },288 ],289 [],290 )291
292 return (293 <DataTableRoot data={grid.rows} columns={columns} getRowId={r => r.id}>294 <DataGrid grid={grid} className="space-y-2 outline-none">295 {/* Flex-fill + drag-resizable columns */}296 <DataTableColumnResize />297 <DataGridToolbar>298 <DataGridAddRows count={1} />299 <span className="ms-auto text-sm text-muted-foreground">300 Double-click a Color cell to open the palette.301 </span>302 </DataGridToolbar>303 <DataTable maxHeight={320}>304 <DataTableVirtualizedHeader />305 <DataTableVirtualizedBody<GridRow>306 estimateSize={37}307 fixedRowHeight308 getCellClassName={gridCellClassName}309 />310 </DataTable>311 </DataGrid>312 </DataTableRoot>313 )314}