From 579900cf5ed49fae54c4e7c92a5fccf5dca5bf6d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:01:42 +0000 Subject: [PATCH 1/4] fix(components): size an auto-width column from what its cells draw, and stack right-pinned columns The data table estimated an auto-sized column from the stored value's length. A currency cell storing 200000 draws "200,000.00" and truncated; a lookup storing an id draws a name and was too wide, and the columns' sum pushed the scrolling region under the right-pinned Actions column. The table now reads back what each auto-sized column's header and cells drew: the natural width of that content where the page is laid out, its text length where it is not, and the stored value only before the first draw. Two right-pinned columns no longer share right-0: each sticks at the measured width of the pinned cells after it. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../data-table-drawn-width-11682.test.tsx | 323 ++++++++++++++++++ ...a-table-masked-client-paths-10657.test.tsx | 11 +- .../src/renderers/complex/data-table.tsx | 304 ++++++++++++++++- 3 files changed, 624 insertions(+), 14 deletions(-) create mode 100644 packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx diff --git a/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx new file mode 100644 index 0000000000..24c945cdec --- /dev/null +++ b/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx @@ -0,0 +1,323 @@ +/** + * 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#11682 — an auto-sized column is sized from what its cells DRAW, + * and a sticky column never covers a cell's content. + * + * The estimate used to read the STORED value: `String(row[key]).length`, at + * 8px a character plus 48px. On the showcase at a 1440px viewport that was + * wrong both ways. Budget stores `200000` (6 characters, 96px) and draws + * `200,000.00`, which truncated. Tasks' Assignee drew 171px of text in a + * 240px column, and the columns' sum came to 1230px in a 1184px content box, + * so the scrolling region passed 46px under the right-pinned Actions column, + * and the right-aligned Progress percentage with it. + * + * ## Which pins are arithmetic and which replay geometry + * + * happy-dom lays nothing out: every `getBoundingClientRect()` is 0x0. So: + * + * - ARITHMETIC — the first block runs on that, as every page with no layout + * does: the drawn TEXT is read back and its length is the estimate's input. + * - REPLAYED GEOMETRY — the second block gives the table a fake layout that + * answers ONLY the probe (an element at `width: max-content`) and answers + * it with the natural widths measured in Chromium on the showcase's Projects + * and Tasks lists at a 1440px viewport (the `MEASURED` table below, one + * reading, ⛔ not re-derived here). A table that never probes reads 0 and + * falls back to the text path, so these pins fail if the probe is gone. + * What they assert is the arithmetic of that reading: the Budget column is + * given at least what its widest amount needs, and the Tasks columns fit + * the measured 1184px content box beside the measured fixed columns, so no + * scrolling column passes under the pinned Actions column. The live + * readings themselves are the PR's. + * - The stacked-pin block mocks the header cells' widths, the way + * `data-table-sticky-offsets.test.tsx` does for the left pins. + */ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import React from 'react'; +import { ComponentRegistry } from '@object-ui/core'; +import '../data-table'; + +function DataTable(props: { schema: Record }) { + const Impl = ComponentRegistry.get('data-table') as any; + if (!Impl) throw new Error('data-table not registered'); + return ; +} + +const header = (label: string): HTMLElement => { + const th = Array.from(document.querySelectorAll('thead th')).find( + (el) => (el.textContent ?? '').trim() === label, + ); + expect(th, `CONTROL: a "${label}" header rendered`).toBeTruthy(); + return th as HTMLElement; +}; +const px = (value: string) => parseFloat(value); + +/** The body cells of the column headed `label`, in row order. */ +function bodyCells(label: string): HTMLElement[] { + const ths = Array.from(document.querySelectorAll('thead tr th')); + const index = ths.findIndex((th) => th.textContent?.trim() === label); + if (index < 0) throw new Error(`no header "${label}"`); + return Array.from(document.querySelectorAll('tbody tr')).map((tr) => tr.children[index] as HTMLElement); +} + +const AMOUNT = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +afterEach(() => { + vi.restoreAllMocks(); + cleanup(); +}); + +describe('data-table sizes a column from the text its cells draw, where nothing is laid out (objectui#11682)', () => { + it('a currency cell is sized from "200,000.00", not from the stored 200000', () => { + const schema = (cell?: (v: number) => string) => ({ + type: 'data-table', + data: [{ id: '1', budget: 200000 }], + columns: [{ header: 'Budget', accessorKey: 'budget', ...(cell ? { cell } : {}) }], + pagination: false, + searchable: false, + }); + render( AMOUNT.format(v))} />); + // 10 drawn characters: 10 * 8 + 48. + expect(header('Budget').style.width).toBe('128px'); + expect(bodyCells('Budget')[0].style.maxWidth, 'the cell is clamped to the same width').toBe('128px'); + cleanup(); + // CONTROL: the same column drawing its stored value is sized from it. + render(); + expect(header('Budget').style.width).toBe('96px'); + }); + + it('a lookup cell is sized from the name it draws, not the id it stores', () => { + render( + 'Mobile App' }], + pagination: false, + searchable: false, + }} + />, + ); + // 'Mobile App' is 10 characters (128px); the 16-character id was 176px. + expect(header('Project').style.width).toBe('128px'); + }); + + it('re-sizes when a cell draws its text after the table rendered (a lookup resolving its name)', async () => { + const Resolving = () => { + const [name, setName] = React.useState(''); + React.useEffect(() => { + const t = setTimeout(() => setName('Website Relaunch Programme'), 0); + return () => clearTimeout(t); + }, []); + return {name}; + }; + render( + }], + pagination: false, + searchable: false, + }} + />, + ); + // 26 characters once resolved: 26 * 8 + 48. The table itself does not + // re-render when the cell does; the body observer is what re-reads it. + await waitFor(() => expect(header('Project').style.width).toBe('256px')); + }); +}); + +/** + * Natural (`max-content`) widths read in Chromium on the showcase lists at a + * 1440px viewport, console dev server against the showcase backend, keyed by + * column and drawn text. Headers are keyed by their label. One reading, kept + * as a fixture; the PR records how it was taken. + */ +const MEASURED: Record> = { + budget: { Budget: 58.03, '150,000.00': 72.61, '50,000.00': 64.38, '200,000.00': 72.61, '600,000.00': 72.61, '90,000.00': 64.38 }, + title: { + Title: 42.16, 'App wireframes': 96.23, 'Build homepage': 98.05, 'PII access review': 104.52, 'Content backlog': 99.72, + 'Audit current IA': 94.83, 'Design system': 89.31, 'Ingest pipeline': 88.3, 'Evidence collection': 117.77, + 'SEO migration plan': 115.09, 'Warehouse schema': 119.83, + }, + project: { Project: 39.22, 'Mobile App': 68.17, 'Website Relaunch': 106.94, 'Compliance Audit': 105.78, 'Data Platform': 81.08 }, + assignee: { Assignee: 69.77, 'auditor.demo@example.com': 171.02, 'admin@objectos.ai': 113.94, 'phone.demo@example.com': 167.97 }, + status: { Status: 53.42, Done: 51.72, 'In Progress': 85.59, 'To Do': 54.42, Backlog: 67.36, 'In Review': 75.83 }, + priority: { Priority: 58.41, Medium: 67.89, High: 48.25, Urgent: 60.47, Low: 45.47 }, + due_date: { + 'Due Date': 69.22, 'Overdue 6d': 70.61, 'Oct 16': 40.92, 'Oct 18': 40.92, 'Nov 5': 35.69, 'Sep 18': 43.19, + 'In 2 days': 54.25, 'Oct 26': 40.92, 'Oct 31': 40.92, 'Oct 21': 40.92, 'In 3 days': 54.25, + }, + // The progress cell draws a 64px bar beside the number; both are in its width. + progress: { Progress: 67.78, '100%': 109.36, '45%': 101.11, '0%': 92.84, '80%': 101.11, '55%': 101.11, '90%': 101.11 }, +}; +/** The measured content box of the list, and its fixed columns: checkbox, `#`, Actions. */ +const CONTENT_BOX = 1184; +const FIXED_COLUMNS = 28 + 39.22 + 66.66; +/** Measured cell padding, and the frozen first column's 2px right border. */ +const CELL_CSS = 'th, td { padding-left: 12px; padding-right: 12px; } .border-r-2 { border-right: 2px solid; }'; + +function installMeasuredLayout() { + const style = document.createElement('style'); + style.textContent = CELL_CSS; + document.head.appendChild(style); + const original = HTMLElement.prototype.getBoundingClientRect; + const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const key = this.getAttribute('data-auto-width-key'); + // Only the probe is laid out; everything else answers what happy-dom does. + if (key === null || this.style.width !== 'max-content') return original.call(this); + const text = (this.textContent ?? '').trim(); + const width = MEASURED[key]?.[text]; + if (width === undefined) throw new Error(`no measured width for ${key} / "${text}"`); + return { width, height: 20, top: 0, left: 0, bottom: 20, right: width, x: 0, y: 0, toJSON: () => ({}) } as DOMRect; + }); + return () => { + spy.mockRestore(); + style.remove(); + }; +} + +const STATUS: Record = { done: 'Done', in_progress: 'In Progress', todo: 'To Do', backlog: 'Backlog', in_review: 'In Review' }; +const PRIORITY: Record = { low: 'Low', medium: 'Medium', high: 'High', urgent: 'Urgent' }; +/** The showcase's All Tasks rows: stored values, and the due-date text the cell drew that day. */ +const TASKS = [ + ['App wireframes', 'Mobile App', 'auditor.demo@example.com', 'done', 'medium', 'Overdue 6d', 100], + ['Build homepage', 'Website Relaunch', 'admin@objectos.ai', 'in_progress', 'high', 'Oct 16', 45], + ['PII access review', 'Compliance Audit', 'phone.demo@example.com', 'todo', 'urgent', 'Oct 18', 0], + ['Content backlog', 'Website Relaunch', 'phone.demo@example.com', 'backlog', 'low', 'Nov 5', 0], + ['Audit current IA', 'Website Relaunch', 'auditor.demo@example.com', 'done', 'medium', 'Sep 18', 100], + ['Design system', 'Website Relaunch', 'auditor.demo@example.com', 'in_review', 'high', 'In 2 days', 80], + ['Ingest pipeline', 'Data Platform', 'admin@objectos.ai', 'in_progress', 'urgent', 'Oct 26', 55], + ['Evidence collection', 'Compliance Audit', 'phone.demo@example.com', 'backlog', 'medium', 'Oct 31', 0], + ['SEO migration plan', 'Website Relaunch', 'admin@objectos.ai', 'todo', 'medium', 'Oct 21', 0], + ['Warehouse schema', 'Data Platform', 'admin@objectos.ai', 'in_review', 'high', 'In 3 days', 90], +].map(([title, project, assignee, status, priority, due, progress], i) => ({ + id: `t${i + 1}`, + title, + // A lookup arrives expanded; the cell draws its name. + project: { id: `p-${project}`, name: project }, + assignee, + status, + priority, + due_date: `2026-10-${String(10 + i).padStart(2, '0')}`, + due_text: due, + progress, +})); + +const TASK_COLUMNS = [ + { header: 'Title', accessorKey: 'title' }, + { header: 'Project', accessorKey: 'project', cell: (v: { name: string }) => v.name }, + { header: 'Assignee', accessorKey: 'assignee' }, + { header: 'Status', accessorKey: 'status', cell: (v: string) => STATUS[v] }, + { header: 'Priority', accessorKey: 'priority', cell: (v: string) => PRIORITY[v] }, + { header: 'Due Date', accessorKey: 'due_date', cell: (_v: string, row: { due_text: string }) => row.due_text }, + { header: 'Progress', accessorKey: 'progress', align: 'right', cell: (v: number) => `${v}%` }, +]; + +describe('data-table sizes a column from its measured drawn content, where the page is laid out (objectui#11682)', () => { + let uninstall: () => void = () => {}; + beforeEach(() => { + uninstall = installMeasuredLayout(); + }); + afterEach(() => uninstall()); + + it('Budget: the widest drawn amount renders whole in the width the column is given', () => { + render( + ({ id: String(i), budget })), + columns: [{ header: 'Budget', accessorKey: 'budget', align: 'right', cell: (v: number) => AMOUNT.format(v) }], + pagination: false, + searchable: false, + }} + />, + ); + const width = px(header('Budget').style.width); + expect(width - 24, 'the content box holds the widest drawn amount').toBeGreaterThanOrEqual(72.61); + for (const td of bodyCells('Budget')) expect(px(td.style.maxWidth)).toBe(width); + }); + + it('Progress: the Tasks list fits its 1440px content box, so no scrolling column passes under the pinned Actions column', () => { + render( + , + ); + const widths = TASK_COLUMNS.map((c) => px(header(c.header).style.width)); + const total = widths.reduce((a, b) => a + b, 0) + FIXED_COLUMNS; + expect(total, `the columns sum to ${total}px`).toBeLessThanOrEqual(CONTENT_BOX); + expect(px(header('Progress').style.width) - 24, 'the bar and the number render whole').toBeGreaterThanOrEqual(109.36); + }); + + it('the probe leaves no width behind on what it measured', () => { + render( + , + ); + const probed = Array.from(document.querySelectorAll('[data-auto-width-key]')); + expect(probed.length, 'CONTROL: the header labels and cells were probed').toBe(TASK_COLUMNS.length * (TASKS.length + 1)); + for (const el of probed) expect(el.style.width).toBe(''); + }); +}); + +describe('data-table stacks right-pinned columns instead of overlaying them (objectui#11682)', () => { + const PINNED = 'sticky right-0 z-10 bg-background border-l border-border'; + /** Header widths the mocked layout reports: the four data columns, in order. */ + const WIDTHS = [150, 120, 110, 67]; + + function renderPinned(pinnedCount: number) { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + const index = this.tagName === 'TH' && this.parentElement + ? Array.prototype.indexOf.call(this.parentElement.children, this) + : -1; + const width = index >= 0 ? (WIDTHS[index] ?? 0) : 0; + return { width, height: 40, top: 0, left: 0, bottom: 40, right: width, x: 0, y: 0, toJSON: () => ({}) } as DOMRect; + }); + const columns = ['Name', 'Owner', 'Due', 'Actions'].map((h, i) => ({ + header: h, + accessorKey: h.toLowerCase(), + ...(i >= 4 - pinnedCount ? { className: PINNED, cellClassName: PINNED } : {}), + })); + render( + , + ); + } + + it('an inner right pin sticks beside the outer one, at the outer one\'s measured width', () => { + renderPinned(2); + expect(header('Due').style.right, 'the inner pin clears the 67px outer pin').toBe('67px'); + expect(bodyCells('Due')[0].style.right).toBe('67px'); + expect(header('Actions').style.right, 'the outermost pin stays at right-0').toBe(''); + expect(bodyCells('Actions')[0].style.right).toBe(''); + }); + + it('CONTROL: one right pin renders exactly as before', () => { + renderPinned(1); + expect(header('Actions').style.right).toBe(''); + expect(header('Due').style.right).toBe(''); + }); +}); diff --git a/packages/components/src/renderers/complex/__tests__/data-table-masked-client-paths-10657.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-masked-client-paths-10657.test.tsx index b22053c05c..54a2d90260 100644 --- a/packages/components/src/renderers/complex/__tests__/data-table-masked-client-paths-10657.test.tsx +++ b/packages/components/src/renderers/complex/__tests__/data-table-masked-client-paths-10657.test.tsx @@ -193,10 +193,17 @@ describe('data-table sizes a masked column without reading its values (objectui# expect(long.key, 'the masked column width does not follow the raw value').toBe(short.key); }); - it('CONTROL: with the flag absent the same column grows with the raw value', () => { + it('CONTROL: with the flag absent the same column is sized from the mask its cells draw', () => { + // objectui#11682 moved every unmasked column from its stored value to what + // its cells DRAW, so this control no longer grows with the raw value: the + // cell draws the producer's mask whatever the value is. What still tells + // the flag apart is the read itself: unflagged, the drawn mask sizes the + // column; flagged, nothing in its cells is read and the header alone does. const short = widths(undefined, SHORT); const long = widths(undefined, LONG); - expect(long.key).not.toBe(short.key); + expect(long.key, 'the drawn mask, not the raw value, sizes the column').toBe(short.key); + const masked = widths({ masked: true }, SHORT); + expect(short.key, 'the flag is what keeps the cells out of the read').not.toBe(masked.key); }); it('an explicit width on a masked column is kept', () => { diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 61f41badf4..7c4ed32c70 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -717,6 +717,140 @@ function isMaskedColumnKey(columns: readonly TableColumn[], accessorKey: string) return !column || column.masked === true; } +/** + * A right-pinned column: its sticky utilities arrive on `col.className` (the + * auto-pinned row-actions column, and an author's `pinned: 'right'`, both + * from `ObjectGrid`). + */ +function isPinnedRightColumn(col: { className?: unknown }): boolean { + return typeof col.className === 'string' + && /\bsticky\b/.test(col.className) + && /\bright-0\b/.test(col.className); +} + +/** + * The attribute an auto-sized column's header label and display cells carry, + * naming the column (its `accessorKey`), so the auto-width estimate can read + * back what they drew (objectui#11682). The two JSX sites spell it literally; + * the read below selects by this constant. + */ +const AUTO_WIDTH_ATTR = 'data-auto-width-key'; + +/** + * The column an auto-width target names: only one the estimate sizes (no + * explicit `width`, not `fitContent`), and never a masked one, whose values + * the estimate does not read (objectui#10657). + */ +function autoWidthKey(col: TableColumn): string | undefined { + return !col.width && !col.fitContent && !col.masked ? col.accessorKey : undefined; +} + +/** + * What an auto-sized column's header and cells DREW, read back from the + * rendered table (objectui#11682). Keyed by `accessorKey`. + */ +type DrawnColumnSizes = { + /** Longest drawn cell text, in characters: the estimate's input where nothing is laid out. */ + chars: Record; + /** + * Width, in px, that renders the column's widest drawn cell and its header + * whole, padding and borders included; `null` where nothing is laid out + * (no layout engine, or a table inside a hidden panel). + */ + px: Record | null; +}; + +/** + * The elements the drawn-size read measures: this table's own header labels + * and display cells, never a nested table's (each match is a direct child of + * one of THIS table's header or body cells). + */ +function drawnSizeTargets( + headerRow: HTMLTableRowElement | null, + body: HTMLTableSectionElement | null, +): { heads: HTMLElement[]; cells: HTMLElement[] } { + return { + heads: headerRow ? Array.from(headerRow.querySelectorAll(`:scope > th > div > [${AUTO_WIDTH_ATTR}]`)) : [], + cells: body ? Array.from(body.querySelectorAll(`:scope > tr > td > [${AUTO_WIDTH_ATTR}]`)) : [], + }; +} + +/** + * Everything a drawn-size read depends on: each target's column, its text, + * and the classes of the cell around it (they set its font and padding: the + * row density, a staged edit's weight). An equal signature means an equal + * read, so the measurement is skipped. + */ +function drawnSizeSignature(targets: readonly HTMLElement[]): string { + const firstBoxClass = new Map(); + const parts: string[] = []; + for (const el of targets) { + const key = el.getAttribute(AUTO_WIDTH_ATTR) ?? ''; + const box = el.closest('th, td'); + const boxClass = `${box?.tagName ?? ''} ${box?.className ?? ''}`; + const slot = `${box?.tagName ?? ''}:${key}`; + if (!firstBoxClass.has(slot)) firstBoxClass.set(slot, boxClass); + parts.push(key, el.textContent ?? '', firstBoxClass.get(slot) === boxClass ? '' : boxClass); + } + return JSON.stringify([...firstBoxClass.entries(), ...parts]); +} + +/** A cell's horizontal padding plus borders, in px (0 where nothing is computed). */ +function horizontalInset(box: Element): number { + const cs = getComputedStyle(box); + return ['padding-left', 'padding-right', 'border-left-width', 'border-right-width'] + .map((prop) => parseFloat(cs.getPropertyValue(prop))) + .reduce((acc, v) => acc + (Number.isFinite(v) ? v : 0), 0); +} + +/** + * Read what the targets drew. The text lengths are read always; the widths + * only where the page is laid out. Each target's NATURAL width (its content + * on one line, at `max-content`) is read with one layout for all of them and + * its own inline width restored at once, inside the same task, so no frame is + * ever painted with the probe in place. The targets carry no React-managed + * `style`, so the probe and its removal are invisible to React. + */ +function measureDrawnColumnSizes(heads: readonly HTMLElement[], cells: readonly HTMLElement[]): DrawnColumnSizes { + const chars: Record = {}; + for (const el of cells) { + const key = el.getAttribute(AUTO_WIDTH_ATTR) ?? ''; + const len = (el.textContent ?? '').length; + if (!(key in chars) || len > chars[key]) chars[key] = len; + } + const targets = [...heads, ...cells]; + const saved = targets.map((el) => el.style.width); + for (const el of targets) el.style.width = 'max-content'; + const natural = targets.map((el) => el.getBoundingClientRect().width); + targets.forEach((el, i) => { el.style.width = saved[i]; }); + if (!natural.some((w) => Number.isFinite(w) && w > 0)) return { chars, px: null }; + const insets = new Map(); + const px: Record = {}; + targets.forEach((el, i) => { + const key = el.getAttribute(AUTO_WIDTH_ATTR) ?? ''; + const box = el.closest('th, td'); + let inset = 0; + if (box) { + const boxKey = `${box.tagName} ${box.className}`; + if (!insets.has(boxKey)) insets.set(boxKey, horizontalInset(box)); + inset = insets.get(boxKey) as number; + } + const need = Math.ceil((Number.isFinite(natural[i]) ? natural[i] : 0) + inset); + if (!(key in px) || need > px[key]) px[key] = need; + }); + return { chars, px }; +} + +function sameNumberRecord(a: Record | null, b: Record | null): boolean { + if (a === null || b === null) return a === b; + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k]); +} + +function sameDrawnSizes(a: DrawnColumnSizes, b: DrawnColumnSizes): boolean { + return sameNumberRecord(a.chars, b.chars) && sameNumberRecord(a.px, b.px); +} + /** * Enterprise-level data table component with Airtable-like features. * @@ -970,6 +1104,11 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { })); }, [rawColumns]); + // What each auto-sized column's cells DREW (objectui#11682), keyed by + // `accessorKey` and read back from the rendered table by the layout effect + // that drives `readDrawnSizesRef` below. `null` until the table has drawn. + const [drawnSizes, setDrawnSizes] = useState(null); + // Auto-size columns: estimate width from header and data content for columns without explicit widths const autoSizedWidths = useMemo(() => { const widths: Record = {}; @@ -990,24 +1129,53 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // estimating them from an absent string value pins them to the 80px // floor and clips inline buttons. Leave them out of the width map. if (col.fitContent) continue; + // Every column but a masked one is sized from WHAT ITS CELLS DREW + // (objectui#11682): a currency cell stores `200000` and draws + // `200,000.00`, a progress cell stores `45` and draws a bar and `45%`, a + // lookup stores an id and draws a name, a date stores an ISO string and + // draws `Feb 3, 2027`. Sized from the stored value, the first two were + // too narrow (the cell truncated) and the last two too wide (the sum + // pushed the scrolling columns under the right-pinned actions column). + // The drawn content is whatever the column's own `cell` renderer, or + // this table's `formatCellValue`, put in the cell — read back below, so + // no formatter is repeated here and no column key is needed to reach it. + // + // Where the page is laid out, the read is the width the widest drawn + // cell (and the header) needs to render whole, padding included. + const drawnPx = col.masked ? undefined : drawnSizes?.px?.[col.accessorKey]; + if (drawnPx !== undefined) { + widths[col.accessorKey] = Math.min(400, Math.max(80, drawnPx)); + continue; + } const headerLen = (col.header || '').length; let maxLen = headerLen; // A MASKED column never reads its values here (objectui#10657). Sized // from them, its width grew with the credential's length. Its cells draw // the producer's mask, which does not depend on the value, so the header // alone sizes it, with the same floor as every other column. - // Sample up to 50 rows for content width estimation - const sampleRows = col.masked ? [] : data.slice(0, 50); - for (const row of sampleRows) { - const val = row[col.accessorKey]; - const len = val != null ? String(val).length : 0; - if (len > maxLen) maxLen = len; + // + // Where nothing is laid out (no layout engine, or a table drawn inside a + // hidden panel) the drawn TEXT is still there, and its length is the + // estimate's input. Before the table has drawn at all (the first render + // pass, which the layout effect re-sizes before paint, or a render with + // no effects) the stored value is the only text there is. + const drawnLen = col.masked ? undefined : drawnSizes?.chars[col.accessorKey]; + if (drawnLen !== undefined) { + if (drawnLen > maxLen) maxLen = drawnLen; + } else { + // Sample up to 50 rows for content width estimation + const sampleRows = col.masked ? [] : data.slice(0, 50); + for (const row of sampleRows) { + const val = row[col.accessorKey]; + const len = val != null ? String(val).length : 0; + if (len > maxLen) maxLen = len; + } } // Estimate pixel width: ~8px per character + 48px padding, min 80, max 400 widths[col.accessorKey] = Math.min(400, Math.max(80, maxLen * 8 + 48)); } return widths; - }, [rawColumns, data]); + }, [rawColumns, data, drawnSizes]); // State management const [searchQuery, setSearchQuery] = useState(''); @@ -1079,6 +1247,41 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { .forEach((cell) => observer.observe(cell)); return () => observer.disconnect(); }, [stickyLeadingCount, columns]); + + // Sticky-right offsets for the trailing pinned cells (objectui#11682), the + // mirror of the lefts above. Every right-pinned column carries `right-0`, so + // with two of them (an author's `pinned: 'right'` beside the auto-pinned + // row-actions column) both stuck to the same edge and the outer one drew + // over the inner one's content. Pin each at the cumulative measured width of + // the pinned header cells AFTER it instead; the outermost stays at + // `right-0`, so a table with one right-pinned column renders as before. + const [measuredStickyRights, setMeasuredStickyRights] = useState | null>(null); + useLayoutEffect(() => { + const headerRow = headerRowRef.current; + const leading = (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0); + const pinned = columns + .map((col, index) => ({ key: col.accessorKey, index, col })) + .filter(({ col }) => isPinnedRightColumn(col)); + const cells = headerRow ? pinned.map(({ index }) => headerRow.children[leading + index] as HTMLElement | undefined) : []; + if (pinned.length < 2 || cells.some((cell) => !cell)) { + setMeasuredStickyRights(null); + return; + } + const measure = () => { + const rights: Record = {}; + let acc = 0; + for (let j = pinned.length - 1; j >= 0; j--) { + rights[pinned[j].key] = acc; + acc += (cells[j] as HTMLElement).getBoundingClientRect().width; + } + setMeasuredStickyRights((prev) => (sameNumberRecord(prev, rights) ? prev : rights)); + }; + measure(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(measure); + cells.forEach((cell) => observer.observe(cell as HTMLElement)); + return () => observer.disconnect(); + }, [selectable, showRowNumbers, columns]); const [draggedColumn, setDraggedColumn] = useState(null); const [dragOverColumn, setDragOverColumn] = useState(null); const [editingCell, setEditingCell] = useState<{ rowIndex: number; columnKey: string } | null>(null); @@ -2104,6 +2307,70 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { return () => document.removeEventListener('pointerdown', onPointerDown, true); }, [editingCell]); + // objectui#11682 — read back what the auto-sized columns DREW, for the + // width estimate (`autoSizedWidths`). Each such column's header label and + // display cells carry `AUTO_WIDTH_ATTR`; what they hold is the formatted + // content the column's own renderer produced (the display locale's grouping + // and currency, a percent's conversion and its bar, a lookup's resolved + // name, a select's badge), so the estimate never re-derives it. + // + // Read in a layout effect after every commit, so the first frame the user + // sees is already sized from the drawn content, and measured only when what + // was drawn changed (`drawnSizeSignature`): a commit that draws the same + // text in the same cells costs two queries and no layout. Read again when a + // cell's content changes AFTER this table rendered (a lookup cell resolves + // its name asynchronously, inside its own component), through a + // MutationObserver on the body; when the table changes size (a table shown + // from a hidden panel is laid out for the first time); and once the fonts + // have loaded (a width read in a fallback font is not the drawn one). Text + // and nodes only are observed: the probe and the style writes a re-size + // makes are attribute changes, and the state is replaced only when a size + // moved, so a read can never feed itself. + // + // Skipped while a cell is edited: its display element is unmounted for the + // editor, and a read then would size its column without it. + const tableBodyRef = useRef(null); + const drawnSignatureRef = useRef(null); + // The last read, mirrored so an equal read writes no state at all (and so + // costs this table no commit). + const drawnSizesRef = useRef(null); + const readDrawnSizesRef = useRef<(force?: boolean) => void>(() => {}); + readDrawnSizesRef.current = (force = false) => { + const body = tableBodyRef.current; + if (!body || editingCellRef.current) return; + const { heads, cells } = drawnSizeTargets(headerRowRef.current, body); + const signature = drawnSizeSignature([...heads, ...cells]); + if (!force && signature === drawnSignatureRef.current) return; + const next = measureDrawnColumnSizes(heads, cells); + // A read with nothing laid out is not remembered, so the next commit or + // resize reads again instead of keeping the text-only estimate. + drawnSignatureRef.current = next.px ? signature : null; + if (drawnSizesRef.current && sameDrawnSizes(drawnSizesRef.current, next)) return; + drawnSizesRef.current = next; + setDrawnSizes(next); + }; + useLayoutEffect(() => { + readDrawnSizesRef.current(); + }); + useEffect(() => { + const body = tableBodyRef.current; + if (!body) return; + const read = () => readDrawnSizesRef.current(); + const mutations = typeof MutationObserver === 'undefined' ? null : new MutationObserver(read); + mutations?.observe(body, { subtree: true, childList: true, characterData: true }); + const table = tableRef.current; + const resizes = typeof ResizeObserver === 'undefined' || !table ? null : new ResizeObserver(read); + if (table) resizes?.observe(table); + let live = true; + const fonts = typeof document === 'undefined' ? undefined : document.fonts; + fonts?.ready?.then(() => { if (live) readDrawnSizesRef.current(true); }); + return () => { + live = false; + mutations?.disconnect(); + resizes?.disconnect(); + }; + }, []); + // Cleanup on unmount useEffect(() => { return () => { @@ -2272,9 +2539,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // because `cn` is tailwind-merge — would win over that `sticky` // and let the header scroll away while its body cells stay pinned. // Detect it here so we skip `relative` and re-assert the pin. - const isPinnedRight = typeof col.className === 'string' - && /\bsticky\b/.test(col.className) - && /\bright-0\b/.test(col.className); + const isPinnedRight = isPinnedRightColumn(col); + const pinnedRightOffset = isPinnedRight ? measuredStickyRights?.[col.accessorKey] : undefined; const frozenOffset = isFrozen ? measuredStickyLefts?.[(selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + index] ?? columns.slice(0, index).reduce((sum, c, i) => { @@ -2312,6 +2578,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { width: columnWidth, minWidth: columnWidth, ...(isFrozen && { left: frozenOffset }), + ...(pinnedRightOffset ? { right: pinnedRightOffset } : {}), }} draggable={reorderEnabled} onDragStart={(e) => handleColumnDragStart(e, index)} @@ -2325,7 +2592,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { "flex items-center", col.align === 'right' ? 'justify-end' : 'justify-between' )}> -
+
{reorderEnabled && ( )} @@ -2380,7 +2652,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { )} - + {paginatedData.length === 0 ? ( { // TEXT column authored with both keys would get, and // that is the case this branch refuses. const isWrap = col.wrap === true && !isFit; + const pinnedRightOffset = isPinnedRightColumn(col) ? measuredStickyRights?.[col.accessorKey] : undefined; const columnWidth = isFit ? '1%' : (columnWidths[col.accessorKey] || col.width || autoSizedWidths[col.accessorKey]); @@ -2674,6 +2947,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { minWidth: isFit ? undefined : columnWidth, maxWidth: isFit ? undefined : columnWidth, ...(isFrozen && { left: frozenOffset }), + // Stacked right pins (objectui#11682): the + // header cell's offset, so each body cell sticks + // where its column does. + ...(pinnedRightOffset ? { right: pinnedRightOffset } : {}), }} onDoubleClick={(e) => { // Entering edit mode must NOT also fire the row's @@ -2913,6 +3190,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // the title carried the raw value, so a hover // showed what the cell's mask hides. title={!isFit && !col.masked && cellValue != null && typeof cellValue !== 'object' ? String(cellValue) : undefined} + // The cell half of the drawn-size read the + // auto width is estimated from (objectui#11682). + data-auto-width-key={autoWidthKey(col)} > {typeof col.cell === 'function' ? col.cell(cellValue, row) From c25a3e5024aebc6f02b1d930a6108a0cac741f4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:13:06 +0000 Subject: [PATCH 2/4] chore(changeset): declare the data table's drawn-content width as a components patch Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .changeset/11682-auto-width-drawn-content.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/11682-auto-width-drawn-content.md diff --git a/.changeset/11682-auto-width-drawn-content.md b/.changeset/11682-auto-width-drawn-content.md new file mode 100644 index 0000000000..bf98e38f64 --- /dev/null +++ b/.changeset/11682-auto-width-drawn-content.md @@ -0,0 +1,13 @@ +--- +'@object-ui/components': patch +--- + +The data table sizes an auto-width column from what its cells draw, and a right-pinned column no longer covers another one (objectui#11682). + +**Auto width.** A column with no `width` was estimated from the length of its stored value. A currency cell stores `200000` and draws `200,000.00`, so it truncated. A lookup stores an id and draws a name, and a date stores an ISO string and draws `Feb 3, 2027`, so those columns were too wide. On the showcase's Tasks list at a 1440px viewport the columns added up to more than the list's width, and the right-aligned Progress percentage sat under the right-pinned Actions column. + +The table now reads back what each auto-sized column's header and cells drew. Where the page is laid out, the column gets the width its widest drawn cell and its header need to render whole, padding included. Where nothing is laid out, the drawn text's length is the estimate's input, at the same 8px a character as before. The stored value is read only before the first draw. The 80px floor and the 400px cap stay. A masked column is still sized from its header alone. An explicit `width`, a `fitContent` column and a width the user dragged are unchanged. The width follows the rows that are drawn, so on a table that pages, searches or sorts its own rows it can change with the page, the search or the sort. + +**Right-pinned columns.** Every right-pinned column stuck at `right: 0`, so a column an author pinned right beside the auto-pinned row-actions column was drawn under it. Each one now sticks at the measured width of the pinned columns after it, as left-pinned columns already do. A table with one right-pinned column renders as before. + +Nothing is added to the package entry: no export, prop, type member or schema key. From 75f9570de31313a128795ac500ab4e63a438d1f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:14:25 +0000 Subject: [PATCH 3/4] refactor(components): assign the drawn-size read inside its layout effect The latest-function ref was assigned during render, which the react-hooks refs rule flags. The assignment now happens in the same deps-less layout effect that calls it, before the observers' passive effect can. The new test types the registry lookup instead of casting. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../data-table-drawn-width-11682.test.tsx | 2 +- .../src/renderers/complex/data-table.tsx | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx index 24c945cdec..428d3768cc 100644 --- a/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx +++ b/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx @@ -46,7 +46,7 @@ import { ComponentRegistry } from '@object-ui/core'; import '../data-table'; function DataTable(props: { schema: Record }) { - const Impl = ComponentRegistry.get('data-table') as any; + const Impl = ComponentRegistry.get('data-table') as React.ComponentType<{ schema: unknown }> | undefined; if (!Impl) throw new Error('data-table not registered'); return ; } diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 7c4ed32c70..c0533e7511 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -2335,21 +2335,21 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { // costs this table no commit). const drawnSizesRef = useRef(null); const readDrawnSizesRef = useRef<(force?: boolean) => void>(() => {}); - readDrawnSizesRef.current = (force = false) => { - const body = tableBodyRef.current; - if (!body || editingCellRef.current) return; - const { heads, cells } = drawnSizeTargets(headerRowRef.current, body); - const signature = drawnSizeSignature([...heads, ...cells]); - if (!force && signature === drawnSignatureRef.current) return; - const next = measureDrawnColumnSizes(heads, cells); - // A read with nothing laid out is not remembered, so the next commit or - // resize reads again instead of keeping the text-only estimate. - drawnSignatureRef.current = next.px ? signature : null; - if (drawnSizesRef.current && sameDrawnSizes(drawnSizesRef.current, next)) return; - drawnSizesRef.current = next; - setDrawnSizes(next); - }; useLayoutEffect(() => { + readDrawnSizesRef.current = (force = false) => { + const body = tableBodyRef.current; + if (!body || editingCellRef.current) return; + const { heads, cells } = drawnSizeTargets(headerRowRef.current, body); + const signature = drawnSizeSignature([...heads, ...cells]); + if (!force && signature === drawnSignatureRef.current) return; + const next = measureDrawnColumnSizes(heads, cells); + // A read with nothing laid out is not remembered, so the next commit or + // resize reads again instead of keeping the text-only estimate. + drawnSignatureRef.current = next.px ? signature : null; + if (drawnSizesRef.current && sameDrawnSizes(drawnSizesRef.current, next)) return; + drawnSizesRef.current = next; + setDrawnSizes(next); + }; readDrawnSizesRef.current(); }); useEffect(() => { From 334113240d89f1b378ccd4ffc9db234ac384803d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:27:24 +0000 Subject: [PATCH 4/4] fix(components): measure the space a cell keeps around its content instead of reading its padding Under the table's collapsed borders a cell beside the frozen column's 2px rule, or beside the Actions column's 1px rule, carries half of that rule, and no computed style of its own reports it. Read off the computed padding, the CRM Workbench's Status column came out 1px short and its "Completed" badge truncated. The read now takes each cell's width less its content box's, in the same layout that reads the boxes, and the replayed-geometry pins carry the measured insets. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../data-table-drawn-width-11682.test.tsx | 72 +++++++++++++------ .../src/renderers/complex/data-table.tsx | 47 ++++++------ 2 files changed, 76 insertions(+), 43 deletions(-) diff --git a/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx b/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx index 428d3768cc..8ff623a653 100644 --- a/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx +++ b/packages/components/src/renderers/complex/__tests__/data-table-drawn-width-11682.test.tsx @@ -30,11 +30,12 @@ * and Tasks lists at a 1440px viewport (the `MEASURED` table below, one * reading, ⛔ not re-derived here). A table that never probes reads 0 and * falls back to the text path, so these pins fail if the probe is gone. - * What they assert is the arithmetic of that reading: the Budget column is - * given at least what its widest amount needs, and the Tasks columns fit - * the measured 1184px content box beside the measured fixed columns, so no - * scrolling column passes under the pinned Actions column. The live - * readings themselves are the PR's. + * The cells' insets (cell width less content box) are replayed from the + * same reading. What these pins assert is the arithmetic of it: the Budget + * column is given at least what its widest amount needs, and the Tasks + * columns fit the measured 1184px content box beside the measured fixed + * columns, each one whole, so no scrolling column passes under the pinned + * Actions column. The live readings themselves are the PR's. * - The stacked-pin block mocks the header cells' widths, the way * `data-table-sticky-offsets.test.tsx` does for the left pins. */ @@ -163,27 +164,44 @@ const MEASURED: Record> = { /** The measured content box of the list, and its fixed columns: checkbox, `#`, Actions. */ const CONTENT_BOX = 1184; const FIXED_COLUMNS = 28 + 39.22 + 66.66; -/** Measured cell padding, and the frozen first column's 2px right border. */ -const CELL_CSS = 'th, td { padding-left: 12px; padding-right: 12px; } .border-r-2 { border-right: 2px solid; }'; +/** + * The space each cell keeps around its content, measured the same way (cell + * width less content-box width). 24px is the 12px padding either side. Under + * the table's collapsed borders the frozen first column's 2px rule is split + * with its neighbour (25px each), and the Actions column's 1px left rule with + * the column before it (24.5px), and no computed style of those cells says so. + */ +const INSET: Record = { title: 25, project: 25, progress: 24.5 }; +const insetOf = (key: string) => INSET[key] ?? 24; +/** + * A fake layout for the table: a cell is as wide as its `style.width`, its + * content box is that less the measured inset, and the probe (an + * auto-width target at `max-content`) is as wide as `MEASURED` says. + */ function installMeasuredLayout() { - const style = document.createElement('style'); - style.textContent = CELL_CSS; - document.head.appendChild(style); const original = HTMLElement.prototype.getBoundingClientRect; + const rect = (width: number) => + ({ width, height: 20, top: 0, left: 0, bottom: 20, right: width, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect; const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - const key = this.getAttribute('data-auto-width-key'); - // Only the probe is laid out; everything else answers what happy-dom does. - if (key === null || this.style.width !== 'max-content') return original.call(this); - const text = (this.textContent ?? '').trim(); - const width = MEASURED[key]?.[text]; - if (width === undefined) throw new Error(`no measured width for ${key} / "${text}"`); - return { width, height: 20, top: 0, left: 0, bottom: 20, right: width, x: 0, y: 0, toJSON: () => ({}) } as DOMRect; + const own = this.getAttribute('data-auto-width-key'); + if (own !== null && this.style.width === 'max-content') { + const text = (this.textContent ?? '').trim(); + const width = MEASURED[own]?.[text]; + if (width === undefined) throw new Error(`no measured width for ${own} / "${text}"`); + return rect(width); + } + if (this.tagName === 'TD' || this.tagName === 'TH') return rect(parseFloat(this.style.width) || 0); + // The element a cell's content box is read off: the display cell itself, + // or the full-width row a header label sits in. + const key = own ?? (this.parentElement?.tagName === 'TH' + ? this.querySelector(':scope > [data-auto-width-key]')?.getAttribute('data-auto-width-key') ?? null + : null); + const box = this.closest('th, td') as HTMLElement | null; + if (key !== null && box) return rect((parseFloat(box.style.width) || 0) - insetOf(key)); + return original.call(this); }); - return () => { - spy.mockRestore(); - style.remove(); - }; + return () => spy.mockRestore(); } const STATUS: Record = { done: 'Done', in_progress: 'In Progress', todo: 'To Do', backlog: 'Backlog', in_review: 'In Review' }; @@ -243,7 +261,7 @@ describe('data-table sizes a column from its measured drawn content, where the p />, ); const width = px(header('Budget').style.width); - expect(width - 24, 'the content box holds the widest drawn amount').toBeGreaterThanOrEqual(72.61); + expect(width - insetOf('budget'), 'the content box holds the widest drawn amount').toBeGreaterThanOrEqual(72.61); for (const td of bodyCells('Budget')) expect(px(td.style.maxWidth)).toBe(width); }); @@ -263,7 +281,15 @@ describe('data-table sizes a column from its measured drawn content, where the p const widths = TASK_COLUMNS.map((c) => px(header(c.header).style.width)); const total = widths.reduce((a, b) => a + b, 0) + FIXED_COLUMNS; expect(total, `the columns sum to ${total}px`).toBeLessThanOrEqual(CONTENT_BOX); - expect(px(header('Progress').style.width) - 24, 'the bar and the number render whole').toBeGreaterThanOrEqual(109.36); + expect(px(header('Progress').style.width) - insetOf('progress'), 'the bar and the number render whole') + .toBeGreaterThanOrEqual(109.36); + // And no column bought that by truncating: each content box holds its + // widest drawn cell and its label, the collapsed-border insets included. + for (const c of TASK_COLUMNS) { + const widest = Math.max(...Object.values(MEASURED[c.accessorKey])); + expect(px(header(c.header).style.width) - insetOf(c.accessorKey), `${c.header} renders whole`) + .toBeGreaterThanOrEqual(widest); + } }); it('the probe leaves no width behind on what it measured', () => { diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index c0533e7511..9b34e266ed 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -795,21 +795,30 @@ function drawnSizeSignature(targets: readonly HTMLElement[]): string { return JSON.stringify([...firstBoxClass.entries(), ...parts]); } -/** A cell's horizontal padding plus borders, in px (0 where nothing is computed). */ -function horizontalInset(box: Element): number { - const cs = getComputedStyle(box); - return ['padding-left', 'padding-right', 'border-left-width', 'border-right-width'] - .map((prop) => parseFloat(cs.getPropertyValue(prop))) - .reduce((acc, v) => acc + (Number.isFinite(v) ? v : 0), 0); +/** + * The box a target is drawn in, and the element that spans that box's content + * width: a display cell is itself `w-full` in its `td`; a header label sits + * in the `th`'s full-width row (`th > div > label`). + */ +function drawnSizeBox(el: HTMLElement): { box: Element | null; span: Element | null } { + const box = el.closest('th, td'); + return { box, span: box?.tagName === 'TH' ? el.parentElement : el }; } /** * Read what the targets drew. The text lengths are read always; the widths - * only where the page is laid out. Each target's NATURAL width (its content - * on one line, at `max-content`) is read with one layout for all of them and - * its own inline width restored at once, inside the same task, so no frame is - * ever painted with the probe in place. The targets carry no React-managed - * `style`, so the probe and its removal are invisible to React. + * only where the page is laid out. + * + * A column's width is its widest target's NATURAL width (the content on one + * line, at `max-content`) plus the space its cell keeps around the content. + * That space is MEASURED (the cell's width less its content box's), never + * read off the computed padding: under the table's collapsed borders a cell + * beside a frozen column's 2px rule carries half of it, which no computed + * style of its own reports. One layout reads the boxes, one reads the probe, + * and each target's own inline width is restored at once, inside the same + * task, so no frame is ever painted with the probe in place. The targets + * carry no React-managed `style`, so the probe and its removal are invisible + * to React. */ function measureDrawnColumnSizes(heads: readonly HTMLElement[], cells: readonly HTMLElement[]): DrawnColumnSizes { const chars: Record = {}; @@ -819,23 +828,21 @@ function measureDrawnColumnSizes(heads: readonly HTMLElement[], cells: readonly if (!(key in chars) || len > chars[key]) chars[key] = len; } const targets = [...heads, ...cells]; + const insets = targets.map((el) => { + const { box, span } = drawnSizeBox(el); + if (!box || !span) return 0; + const inset = box.getBoundingClientRect().width - span.getBoundingClientRect().width; + return Number.isFinite(inset) && inset > 0 ? inset : 0; + }); const saved = targets.map((el) => el.style.width); for (const el of targets) el.style.width = 'max-content'; const natural = targets.map((el) => el.getBoundingClientRect().width); targets.forEach((el, i) => { el.style.width = saved[i]; }); if (!natural.some((w) => Number.isFinite(w) && w > 0)) return { chars, px: null }; - const insets = new Map(); const px: Record = {}; targets.forEach((el, i) => { const key = el.getAttribute(AUTO_WIDTH_ATTR) ?? ''; - const box = el.closest('th, td'); - let inset = 0; - if (box) { - const boxKey = `${box.tagName} ${box.className}`; - if (!insets.has(boxKey)) insets.set(boxKey, horizontalInset(box)); - inset = insets.get(boxKey) as number; - } - const need = Math.ceil((Number.isFinite(natural[i]) ? natural[i] : 0) + inset); + const need = Math.ceil((Number.isFinite(natural[i]) ? natural[i] : 0) + insets[i]); if (!(key in px) || need > px[key]) px[key] = need; }); return { chars, px };