diff --git a/.changeset/auto-width-naming-cleanup.md b/.changeset/auto-width-naming-cleanup.md new file mode 100644 index 000000000..c57802c77 --- /dev/null +++ b/.changeset/auto-width-naming-cleanup.md @@ -0,0 +1,14 @@ +--- +"@pretable/react": minor +"@pretable/core": minor +--- + +`autosizeColumns()` becomes `setAllColumnsAutoWidth(auto)`, and undeclared columns have one default width. + +**The rename.** The grid handle's `autosizeColumns()` never sized anything to content — it put every column into the auto-width set, the mode bit that says "the grid manages this column's width". Nothing in the column-width path measures a cell. It is now `grid.setAllColumnsAutoWidth(auto: boolean)`, symmetric with the per-column `setColumnAutoWidth(columnId, auto)` that shipped alongside the tool panel, and it moves the roster in BOTH directions — `false` freezes every column at the engine's stored width, which the old name could not express. The surface's `autosize?: boolean | AutosizeOptions` prop is likewise `allColumnsAutoWidth?: boolean`, and the `AutosizeOptions` type is gone from both packages: it was a tuning bag (`averageCharWidth`, `cellPaddingPx`, min/max) for a measurement pass that does not exist, so every field was inert. + +**One default width.** A column that declares no `widthPx` was drawn by the renderer at 140px but STORED by the engine at 160px, so turning auto width off on a never-resized column jumped its width 140 → 160 for no reason a user could see. Both numbers now come from one shared constant (140, and 220 for a `wrap: "text"` column) and the engine seeds its stored width through the renderer's own resolver, so the freeze lands on the pixel the column was already drawing. 140 won because it is what undeclared columns have always painted at — no grid repaints as a result of this change. + +**A double-click that does something.** Double-clicking a column's resize handle was wired to a no-op. It now calls `setColumnAutoWidth(columnId, true)`, the pointer shortcut for handing that column's width back to the grid, which is what the docs had claimed all along. It no longer fires `onColumnWidthsChange`: that callback reports the engine's STORED widths, and handing a column to the grid moves none of them — the notification announced a change that had not happened. + +**Fixed: auto width did not survive a controlled `state.columnWidths`.** `setColumnWidth` cleared a column's auto bit unconditionally, and a controlled consumer replays its whole widths map through `setColumnWidth` on every write-back pass. So any re-render of such a consumer silently took every column in the map back out of the auto set — the tool panel's Auto width toggle and the resize handle's double-click both appeared to work and were undone before paint. The bit is now cleared only when the write actually MOVES the stored width, compared across grid-core's own min/max clamping rather than against the requested number. This is a user-visible fix to behavior that shipped with the tool panel's auto-width toggle, not a consequence of the rename. diff --git a/apps/bench/src/bench-app.tsx b/apps/bench/src/bench-app.tsx index 88d33d5ba..40f120167 100644 --- a/apps/bench/src/bench-app.tsx +++ b/apps/bench/src/bench-app.tsx @@ -146,7 +146,8 @@ export function BenchApp({ search, browserVersion }: BenchAppProps) { }, []); /** * Adapter-agnostic autosize entry point. Each adapter calls back with - * a closure over its native autosize API (pretable: grid.autosizeColumns; + * a closure over its native autosize API (pretable: + * grid.setAllColumnsAutoWidth(true) — the grid-managed-width mode bit; * ag-grid: gridApi.autoSizeColumns; mui: apiRef.autosizeColumns). The * autosize bench script awaits this callback and times to the next paint. */ diff --git a/apps/bench/src/pretable-adapter.tsx b/apps/bench/src/pretable-adapter.tsx index bec785914..7cc9a8f8a 100644 --- a/apps/bench/src/pretable-adapter.tsx +++ b/apps/bench/src/pretable-adapter.tsx @@ -157,8 +157,10 @@ export interface PretableAdapterProps { initialRows?: readonly ScenarioRow[]; /** * Called once the adapter has a usable autosize entry point. The - * supplied callback wraps `grid.autosizeColumns()` so the bench - * harness can invoke it on demand for the autosize script. + * supplied callback wraps `grid.setAllColumnsAutoWidth(true)` — pretable's + * "grid-managed width" mode bit, its nearest analog to the other grids' + * autosize APIs — so the bench harness can invoke it on demand for the + * autosize script. */ onAutosizeReady?: (autosize: () => Promise | void) => void; /** @@ -416,7 +418,7 @@ export function PretableAdapter({ gridInstanceIdRef.current = String(gridInstanceSeq); publishGridInstanceId(); onGridReadyRef.current?.(grid); - onAutosizeReadyRef.current?.(grid.autosizeColumns); + onAutosizeReadyRef.current?.(() => grid.setAllColumnsAutoWidth(true)); }, [publishGridInstanceId], ); @@ -547,7 +549,7 @@ export function PretableAdapter({ @@ -16,7 +16,7 @@ Every header where `column.resizable !== false` exposes a 4px hit-target on its Per-column `minWidthPx` and `maxWidthPx` clamp the result. Engine-wide defaults are 40px min and 800px max; supply tighter bounds on the column when the data has known constraints. -Double-clicking the resize handle calls `grid.autosizeColumn(columnId)` — the keyboard-free shortcut for "fit this column to its content." +Double-clicking the resize handle calls `grid.setColumnAutoWidth(columnId, true)` — the keyboard-free shortcut for handing this column's width back to the grid. See [Auto width](#auto-width) for what the grid does with it. The synthetic row-select column has no resize handle. Set `resizable: false` on any other column to opt it out. @@ -26,9 +26,9 @@ There is no key that resizes a column, and on a touch device there is no gesture That is a deliberate removal, not an oversight. The handle is 4px wide, and 4px is not a target a finger can acquire — well under the 24px [WCAG 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) asks for. Inflating it instead was the other option and it costs more than it buys: the trailing edge it would need is the same edge the filter funnel and column menu spend on their own 24×24 targets, and dragging a column edge inside a phone-width viewport is a poor interaction even with a large target. So the strip is dropped and the 48px it freed goes to the two controls that remain — see [Filtering § The funnel on touch](/docs/grid/filtering#the-funnel-on-touch). -`display: none` rather than a `matchMedia` guard in `@pretable/react` is also deliberate: a media query is evaluated by the engine on both sides of a stream, so a server-rendered grid has no client/server disagreement for hydration to reconcile. Nothing changes on a fine pointer — the strip, the drag, and the double-click-to-autosize are exactly as described above. +`display: none` rather than a `matchMedia` guard in `@pretable/react` is also deliberate: a media query is evaluated by the engine on both sides of a stream, so a server-rendered grid has no client/server disagreement for hydration to reconcile. Nothing changes on a fine pointer — the strip, the drag, and the double-click are exactly as described above. -If your app needs touch resizing, put it behind an explicit control (a menu item calling `grid.setColumnWidth`, or `grid.autosizeColumn`) rather than restoring the strip. +If your app needs touch resizing, put it behind an explicit control (a menu item calling `grid.setColumnWidth`, or `grid.setColumnAutoWidth`) rather than restoring the strip. The [tool panel](/docs/grid/tool-panel)'s column row menu already ships exactly that. ## Reorder @@ -64,7 +64,7 @@ Left-pinned columns render first, in a sticky group flush against the viewport's **Array order is visual order.** The engine's column array is always grouped — leading pinned columns, then the unpinned ones, then trailing pinned columns — so a column's index in that array is the position it renders at. `aria-colindex` is derived from the same index, which means assistive technology reports the position the column is actually drawn at rather than a stale array slot. Both `setColumnPinned` and reorder maintain the grouping: pinning moves the column into its region, and a move that lands in a region takes that region's pin. -You do not have to declare columns in that order. A `columns` array that interleaves pinned and unpinned entries is regrouped on the way in — at mount, on every prop update, and on `resetColumnLayout` — with relative order preserved inside each region. So `[symbol, note (right), name]` becomes `[symbol, name, note]`, and the column you declared second still renders last where its pin puts it. +You do not have to declare columns in that order. A `columns` array that interleaves pinned and unpinned entries is regrouped on the way in — at mount, on every prop update, and on the tool panel's Reset columns — with relative order preserved inside each region. So `[symbol, note (right), name]` becomes `[symbol, name, note]`, and the column you declared second still renders last where its pin puts it. **Pinned columns are never virtualized away.** Horizontal virtualization only windows the scrollable group, and that window runs from `scrollLeft` to `scrollLeft + viewportWidth - pinnedRightWidth`: it ends before the right-pinned group, which overlays the viewport's trailing edge, but it deliberately does _not_ subtract the left-pinned width. That over-includes the columns currently hidden behind the left-pinned group, which is the conservative direction — a column is rendered when in doubt, never dropped. A pinned column is in the render snapshot at every scroll position, so a pinned "actions" or identity column can't scroll out from under the user. @@ -74,17 +74,22 @@ The synthetic row-select column is always at position 0; it is never pinnable, r [Row grouping](/docs/grid/grouping) changes the drawn column list: grouped columns are dropped from the data area unless `hideGroupedColumns: false`, and a derived group column is prepended. Because the list is then regrouped into its pinned regions, an unpinned group column heads the _scrolling_ run and therefore sits after your left-pinned columns — pass `groupColumn={{ pinned: "left" }}` to seat it ahead of them. Read the drawn order from `grid.getColumns()`, never from the `columns` prop. -## Autosize +## Auto width -- `grid.autosizeColumn(columnId, options?)` — fit one column to its measured content width. -- `grid.autosizeColumns(options?)` — fit every column. -- Double-click on a column's resize handle is the keyboard-free shortcut for the single-column form. +**Auto width is a mode bit, not a content fit.** On, it means "let the grid manage this column's width": the column draws at the renderer's default, or takes a flex share when the column declares `flex` — nothing measures cell content anywhere in the width path. Off means manual, at whatever width the engine currently stores. -`AutosizeOptions` controls the algorithm (sample size, padding, header inclusion). Defaults are sensible; reach for the options when you need to constrain measurement on very long datasets. +- `grid.setColumnAutoWidth(columnId, auto)` — move one column into or out of the auto set. +- `grid.setAllColumnsAutoWidth(auto)` — move every column at once, both directions. +- Double-click on a column's resize handle is the keyboard-free shortcut for the single-column form, turning auto **on**. +- The [tool panel](/docs/grid/tool-panel)'s column row menu exposes the same bit as an **Auto width** toggle, and reflects it. + +Columns that declare no `widthPx` start in the auto set; declared ones start manual. Sizing a column yourself takes it out — a resize drag and `grid.setColumnWidth` are both manual gestures, and each writes the width it lands on. + +Turning auto **off** freezes the column at the engine's stored width. For a column you never resized, that stored width is the same number the grid was already drawing — the renderer's undeclared-width default and the engine's stored default are one shared constant — so the freeze does not move a pixel. ## Reset -`grid.resetColumnLayout()` restores order, widths, and pinned state to the original `columns` prop snapshot taken at mount. Useful for "Reset layout" toolbar buttons that recover after a user has rearranged the grid. +The [tool panel](/docs/grid/tool-panel)'s columns section carries a **Reset columns** button, and it is the only reset the library ships: it restores the order, pinning, visibility, and auto-width state the grid mounted with. There is no `reset` method on the grid handle — to recover the mount-time layout from your own toolbar button, control the slices you care about (below) and write your saved snapshot back through `state.columnWidths` / `state.columnOrder` / `state.columnPinned`. ## Controlled state diff --git a/apps/website/content/docs/grid/editing.mdx b/apps/website/content/docs/grid/editing.mdx index a457cd61b..b8e694e8d 100644 --- a/apps/website/content/docs/grid/editing.mdx +++ b/apps/website/content/docs/grid/editing.mdx @@ -310,10 +310,10 @@ When `validate` returns a string the edit returns to `editing` with `snapshot.ed `saving` and `error` are the two phases text on this page can only assert, not show — the field visibly goes read-only and `aria-busy` while a commit is in flight, and a rejection leaves the editor open with an inline message. That's exactly what happens in the grid at the top of this page when you edit **Quantity** to a negative number: the field dims for ~800ms (`saving`), then the commit is rejected with an inline message (`error`). -For most apps the default editor handles all of this and you never touch the phases directly. If you render cells yourself (a custom `render`, or the [headless engine](/docs/headless)), read `grid.getSnapshot().editing` — `{ rowId, columnId, draft, status, error? }` — to drive your own in-cell editor or status affordance: +For most apps the default editor handles all of this and you never touch the phases directly. If you render cells yourself (a custom `render`, or the [headless engine](/docs/headless)), read `grid.getState().editing` — `{ rowId, columnId, draft, status, error? }` — to drive your own in-cell editor or status affordance: ```tsx -const { editing } = grid.getSnapshot(); +const { editing } = grid.getState(); if (editing?.status === "saving") { // show a spinner in the cell at editing.rowId / editing.columnId } diff --git a/apps/website/content/docs/grid/index.mdx b/apps/website/content/docs/grid/index.mdx index c3048f57c..319bce5a3 100644 --- a/apps/website/content/docs/grid/index.mdx +++ b/apps/website/content/docs/grid/index.mdx @@ -144,7 +144,6 @@ The other 47 tokens in [the theming contract](/docs/theming/token-reference) are The engine has more capabilities than this section covers: -- **Column autosize** — the `autosize` option on `usePretable`; resizes columns to content. Not yet documented as a recipe. - **Streaming and transactions** — ordinary React updates use the `rows` prop; high-frequency producers explicitly own a row model and connect the [streaming adapter](/docs/streaming). - **Per-row measured heights** — pass `measuredHeights: Record` to `usePretable` for content-aware row sizing. The bench's `pretable-adapter.tsx` shows this pattern. diff --git a/apps/website/content/docs/grid/keyboard.mdx b/apps/website/content/docs/grid/keyboard.mdx index 802ef6d8f..dbf796b5c 100644 --- a/apps/website/content/docs/grid/keyboard.mdx +++ b/apps/website/content/docs/grid/keyboard.mdx @@ -214,7 +214,7 @@ grid.setFocus({ rowId, columnId }); grid.moveFocus("down"); grid.moveFocus("right", { extend: true }); // shift+right equivalent grid.moveFocus("down", { jumpToEdge: true }); // cmd+down equivalent -grid.selectAll(); +grid.selectAllVisibleRows(); grid.clearSelection(); ``` diff --git a/apps/website/content/docs/grid/tool-panel.mdx b/apps/website/content/docs/grid/tool-panel.mdx index c028c7781..0ce9c3da8 100644 --- a/apps/website/content/docs/grid/tool-panel.mdx +++ b/apps/website/content/docs/grid/tool-panel.mdx @@ -71,7 +71,7 @@ The pane lists every data column in **drawn order** — the order the engine act - **A ⋮ menu** with Pin left, Pin right, Unpin, and an **Auto width** toggle. The menu is also the only way to pin into an _empty_ pinned group: with no rows in a subgroup there is no boundary to drag or arrow across, so the menu is the affordance that creates the first member. - **Search** filters the list by column label; **Reset columns** restores the order, pinning, visibility, _and_ auto-width state the grid mounted with. -**Auto width is a mode bit, not a content fit.** Checked, it means "let the grid manage this column's width": the column draws at the renderer's default, or takes a flex share when the column declares `flex` — nothing measures cell content. Columns that declare no `widthPx` start in auto mode; sizing a column yourself — dragging its header resize strip, or calling `setColumnWidth` — turns it off, and the toggle reflects that the next time the menu opens. Turning the toggle back on hands the width back to the grid. The same mode bit is scriptable as `grid.setColumnAutoWidth(columnId, auto)` on the handle. One visible consequence worth expecting: turning auto **off** on a column you never resized jumps its width from 140px to 160px — the renderer's default and the engine's stored default are two different numbers, so freezing the column at the engine's width is a real (if small) move, not a no-op. The toggle's label is the `toolPanelAutoWidthLabel` message. +**Auto width is a mode bit, not a content fit.** Checked, it means "let the grid manage this column's width": the column draws at the renderer's default, or takes a flex share when the column declares `flex` — nothing measures cell content. Columns that declare no `widthPx` start in auto mode; sizing a column yourself — dragging its header resize strip, or calling `setColumnWidth` — turns it off, and the toggle reflects that the next time the menu opens. Turning the toggle back on hands the width back to the grid. The same mode bit is scriptable as `grid.setColumnAutoWidth(columnId, auto)` on the handle, and `grid.setAllColumnsAutoWidth(auto)` moves every column at once. Turning auto **off** freezes the column at the engine's stored width — for a column you never resized that is the same number the grid was already drawing (the renderer's default and the engine's stored default are one shared constant), so the freeze is visually a no-op, not a jump. The toggle's label is the `toolPanelAutoWidthLabel` message. Everything the panel commits writes straight into the engine, so the grid it changes is the same layout header gestures change. That has one consequence worth knowing before you control layout state: a controlled `state.columnOrder` or `state.columnPinned` remains the authority, and it re-imposes the prop's layout over the panel's commits whenever the write-back effect re-runs — any state change reaching the surface is enough. Leave those slices uncontrolled when the panel should own them. diff --git a/apps/website/content/examples/column-layout/ColumnLayoutGrid.tsx b/apps/website/content/examples/column-layout/ColumnLayoutGrid.tsx index 3faa482f8..863a84b5c 100644 --- a/apps/website/content/examples/column-layout/ColumnLayoutGrid.tsx +++ b/apps/website/content/examples/column-layout/ColumnLayoutGrid.tsx @@ -38,11 +38,11 @@ export function ColumnLayoutGrid() {

