diff --git a/.changeset/6882-datatable-declare-render-cell-editor-and-cell-classname.md b/.changeset/6882-datatable-declare-render-cell-editor-and-cell-classname.md
new file mode 100644
index 0000000000..f7de03743e
--- /dev/null
+++ b/.changeset/6882-datatable-declare-render-cell-editor-and-cell-classname.md
@@ -0,0 +1,56 @@
+---
+'@object-ui/types': minor
+'@object-ui/components': minor
+---
+
+feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema`
+
+`data-table` has read both keys on its production path all along — `renderCellEditor`
+through a `(schema as any)` cast, `cellClassName` by destructuring it into the class of
+its three utility cells (the selection checkbox, the row number, the row actions).
+Neither was declared, so authoring either one was unchecked: a misspelling produced no
+error and no widget, and no editor completion offered them.
+`DataTableSchema` now declares both, and the cast in `data-table.tsx` is gone rather
+than replaced.
+
+What you can write after this change that you could not write before, exactly:
+**nothing new runs.** Both keys had the same effect yesterday, because
+`BaseSchema`'s `[key: string]: any` already admitted them at any type at all. What
+changes is that they are now *checked* and *documented*:
+
+```ts
+const schema: DataTableSchema = {
+ type: 'data-table',
+ columns, data,
+ cellClassName: 'px-2 py-1 text-sm', // utility cells only (see below)
+ renderCellEditor: ({ column, value, commit, cancel }) =>
+ column.type === 'select'
+ ?
+ : null, // null → fall through to the built-in editor
+};
+```
+
+⚠️ **One reject direction, deliberate.** Because the keys were previously absorbed by
+the index signature as `any`, authored values of the *wrong shape* also compiled and
+silently did nothing. They are now compile errors:
+
+- `cellClassName` is declared `string`, matching `BaseSchema.className` and
+ `TableColumn.cellClassName`. The renderer passes it through `cn()`, which would
+ also swallow `['a','b']` or `{ a: true }` — those spellings now fail to compile.
+ One authored spelling for a class slot is the contract.
+- `renderCellEditor` is declared as the function `data-table` actually calls. A
+ non-function value (or a function with an incompatible context/return type) now
+ fails to compile instead of being ignored at runtime.
+
+⚠️ **What the schema-level `cellClassName` actually styles.** It is NOT the
+table-level twin of the per-column key: the two reach **disjoint** cells. Measured on
+the render, the schema-level key is folded into the **utility** cells only — the
+selection-checkbox cell, the row-number cell and the row-actions cell — while every
+**data** cell folds `TableColumn.cellClassName` and nothing else. Row density is
+therefore a pair of settings (`ObjectGrid` sets both), and the schema-level key alone
+leaves data cells at the primitive's default `p-4`. The docblock, the zod `describe`
+and `content/docs/components/complex/data-table.mdx` all say this now.
+
+No runtime behaviour changed anywhere, and nothing was retired. The zod mirror
+(`@object-ui/types/zod`) gains both keys in the same stroke, so the validator accepts
+what the published types now invite.
diff --git a/content/docs/components/complex/data-table.mdx b/content/docs/components/complex/data-table.mdx
index ca5031f688..ed00fdd0c0 100644
--- a/content/docs/components/complex/data-table.mdx
+++ b/content/docs/components/complex/data-table.mdx
@@ -43,9 +43,23 @@ interface DataTableSchema {
// Advanced features
resizableColumns?: boolean; // Allow column resizing (default: true)
reorderableColumns?: boolean; // Allow column reordering (default: true)
-
+
+ // Inline editing
+ editable?: boolean; // Enable inline cell editing (default: false)
+ singleClickEdit?: boolean; // Enter edit mode on single click (default: false)
+ renderCellEditor?: (ctx: { // Host-supplied editor widget; null -> built-in input
+ column: any;
+ row: any;
+ value: any;
+ stage: (v: any) => void;
+ commit: (v?: any) => void;
+ cancel: () => void;
+ }) => ReactNode;
+
// Styling
- className?: string; // Tailwind CSS classes
+ className?: string; // Tailwind CSS classes on the table wrapper
+ cellClassName?: string; // Tailwind CSS classes on the utility cells only
+ // (select / row number / row actions)
// Base properties
id?: string;
@@ -54,6 +68,62 @@ interface DataTableSchema {
}
```
+## Cell styling
+
+`className` styles the table wrapper. Body cells have **two** class slots, and they
+reach **disjoint** cells — neither is a superset of the other, and no cell gets both:
+
+- `TableColumn.cellClassName` — the **data** cells of that one column.
+- `DataTableSchema.cellClassName` — the table's **utility** cells only: the leading
+ selection-checkbox cell (`selectable`), the row-number cell (`showRowNumbers`), and
+ the trailing row-actions cell (`rowActions`). It never reaches a data cell.
+
+(The empty-state cell and the add-record row take neither.)
+
+Row density is therefore a **pair** of settings, not one. Per-cell padding is where
+row height has to be expressed — height is a property of the cells, not of the row
+element, so `rowClassName` cannot express it — so a compact table sets the same
+density class on every column *and* on the schema, the second so the checkbox and
+row-number cells stay the same height as the data beside them. That is exactly what
+`object-grid` does for its `rowHeight` modes:
+
+```json
+{
+ "type": "data-table",
+ "selectable": true,
+ "showRowNumbers": true,
+ "cellClassName": "px-2 py-1 text-sm",
+ "columns": [
+ { "header": "Name", "accessorKey": "name", "cellClassName": "px-2 py-1 text-sm" },
+ { "header": "Amount", "accessorKey": "amount", "cellClassName": "px-2 py-1 text-sm text-right" }
+ ],
+ "data": [
+ { "name": "Ada Lovelace", "amount": 120 },
+ { "name": "Grace Hopper", "amount": 340 }
+ ]
+}
+```
+
+Drop the per-column half and the data cells stay at the table primitive's default
+`p-4` — the schema-level key alone does not compact a row.
+
+## Inline editing
+
+With `editable: true` a cell enters edit mode on double-click (or on single click
+with `singleClickEdit: true`) and the table renders one of its built-in editors —
+text, number, date — chosen from the column's `type`.
+
+`renderCellEditor` lets the host supply a widget instead. The table calls it first
+for every cell it is about to edit; return a node to use it, or `null` to fall
+through to the built-in editor for that column. This is how `object-grid` gives a
+`select` or `lookup` cell the same dedicated control the form uses, without the
+component layer having to re-implement it.
+
+The returned node is wrapped by the table so it inherits the exit-edit
+affordances the built-in editors have: Enter commits from a single-line input,
+Escape cancels, and a click outside commits. Use `stage` to record a value while
+staying in edit mode, `commit` to save, and `cancel` to discard.
+
## Examples
### Product Inventory
diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx
index 57efaae010..be0dc35457 100644
--- a/packages/components/src/renderers/complex/data-table.tsx
+++ b/packages/components/src/renderers/complex/data-table.tsx
@@ -2294,16 +2294,17 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
// re-implement select/boolean/etc. down here in the
// (fields-free) component layer. Returning null means "no
// widget for this type" → fall through to the built-ins.
- const injectEditor = (schema as any).renderCellEditor as
- | ((ctx: {
- column: any;
- row: any;
- value: any;
- stage: (v: any) => void;
- commit: (v?: any) => void;
- cancel: () => void;
- }) => React.ReactNode)
- | undefined;
+ //
+ // This used to be `(schema as any).renderCellEditor as
+ // (…) => React.ReactNode` — a cast that existed for one
+ // reason only: `DataTableSchema` did not declare the key
+ // this renderer has always read, so the read had to
+ // re-state the contract locally and the schema had to be
+ // opened up to let it. objectui#6882 declared it (the
+ // 2026-08-30 ruling), so the read is typed at its source
+ // and the ctx shape below is checked against the
+ // declaration instead of asserted against nothing.
+ const injectEditor = schema.renderCellEditor;
if (typeof injectEditor === 'function') {
const node = injectEditor({
column: col,
diff --git a/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts b/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
new file mode 100644
index 0000000000..d16fc08225
--- /dev/null
+++ b/packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
@@ -0,0 +1,146 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#6882 — `DataTableSchema` DECLARES the two schema-level keys
+ * `data-table.tsx` has always read: `renderCellEditor` and `cellClassName`
+ * (maintainer ruling 2026-08-30, option A).
+ *
+ * ## Why this pin is compile-time, and why a runtime pin would measure nothing
+ *
+ * The enforcement being added is a TYPE declaration. `data-table` behaves
+ * IDENTICALLY before and after it: both keys were already read on the
+ * production path — `renderCellEditor` through an `(schema as any)` cast,
+ * `cellClassName` by destructuring into every body cell's class — so no render
+ * changes, no runtime value changes, and a rendering test is blind to the whole
+ * change. What can fail is a COMPILE. Same reading as
+ * `plugin-grid/src/__tests__/dataTableSchemaSlot-6459.test.ts` next door.
+ *
+ * ## ⚠️ The index signature is what makes the naive pin vacuous
+ *
+ * `DataTableSchema extends BaseSchema`, and `BaseSchema` carries
+ * `[key: string]: any`. So `DataTableSchema['renderCellEditor']` resolves to
+ * `any` whether or not the key is declared, and every "is this key there?"
+ * spelling written over the raw type answers `true` for EVERY string —
+ * including `bogusKeyNobodyDeclared`. A pin written that way is green before
+ * the fix, green after it, and measures nothing.
+ *
+ * `Declared<>` below strips the signature so NON-MEMBERSHIP can exist, which is
+ * the only state in which a membership question has an answer.
+ *
+ * ## ⚠️ …and `extends` alone is vacuous a second way
+ *
+ * `Expect` is satisfied by `never` (assignable
+ * to everything) and by `any`. `Equal<>` below is the invariant
+ * (function-parameter-identity) comparison instead, so neither passes.
+ *
+ * ## How the DIRECTION is proved, rather than asserted
+ *
+ * Four `@ts-expect-error` directives below are the load-bearing half. TypeScript
+ * reports an UNUSED `@ts-expect-error` as an error (TS2578), so each of them is
+ * a claim that the instrument REFUSES something:
+ *
+ * - `Expect` must be refused → the assertion helper has teeth;
+ * - `Equal` and `Equal` must resolve `false` → the
+ * comparison is invariant, not `extends`-shaped;
+ * - `IsDeclaredOn<'…probe…'>` must resolve `false` → the strip really removed
+ * the index signature, so a non-member is answerable.
+ *
+ * Break any part of the instrument — make `Expect` accept anything, make
+ * `Equal` bivariant, make `Declared` a no-op — and this file goes RED on the
+ * now-unused directive rather than quietly passing. That is the property the
+ * positive assertions borrow their meaning from.
+ */
+import { describe, it, expect } from 'vitest';
+import type { DataTableSchema } from '../data-display.js';
+
+/**
+ * `T` with its string/number index signatures removed — the same shape
+ * `plugin-grid`'s `RemoveIndexSignature` uses at the seam, restated here so
+ * this package's pin does not depend on a downstream package.
+ */
+type Declared = {
+ [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
+};
+
+/** Invariant type equality. `A extends B` is NOT this: `never` and `any` pass that. */
+type Equal =
+ (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false;
+
+/** The only assertion form used here — its constraint is what refuses `false`. */
+type Expect = T;
+
+/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
+type IsDeclaredOn = K extends keyof Declared ? true : false;
+
+/* ── Direction proofs: a broken instrument makes THIS file red ─────────────── */
+
+// @ts-expect-error objectui#6882 — `Expect` must refuse `false`. Widen its constraint and this directive goes unused (TS2578).
+type _ExpectRefusesFalse = Expect;
+
+// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
+type _EqualRefusesNever = Expect>;
+
+// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
+type _EqualRefusesAny = Expect>;
+
+// @ts-expect-error objectui#6882 — a key nothing declares must answer `false`. If `Declared<>` stopped stripping `[key: string]: any`, this would answer `true` and the directive would go unused.
+type _UndeclaredKeyIsRefused = Expect>;
+
+/* ── The assertions the card is about ─────────────────────────────────────── */
+
+/** RED before objectui#6882's declaration, green after. */
+type _RenderCellEditorIsDeclared = Expect>;
+/** RED before objectui#6882's declaration, green after. */
+type _CellClassNameIsDeclared = Expect>;
+
+/**
+ * The context object `data-table.tsx` actually passes to the injected editor,
+ * transcribed from its call site. Declaring the key with any other shape is a
+ * different (and false) statement about the renderer, so the shape is pinned,
+ * not just the membership.
+ */
+type CellEditorContext = {
+ column: any;
+ row: any;
+ value: any;
+ stage: (v: any) => void;
+ commit: (v?: any) => void;
+ cancel: () => void;
+};
+
+type _RenderCellEditorShape = Expect<
+ Equal<
+ Declared['renderCellEditor'],
+ ((ctx: CellEditorContext) => React.ReactNode) | undefined
+ >
+>;
+
+/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
+type _CellClassNameShape = Expect['cellClassName'], string | undefined>>;
+
+describe('objectui#6882 — DataTableSchema declares the two keys data-table reads', () => {
+ /**
+ * The runtime half exists only so the compile-time pins above have a file
+ * vitest also runs; the assertions that matter are erased before this runs.
+ * It does carry one honest statement: an author writing both keys produces an
+ * ordinary `DataTableSchema` value, no cast anywhere.
+ */
+ it('an author can write both keys on a plain DataTableSchema value', () => {
+ const authored: DataTableSchema = {
+ type: 'data-table',
+ columns: [{ header: 'Name', accessorKey: 'name' }],
+ data: [],
+ cellClassName: 'px-3 py-1',
+ renderCellEditor: ({ value }) => (value == null ? null : null),
+ };
+
+ expect(authored.cellClassName).toBe('px-3 py-1');
+ expect(typeof authored.renderCellEditor).toBe('function');
+ });
+});
diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts
index b76c8302f7..b648c58bf0 100644
--- a/packages/types/src/data-display.ts
+++ b/packages/types/src/data-display.ts
@@ -919,6 +919,36 @@ export interface DataTableSchema extends BaseSchema {
* @default false
*/
singleClickEdit?: boolean;
+ /**
+ * Host-supplied cell editor for inline editing (objectui#6882).
+ *
+ * When a cell enters edit mode the table calls this FIRST and renders what it
+ * returns; returning `null` means "no widget for this column" and the table
+ * falls through to its built-in text / number / date inputs. It exists so a
+ * higher layer (e.g. `ObjectGrid`) can hand a cell the SAME dedicated widget
+ * the form uses for that field type — select, lookup, boolean — without the
+ * component layer, which is deliberately `@object-ui/fields`-free, having to
+ * re-implement any of them.
+ *
+ * The returned node is wrapped by the table so it gains the exit-edit
+ * affordances the built-in editors have (Enter commits from a single-line
+ * input, Escape cancels, click-outside commits); `stage` records a value
+ * without leaving edit mode, `commit` saves, `cancel` discards.
+ *
+ * ⚠️ Declared as of objectui#6882 (maintainer ruling 2026-08-30). `data-table`
+ * has read this key on the production path since inline editing landed — it
+ * did so through a `(schema as any)` cast, which existed for no reason other
+ * than this declaration's absence and is gone with it. Nothing new runs; a
+ * misspelling is now caught at authoring time instead of failing silently.
+ */
+ renderCellEditor?: (ctx: {
+ column: any;
+ row: any;
+ value: any;
+ stage: (v: any) => void;
+ commit: (v?: any) => void;
+ cancel: () => void;
+ }) => React.ReactNode;
/**
* Cell value change handler
* Called when a cell value is edited
@@ -949,6 +979,38 @@ export interface DataTableSchema extends BaseSchema {
* Function that returns CSSProperties for each row (e.g., from conditionalFormatting).
*/
rowStyle?: (row: any, index: number) => React.CSSProperties | undefined;
+ /**
+ * Extra CSS classes folded into the table's UTILITY body cells
+ * (objectui#6882) — and ONLY those three, each rendered only when its
+ * feature is on: the leading selection-checkbox cell (`selectable`), the
+ * row-number cell (`showRowNumbers`), and the trailing row-actions cell
+ * (`rowActions`).
+ *
+ * ⚠️ It does NOT reach a data cell. A data cell folds
+ * {@link TableColumn.cellClassName} — the per-column key — and nothing else,
+ * so the two class slots style DISJOINT cells and never combine on one cell.
+ * (The empty-state cell and the add-record row cell take neither.) The
+ * population is checkable: `data-table.tsx` folds this key at exactly three
+ * `cn(cellClassName, …)` call sites, and the data-cell one folds
+ * `col.cellClassName`.
+ *
+ * Its live use is row density, and it is only half of that. Row height is a
+ * property of the cells, not of the `` — `rowClassName` cannot express
+ * it — so a host that renders compact / short / tall rows sets the per-cell
+ * padding on BOTH slots: `ObjectGrid` folds its density class into every
+ * column's `cellClassName` and passes the same class here, which is what
+ * keeps the checkbox and row-number cells the same height as the data beside
+ * them. Setting only this key leaves every data cell at the table
+ * primitive's default `p-4`.
+ *
+ * ⚠️ Declared as of objectui#6882 (maintainer ruling 2026-08-30). `data-table`
+ * has destructured this key off the schema and folded it into those three
+ * cells all along; only the declaration was missing. `string` matches
+ * {@link BaseSchema.className} and {@link TableColumn.cellClassName} — the
+ * renderer passes it through `cn()`, which would also swallow an array or an
+ * object, but one authored spelling for a class slot is the contract.
+ */
+ cellClassName?: string;
/**
* Number of columns to freeze (left-pin)
* When set, the first N columns remain fixed while the rest scroll horizontally.
diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts
index 3da1839123..b396ab2aa4 100644
--- a/packages/types/src/zod/data-display.zod.ts
+++ b/packages/types/src/zod/data-display.zod.ts
@@ -244,6 +244,8 @@ export const DataTableSchema = BaseSchema.extend({
}).optional().describe('Per-record CEL predicates for the built-in row Delete item (objectui#2614)'),
onSelectionChange: z.function().optional().describe('Selection change handler'),
onColumnsReorder: z.function().optional().describe('Column reorder handler'),
+ cellClassName: z.string().optional().describe('Extra classes folded into the utility body cells only — the selection, row-number and row-actions cells; data cells fold the per-column `cellClassName` instead, so row density has to be set on both (objectui#6882)'),
+ renderCellEditor: z.function().optional().describe('Host-supplied inline cell editor; returning null falls through to the built-in text/number/date inputs (objectui#6882)'),
frozenColumns: z.number().optional().describe('Number of frozen columns'),
showRowNumbers: z.boolean().optional().describe('Show row numbers'),
emptyAction: SchemaNodeSchema.optional().describe('Optional schema node rendered inside the empty-state, e.g. an "Add record" button. Lets the empty state become an actionable invitation rather than a dead end.'),