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
43 changes: 43 additions & 0 deletions .changeset/6424-tablecolumn-declares-fitcontent.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
'@object-ui/types': minor
---

`TableColumn` declares `fitContent`, the content-hugging flag `data-table` has
honoured all along (objectui#6424, maintainer ruling 2026-08-28, Option A — the
card's second key, in the shape #6615 landed `headerIcon` in).

The key was undeclared-but-honoured: `data-table` skips `fitContent` columns in
the auto-width pass and renders them as a `width:1%` + `whitespace-nowrap` cell
with no `overflow-hidden` clamp — but the published declaration refused the key,
so a typed author writing `{ accessorKey: '_actions', fitContent: true }` got a
compile error for a key the renderer implements, and `TableColumnSchema.parse`
silently STRIPPED it, while the same key placed by an untyped producer worked.
The runtime admitted a vocabulary the declaration refused — the second de-facto
contract AGENTS.md #0.1 forbids, here with the CONSUMER out of step.

Retiring the reads instead was excluded BY MEASUREMENT, not preference: shipped
source authors the key (`ObjectGrid` writes `fitContent: true` on the injected
row-actions `_actions` column) and `data-table-fit-content.test.tsx` pins the
result. Retiring would re-clip inline row-action buttons.

- `TableColumn.fitContent?: boolean` — serializable metadata, unlike the
`React.ReactNode` slot `headerIcon` is.
- `TableColumnSchema` mirrors it as `z.boolean().optional()`: the flag now
SURVIVES parse instead of vanishing, and a non-boolean is a loud refusal
naming the key rather than acceptance-without-validation. Pinned by output
survival, not parse acceptance — acceptance was green before, while the flag
was stripped.
- `StaticTableColumn` / `StaticTableColumnSchema` tombstone it (`?: never` +
`z.never().optional()`), per #5474's lockstep rule: every rich key needs a
deliberate static-side decision, and the static renderer has no auto-width
pass to opt out of (its measured read set is the five live keys). Authoring
it on a static `table` column is a loud parse refusal naming the key, not a
silent strip.

No runtime behaviour changes in `data-table` itself — the reads were already
live; the declaration and the parse road now agree with them. The two
`(col as any).fitContent` sites drop with the declaration, but that removal is
bookkeeping rather than the fix: `col` is already `any` at both sites, widened
by the file's own `col: any` normalization, so the casts were redundant at
compile time today. They become load-bearing the moment those render callbacks
are typed — which is the standing instrument gap, not closed here.
Original file line numberDiff line numberDiff line change
Expand Up@@ -194,3 +194,70 @@ describe('data-table columns — `headerIcon` is DECLARED and renders (#6424)',
}
});
});