Drag a header to reorder, drag its right-edge handle to resize, - double-click the handle to autosize. Symbol is pinned - left and Note is pinned right — drag a column into - either group to pin it there, or out to unpin it. Resizing needs a fine - pointer: the handle is a 4px strip, so it is not drawn on a touch - device. + double-click the handle to hand the width back to the grid.{" "} + Symbol is pinned left and Note is + pinned right — drag a column into either group to pin it there, or out + to unpin it. Resizing needs a fine pointer: the handle is a 4px strip, + so it is not drawn on a touch device.

ariaLabel="Instrument positions" diff --git a/apps/website/lib/docs/__tests__/docs-api-surface.test.ts b/apps/website/lib/docs/__tests__/docs-api-surface.test.ts index 7931814e1..292d838ef 100644 --- a/apps/website/lib/docs/__tests__/docs-api-surface.test.ts +++ b/apps/website/lib/docs/__tests__/docs-api-surface.test.ts @@ -3096,6 +3096,88 @@ describe("docs API surface matches the generated API reports", () => { ).toEqual([]); }); + test("every `grid.method(…)` the docs call is a real member of the surface", () => { + // The sweep above reads `Pretable*` TYPE names. A method call written in + // prose is neither an import nor a capitalised type, so it was invisible + // to both checks — and grid/column-layout.mdx spent an entire arc + // documenting `grid.autosizeColumn(columnId, options?)` and + // `grid.resetColumnLayout()`, two methods that have never existed on any + // handle, complete with an options bag and a "fit this column to its + // content" promise for a width path that measures nothing. A reader who + // typed either got a compile error out of the page that taught it. + // + // The vocabulary is deliberately WIDE: every name declared as a member at + // the top level of any exported interface or object type alias, across + // every reported package, pooled into one set. This check does not ask + // whether the method is on the handle the surrounding prose happens to + // hold — a page may be writing about `PretableReactGrid`, + // `PretableSurfaceGrid`, or the headless `PretableGridUiCore`, and + // deciding which from prose is a job for a reader, not a regex. It asks + // the one question a regex can answer honestly: does this name exist + // anywhere in the public surface? A phantom fails that; a real method + // written against a slightly different handle does not. Erring wide is + // the conservative direction — this guard is not an authority on WHERE a + // method lives, only on whether it is real. + // + // Scoped to a `grid.` receiver and a following `(`, which is what keeps + // the false-positive rate at zero on the current corpus: `pretable/ + // grid.css` (31 hits) and `gridRef.current` are not calls, and the docs + // spell every genuine handle call with its parentheses. + const declared = new Set(); + for (const pkg of REPORTED_PACKAGES) { + for (const line of fs + .readFileSync(reportPathFor(pkg), "utf8") + .split("\n")) { + const member = MEMBER_RE.exec(line); + if (member) declared.add(member[1] as string); + } + } + + // Fail closed, twice over: an empty vocabulary would pass everything, and + // an empty corpus would check nothing. + expect( + declared.size, + "the API reports yielded no interface members at all; MEMBER_RE has " + + "gone blind rather than the surface having gone empty.", + ).toBeGreaterThan(50); + + const called = PAGES.flatMap((page) => + [...page.raw.matchAll(/\bgrid\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)].map( + (match) => ({ page: page.rel, name: match[1] as string }), + ), + ); + expect( + called.length, + `no \`grid.method(…)\` call appears anywhere under ${DOCS_ROOT}. The ` + + "docs cannot have stopped calling the handle; this sweep is reading " + + "an empty corpus.", + ).toBeGreaterThan(0); + + const unknown = [ + ...new Map( + called + .filter(({ name }) => !declared.has(name)) + .map((hit) => [`${hit.page}:${hit.name}`, hit]), + ).values(), + ]; + + expect( + unknown, + [ + "A docs page calls a `grid.` method that no exported type declares, so", + "a reader who copies the line gets a compile error. Renames land in", + "the reports; prose does not move on its own.", + "", + ...unknown.map(({ page, name }) => `${page}: grid.${name}(…)`), + "", + "If the receiver here is a LOCAL `grid` of your own — a DOM node, a", + "third-party handle — this sweep has no way to tell it from the", + "library's: rename the variable in your example and the hit goes away.", + REMEDY_REGENERATE, + ].join("\n"), + ).toEqual([]); + }); + test("every @pretable import in the docs sits inside a fence this file can see", () => { // The import check reads the docs through FENCE_RE, and a block FENCE_RE // misses is a page whose imports are unchecked while every test here stays diff --git a/packages/core/core.api.md b/packages/core/core.api.md index 587a61081..c96da8b55 100644 --- a/packages/core/core.api.md +++ b/packages/core/core.api.md @@ -4,18 +4,6 @@ ```ts -// @public -export interface AutosizeOptions { - // (undocumented) - averageCharWidth?: number; - // (undocumented) - cellPaddingPx?: number; - // (undocumented) - maxWidthPx?: number; - // (undocumented) - minWidthPx?: number; -} - // @public (undocumented) export type ColumnAggregateValueOf> = TColumns extends readonly (infer TColumn)[] ? TColumn extends { readonly id: TColumnId; diff --git a/packages/core/src/__tests__/create-grid.test.ts b/packages/core/src/__tests__/create-grid.test.ts index 1e7ecbea9..2f601c2a2 100644 --- a/packages/core/src/__tests__/create-grid.test.ts +++ b/packages/core/src/__tests__/create-grid.test.ts @@ -36,7 +36,11 @@ describe("createGrid", () => { observedRowModelRevision: null, columnLayout: [ { id: "name", widthPx: 240 }, - { id: "score", widthPx: 160, pinned: "right" }, + // The undeclared-width default, now one shared constant with the + // renderer's drawing fallback (140, was 160 here) — so a column the + // grid draws at its default is STORED at that same number, and + // freezing it with `setColumnAutoWidth(id, false)` moves no pixel. + { id: "score", widthPx: 140, pinned: "right" }, ], }); diff --git a/packages/core/src/public_api.ts b/packages/core/src/public_api.ts index 7ebef36bc..2aefcef31 100644 --- a/packages/core/src/public_api.ts +++ b/packages/core/src/public_api.ts @@ -30,7 +30,6 @@ export { } from "@pretable-internal/grid-core"; export type { - AutosizeOptions, ColumnFilter, ColumnAggregateValueOf, ColumnIdOf, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8bed7a07a..bbb866cfa 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,4 @@ export type { - AutosizeOptions, ColumnFilter, FilterOperator, ColumnOption, diff --git a/packages/grid-core/src/__tests__/grid-ui-core.test.ts b/packages/grid-core/src/__tests__/grid-ui-core.test.ts index de3aeef71..71fe90ffb 100644 --- a/packages/grid-core/src/__tests__/grid-ui-core.test.ts +++ b/packages/grid-core/src/__tests__/grid-ui-core.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from "vitest"; +import { DEFAULT_COLUMN_WIDTH_PX } from "@pretable-internal/layout-core"; import { createColumnHelper, createLocalRowModel, @@ -75,10 +76,16 @@ describe("UI-only grid core", () => { const grid = createGridUiCore({ rowModel, columns: modelColumns }); + // The stored default is layout-core's shared constant, not a second + // copy: renderer-dom draws an undeclared column at the SAME number, so + // freezing such a column (auto width off) never moves a pixel. Read the + // constant rather than re-typing 140 — a divergence is the bug this + // unification closed. expect(grid.getState().columnLayout).toEqual([ - { id: "name", widthPx: 160 }, - { id: "quantity", widthPx: 160 }, + { id: "name", widthPx: DEFAULT_COLUMN_WIDTH_PX }, + { id: "quantity", widthPx: DEFAULT_COLUMN_WIDTH_PX }, ]); + expect(DEFAULT_COLUMN_WIDTH_PX).toBe(140); }); test("model commits do not wake grid subscribers until the matching layout revision is observed", () => { diff --git a/packages/grid-core/src/create-grid-ui-core.ts b/packages/grid-core/src/create-grid-ui-core.ts index 6d5db4aa1..43576feb4 100644 --- a/packages/grid-core/src/create-grid-ui-core.ts +++ b/packages/grid-core/src/create-grid-ui-core.ts @@ -1,3 +1,8 @@ +// The engine's stored default for a column that declares no `widthPx` — +// layout-core's single source of truth, shared with renderer-dom's drawing +// fallback so the stored width and the drawn width agree for a never-resized +// column (see column-defaults.ts for the decision note). +import { DEFAULT_COLUMN_WIDTH_PX } from "@pretable-internal/layout-core"; import type { ColumnIdOf, ColumnValueOf, @@ -110,7 +115,6 @@ const EMPTY_VIEWPORT: Readonly = Object.freeze({ height: 0, width: 0, }); -const DEFAULT_COLUMN_WIDTH_PX = 160; function sameValueZero(left: string | number, right: string | number): boolean { return left === right || (left !== left && right !== right); diff --git a/packages/grid-core/src/index.ts b/packages/grid-core/src/index.ts index da92c1500..bd020e4b9 100644 --- a/packages/grid-core/src/index.ts +++ b/packages/grid-core/src/index.ts @@ -74,7 +74,4 @@ export type { PretableIndexedWindowing, PretableRowSelectionState, } from "./types"; -export type { - AutosizeOptions, - PretableRowRange, -} from "@pretable-internal/layout-core"; +export type { PretableRowRange } from "@pretable-internal/layout-core"; diff --git a/packages/layout-core/src/column-defaults.ts b/packages/layout-core/src/column-defaults.ts new file mode 100644 index 000000000..8d8b405e6 --- /dev/null +++ b/packages/layout-core/src/column-defaults.ts @@ -0,0 +1,25 @@ +/** + * The width a column draws at when nothing declares one — the single source + * of truth for every layer that needs a fallback width. + * + * DECIDED 2026-08-30 (auto-width cleanup): these numbers used to live in two + * places that disagreed — renderer-dom drew undeclared columns at 140px + * (220px wrapped) while grid-core STORED 160px for the same columns — so + * turning auto width off on a never-resized column visibly jumped 140→160. + * 140 won because it is the number every undeclared-width column has always + * actually painted at (the renderer's fallback); moving the renderer to 160 + * instead would have re-painted every example, bench scenario, and docs + * fixture that leaves widths undeclared. grid-core imports + * {@link DEFAULT_COLUMN_WIDTH_PX} for its stored default, renderer-dom + * resolves both through `resolveColumnWidth`, and @pretable/react seeds the + * engine through that same resolver — one home, three consumers, no jump. + */ +export const DEFAULT_COLUMN_WIDTH_PX = 140; + +/** + * The undeclared-width fallback for a `wrap: "text"` column. Wrapped cells + * trade height for width, so their default is wider than + * {@link DEFAULT_COLUMN_WIDTH_PX} — see that constant's decision note for + * why these two numbers are the only copies. + */ +export const DEFAULT_WRAPPED_COLUMN_WIDTH_PX = 220; diff --git a/packages/layout-core/src/index.ts b/packages/layout-core/src/index.ts index 72eb5bd07..563c066fb 100644 --- a/packages/layout-core/src/index.ts +++ b/packages/layout-core/src/index.ts @@ -1,3 +1,7 @@ +export { + DEFAULT_COLUMN_WIDTH_PX, + DEFAULT_WRAPPED_COLUMN_WIDTH_PX, +} from "./column-defaults"; export { createRowHeightIndex } from "./row-height-index"; export { planColumns } from "./column-plan"; export { planViewport } from "./viewport-plan"; @@ -8,7 +12,6 @@ export type { ScrollTopToRevealInput, } from "./scroll-to-reveal"; export type { - AutosizeOptions, ColumnPlan, CreateRowHeightIndexOptions, PretableRowRange, diff --git a/packages/layout-core/src/types.ts b/packages/layout-core/src/types.ts index 674390877..942745e83 100644 --- a/packages/layout-core/src/types.ts +++ b/packages/layout-core/src/types.ts @@ -369,15 +369,3 @@ export interface ColumnPlan { pinnedLeftWidth: number; pinnedRightWidth: number; } - -/** - * Tuning knobs for column autosize calculations. - * - * @public - */ -export interface AutosizeOptions { - maxWidthPx?: number; - minWidthPx?: number; - averageCharWidth?: number; - cellPaddingPx?: number; -} diff --git a/packages/react/react.api.md b/packages/react/react.api.md index 557ab665a..9399cce2c 100644 --- a/packages/react/react.api.md +++ b/packages/react/react.api.md @@ -11,18 +11,6 @@ import { HTMLAttributes } from 'react'; import { ReactElement } from 'react'; import { ReactNode } from 'react'; -// @public -export interface AutosizeOptions { - // (undocumented) - averageCharWidth?: number; - // (undocumented) - cellPaddingPx?: number; - // (undocumented) - maxWidthPx?: number; - // (undocumented) - minWidthPx?: number; -} - // @public export function buildExportFileName(input: BuildExportFileNameArgs): string; @@ -1695,7 +1683,7 @@ export type PretableReactGrid void; readonly setHideGroupedColumns: (value: boolean) => void; readonly setColumnAggregate: (columnId: TColumnId, aggregate: unknown) => void; - readonly autosizeColumns: () => void; + readonly setAllColumnsAutoWidth: (auto: boolean) => void; readonly measureRow: (ref: PretableVisibleRowRef, height: number) => void; readonly dispose: () => void; readonly setQuery: (query: PretableQueryFor) => PretableQueryTransition | void; @@ -2312,12 +2300,11 @@ export interface PretableSurfaceSharedProps[]> { + allColumnsAutoWidth?: boolean; // (undocumented) ariaDescribedBy?: string; // (undocumented) ariaLabel: string; - // (undocumented) - autosize?: boolean | AutosizeOptions; copyToClipboard?: (payload: CopyPayload) => void | Promise; copyWithHeaders?: boolean; csvOptions?: PretableCsvOptions; diff --git a/packages/react/src/__tests__/column-auto-width.test.tsx b/packages/react/src/__tests__/column-auto-width.test.tsx index c9b50239e..ea000ec42 100644 --- a/packages/react/src/__tests__/column-auto-width.test.tsx +++ b/packages/react/src/__tests__/column-auto-width.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { useState } from "react"; import { afterEach, describe, expect, it } from "vitest"; import type { PretableColumn } from "../public_api"; @@ -12,18 +13,23 @@ import type { PretableSurfaceGrid } from "../pretable-surface"; * * WHAT JSDOM CAN AND CANNOT SEE — read before trusting these numbers. jsdom * performs no text measurement, so "content drives the width" is not - * observable here; that pixel proof belongs to the Playwright pass (SP5 - * Task 4). What jsdom CAN see is the seam the auto set actually acts + * observable here — and would not be anywhere: auto width is a MODE BIT, + * not a content fit. What jsdom CAN see is the seam the auto set acts * through: `mergeRenderColumns` (pretable-model.ts) strips `widthPx` from * every column in the auto set, so the renderer — not the engine — owns an * auto column's drawn width (`resolveColumnWidth`'s fallback, 140px for an * unwrapped column, or a flex share), while a manual column draws at the - * engine's stored width (grid-core's `DEFAULT_COLUMN_WIDTH_PX` 160 when the - * props declared none). 140 ≠ 160 ≠ any declared width, so auto-set - * membership is directly observable as WHICH owner the drawn header width - * follows: renderer fallback ⇒ auto, engine store ⇒ manual. Every assertion - * below reads the header cell's inline `style.width` — the real DOM output — - * never the store. + * engine's stored width. + * + * Since the width-default unification, the engine's stored width for an + * undeclared column IS the renderer's fallback (140 — layout-core's + * `DEFAULT_COLUMN_WIDTH_PX`, seeded through the same `resolveColumnWidth`), + * so for a never-resized undeclared column the two owners agree by design — + * that agreement is itself pinned below ("no jump"). Membership therefore + * has to be made observable by declaring or writing widths that DIFFER from + * 140 before toggling: renderer fallback ⇒ auto, stored width ⇒ manual. + * Every assertion reads the header cell's inline `style.width` — the real + * DOM output — never the store. */ type DemoRow = { @@ -34,12 +40,13 @@ type DemoRow = { const rows: DemoRow[] = [{ id: "r1", fixed: "x", fluid: "y" }]; -/** The renderer's fallback for an unwrapped column with no `widthPx` — - * `FIXED_COLUMN_WIDTH` in renderer-dom's create-renderer.ts. */ +/** The renderer's fallback for an unwrapped column with no `widthPx` — and, + * since the unification, ALSO what the engine stores for such a column: + * layout-core's `DEFAULT_COLUMN_WIDTH_PX`, one number, two owners. */ const RENDERER_AUTO_WIDTH = 140; -/** grid-core's `DEFAULT_COLUMN_WIDTH_PX`: what the engine stores for a - * column whose props declared no width. */ -const ENGINE_DEFAULT_WIDTH = 160; +/** A width no default resolves to, written via `setColumnWidth` where a test + * needs manual-vs-auto to be pixel-distinguishable. */ +const MANUAL_WIDTH = 200; /** The "fixed" column's declared `widthPx`. */ const DECLARED_WIDTH = 120; @@ -114,9 +121,11 @@ describe("column auto width", () => { // Manual: the engine's stored (declared) width is what draws. expect(h.drawnWidth("fixed")).toBe(DECLARED_WIDTH); expect(h.engineWidth("fixed")).toBe(DECLARED_WIDTH); - // Auto: the engine stores its 160 default, but the RENDERER owns the - // drawn width — the divergence is the membership proof. - expect(h.engineWidth("fluid")).toBe(ENGINE_DEFAULT_WIDTH); + // Auto: the renderer owns the drawn width — AND the engine's stored + // default is the same number, seeded through the renderer's own + // resolver. Stored == drawn is the unification's whole point: freezing + // this column later must be a no-op, not a jump. + expect(h.engineWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); expect(h.drawnWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); }); @@ -133,46 +142,147 @@ describe("column auto width", () => { expect(h.engineWidth("fixed")).toBe(DECLARED_WIDTH); }); - it("setColumnAutoWidth(id, false) freezes at the engine's stored width", () => { + it("setColumnAutoWidth(id, false) freezes at the engine's stored width — a no-op pixel for a never-resized column", () => { const h = mount(); act(() => { h.grid.setColumnAutoWidth("fluid", false); }); - // Off means manual at the engine's current stored width (spec B1) — - // the drawn width now follows the store, not the renderer. - expect(h.drawnWidth("fluid")).toBe(ENGINE_DEFAULT_WIDTH); + // Off means manual at the engine's current stored width (spec B1). For + // a never-resized column that stored width IS the renderer's default — + // the old 140→160 jump is gone by construction. + expect(h.drawnWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); + expect(h.engineWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); // The other column is untouched. expect(h.drawnWidth("fixed")).toBe(DECLARED_WIDTH); + // And the column really is MANUAL now, not still auto at the same + // pixel: store a different width (which is a manual write anyway), turn + // auto on, then off again — the freeze lands on the stored 200, which + // only a store-following column can draw. + act(() => { + h.grid.setColumnWidth("fluid", MANUAL_WIDTH); + h.grid.setColumnAutoWidth("fluid", true); + }); + expect(h.drawnWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); + act(() => { + h.grid.setColumnAutoWidth("fluid", false); + }); + expect(h.drawnWidth("fluid")).toBe(MANUAL_WIDTH); }); - it("setColumnWidth still flips auto OFF, and autosizeColumns still sets ALL auto", () => { + it("setColumnWidth still flips auto OFF, and setAllColumnsAutoWidth moves the whole roster both ways", () => { const h = mount(); // Old behavior 1: an explicit width write turns tracking off AND applies. act(() => { - h.grid.setColumnWidth("fluid", 200); + h.grid.setColumnWidth("fluid", MANUAL_WIDTH); }); - expect(h.drawnWidth("fluid")).toBe(200); - expect(h.engineWidth("fluid")).toBe(200); - // Old behavior 2: autosizeColumns keeps its all-columns meaning. + expect(h.drawnWidth("fluid")).toBe(MANUAL_WIDTH); + expect(h.engineWidth("fluid")).toBe(MANUAL_WIDTH); + // The all-columns form, on: every column joins the auto set. act(() => { - h.grid.autosizeColumns(); + h.grid.setAllColumnsAutoWidth(true); }); expect(h.drawnWidth("fixed")).toBe(RENDERER_AUTO_WIDTH); expect(h.drawnWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); // And the engine still remembers both stored widths underneath. expect(h.engineWidth("fixed")).toBe(DECLARED_WIDTH); - expect(h.engineWidth("fluid")).toBe(200); + expect(h.engineWidth("fluid")).toBe(MANUAL_WIDTH); + // The symmetric half the old `autosizeColumns()` name never offered: + // off freezes EVERY column at its stored width. + act(() => { + h.grid.setAllColumnsAutoWidth(false); + }); + expect(h.drawnWidth("fixed")).toBe(DECLARED_WIDTH); + expect(h.drawnWidth("fluid")).toBe(MANUAL_WIDTH); + }); + + it("double-clicking the resize handle hands the column's width to the grid", () => { + const h = mount(); + // Manual at its declared width before the gesture. + expect(h.drawnWidth("fixed")).toBe(DECLARED_WIDTH); + const handle = h.view.container.querySelector( + `[data-pretable-resize-handle][data-pretable-column-id="fixed"]`, + ) as HTMLElement | null; + if (handle === null) throw new Error("No resize handle for fixed"); + act(() => { + fireEvent.doubleClick(handle); + }); + // The double-click is the pointer shortcut for setColumnAutoWidth(id, + // true): the RENDERER owns the drawn width now (mode bit, not a content + // fit — nothing measured anything)... + expect(h.drawnWidth("fixed")).toBe(RENDERER_AUTO_WIDTH); + // ...and non-destructively: the engine still stores the declared width. + expect(h.engineWidth("fixed")).toBe(DECLARED_WIDTH); + }); + + it("the auto bit survives a controlled columnWidths round trip", () => { + // The shape of the column-layout docs example: `state.columnWidths` is + // controlled and `onColumnWidthsChange` feeds it back, so every commit + // re-renders the consumer and the write-back effect replays the whole + // widths map through `setColumnWidth`. + // + // That replay used to clear the auto bit for every column in the map, + // which made auto width unusable for any controlled consumer: the + // double-click below (and the tool panel's toggle, identically) set the + // bit and had it un-set before paint. `setColumnWidth` now clears the + // bit only when it MOVES the stored width, and a replay of unchanged + // widths moves none. + function Controlled() { + const [columnWidths, setColumnWidths] = useState< + Partial> + >(() => ({ fixed: DECLARED_WIDTH })); + return ( + + ariaLabel="Controlled widths" + columns={columns} + getRowId={(row) => row.id} + onColumnWidthsChange={setColumnWidths} + onGridReady={() => undefined} + rows={rows} + state={{ columnWidths }} + viewportHeight={200} + /> + ); + } + const view = render(); + const drawn = (columnId: string) => { + const cell = view.container.querySelector( + `[data-pretable-header-cell][data-pretable-column-id="${columnId}"]`, + ) as HTMLElement | null; + if (cell === null) throw new Error(`No header cell for ${columnId}`); + return Number.parseFloat(cell.style.width.replace("px", "")); + }; + expect(drawn("fixed")).toBe(DECLARED_WIDTH); + + const handle = view.container.querySelector( + `[data-pretable-resize-handle][data-pretable-column-id="fixed"]`, + ) as HTMLElement | null; + if (handle === null) throw new Error("No resize handle for fixed"); + act(() => { + fireEvent.doubleClick(handle); + }); + + // The renderer owns the drawn width, and it STAYS owned across the + // controlled re-render the gesture provokes — this is the assertion that + // fails (120, the declared width, reasserted) without the guard. + expect(drawn("fixed")).toBe(RENDERER_AUTO_WIDTH); + // A second, unrelated commit through the same controlled loop must not + // undo it either: nothing about `state` changing is a width write. + act(() => { + view.rerender(); + }); + expect(drawn("fixed")).toBe(RENDERER_AUTO_WIDTH); }); it("Reset columns restores the INITIAL auto set, both directions", () => { const h = mount({ toolPanel: true }); - // Drift both ways from the initial state. + // Drift both ways from the initial state — the manual drift goes + // through `setColumnWidth` so it is pixel-distinguishable from auto. act(() => { h.grid.setColumnAutoWidth("fixed", true); - h.grid.setColumnAutoWidth("fluid", false); + h.grid.setColumnWidth("fluid", MANUAL_WIDTH); }); expect(h.drawnWidth("fixed")).toBe(RENDERER_AUTO_WIDTH); - expect(h.drawnWidth("fluid")).toBe(ENGINE_DEFAULT_WIDTH); + expect(h.drawnWidth("fluid")).toBe(MANUAL_WIDTH); // Reset: "fixed" declared a width, so it returns to manual; "fluid" // declared none, so it returns to auto. act(() => { diff --git a/packages/react/src/__tests__/tool-panel.test.tsx b/packages/react/src/__tests__/tool-panel.test.tsx index f32c6145e..1e209652e 100644 --- a/packages/react/src/__tests__/tool-panel.test.tsx +++ b/packages/react/src/__tests__/tool-panel.test.tsx @@ -991,10 +991,11 @@ describe("columns section row menu — auto width toggle", () => { { id: "a", header: "Alpha", widthPx: 120 }, { id: "b", header: "Bravo" }, ]; - /** renderer-dom's `FIXED_COLUMN_WIDTH` — what an auto column draws at. */ + /** layout-core's `DEFAULT_COLUMN_WIDTH_PX` — what an auto column draws + * at, and (since the width-default unification) ALSO what the engine + * stores for a column that declared none: one number, so toggling auto + * off on a never-resized column freezes in place instead of jumping. */ const RENDERER_AUTO_WIDTH = 140; - /** grid-core's `DEFAULT_COLUMN_WIDTH_PX` — the engine's store for "b". */ - const ENGINE_DEFAULT_WIDTH = 160; const drawnWidth = ( h: ReturnType, @@ -1045,10 +1046,13 @@ describe("columns section row menu — auto width toggle", () => { // does not — the native menuitemcheckbox pattern). expect(h.menu()).not.toBeNull(); expect(autoWidthItem(h)).toHaveAttribute("aria-checked", "false"); - // Off ⇒ manual at the ENGINE's stored width (its 160 default — the - // props declared none), and the OTHER column is untouched: the write - // carried the pressed row's id, not some fixed one. - expect(drawnWidth(h, "b")).toBe(ENGINE_DEFAULT_WIDTH); + // Off ⇒ manual at the ENGINE's stored width. For a never-resized + // undeclared column that stored width IS the renderer's default (the + // unification), so the pixel does not move — the aria-checked flip + // above is the membership proof here; column-auto-width.test.tsx pins + // the freeze at a distinguishable width. The OTHER column is untouched: + // the write carried the pressed row's id, not some fixed one. + expect(drawnWidth(h, "b")).toBe(RENDERER_AUTO_WIDTH); expect(drawnWidth(h, "a")).toBe(120); // And back on: the renderer owns the width again. @@ -1113,7 +1117,9 @@ describe("columns section row menu — auto width toggle", () => { fireEvent.click(autoWidthItem(h)); // "b": auto → manual fireEvent.keyDown(autoWidthItem(h), { key: "Escape" }); expect(drawnWidth(h, "a")).toBe(RENDERER_AUTO_WIDTH); - expect(drawnWidth(h, "b")).toBe(ENGINE_DEFAULT_WIDTH); + // "b" froze at its stored width — the renderer's default, same pixel + // (the unification); the aria-checked reads below carry membership. + expect(drawnWidth(h, "b")).toBe(RENDERER_AUTO_WIDTH); fireEvent.click(h.reset()); diff --git a/packages/react/src/pretable-model.ts b/packages/react/src/pretable-model.ts index 1a95c89f2..ac7cf114c 100644 --- a/packages/react/src/pretable-model.ts +++ b/packages/react/src/pretable-model.ts @@ -1,6 +1,7 @@ import { createDomRenderSnapshot, createRowLayoutController, + resolveColumnWidth, type DomLayoutColumn, } from "@pretable-internal/renderer-dom"; import { @@ -146,7 +147,8 @@ export type PretableReactGrid< * makes the column manual again at the engine's current stored width, with * no width write of its own. Columns that declare no `widthPx` start in the * set; {@link setColumnWidth} takes a column OUT of it (an explicit width is - * a manual gesture), and `autosizeColumns` puts EVERY column in. Declared + * a manual gesture), and {@link setAllColumnsAutoWidth} moves EVERY column + * at once. Declared * here beside `setColumnWidth` rather than inherited: the auto set lives in * this layer's store, not in grid-core, so the facade is its only home. */ @@ -221,7 +223,15 @@ export type PretableReactGrid< columnId: TColumnId, aggregate: unknown, ) => void; - readonly autosizeColumns: () => void; + /** + * The all-columns form of {@link setColumnAutoWidth}: `true` puts EVERY + * column into the auto-width set (the grid manages each drawn width), + * `false` takes every column out, freezing each at the engine's current + * stored width. The same mode bit, applied across the roster — a rename of + * the old `autosizeColumns()`, whose name promised a content fit that + * nothing in the width path computes. + */ + readonly setAllColumnsAutoWidth: (auto: boolean) => void; /** Reports a measured visible-row height to the indexed layout. */ readonly measureRow: ( ref: PretableVisibleRowRef, @@ -397,7 +407,7 @@ function mergeRenderColumns( (column) => layout.find((entry) => entry.id === column.id) ?? { id: column.id, - widthPx: column.widthPx ?? 160, + widthPx: resolveColumnWidth(column), ...(column.pinned === undefined ? {} : { pinned: column.pinned }), }, ) @@ -548,7 +558,8 @@ export interface WindowState { * The `ɵautoWidths` read seam the facade carries at runtime: subscribe + * getState over the auto-width set. Not on {@link PretableReactGrid} — the * public voice over the set is `setColumnAutoWidth` / `setColumnWidth` / - * `autosizeColumns`; this reader exists for the surface's own chrome, which + * `setAllColumnsAutoWidth`; this reader exists for the surface's own chrome, + * which * must also REFLECT membership (the tool panel). Reached by a cast at the * consumer, the `setWindowState` pattern. * @@ -623,7 +634,16 @@ export function usePretableModelInternal< const stores = useMemo(() => { const gridCore = createGridUiCore({ rowModel, - columns: initialColumns, + // Widths resolved through the renderer's own fallback (140, or 220 + // wrapped) rather than left for grid-core's wrap-blind default, so the + // engine's stored width for an undeclared column is exactly the number + // the renderer draws while it is auto — turning auto off is then a + // freeze, never a jump. + columns: initialColumns.map((column) => + column.widthPx === undefined + ? { ...column, widthPx: resolveColumnWidth(column) } + : column, + ), // Spread-or-omit, not `?? false`: grid-core keeps the key ABSENT when // the option is absent, and that distinction is the whole reason the // option is optional. This `useMemo` runs once per row model, so this @@ -756,15 +776,32 @@ export function usePretableModelInternal< const facade = Object.create(stores.gridCore) as Record; facade.rowModel = rowModel; facade.setQuery = setQuery; - facade.autosizeColumns = () => { + facade.setAllColumnsAutoWidth = (auto: boolean) => { for (const column of presentationColumnsRef.current) { - stores.autoWidths.setAuto(column.id, true); + stores.autoWidths.setAuto(column.id, auto); } }; facade.measureRow = stores.controller.measure; facade.setColumnWidth = (columnId: TColumnId, width: number) => { + // An explicit width write takes the column OUT of the auto set — but + // only when it MOVES the stored width. Clearing the bit + // unconditionally made auto width unusable under a controlled + // `state.columnWidths`: every write-back pass replays the whole map + // through here, so any re-render of the consumer silently un-set every + // column's bit. `setColumnAutoWidth(id, true)` (the tool panel's + // toggle, the resize handle's double-click) appeared to work and was + // undone before paint. + // + // Read the store on both sides rather than comparing to the ARGUMENT: + // grid-core clamps against the column's min/max, so a request that + // clamps back onto the current width is not a move either. + const storedWidth = (): number | undefined => + stores.gridCore + .getState() + .columnLayout.find((entry) => entry.id === columnId)?.widthPx; + const before = storedWidth(); stores.gridCore.setColumnWidth(columnId, width); - stores.autoWidths.setAuto(columnId, false); + if (storedWidth() !== before) stores.autoWidths.setAuto(columnId, false); }; facade.setColumnAutoWidth = (columnId: TColumnId, auto: boolean) => { stores.autoWidths.setAuto(columnId as string, auto); @@ -1028,7 +1065,11 @@ export function usePretableModelInternal< // is dropped here for good, by design. if (restoredIds.has(column.id) && prior === undefined) continue; if (prior === undefined || prior.widthPx !== column.widthPx) { - stores.gridCore.setColumnWidth(column.id, column.widthPx ?? 160); + // The engine's stored width for an undeclared column is the SAME + // number the renderer would draw it at (140, or 220 wrapped), so a + // later `setColumnAutoWidth(id, false)` freezes the column where it + // already is instead of jumping to a divergent engine default. + stores.gridCore.setColumnWidth(column.id, resolveColumnWidth(column)); stores.autoWidths.setAuto(column.id, column.widthPx === undefined); } if (prior?.pinned !== column.pinned) { diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index e6d28e595..6f563da11 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -19,7 +19,6 @@ import { } from "react"; import { GROUP_COLUMN_ID } from "@pretable/core"; import type { - AutosizeOptions, ColumnIdOf, ColumnValueOf, ColumnFilter, @@ -520,7 +519,7 @@ interface SurfaceFacade { markEditError(message: string): void; commitEditSucceeded(): void; cancelEdit(): void; - autosizeColumn(): void; + setColumnAutoWidth(columnId: string, auto: boolean): void; } async function defaultCopyToClipboard(payload: CopyPayload): Promise { @@ -1183,7 +1182,16 @@ export interface PretableSurfaceSharedProps< phase: PretableDataState["phase"]; loadedRowCount: number; }) => ReactNode; - autosize?: boolean | AutosizeOptions; + /** + * When `true`, puts EVERY column into auto-width mode at mount (and again + * whenever the value turns true) — the declarative twin of + * `grid.setAllColumnsAutoWidth(true)`. Auto width is a mode bit, not a + * content fit: the grid manages each drawn width (the renderer's default, + * or a flex share when the column declares `flex`); nothing measures cell + * content. Omitted or `false`, the per-column defaults stand — columns + * that declare no `widthPx` start auto, declared ones start manual. + */ + allColumnsAutoWidth?: boolean; groupColumn?: PretableGroupColumnOptions; getBodyCellClassName?: ( input: PretableSurfaceBodyCellInput, @@ -1821,7 +1829,7 @@ export function PretableSurface< resultMeta, dataState, renderBodyState, - autosize, + allColumnsAutoWidth, columns: inputColumns, model, beforeRowChange, @@ -2659,8 +2667,8 @@ export function PretableSurface< }; }, [indexed.rowModel]); useEffect(() => { - if (autosize) indexedGrid.autosizeColumns(); - }, [autosize, indexedGrid]); + if (allColumnsAutoWidth) indexedGrid.setAllColumnsAutoWidth(true); + }, [allColumnsAutoWidth, indexedGrid]); const indexedSnapshot = indexed.gridSnapshot; // What `state.rowSelection` last WROTE, and what it wrote it against. See // {@link PretableSurfaceState.rowSelection}: re-asserting an unchanged @@ -3440,7 +3448,9 @@ export function PretableSurface< editOperationTokenRef.current += 1; indexedGrid.cancelEdit(); }, - autosizeColumn() {}, + setColumnAutoWidth(columnId: string, auto: boolean) { + indexedGrid.setColumnAutoWidth(columnId, auto); + }, scrollToRow(rowId: TRowId) { const index = surfaceContextRef.current.rowModelSnapshot.indexOf({ kind: "data", @@ -4877,7 +4887,7 @@ export function PretableSurface< // engine state precedence), so the `columns` prop can be permanently stale. // `PlannedColumn.left` is a left-pinned column's sticky offset — the summed // width of the left-pinned columns before it, measured with engine widths so - // it also tracks resize and autosize. + // it also tracks resize and auto width. // Build per-column left/width arrays indexed by effectiveColumn index. // After a reorder, grid.options.columns (engine state, used to build @@ -6656,7 +6666,7 @@ export function PretableSurface< // width, which is what this column is currently rendering // (`effWidth`). The `columns` prop is not a source of // truth for width: the engine owns it after the first - // resize / autosize / controlled `state.columnWidths` + // resize / auto width / controlled `state.columnWidths` // apply, and `mergeColumnsFromProps` gives engine state // precedence, so `column.widthPx` still reads as the // ORIGINAL declared width forever. Anchoring to it made @@ -6730,10 +6740,20 @@ export function PretableSurface< wasResizingRef.current = false; return; } - grid.autosizeColumn(); - onColumnWidthsChange?.( - buildWidthsMap(grid as unknown as SurfaceFacade), - ); + // The pointer shortcut for the auto-width MODE BIT: + // hand this column's drawn width back to the grid. Not + // a content fit — nothing measures cells anywhere in + // the width path. + // + // No `onColumnWidthsChange` here, deliberately. That + // callback reports the ENGINE's stored widths, and + // this gesture moves none of them — auto merely + // withholds the stored width from the renderer. Firing + // it announced a change that had not happened, and + // under a controlled `state.columnWidths` the + // announcement came straight back through the + // write-back loop as a `setColumnWidth` replay. + grid.setColumnAutoWidth(column.id, true); }} /> ) : null} diff --git a/packages/react/src/public_api.ts b/packages/react/src/public_api.ts index c3260b536..c7e30e472 100644 --- a/packages/react/src/public_api.ts +++ b/packages/react/src/public_api.ts @@ -164,7 +164,6 @@ export { numberFormats, } from "@pretable/core"; export type { - AutosizeOptions, ColumnAlign, ColumnIdOf, ColumnFilter, diff --git a/packages/renderer-dom/src/create-renderer.ts b/packages/renderer-dom/src/create-renderer.ts index 860858434..bdbbfe388 100644 --- a/packages/renderer-dom/src/create-renderer.ts +++ b/packages/renderer-dom/src/create-renderer.ts @@ -1,4 +1,6 @@ import { + DEFAULT_COLUMN_WIDTH_PX, + DEFAULT_WRAPPED_COLUMN_WIDTH_PX, distributeFlexWidths, planColumns, } from "@pretable-internal/layout-core"; @@ -34,8 +36,6 @@ import type { * drifted into disagreeing in the first place. */ export const DEFAULT_ROW_HEIGHT = 44; -const WRAPPED_COLUMN_WIDTH = 220; -const FIXED_COLUMN_WIDTH = 140; // Calibrated against actual browser metrics for Inter Variable at 16px in // the bench app (cell line-height computed by getComputedStyle = "24px"). // Mismatched constants caused H1's row_height_error_p95_px to fail at 5px @@ -704,15 +704,18 @@ function readCellValue( /** * The width `planColumns` is fed for a column, including the fallbacks applied - * when the column declares no `widthPx`. Module-private on purpose: every plan - * built from `PretableColumn`s goes through `createDomRenderSnapshot` or - * `planColumnLayout`, so no caller outside this file has to know the fallbacks - * — which is exactly how a second copy of them would get started. + * when the column declares no `widthPx` (layout-core's shared defaults — see + * column-defaults.ts for the decision note). Exported so @pretable/react can + * seed the ENGINE's stored width through the exact same resolution: the + * stored width and the drawn width of a never-resized column must be the + * same number, or toggling auto width off visibly jumps. One resolver, no + * second copy of the fallbacks. */ -function resolveColumnWidth( +export function resolveColumnWidth( column: DomLayoutColumn, ): number { return ( - column.widthPx ?? (column.wrap ? WRAPPED_COLUMN_WIDTH : FIXED_COLUMN_WIDTH) + column.widthPx ?? + (column.wrap ? DEFAULT_WRAPPED_COLUMN_WIDTH_PX : DEFAULT_COLUMN_WIDTH_PX) ); } diff --git a/packages/renderer-dom/src/index.ts b/packages/renderer-dom/src/index.ts index 50f759328..34b2f56bd 100644 --- a/packages/renderer-dom/src/index.ts +++ b/packages/renderer-dom/src/index.ts @@ -1,4 +1,8 @@ -export { createDomRenderSnapshot, planColumnLayout } from "./create-renderer"; +export { + createDomRenderSnapshot, + planColumnLayout, + resolveColumnWidth, +} from "./create-renderer"; export { createRowLayoutController } from "./row-layout-controller"; export type { CellWrapMode,