feat(ui): size a table column to its content on double-click

Double-clicking a column's resize handle called column.resetSize, which
cleared the stored width and let TanStack fall back to its generic default of
150 — a number unrelated to any width this table was designed with. "Reset"
therefore widened priority from 130, collapsed labels from 220, and clamped
title to its 260 minimum. It restored nothing.

It now sizes the column to its widest rendered cell, the convention Excel,
Sheets, AG Grid and Notion all share for that gesture.

Fixed table-layout ignores content and the cells truncate their own text, so
nothing on screen reports the width the content wants. The measurement lifts
both constraints across the column's cells, reads them, and restores
everything within the same task, so the browser paints once — after the
restore — and the intermediate layout is never seen. Only the rows inside the
virtual window are measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-07-29 13:52:18 +08:00
parent bd7098a078
commit 37e4ed5330
2 changed files with 96 additions and 1 deletions

View File

@@ -144,6 +144,59 @@ export function DataTable<TData>({
[clampColumnWidth],
);
// Double-click on the resize handle sizes the column to its content, the
// convention every spreadsheet and grid shares. It replaces column.resetSize,
// which cleared the stored width and let TanStack fall back to its generic
// 150 — a number unrelated to any of this table's designed widths, so
// "reset" widened some columns and collapsed others.
//
// Fixed table-layout ignores content, and cells truncate their own text, so
// nothing on screen reports the width the content actually wants. The
// measurement lifts both constraints on the column's cells, reads them, and
// puts everything back within the same task — the browser paints once, after
// the restore, so the intermediate layout is never seen.
const autoFitColumn = React.useCallback(
(header: TanstackHeader<TData, unknown>) => {
const container = scrollRef.current;
const tableElement = container?.querySelector("table");
if (!container || !tableElement) return;
const cells = container.querySelectorAll<HTMLElement>(
`[data-column-id="${CSS.escape(header.column.id)}"]`,
);
if (!cells.length) return;
const previousLayout = tableElement.style.tableLayout;
const previous = Array.from(cells, (cell) => ({
cell,
width: cell.style.width,
maxWidth: cell.style.maxWidth,
overflow: cell.style.overflow,
}));
tableElement.style.tableLayout = "auto";
for (const cell of cells) {
cell.style.width = "max-content";
cell.style.maxWidth = "none";
cell.style.overflow = "visible";
}
let widest = 0;
for (const cell of cells) {
widest = Math.max(widest, cell.getBoundingClientRect().width);
}
for (const entry of previous) {
entry.cell.style.width = entry.width;
entry.cell.style.maxWidth = entry.maxWidth;
entry.cell.style.overflow = entry.overflow;
}
tableElement.style.tableLayout = previousLayout;
if (widest > 0) setColumnWidth(header, widest);
},
[setColumnWidth],
);
const beginColumnResize = React.useCallback(
(
header: TanstackHeader<TData, unknown>,
@@ -423,7 +476,7 @@ export function DataTable<TData>({
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
header.column.resetSize();
autoFitColumn(header);
}}
onKeyDown={(event) =>
handleResizeKeyDown(header, event)
@@ -538,6 +591,7 @@ function DataTableBody<TData>({
return (
<TableCell
key={cell.id}
data-column-id={cell.column.id}
// px-4 across the board so cell content aligns with the
// surrounding toolbar's px-4. Narrow trailing columns
// (chevron / actions) declare enough width for icon + padding.

View File

@@ -185,6 +185,47 @@ describe("DataTable column resize", () => {
).toBe("300px");
});
it("sizes a column to its widest rendered cell on double-click", () => {
const { onSizingChange, handle } = setup();
// Fixed table-layout ignores content and the cells truncate their own
// text, so the measurement lifts both constraints before reading. Stand in
// for the layout jsdom will not perform.
for (const cell of document.querySelectorAll<HTMLElement>(
'[data-column-id="status"]',
)) {
cell.getBoundingClientRect = () =>
({ width: cell.tagName === "TH" ? 96 : 268 }) as DOMRect;
}
act(() => {
handle.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
});
// The widest cell wins, not the header and not the current width.
expect(onSizingChange).toHaveBeenCalledWith(
expect.objectContaining({ status: 268 }),
);
});
it("leaves the column alone when double-clicked with nothing rendered", () => {
const { onSizingChange, handle } = setup();
for (const cell of document.querySelectorAll<HTMLElement>(
'[data-column-id="status"]',
)) {
cell.getBoundingClientRect = () => ({ width: 0 }) as DOMRect;
}
act(() => {
handle.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
});
// A zero measurement means the column was never laid out; committing it
// would collapse the column to its minimum for no reason.
expect(onSizingChange).not.toHaveBeenCalled();
});
it("casts a shadow past the frozen columns only once scrolled sideways", () => {
render(<ResizableTable onSizingChange={vi.fn()} pinFirstColumn />);