Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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'
? <MyPicker value={value} onSelect={commit} onDismiss={cancel} />
: 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.
74 changes: 72 additions & 2 deletions content/docs/components/complex/data-table.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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
Expand Down
21 changes: 11 additions & 10 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<X extends true ? true : false>` 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<false>` must be refused → the assertion helper has teeth;
* - `Equal<never, true>` and `Equal<any, true>` 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<T> = {
[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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The only assertion form used here — its constraint is what refuses `false`. */
type Expect<T extends true> = T;

/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
type IsDeclaredOn<K extends PropertyKey> = K extends keyof Declared<DataTableSchema> ? 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<false>;

// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
type _EqualRefusesNever = Expect<Equal<never, true>>;

// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
type _EqualRefusesAny = Expect<Equal<any, true>>;

// @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<IsDeclaredOn<'bogusKeyNobodyDeclares6882'>>;

/* ── The assertions the card is about ─────────────────────────────────────── */

/** RED before objectui#6882's declaration, green after. */
type _RenderCellEditorIsDeclared = Expect<IsDeclaredOn<'renderCellEditor'>>;
/** RED before objectui#6882's declaration, green after. */
type _CellClassNameIsDeclared = Expect<IsDeclaredOn<'cellClassName'>>;

/**
* 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<DataTableSchema>['renderCellEditor'],
((ctx: CellEditorContext) => React.ReactNode) | undefined
>
>;

/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
type _CellClassNameShape = Expect<Equal<Declared<DataTableSchema>['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');
});
});
62 changes: 62 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 `<tr>` — `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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema` by os-sam · Pull Request #6918 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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'
? <MyPicker value={value} onSelect={commit} onDismiss={cancel} />
: 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.
74 changes: 72 additions & 2 deletions content/docs/components/complex/data-table.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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
Expand Down
21 changes: 11 additions & 10 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<X extends true ? true : false>` 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<false>` must be refused → the assertion helper has teeth;
* - `Equal<never, true>` and `Equal<any, true>` 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<T> = {
[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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The only assertion form used here — its constraint is what refuses `false`. */
type Expect<T extends true> = T;

/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
type IsDeclaredOn<K extends PropertyKey> = K extends keyof Declared<DataTableSchema> ? 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<false>;

// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
type _EqualRefusesNever = Expect<Equal<never, true>>;

// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
type _EqualRefusesAny = Expect<Equal<any, true>>;

// @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<IsDeclaredOn<'bogusKeyNobodyDeclares6882'>>;

/* ── The assertions the card is about ─────────────────────────────────────── */

/** RED before objectui#6882's declaration, green after. */
type _RenderCellEditorIsDeclared = Expect<IsDeclaredOn<'renderCellEditor'>>;
/** RED before objectui#6882's declaration, green after. */
type _CellClassNameIsDeclared = Expect<IsDeclaredOn<'cellClassName'>>;

/**
* 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<DataTableSchema>['renderCellEditor'],
((ctx: CellEditorContext) => React.ReactNode) | undefined
>
>;

/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
type _CellClassNameShape = Expect<Equal<Declared<DataTableSchema>['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');
});
});
62 changes: 62 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 `<tr>` — `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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema` by os-sam · Pull Request #6918 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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'
? <MyPicker value={value} onSelect={commit} onDismiss={cancel} />
: 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.
74 changes: 72 additions & 2 deletions content/docs/components/complex/data-table.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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
Expand Down
21 changes: 11 additions & 10 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<X extends true ? true : false>` 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<false>` must be refused → the assertion helper has teeth;
* - `Equal<never, true>` and `Equal<any, true>` 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<T> = {
[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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The only assertion form used here — its constraint is what refuses `false`. */
type Expect<T extends true> = T;

/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
type IsDeclaredOn<K extends PropertyKey> = K extends keyof Declared<DataTableSchema> ? 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<false>;

// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
type _EqualRefusesNever = Expect<Equal<never, true>>;

// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
type _EqualRefusesAny = Expect<Equal<any, true>>;

// @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<IsDeclaredOn<'bogusKeyNobodyDeclares6882'>>;

/* ── The assertions the card is about ─────────────────────────────────────── */

/** RED before objectui#6882's declaration, green after. */
type _RenderCellEditorIsDeclared = Expect<IsDeclaredOn<'renderCellEditor'>>;
/** RED before objectui#6882's declaration, green after. */
type _CellClassNameIsDeclared = Expect<IsDeclaredOn<'cellClassName'>>;

/**
* 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<DataTableSchema>['renderCellEditor'],
((ctx: CellEditorContext) => React.ReactNode) | undefined
>
>;

/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
type _CellClassNameShape = Expect<Equal<Declared<DataTableSchema>['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');
});
});
62 changes: 62 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 `<tr>` — `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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema` by os-sam · Pull Request #6918 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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'
? <MyPicker value={value} onSelect={commit} onDismiss={cancel} />
: 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.
74 changes: 72 additions & 2 deletions content/docs/components/complex/data-table.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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
Expand Down
21 changes: 11 additions & 10 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<X extends true ? true : false>` 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<false>` must be refused → the assertion helper has teeth;
* - `Equal<never, true>` and `Equal<any, true>` 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<T> = {
[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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The only assertion form used here — its constraint is what refuses `false`. */
type Expect<T extends true> = T;

/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
type IsDeclaredOn<K extends PropertyKey> = K extends keyof Declared<DataTableSchema> ? 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<false>;

// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
type _EqualRefusesNever = Expect<Equal<never, true>>;

// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
type _EqualRefusesAny = Expect<Equal<any, true>>;

// @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<IsDeclaredOn<'bogusKeyNobodyDeclares6882'>>;

/* ── The assertions the card is about ─────────────────────────────────────── */

/** RED before objectui#6882's declaration, green after. */
type _RenderCellEditorIsDeclared = Expect<IsDeclaredOn<'renderCellEditor'>>;
/** RED before objectui#6882's declaration, green after. */
type _CellClassNameIsDeclared = Expect<IsDeclaredOn<'cellClassName'>>;

/**
* 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<DataTableSchema>['renderCellEditor'],
((ctx: CellEditorContext) => React.ReactNode) | undefined
>
>;

/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
type _CellClassNameShape = Expect<Equal<Declared<DataTableSchema>['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');
});
});
62 changes: 62 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 `<tr>` — `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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema` by os-sam · Pull Request #6918 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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'
? <MyPicker value={value} onSelect={commit} onDismiss={cancel} />
: 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.
74 changes: 72 additions & 2 deletions content/docs/components/complex/data-table.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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
Expand Down
21 changes: 11 additions & 10 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<X extends true ? true : false>` 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<false>` must be refused → the assertion helper has teeth;
* - `Equal<never, true>` and `Equal<any, true>` 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<T> = {
[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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The only assertion form used here — its constraint is what refuses `false`. */
type Expect<T extends true> = T;

/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
type IsDeclaredOn<K extends PropertyKey> = K extends keyof Declared<DataTableSchema> ? 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<false>;

// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
type _EqualRefusesNever = Expect<Equal<never, true>>;

// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
type _EqualRefusesAny = Expect<Equal<any, true>>;

// @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<IsDeclaredOn<'bogusKeyNobodyDeclares6882'>>;

/* ── The assertions the card is about ─────────────────────────────────────── */

/** RED before objectui#6882's declaration, green after. */
type _RenderCellEditorIsDeclared = Expect<IsDeclaredOn<'renderCellEditor'>>;
/** RED before objectui#6882's declaration, green after. */
type _CellClassNameIsDeclared = Expect<IsDeclaredOn<'cellClassName'>>;

/**
* 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<DataTableSchema>['renderCellEditor'],
((ctx: CellEditorContext) => React.ReactNode) | undefined
>
>;

/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
type _CellClassNameShape = Expect<Equal<Declared<DataTableSchema>['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');
});
});
62 changes: 62 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 `<tr>` — `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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema` by os-sam · Pull Request #6918 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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'
? <MyPicker value={value} onSelect={commit} onDismiss={cancel} />
: 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.
74 changes: 72 additions & 2 deletions content/docs/components/complex/data-table.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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
Expand Down
21 changes: 11 additions & 10 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<X extends true ? true : false>` 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<false>` must be refused → the assertion helper has teeth;
* - `Equal<never, true>` and `Equal<any, true>` 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<T> = {
[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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The only assertion form used here — its constraint is what refuses `false`. */
type Expect<T extends true> = T;

/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
type IsDeclaredOn<K extends PropertyKey> = K extends keyof Declared<DataTableSchema> ? 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<false>;

// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
type _EqualRefusesNever = Expect<Equal<never, true>>;

// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
type _EqualRefusesAny = Expect<Equal<any, true>>;

// @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<IsDeclaredOn<'bogusKeyNobodyDeclares6882'>>;

/* ── The assertions the card is about ─────────────────────────────────────── */

/** RED before objectui#6882's declaration, green after. */
type _RenderCellEditorIsDeclared = Expect<IsDeclaredOn<'renderCellEditor'>>;
/** RED before objectui#6882's declaration, green after. */
type _CellClassNameIsDeclared = Expect<IsDeclaredOn<'cellClassName'>>;

/**
* 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<DataTableSchema>['renderCellEditor'],
((ctx: CellEditorContext) => React.ReactNode) | undefined
>
>;

/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
type _CellClassNameShape = Expect<Equal<Declared<DataTableSchema>['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');
});
});
62 changes: 62 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 `<tr>` — `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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema` by os-sam · Pull Request #6918 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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'
? <MyPicker value={value} onSelect={commit} onDismiss={cancel} />
: 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.
74 changes: 72 additions & 2 deletions content/docs/components/complex/data-table.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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
Expand Down
21 changes: 11 additions & 10 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<X extends true ? true : false>` 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<false>` must be refused → the assertion helper has teeth;
* - `Equal<never, true>` and `Equal<any, true>` 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<T> = {
[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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The only assertion form used here — its constraint is what refuses `false`. */
type Expect<T extends true> = T;

/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
type IsDeclaredOn<K extends PropertyKey> = K extends keyof Declared<DataTableSchema> ? 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<false>;

// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
type _EqualRefusesNever = Expect<Equal<never, true>>;

// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
type _EqualRefusesAny = Expect<Equal<any, true>>;

// @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<IsDeclaredOn<'bogusKeyNobodyDeclares6882'>>;

/* ── The assertions the card is about ─────────────────────────────────────── */

/** RED before objectui#6882's declaration, green after. */
type _RenderCellEditorIsDeclared = Expect<IsDeclaredOn<'renderCellEditor'>>;
/** RED before objectui#6882's declaration, green after. */
type _CellClassNameIsDeclared = Expect<IsDeclaredOn<'cellClassName'>>;

/**
* 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<DataTableSchema>['renderCellEditor'],
((ctx: CellEditorContext) => React.ReactNode) | undefined
>
>;

/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
type _CellClassNameShape = Expect<Equal<Declared<DataTableSchema>['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');
});
});
62 changes: 62 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 `<tr>` — `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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(types): declare `renderCellEditor` and schema-level `cellClassName` on `DataTableSchema` by os-sam · Pull Request #6918 · objectstack-ai/objectui · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff line change
@@ -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'
? <MyPicker value={value} onSelect={commit} onDismiss={cancel} />
: 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.
74 changes: 72 additions & 2 deletions content/docs/components/complex/data-table.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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
Expand Down
21 changes: 11 additions & 10 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
146 changes: 146 additions & 0 deletions packages/types/src/__tests__/data-table-declared-keys-6882.test.ts
Original file line numberDiff line numberDiff line change
@@ -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<X extends true ? true : false>` 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<false>` must be refused → the assertion helper has teeth;
* - `Equal<never, true>` and `Equal<any, true>` 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<T> = {
[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<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;

/** The only assertion form used here — its constraint is what refuses `false`. */
type Expect<T extends true> = T;

/** Is `K` a DECLARED member of `DataTableSchema` (index signature stripped)? */
type IsDeclaredOn<K extends PropertyKey> = K extends keyof Declared<DataTableSchema> ? 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<false>;

// @ts-expect-error objectui#6882 — `never` must NOT read as equal to `true`. An `extends`-shaped comparison would let it through.
type _EqualRefusesNever = Expect<Equal<never, true>>;

// @ts-expect-error objectui#6882 — `any` must NOT read as equal to `true`, for the same reason.
type _EqualRefusesAny = Expect<Equal<any, true>>;

// @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<IsDeclaredOn<'bogusKeyNobodyDeclares6882'>>;

/* ── The assertions the card is about ─────────────────────────────────────── */

/** RED before objectui#6882's declaration, green after. */
type _RenderCellEditorIsDeclared = Expect<IsDeclaredOn<'renderCellEditor'>>;
/** RED before objectui#6882's declaration, green after. */
type _CellClassNameIsDeclared = Expect<IsDeclaredOn<'cellClassName'>>;

/**
* 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<DataTableSchema>['renderCellEditor'],
((ctx: CellEditorContext) => React.ReactNode) | undefined
>
>;

/** Matches `TableColumn.cellClassName` and `BaseSchema.className` — both `string`. */
type _CellClassNameShape = Expect<Equal<Declared<DataTableSchema>['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');
});
});
62 changes: 62 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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 `<tr>` — `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.
Expand Down
Loading
Loading