describe('data-table columns — `fitContent` is DECLARED and survives parse (#6424)', () => {
// The card's SECOND key, ruled 2026-08-28 (Option A) in the same shape
// `headerIcon` landed in above. Retire was excluded by measurement — shipped
// source authors `fitContent: true` on the injected row-actions column — so
// the key is declared on `TableColumn` + its zod mirror instead.
//
// The RENDER half is already pinned, and deliberately not duplicated here:
// `data-table-fit-content.test.tsx` holds the width:1% + nowrap + no-clip
// behaviour. What was broken and is fixed here is the AUTHORING surface, so
// that is what these pin.

it('SURVIVES the zod mirror parse — no longer silently stripped', () => {
// Acceptance cannot pin this: a non-strict z.object() ACCEPTS an
// undeclared key and silently STRIPS it, so this exact parse was green
// BEFORE the declaration — while the flag vanished and the row-actions
// column fell back to the estimated 80px floor that clipped its buttons.
// The pin is survival into the parsed OUTPUT, same discipline as
// `headerIcon` above and `editable: false` in
// `static-table-narrow-surface.test.ts`.
const result = TableColumnSchema.safeParse({
header: 'Actions',
accessorKey: '_actions',
fitContent: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect('fitContent' in result.data).toBe(true);
expect(result.data.fitContent).toBe(true);
}
});

it('is TYPED by the mirror, not waved through — a non-boolean is a loud refusal', () => {
// `fitContent` is serializable metadata, unlike the `z.any()` runtime
// slots (`cell`, `headerIcon`), so the mirror types it. Without this the
// declaration would buy acceptance without validation — the lenient face
// that lets AI-authored metadata errors through (the defect objectui#5853
// fixed for `type`).
const result = TableColumnSchema.safeParse({
header: 'Actions',
accessorKey: '_actions',
fitContent: 'yes',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((i) => i.path.join('.'))).toContain('fitContent');
}
});

it('parses through the whole authored column — declared keys travel together', () => {
// Both of this card's keys on one column, the way ObjectGrid emits them.
const node = React.createElement('svg');
const result = TableColumnSchema.safeParse({
header: 'Actions',
accessorKey: '_actions',
headerIcon: node,
fitContent: true,
align: 'right',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.fitContent).toBe(true);
expect(result.data.headerIcon).toBe(node);
expect(result.data.align).toBe('right');
}
});
});
4 changes: 2 additions & 2 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -1881,7 +1881,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
{columns.map((col, index) => {
// `fitContent` columns hug their content (no fixed width /
// char-estimate) so inline row-action buttons never get clipped.
const isFit = (col as any).fitContent === true
const isFit = col.fitContent === true
&& !columnWidths[col.accessorKey] && !col.width;
const columnWidth = isFit
? '1%'
Expand DownExpand Up@@ -2150,7 +2150,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
</TableCell>
)}
{columns.map((col, colIndex) => {
const isFit = (col as any).fitContent === true
const isFit = col.fitContent === true
&& !columnWidths[col.accessorKey] && !col.width;
const columnWidth = isFit
? '1%'
Expand Down
90 changes: 79 additions & 11 deletions packages/types/src/__tests__/static-table-narrow-surface.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,13 +62,28 @@ type Equal<A, B> =
type Expect<T extends true> = T;

// Side 3, type level: the narrow shape declares exactly the rich shape's key
// set — five live, thirteen tombstoned, none invented, none forgotten. If a key
// is ever added to `TableColumn` without a deliberate decision on the static
// side (live or tombstone), this line goes red. (`headerIcon` was the first
// key to arrive through that gate — added rich by objectui#6424, tombstoned
// here — and #6425's three declared field-meta overrides followed it.)
// set — five live, fourteen tombstoned, none invented, none forgotten. If a
// key is ever added to `TableColumn` without a deliberate decision on the
// static side (live or tombstone), this line goes red. (`headerIcon` was the
// first key to arrive through that gate — added rich by objectui#6424,
// tombstoned here — then #6425's three declared field-meta overrides, and now
// `fitContent`, objectui#6424's second key.)
type _SameKeySet = Expect<Equal<keyof StaticTableColumn, keyof TableColumn>>;

// The DECLARATION itself, read directly off the interface — no object literal
// anywhere, so excess-property freshness plays no part. If `fitContent` were
// undeclared, `TableColumn['fitContent']` would not resolve and this line
// would not compile; if it were declared at a different type, `Equal` fails.
// This is the pin a fresh-literal test cannot give: a literal is refused for
// an undeclared key AND accepted for a declared one, so it measures freshness
// and declaration together, while this measures only the declaration
// (objectui#6424, maintainer ruling 2026-08-28, Option A).
type _FitContentDeclaredRich = Expect<Equal<TableColumn['fitContent'], boolean | undefined>>;
// The static twin's tombstone, same freshness-free route: `?: never` (never
// `undefined`-only, never absent). Absence would make this line fail to
// compile, which is the lockstep rule's whole point.
type _FitContentTombstonedStatic = Expect<Equal<StaticTableColumn['fitContent'], undefined>>;

/* ── fixtures ────────────────────────────────────────────────────────────── */

const LIVE_COLUMN = {
Expand All@@ -79,12 +94,14 @@ const LIVE_COLUMN = {
width: 120,
};

/** The thirteen keys the narrow surface refuses, with the value an author
* would plausibly write for each: nine the #5474 split retired, plus the four
/** The fourteen keys the narrow surface refuses, with the value an author
* would plausibly write for each: nine the #5474 split retired, plus the five
* that joined the RICH shape later and are tombstoned here under the lockstep
* rule — `headerIcon` (objectui#6424) and the three field-meta overrides
* objectui#6425 declared (`format` / `options` / `currency`). The static
* renderer reads none of them. */
* rule — `headerIcon` and `fitContent` (objectui#6424's two keys) and the
* three field-meta overrides objectui#6425 declared (`format` / `options` /
* `currency`). The static renderer reads none of them: its measured read set
* is the five live keys, and it has no auto-width pass for `fitContent` to
* opt out of. */
const RETIRED_COLUMN_KEYS: Record<string, unknown> = {
minWidth: 80,
align: 'right',
Expand All@@ -96,6 +113,7 @@ const RETIRED_COLUMN_KEYS: Record<string, unknown> = {
editable: false,
cell: () => 'x',
headerIcon: 'lucide:hash',
fitContent: true,
format: '$0,0',
options: [{ value: 'tech', label: 'Technology' }],
currency: 'EUR',
Expand All@@ -121,6 +139,7 @@ const RICH_COLUMN_KEYS = [
'editable',
'cell',
'headerIcon',
'fitContent',
'format',
'options',
'currency',
Expand DownExpand Up@@ -282,7 +301,7 @@ describe('rich `TableColumn` — NOT narrowed by the split (ruling scope, object
/* ── 3. the two shapes stay in lockstep ──────────────────────────────────── */

describe('the split itself — narrow = rich key set, live = the measured read set', () => {
it('narrow zod declares exactly the interface key set: 5 live + 13 tombstones', () => {
it('narrow zod declares exactly the interface key set: 5 live + 14 tombstones', () => {
expect(liveKeys(StaticTableColumnSchema).sort()).toEqual(
['accessorKey', 'cellClassName', 'className', 'header', 'width'].sort(),
);
Expand DownExpand Up@@ -326,6 +345,55 @@ describe('interface tombstones — authoring a retired key is a tsc error', () =
expect(column.header).toBe('Amount');
});

it('accepts `fitContent` on a rich TableColumn, and REFUSES it on the static twin (#6424)', () => {
// Both halves of the ruled declaration at the tsc layer. The static half
// carries the directive; the rich half deliberately does NOT, so a later
// narrowing of `TableColumn` fails here rather than passing silently.
const rich: TableColumn = {
header: 'Actions',
accessorKey: '_actions',
fitContent: true,
};
const narrow: StaticTableColumn = {
header: 'Actions',
accessorKey: '_actions',
// @ts-expect-error `fitContent` is tombstoned on the narrow surface
// (objectui#6424, under #5474's lockstep rule) — the static renderer
// has no auto-width pass to opt out of; use data-table.
fitContent: true,
};
expect(rich.fitContent).toBe(true);
expect(narrow.header).toBe('Actions');
});

it('FRESHNESS-FREE: the tombstone refuses a NON-FRESH value too, and the rich READ compiles (#6424)', () => {
// Why this test exists on top of the one above. At a FRESH object literal
// an undeclared key and a `?: never` tombstone are indistinguishable —
// both are tsc errors, by excess-property checking. Route the same value
// through a variable and the two come apart: excess properties on a
// NON-FRESH value are structurally fine, so an undeclared key would be
// ACCEPTED here and the directive below would go unused (a red build in
// this package, which type-checks its tests). Only a real tombstone
// refuses it. The literal pins the authoring surface; this pins the
// declaration.
const authored = { header: 'Actions', accessorKey: '_actions', fitContent: true };

// @ts-expect-error `fitContent: boolean` is not assignable to the
// tombstoned `fitContent?: never` — the refusal survives widening, so it
// is the tombstone doing the work, not literal freshness.
const narrow: StaticTableColumn = authored;

// The rich side, freshness-free in the other direction: assigning a wider
// object is legal whether or not the key is declared, so the pin is the
// READ — `column.fitContent` only compiles if `TableColumn` DECLARES it,
// and the annotation pins the declared type while it is at it.
const column: TableColumn = authored;
const fit: boolean | undefined = column.fitContent;

expect(fit).toBe(true);
expect(narrow.accessorKey).toBe('_actions');
});

it('still accepts the interactive keys on the RICH TableColumn (control)', () => {
// No directive here on purpose: if the rich interface ever narrows, this
// literal stops compiling — which is exactly the regression the ruling
Expand Down
27 changes: 27 additions & 0 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -367,6 +367,23 @@ export interface TableColumn {
* standing instrument gap, not closed here.
*/
headerIcon?: React.ReactNode;
/**
* Size this column to its own content instead of to a measured width: the
* auto-width pass skips it, and `data-table` renders it as a `width:1%` +
* `whitespace-nowrap` cell with no `overflow-hidden` clamp. Written by
* `ObjectGrid` on the injected row-actions `_actions` column, whose inline
* buttons carry no string data and were otherwise pinned to the 80px floor
* and clipped.
*
* Declared by objectui#6424 (maintainer ruling 2026-08-28, Option A) — the
* card's second key, in the same shape {@link TableColumn.headerIcon}
* landed in. Retiring the reads was excluded BY MEASUREMENT, not
* preference: shipped source authors `fitContent: true`, and
* `data-table-fit-content.test.tsx` pins the un-clipped result. Before
* this, a typed author got a compile error — and a silent strip from the
* zod mirror — for a key the renderer honours.
*/
fitContent?: boolean;
/**
* Field-meta override: display format pattern for the cell value (e.g.
* `"$0,0"`, `"0%"`, `"YYYY-MM-DD"`), honoured by `object-data-table`'s cell
Expand DownExpand Up@@ -518,6 +535,16 @@ export interface StaticTableColumn {
* @deprecated Not part of the static `table` renderer's contract.
*/
headerIcon?: never;
/**
* NOT on the static `table` surface (objectui#6424, under #5474's lockstep
* rule: every rich key needs a deliberate static-side decision). Declared
* on the rich {@link TableColumn} only — the static renderer has no
* auto-width pass to opt out of and no per-cell overflow clamp to lift
* (its measured read set is the five live keys above). Content-hugging
* columns are `data-table`'s capability.
* @deprecated Not part of the static `table` renderer's contract.
*/
fitContent?: never;
/**
* NOT on the static `table` surface (objectui#6425, under #5474's lockstep
* rule: every rich key needs a deliberate static-side decision). Declared
Expand Down
10 changes: 10 additions & 0 deletions packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,15 @@ export const TableColumnSchema = z.object({
// declaration refused). Declared on the interface, mirrored here, and paired
// in `__tests__/zod-mirror-parity.test.ts`.
headerIcon: z.any().optional().describe('Icon node rendered into the header cell, before the header text'),
// The card's second key (objectui#6424, maintainer ruling 2026-08-28,
// Option A). Unlike `headerIcon`/`cell` above this one is serializable
// metadata, so the mirror TYPES it — `z.boolean()`, not `z.any()`. It was
// silently STRIPPED by this non-strict object before the declaration: the
// same second de-facto contract the `headerIcon` half closed, the renderer
// honouring what the published declaration refused. Pinned by output
// SURVIVAL, not parse acceptance — acceptance was green while the flag
// vanished and the row-actions column fell back to the clipped 80px floor.
fitContent: z.boolean().optional().describe('Size the column to its own content (width:1% + nowrap) instead of a measured width'),
// The three field-meta override keys objectui#6425 declared (maintainer
// ruling 2026-08-27, per-key): honoured by `object-data-table`'s cell
// pipeline — documented behaviour for `format` / `options`, long-shipped
Expand DownExpand Up@@ -178,6 +187,7 @@ export const StaticTableColumnSchema = z.object({
editable: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'),
cell: z.never().optional().describe('RETIRED (objectui#5474) — never read by the static table; use data-table'),
headerIcon: z.never().optional().describe('NOT on the static table surface (objectui#6424) — declared on the rich TableColumn only; use data-table'),
fitContent: z.never().optional().describe('NOT on the static table surface (objectui#6424) — declared on the rich TableColumn only; use data-table'),
format: z.never().optional().describe('NOT on the static table surface (objectui#6425) — declared on the rich TableColumn only; use data-table'),
options: z.never().optional().describe('NOT on the static table surface (objectui#6425) — declared on the rich TableColumn only; use data-table'),
currency: z.never().optional().describe('NOT on the static table surface (objectui#6425) — declared on the rich TableColumn only; use data-table'),
Expand Down
Loading