From d431b5ddbc67863eb9dd2e4f4b9b80582e6ade9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 12:17:48 +0000 Subject: [PATCH] feat(types): declare `fitContent` on `TableColumn` (#6424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `data-table` skips `fitContent` columns in the auto-width pass and renders them as a `width:1%` + nowrap cell with no overflow clamp, but the published declaration refused the key: a typed author got a compile error, and `TableColumnSchema.parse` silently stripped the flag, while an untyped producer's identical key worked. Declare it — interface + `TableColumnSchema` mirror (`z.boolean()`, typed rather than waved through) + the `StaticTableColumn` tombstone under #5474's lockstep rule — in the shape #6615 used for `headerIcon`. Retire was excluded by measurement: shipped source authors the key on the row-actions column. The two `(col as any).fitContent` casts drop with the declaration; they were redundant at compile time already (`col` is `any` via the file's own normalization), so that removal is bookkeeping, not the fix. Part of #6424 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .../6424-tablecolumn-declares-fitcontent.md | 43 +++++++++ .../data-table-declared-column-keys.test.tsx | 67 ++++++++++++++ .../src/renderers/complex/data-table.tsx | 4 +- .../static-table-narrow-surface.test.ts | 90 ++++++++++++++++--- packages/types/src/data-display.ts | 27 ++++++ packages/types/src/zod/data-display.zod.ts | 10 +++ 6 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 .changeset/6424-tablecolumn-declares-fitcontent.md diff --git a/.changeset/6424-tablecolumn-declares-fitcontent.md b/.changeset/6424-tablecolumn-declares-fitcontent.md new file mode 100644 index 0000000000..622f93d18e --- /dev/null +++ b/.changeset/6424-tablecolumn-declares-fitcontent.md @@ -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. diff --git a/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx index b6ce2306a3..690cb8b0bd 100644 --- a/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx +++ b/packages/components/src/renderers/complex/__tests__/data-table-declared-column-keys.test.tsx @@ -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'); + } + }); +}); diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index d0a32a0a67..fa955a6006 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -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%' @@ -2150,7 +2150,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { )} {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%' diff --git a/packages/types/src/__tests__/static-table-narrow-surface.test.ts b/packages/types/src/__tests__/static-table-narrow-surface.test.ts index 77f051cd3e..b59508a5b4 100644 --- a/packages/types/src/__tests__/static-table-narrow-surface.test.ts +++ b/packages/types/src/__tests__/static-table-narrow-surface.test.ts @@ -62,13 +62,28 @@ type Equal = type Expect = 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>; +// 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>; +// 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>; + /* ── fixtures ────────────────────────────────────────────────────────────── */ const LIVE_COLUMN = { @@ -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 = { minWidth: 80, align: 'right', @@ -96,6 +113,7 @@ const RETIRED_COLUMN_KEYS: Record = { editable: false, cell: () => 'x', headerIcon: 'lucide:hash', + fitContent: true, format: '$0,0', options: [{ value: 'tech', label: 'Technology' }], currency: 'EUR', @@ -121,6 +139,7 @@ const RICH_COLUMN_KEYS = [ 'editable', 'cell', 'headerIcon', + 'fitContent', 'format', 'options', 'currency', @@ -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(), ); @@ -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 diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index c6d735d4dd..d8c5f4db6d 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -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 @@ -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 diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index ac32525771..11b6fd0f16 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -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 @@ -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'),