From ed153c7c86743288680b5c06b50f19727ac86e58 Mon Sep 17 00:00:00 2001 From: Peter Young <20213436+petera2c@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:34:29 -0700 Subject: [PATCH] Size auto columns to custom cell content, and ship 4.3.1. --- apps/marketing/src/constants/changelog.ts | 29 ++ packages/angular/package.json | 2 +- packages/core/package.json | 2 +- .../__tests__/autoSizeCellRenderer.test.ts | 358 ++++++++++++++++++ packages/core/src/core/SimpleTableVanilla.ts | 40 ++ packages/core/src/core/api/TableAPIContext.ts | 1 + packages/core/src/core/api/TableAPIImpl.ts | 4 + .../core/src/core/api/buildTableAPIContext.ts | 2 + .../core/src/core/rendering/RenderContext.ts | 2 + .../src/core/rendering/RenderOrchestrator.ts | 1 + .../src/core/rendering/TableRendererDeps.ts | 2 + .../src/core/rendering/buildRenderContext.ts | 2 + .../src/core/rendering/cellRenderContexts.ts | 1 + .../core/src/core/vanilla/VanillaLiveHost.ts | 1 + .../core/src/core/vanilla/applyTableUpdate.ts | 2 + .../vanilla/createVanillaRenderContext.ts | 8 +- .../src/core/vanilla/toTableAPIContextHost.ts | 1 + packages/core/src/managers/AutoSizeManager.ts | 98 ++++- .../core/src/types/HandleResizeStartProps.ts | 2 + packages/core/src/types/TableAPI.ts | 5 + .../core/src/utils/headerCell/resizing.ts | 2 + packages/core/src/utils/headerCell/types.ts | 2 + packages/core/src/utils/headerWidthUtils.ts | 77 +++- packages/core/src/utils/resizeUtils/index.ts | 3 + .../61-AutoSizeCellRendererTests.stories.ts | 352 +++++++++++++++++ .../demos/cell-renderer/CellRendererDemo.tsx | 1 + .../cell-renderer/cell-renderer.demo-data.ts | 18 +- packages/react/package.json | 2 +- packages/react/src/SimpleTable.tsx | 31 +- packages/react/src/types.ts | 4 + packages/react/vitest.config.ts | 1 + packages/solid/package.json | 2 +- packages/svelte/package.json | 2 +- packages/vue/package.json | 2 +- 34 files changed, 1002 insertions(+), 60 deletions(-) create mode 100644 packages/core/src/__tests__/autoSizeCellRenderer.test.ts create mode 100644 packages/core/stories/tests/61-AutoSizeCellRendererTests.stories.ts diff --git a/apps/marketing/src/constants/changelog.ts b/apps/marketing/src/constants/changelog.ts index 41bf69613..af5d5e44e 100644 --- a/apps/marketing/src/constants/changelog.ts +++ b/apps/marketing/src/constants/changelog.ts @@ -11,6 +11,34 @@ export interface ChangelogEntry { }[]; } +export const v4_3_1: ChangelogEntry = { + version: "4.3.1", + date: "2026-09-30", + title: "Auto width for custom cells", + description: + "Columns that size themselves to the content now do that for custom cells too, and a width you drag stays put.", + changes: [ + { + type: "bugfix", + description: + "A column with width \"auto\" and a custom cell now matches a plain column with the same text. Hiding and showing it, changing page, adding rows, or finishing loading used to leave it at the header width.", + link: "/docs/column-width#content-fit-auto", + }, + { + type: "bugfix", + description: + "Dragging an auto-width column wider now keeps that width when you change page.", + link: "/docs/column-width#content-fit-auto", + }, + { + type: "feature", + description: + "refitAutoSizeColumns() on the table API measures auto columns again after your own cells have painted.", + link: "/docs/programmatic-control", + }, + ], +}; + export const v4_3_0: ChangelogEntry = { version: "4.3.0", date: "2026-09-26", @@ -2929,6 +2957,7 @@ export const v1_4_4: ChangelogEntry = { // Array of all changelog entries (newest first) export const CHANGELOG_ENTRIES: ChangelogEntry[] = [ + v4_3_1, v4_3_0, v4_2_9, v4_2_8, diff --git a/packages/angular/package.json b/packages/angular/package.json index 170d5a2a0..5feaabea7 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/angular", - "version": "4.3.0", + "version": "4.3.1", "type": "module", "main": "./dist/fesm2022/simple-table-angular.mjs", "module": "./dist/fesm2022/simple-table-angular.mjs", diff --git a/packages/core/package.json b/packages/core/package.json index 9fde10437..f9647b043 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "simple-table-core", - "version": "4.3.0", + "version": "4.3.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/index.d.ts", diff --git a/packages/core/src/__tests__/autoSizeCellRenderer.test.ts b/packages/core/src/__tests__/autoSizeCellRenderer.test.ts new file mode 100644 index 000000000..a56af6ae4 --- /dev/null +++ b/packages/core/src/__tests__/autoSizeCellRenderer.test.ts @@ -0,0 +1,358 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { SimpleTableVanilla } from "../index"; +import type { ColumnDef } from "../types/ColumnDef"; +import type { TableAPI } from "../types/TableAPI"; + +/** + * `width: "auto"` with an async cell renderer (the React/Vue/Solid portal + * shape: an empty host now, real content on a later turn). jsdom has no + * layout, so offsetWidth is stubbed from text length. A plain column and a + * rendered column hold the same string; they should end at the same width. + */ + +const LONG = "W".repeat(80); +const SHORT = "ab"; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await wait(20); + } + throw new Error("Timed out waiting for condition"); +} + +/** Let a queued portal fill and any post-paint re-fit run. */ +async function flushPaint(): Promise { + await wait(0); + await wait(30); +} + +type Row = { id: number; genre: string; genreRendered: string }; + +function leafWidth(table: SimpleTableVanilla, accessor: string): number { + const find = (list: ColumnDef[]): number | null => { + for (const header of list) { + if (header.children?.length) { + const nested = find(header.children); + if (nested != null) return nested; + } else if (String(header.accessor) === accessor && typeof header.width === "number") { + return header.width; + } + } + return null; + }; + return find(table.getAPI().getHeaders()) ?? 0; +} + +/** + * Async renderer: empty host while measuring, content arrives on a microtask + * once `fill()` has run (same contract as a React portal). + */ +function makeAsyncRenderer() { + const pending: Array<{ el: HTMLElement; value: string }> = []; + let mounted = false; + const fillEl = (el: HTMLElement, value: string) => { + if (!el.textContent) el.textContent = value; + }; + return { + cellRenderer: ({ value }: { value: unknown }) => { + const el = document.createElement("div"); + el.setAttribute("data-st-portal-id", "auto-size-test"); + const text = value == null ? "" : String(value); + if (mounted) { + queueMicrotask(() => fillEl(el, text)); + } else { + pending.push({ el, value: text }); + } + return el; + }, + fill() { + mounted = true; + for (const item of pending) fillEl(item.el, item.value); + pending.length = 0; + }, + }; +} + +let restoreOffsetWidth: (() => void) | null = null; + +function installTextWidth() { + const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get() { + const styleWidth = this.style?.width ?? ""; + if (styleWidth.endsWith("px")) { + const px = parseFloat(styleWidth); + if (px > 0) return px; + } + const structural = + this.classList?.contains("simple-table-root") || + this.classList?.contains("st-body") || + this.classList?.contains("st-header") || + this.classList?.contains("st-content") || + this.classList?.contains("st-main") || + this.classList?.contains("st-cell") || + this.classList?.contains("st-header-cell") || + this.classList?.contains("st-footer"); + if (structural) return 120; + const text = (this.textContent ?? "").replace(/\s+/g, " ").trim(); + return text.length * 8; + }, + }); + restoreOffsetWidth = () => { + if (original) { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", original); + } else { + delete (HTMLElement.prototype as unknown as { offsetWidth?: number }).offsetWidth; + } + restoreOffsetWidth = null; + }; +} + +const mounted: Array<{ table: SimpleTableVanilla; container: HTMLDivElement }> = []; + +afterEach(() => { + for (const entry of mounted.splice(0)) { + entry.table.destroy(); + entry.container.remove(); + } + restoreOffsetWidth?.(); +}); + +function columnsFor(cellRenderer: ColumnDef["cellRenderer"], hideRendered = false): ColumnDef[] { + return [ + { accessor: "id", label: "ID", width: 80, type: "number" }, + { accessor: "genre", label: "Genre", width: "auto", type: "string" }, + { + accessor: "genreRendered", + label: "Genre", + width: "auto", + type: "string", + hide: hideRendered, + cellRenderer, + }, + ]; +} + +function mount( + rows: Row[], + cellRenderer: ColumnDef["cellRenderer"], + extras?: { enablePagination?: boolean; rowsPerPage?: number; columnResizing?: boolean }, +) { + installTextWidth(); + const container = document.createElement("div"); + document.body.appendChild(container); + const table = new SimpleTableVanilla(container, { + columns: columnsFor(cellRenderer), + rows, + getRowId: (params) => String(params.row.id), + height: "240px", + theme: "light", + animations: { enabled: false }, + autoExpandColumns: false, + enablePagination: extras?.enablePagination, + rowsPerPage: extras?.rowsPerPage, + columnResizing: extras?.columnResizing, + }); + table.mount(); + mounted.push({ table, container }); + return { table, container }; +} + +function pair(i: number, genre: string): Row { + return { id: i, genre, genreRendered: genre }; +} + +describe("auto column width with async cell renderers", () => { + it("re-fits a rendered auto column when it is shown again", async () => { + const renderer = makeAsyncRenderer(); + const rows = [pair(1, LONG), pair(2, LONG)]; + const { table, container } = mount(rows, renderer.cellRenderer); + await waitFor(() => container.querySelectorAll(".st-header-cell").length > 0); + + renderer.fill(); + table.refitAutoSizeColumns(); + const fitted = leafWidth(table, "genreRendered"); + expect(leafWidth(table, "genre")).toBeGreaterThan(200); + expect(fitted).toBeGreaterThan(200); + + table.update({ columns: columnsFor(renderer.cellRenderer, true) }); + table.update({ columns: columnsFor(renderer.cellRenderer, false) }); + await flushPaint(); + + const shown = leafWidth(table, "genreRendered"); + expect(shown).toBeGreaterThan(200); + expect(Math.abs(shown - leafWidth(table, "genre"))).toBeLessThan(40); + }); + + it("re-fits both columns when the footer changes page", async () => { + const renderer = makeAsyncRenderer(); + const rows = [ + ...Array.from({ length: 5 }, (_, i) => pair(i + 1, SHORT)), + ...Array.from({ length: 5 }, (_, i) => pair(i + 6, LONG)), + ]; + const { table, container } = mount(rows, renderer.cellRenderer, { + enablePagination: true, + rowsPerPage: 5, + }); + await waitFor(() => container.querySelector('[aria-label="Go to page 2"]') != null); + + renderer.fill(); + table.refitAutoSizeColumns(); + const plainBefore = leafWidth(table, "genre"); + const renderedBefore = leafWidth(table, "genreRendered"); + + container.querySelector('[aria-label="Go to page 2"]')!.click(); + await flushPaint(); + + expect(leafWidth(table, "genre")).toBeGreaterThan(plainBefore + 100); + expect(leafWidth(table, "genreRendered")).toBeGreaterThan(renderedBefore + 100); + expect(Math.abs(leafWidth(table, "genre") - leafWidth(table, "genreRendered"))).toBeLessThan(40); + }); + + it("re-fits both columns when setPage() changes page", async () => { + const renderer = makeAsyncRenderer(); + const rows = [ + ...Array.from({ length: 5 }, (_, i) => pair(i + 1, SHORT)), + ...Array.from({ length: 5 }, (_, i) => pair(i + 6, LONG)), + ]; + const { table, container } = mount(rows, renderer.cellRenderer, { + enablePagination: true, + rowsPerPage: 5, + }); + await waitFor(() => container.querySelectorAll(".st-cell").length > 0); + + renderer.fill(); + table.refitAutoSizeColumns(); + const plainBefore = leafWidth(table, "genre"); + const renderedBefore = leafWidth(table, "genreRendered"); + + await table.getAPI().setPage(2); + await flushPaint(); + + expect(leafWidth(table, "genre")).toBeGreaterThan(plainBefore + 100); + expect(leafWidth(table, "genreRendered")).toBeGreaterThan(renderedBefore + 100); + }); + + it("re-fits a rendered column when longer rows are appended", async () => { + const renderer = makeAsyncRenderer(); + const { table, container } = mount( + Array.from({ length: 8 }, (_, i) => pair(i + 1, SHORT)), + renderer.cellRenderer, + ); + await waitFor(() => container.querySelectorAll(".st-cell").length > 0); + + renderer.fill(); + table.refitAutoSizeColumns(); + const renderedBefore = leafWidth(table, "genreRendered"); + + table.update({ + rows: Array.from({ length: 8 }, (_, i) => pair(i + 1, LONG)), + }); + await flushPaint(); + + expect(leafWidth(table, "genre")).toBeGreaterThan(200); + expect(leafWidth(table, "genreRendered")).toBeGreaterThan(renderedBefore + 100); + }); + + it("does not keep a skeleton measurement after isLoading ends", async () => { + const renderer = makeAsyncRenderer(); + const rows = [pair(1, LONG), pair(2, LONG)]; + const { table, container } = mount(rows, renderer.cellRenderer); + await waitFor(() => container.querySelectorAll(".st-cell").length > 0); + + renderer.fill(); + table.refitAutoSizeColumns(); + const fitted = leafWidth(table, "genreRendered"); + expect(fitted).toBeGreaterThan(200); + + // New row objects rebuild the cells. The renderer fills on a microtask, + // so the loading cycle's own measure still sees empty hosts. + const next = rows.map((row) => ({ ...row })); + table.update({ rows: next, isLoading: true }); + await waitFor(() => container.querySelectorAll(".st-loading-skeleton").length > 0); + table.update({ isLoading: false }); + await flushPaint(); + + const after = leafWidth(table, "genreRendered"); + expect(after).toBeGreaterThan(200); + expect(Math.abs(after - leafWidth(table, "genre"))).toBeLessThan(40); + }); + + it("sizes a rendered column from sampled rows that are not on screen", async () => { + const renderer = makeAsyncRenderer(); + // Long values sit past the rendered window. The head of the table is short. + const rows = Array.from({ length: 120 }, (_, i) => pair(i + 1, i >= 80 ? LONG : SHORT)); + const { table, container } = mount(rows, renderer.cellRenderer); + await waitFor(() => container.querySelectorAll(".st-cell").length > 0); + + renderer.fill(); + table.refitAutoSizeColumns(); + await flushPaint(); + + const plain = leafWidth(table, "genre"); + const rendered = leafWidth(table, "genreRendered"); + expect(plain).toBeGreaterThan(200); + expect(rendered).toBeGreaterThan(200); + expect(Math.abs(plain - rendered)).toBeLessThan(40); + }); + + it("keeps a dragged width when a later page change re-measures", async () => { + const renderer = makeAsyncRenderer(); + const rows = [ + ...Array.from({ length: 5 }, (_, i) => pair(i + 1, SHORT)), + ...Array.from({ length: 5 }, (_, i) => pair(i + 6, LONG)), + ]; + const { table, container } = mount(rows, renderer.cellRenderer, { + enablePagination: true, + rowsPerPage: 5, + columnResizing: true, + }); + await waitFor(() => container.querySelector(".st-header-resize-handle-container") != null); + + renderer.fill(); + table.refitAutoSizeColumns(); + const before = leafWidth(table, "genre"); + + const header = container.querySelector('.st-header-cell[data-accessor="genre"]'); + const handle = header?.querySelector(".st-header-resize-handle-container"); + expect(handle).toBeTruthy(); + handle!.dispatchEvent( + new MouseEvent("mousedown", { clientX: 20, clientY: 10, bubbles: true, cancelable: true }), + ); + document.dispatchEvent( + new MouseEvent("mousemove", { clientX: 220, clientY: 10, bubbles: true, cancelable: true }), + ); + document.dispatchEvent( + new MouseEvent("mouseup", { clientX: 220, clientY: 10, bubbles: true, cancelable: true }), + ); + + const dragged = leafWidth(table, "genre"); + expect(dragged).toBeGreaterThan(before + 100); + + container.querySelector('[aria-label="Go to page 2"]')!.click(); + await flushPaint(); + + expect(Math.abs(leafWidth(table, "genre") - dragged)).toBeLessThan(2); + }); + + it("exposes refitAutoSizeColumns on TableAPI", async () => { + const renderer = makeAsyncRenderer(); + const { table, container } = mount([pair(1, LONG)], renderer.cellRenderer); + await waitFor(() => container.querySelectorAll(".st-cell").length > 0); + + const api = table.getAPI() as TableAPI & { refitAutoSizeColumns?: () => void }; + expect(typeof api.refitAutoSizeColumns).toBe("function"); + + const before = leafWidth(table, "genreRendered"); + renderer.fill(); + api.refitAutoSizeColumns?.(); + await flushPaint(); + + expect(leafWidth(table, "genreRendered")).toBeGreaterThan(before + 100); + }); +}); diff --git a/packages/core/src/core/SimpleTableVanilla.ts b/packages/core/src/core/SimpleTableVanilla.ts index 5a9793015..a2f75475e 100644 --- a/packages/core/src/core/SimpleTableVanilla.ts +++ b/packages/core/src/core/SimpleTableVanilla.ts @@ -567,6 +567,9 @@ export class SimpleTableVanilla { icons: this.resolvedIcons, onRendererHostDiscard: this.config.onRendererHostDiscard, }); + if (this.autoSizeManager.hasPending()) { + this.scheduleAutoSizeRetry(); + } if (!nextHeaders) return; this.headers = nextHeaders; @@ -587,6 +590,28 @@ export class SimpleTableVanilla { this.config.onColumnWidthChange?.(this.headers); } + private autoSizeRetryHandle: number | null = null; + + /** Measure again on the next frame when renderer output was not ready. */ + private scheduleAutoSizeRetry(): void { + if (this.autoSizeRetryHandle != null) return; + this.autoSizeRetryHandle = requestAnimationFrame(() => { + this.autoSizeRetryHandle = null; + if (!this.mounted || !this.autoSizeManager.hasPending()) return; + this.render("auto-size-retry"); + }); + } + + public hasPendingAutoSize(): boolean { + return this.autoSizeManager.hasPending(); + } + + /** Re-measure columns that are still waiting on renderer output. */ + public retryPendingAutoSize(): void { + if (!this.mounted || !this.autoSizeManager.hasPending()) return; + this.render("auto-size-retry"); + } + public refitAutoSizeColumns(): void { if (this.autoSizeManager.getAccessors().size === 0) return; this.autoSizeManager.queuePendingFromAccessors(); @@ -675,6 +700,11 @@ export class SimpleTableVanilla { } destroy(): void { + if (this.autoSizeRetryHandle != null) { + cancelAnimationFrame(this.autoSizeRetryHandle); + this.autoSizeRetryHandle = null; + } + this.autoSizeManager.dispose(); this.mounted = false; this.firstRenderDone = false; this.liveHost = null; @@ -755,6 +785,15 @@ export class SimpleTableVanilla { }, getCurrentPage: () => this.currentPage, setCurrentPage: (page) => { + if ( + page !== this.currentPage && + this.config.enablePagination && + !this.config.serverSidePagination && + this.autoSizeManager.getAccessors().size > 0 + ) { + this.autoSizeManager.discardParkedSamples(); + this.autoSizeManager.queuePendingFromAccessors(); + } this.currentPage = page; }, getFirstRenderDone: () => this.firstRenderDone, @@ -834,6 +873,7 @@ export class SimpleTableVanilla { getEffectiveRowGrouping: () => this.getEffectiveRowGrouping(), applyPivot: (pivot) => this.applyPivot(pivot), onRender: (source) => this.render(source), + refitAutoSizeColumns: () => this.refitAutoSizeColumns(), isCellAnimating: (cellId) => this.animationCoordinator.isInFlight(cellId), hasAnimatingCells: () => this.animationCoordinator.hasInFlight(), runWithoutAnimationSnapshot: (fn) => { diff --git a/packages/core/src/core/api/TableAPIContext.ts b/packages/core/src/core/api/TableAPIContext.ts index a02f60999..b2ba45693 100644 --- a/packages/core/src/core/api/TableAPIContext.ts +++ b/packages/core/src/core/api/TableAPIContext.ts @@ -70,4 +70,5 @@ export interface TableAPIContext { getPivot: () => PivotConfig | null; getPivotHeaders: () => ColumnDef[]; getPivotedRows: () => Row[]; + refitAutoSizeColumns: () => void; } diff --git a/packages/core/src/core/api/TableAPIImpl.ts b/packages/core/src/core/api/TableAPIImpl.ts index afa818c96..4e82e0cff 100644 --- a/packages/core/src/core/api/TableAPIImpl.ts +++ b/packages/core/src/core/api/TableAPIImpl.ts @@ -490,6 +490,10 @@ export class TableAPIImpl { return context.getPivotedRows(); }, + refitAutoSizeColumns: () => { + context.refitAutoSizeColumns(); + }, + toggleColumnEditor: (open?: boolean) => { if (!context.config.enableColumnEditor) return; context.setColumnEditorOpen(open !== undefined ? open : !context.columnEditorOpen); diff --git a/packages/core/src/core/api/buildTableAPIContext.ts b/packages/core/src/core/api/buildTableAPIContext.ts index 704c48f64..e9c62875c 100644 --- a/packages/core/src/core/api/buildTableAPIContext.ts +++ b/packages/core/src/core/api/buildTableAPIContext.ts @@ -52,6 +52,7 @@ export interface TableAPIContextHost { getPivot(): PivotConfig | null; getPivotHeaders(): ColumnDef[]; getPivotedRows(): Row[]; + refitAutoSizeColumns(): void; onRender(): void; invalidateRowsCache(): void; runWithoutAnimationSnapshot(fn: () => void): void; @@ -129,6 +130,7 @@ export const buildTableAPIContext = (host: TableAPIContextHost): TableAPIContext getPivot: () => host.getPivot(), getPivotHeaders: () => host.getPivotHeaders(), getPivotedRows: () => host.getPivotedRows(), + refitAutoSizeColumns: () => host.refitAutoSizeColumns(), onRender: () => host.onRender(), invalidateRowsCache: () => host.invalidateRowsCache(), runWithoutAnimationSnapshot: (fn: () => void) => host.runWithoutAnimationSnapshot(fn), diff --git a/packages/core/src/core/rendering/RenderContext.ts b/packages/core/src/core/rendering/RenderContext.ts index c49323814..632ca6325 100644 --- a/packages/core/src/core/rendering/RenderContext.ts +++ b/packages/core/src/core/rendering/RenderContext.ts @@ -66,6 +66,8 @@ export interface RenderContext { getShrinkFloors?: () => Map; /** Persist user-set widths (drag / double-click auto-fit) as natural widths. */ onAutoExpandNaturalWidths?: (widths: Map) => void; + /** Drag finished. Those columns keep the widths the pointer left them at. */ + onUserColumnResize?: (accessors: Accessor[]) => void; pinnedLeftHeaderRef: { current: HTMLDivElement | null }; pinnedLeftRef: { current: HTMLDivElement | null }; pinnedRightHeaderRef: { current: HTMLDivElement | null }; diff --git a/packages/core/src/core/rendering/RenderOrchestrator.ts b/packages/core/src/core/rendering/RenderOrchestrator.ts index 6a02b7f64..fd08e34ff 100644 --- a/packages/core/src/core/rendering/RenderOrchestrator.ts +++ b/packages/core/src/core/rendering/RenderOrchestrator.ts @@ -863,6 +863,7 @@ export class RenderOrchestrator { onRender: context.onRender, getShrinkFloors: context.getShrinkFloors, onAutoExpandNaturalWidths: context.onAutoExpandNaturalWidths, + onUserColumnResize: context.onUserColumnResize, setIsResizing: context.setIsResizing, setHeaders: context.setHeaders, setCollapsedHeaders: context.setCollapsedHeaders, diff --git a/packages/core/src/core/rendering/TableRendererDeps.ts b/packages/core/src/core/rendering/TableRendererDeps.ts index c9ce5332e..ba8469ad7 100644 --- a/packages/core/src/core/rendering/TableRendererDeps.ts +++ b/packages/core/src/core/rendering/TableRendererDeps.ts @@ -62,6 +62,8 @@ export interface TableRendererDeps { getShrinkFloors?: () => Map; /** Persist user-set widths (drag / double-click auto-fit) as natural widths. */ onAutoExpandNaturalWidths?: (widths: Map) => void; + /** Drag finished. Those columns keep the widths the pointer left them at. */ + onUserColumnResize?: (accessors: Accessor[]) => void; pinnedLeftHeaderRef: { current: HTMLDivElement | null }; pinnedLeftRef: { current: HTMLDivElement | null }; pinnedRightHeaderRef: { current: HTMLDivElement | null }; diff --git a/packages/core/src/core/rendering/buildRenderContext.ts b/packages/core/src/core/rendering/buildRenderContext.ts index 47a56511b..34e72f839 100644 --- a/packages/core/src/core/rendering/buildRenderContext.ts +++ b/packages/core/src/core/rendering/buildRenderContext.ts @@ -59,6 +59,7 @@ export interface RenderContextSource { onRender: () => void; getShrinkFloors: () => Map; onAutoExpandNaturalWidths: (widths: Map) => void; + onUserColumnResize?: (accessors: Accessor[]) => void; setIsResizing: (value: boolean) => void; setHeaders: (headers: ColumnDef[]) => void; setCollapsedHeaders: (headers: Set) => void; @@ -127,6 +128,7 @@ export const buildRenderContext = (source: RenderContextSource): RenderContext = onRender: source.onRender, getShrinkFloors: source.getShrinkFloors, onAutoExpandNaturalWidths: source.onAutoExpandNaturalWidths, + onUserColumnResize: source.onUserColumnResize, setIsResizing: source.setIsResizing, setHeaders: source.setHeaders, setCollapsedHeaders: source.setCollapsedHeaders, diff --git a/packages/core/src/core/rendering/cellRenderContexts.ts b/packages/core/src/core/rendering/cellRenderContexts.ts index 6fd8c3da1..b544f2815 100644 --- a/packages/core/src/core/rendering/cellRenderContexts.ts +++ b/packages/core/src/core/rendering/cellRenderContexts.ts @@ -54,6 +54,7 @@ export const buildHeaderCellContext = ( autoExpandColumns: deps.config.autoExpandColumns ?? false, getShrinkFloors: deps.getShrinkFloors, onAutoExpandNaturalWidths: deps.onAutoExpandNaturalWidths, + onUserColumnResize: deps.onUserColumnResize, essentialAccessors: deps.essentialAccessors, selectableColumns: deps.config.selectableColumns, headers: deps.effectiveHeaders, diff --git a/packages/core/src/core/vanilla/VanillaLiveHost.ts b/packages/core/src/core/vanilla/VanillaLiveHost.ts index dc457fc86..31a462703 100644 --- a/packages/core/src/core/vanilla/VanillaLiveHost.ts +++ b/packages/core/src/core/vanilla/VanillaLiveHost.ts @@ -104,6 +104,7 @@ export interface VanillaLiveHost { getEffectiveRowGrouping(): Accessor[] | undefined; applyPivot(pivot: PivotConfig | null): void; onRender(source: string): void; + refitAutoSizeColumns(): void; isCellAnimating(cellId: string): boolean; hasAnimatingCells(): boolean; runWithoutAnimationSnapshot(fn: () => void): void; diff --git a/packages/core/src/core/vanilla/applyTableUpdate.ts b/packages/core/src/core/vanilla/applyTableUpdate.ts index fb4faf9c9..a511e6fac 100644 --- a/packages/core/src/core/vanilla/applyTableUpdate.ts +++ b/packages/core/src/core/vanilla/applyTableUpdate.ts @@ -50,6 +50,7 @@ export const applyTableUpdate = ( filterManager.updateConfig({ rows: host.getLocalRows() }); } host.syncPivotPipeline(filterManager?.getFilteredRows() ?? host.getLocalRows()); + host.getAutoSizeManager().discardParkedSamples(); host.getAutoSizeManager().queuePendingFromAccessors(); } @@ -97,6 +98,7 @@ export const applyTableUpdate = ( const wasLoading = host.getInternalIsLoading(); host.setInternalIsLoading(config.isLoading); if (wasLoading && !config.isLoading && host.getAutoSizeManager().getAccessors().size > 0) { + host.getAutoSizeManager().discardParkedSamples(); host.getAutoSizeManager().queuePendingFromAccessors(); } } diff --git a/packages/core/src/core/vanilla/createVanillaRenderContext.ts b/packages/core/src/core/vanilla/createVanillaRenderContext.ts index 4ee1f799f..ae4e07be4 100644 --- a/packages/core/src/core/vanilla/createVanillaRenderContext.ts +++ b/packages/core/src/core/vanilla/createVanillaRenderContext.ts @@ -67,6 +67,7 @@ export const createVanillaRenderContext = ( host.getPristineDefaultHeaders(), ), onAutoExpandNaturalWidths: (widths) => host.getAutoSizeManager().recordNaturalWidths(widths), + onUserColumnResize: (accessors) => host.getAutoSizeManager().releaseAccessors(accessors), setIsResizing: (value) => { host.setIsResizing(value); const autoScaleManager = host.getAutoScaleManager(); @@ -113,13 +114,6 @@ export const createVanillaRenderContext = ( getRowStateMap: () => host.getRowStateMap(), setColumnEditorOpen: (open) => host.setColumnEditorOpen(open), setCurrentPage: (page) => { - if ( - page !== host.getCurrentPage() && - host.getConfig().enablePagination && - !host.getConfig().serverSidePagination - ) { - host.getAutoSizeManager().queuePendingFromAccessors(); - } host.setCurrentPage(page); }, }); diff --git a/packages/core/src/core/vanilla/toTableAPIContextHost.ts b/packages/core/src/core/vanilla/toTableAPIContextHost.ts index f41644962..6b73fdbb5 100644 --- a/packages/core/src/core/vanilla/toTableAPIContextHost.ts +++ b/packages/core/src/core/vanilla/toTableAPIContextHost.ts @@ -41,6 +41,7 @@ export const toTableAPIContextHost = (host: VanillaLiveHost): TableAPIContextHos if (state?.active) return state.headers; return host.getHeaders(); }, + refitAutoSizeColumns: () => host.refitAutoSizeColumns(), getPivotedRows: () => { const state = host.getPivotManager()?.getState(); if (state?.active) return state.pivotedRows; diff --git a/packages/core/src/managers/AutoSizeManager.ts b/packages/core/src/managers/AutoSizeManager.ts index 7e1451987..63ddbcfcb 100644 --- a/packages/core/src/managers/AutoSizeManager.ts +++ b/packages/core/src/managers/AutoSizeManager.ts @@ -24,11 +24,17 @@ export interface AutoSizeMeasureContext { * Content-fit column sizing (`width: "auto"`). Distinct from AutoScaleManager, * which stretches already-measured natural widths to fill surplus container space. */ +const MAX_UNSETTLED_PASSES = 4; + export class AutoSizeManager { private autoSizeAccessors: Set = new Set(); private pendingAutoSize: Set = new Set(); private naturalWidths: Map = new Map(); private isAutoSizing: boolean = false; + private unsettledPasses = 0; + private parkedHosts = new Map(); + private sandbox: HTMLDivElement | null = null; + private hostDiscard?: (host: HTMLElement) => void; getAccessors(): Set { return this.autoSizeAccessors; @@ -45,10 +51,36 @@ export class AutoSizeManager { recomputeAccessors(headers: ColumnDef[], collapsedHeaders: Set): void { this.autoSizeAccessors = computeAutoSizeAccessors(headers, collapsedHeaders); this.pendingAutoSize = new Set(this.autoSizeAccessors); + this.unsettledPasses = 0; + this.discardAllParked(); } queuePendingFromAccessors(): void { this.autoSizeAccessors.forEach((accessor) => this.pendingAutoSize.add(accessor)); + this.unsettledPasses = 0; + } + + /** Drop parked renderer samples so the next measure reads the current rows. */ + discardParkedSamples(): void { + this.discardAllParked(); + } + + /** + * A drag chose this column's width. Later content re-measures skip it until + * the column definitions are applied again. + */ + releaseAccessors(accessors: Iterable): void { + for (const accessor of accessors) { + this.autoSizeAccessors.delete(accessor); + this.pendingAutoSize.delete(accessor); + this.discardParked(String(accessor)); + } + } + + dispose(): void { + this.discardAllParked(); + this.sandbox?.remove(); + this.sandbox = null; } clearNaturalWidths(): void { @@ -109,15 +141,19 @@ export class AutoSizeManager { if (!ready) return null; this.isAutoSizing = true; + this.hostDiscard = ctx.onRendererHostDiscard; try { const leaves = getAllVisibleLeafHeaders(ctx.headers, ctx.collapsedHeaders); const leafByAccessor = new Map(leaves.map((leaf) => [leaf.accessor, leaf])); const widths = new Map(); + const unsettled = new Set(); + const forceSettle = this.unsettledPasses >= MAX_UNSETTLED_PASSES; for (const accessor of this.pendingAutoSize) { const leaf = leafByAccessor.get(accessor); if (!leaf) continue; - const { width } = calculateHeaderContentWidth(accessor, { + const key = String(accessor); + const { width, settled } = calculateHeaderContentWidth(accessor, { rows: ctx.rows, header: leaf, styleRoot, @@ -127,11 +163,32 @@ export class AutoSizeManager { filterIcon: ctx.icons.filter, expandIcon: ctx.icons.expand, onRendererHostDiscard: ctx.onRendererHostDiscard, + parkedHosts: this.parkedHosts.get(key), + parkAsyncHost: (host) => this.parkHost(key, host), + discardParkedHosts: () => this.discardParked(key, ctx.onRendererHostDiscard), + forceSettle, }); + if (!settled) { + unsettled.add(accessor); + continue; + } widths.set(accessor, width); } this.pendingAutoSize.clear(); + if (unsettled.size > 0) { + this.unsettledPasses += 1; + if (this.unsettledPasses <= MAX_UNSETTLED_PASSES) { + unsettled.forEach((accessor) => this.pendingAutoSize.add(accessor)); + } else { + this.unsettledPasses = 0; + unsettled.forEach((accessor) => + this.discardParked(String(accessor), ctx.onRendererHostDiscard), + ); + } + } else { + this.unsettledPasses = 0; + } if (widths.size === 0) return null; let changed = false; @@ -153,6 +210,45 @@ export class AutoSizeManager { this.isAutoSizing = false; } } + + private ensureSandbox(): HTMLDivElement { + if (this.sandbox?.isConnected) return this.sandbox; + const sandbox = document.createElement("div"); + sandbox.setAttribute("data-st-autosize-sandbox", ""); + sandbox.style.position = "absolute"; + sandbox.style.left = "-99999px"; + sandbox.style.top = "0"; + sandbox.style.visibility = "hidden"; + sandbox.style.pointerEvents = "none"; + document.body.appendChild(sandbox); + this.sandbox = sandbox; + return sandbox; + } + + private parkHost(accessor: string, host: HTMLElement): void { + this.ensureSandbox().appendChild(host); + const list = this.parkedHosts.get(accessor) ?? []; + list.push(host); + this.parkedHosts.set(accessor, list); + } + + private discardParked( + accessor: string, + onDiscard?: (host: HTMLElement) => void, + ): void { + const list = this.parkedHosts.get(accessor); + if (!list) return; + const discard = onDiscard ?? this.hostDiscard; + for (const host of list) { + discard?.(host); + host.remove(); + } + this.parkedHosts.delete(accessor); + } + + private discardAllParked(): void { + this.parkedHosts.forEach((_hosts, accessor) => this.discardParked(accessor)); + } } export const getAutoSizeStyleRoot = (refs: { diff --git a/packages/core/src/types/HandleResizeStartProps.ts b/packages/core/src/types/HandleResizeStartProps.ts index d8d3ffafd..27543fa15 100644 --- a/packages/core/src/types/HandleResizeStartProps.ts +++ b/packages/core/src/types/HandleResizeStartProps.ts @@ -16,6 +16,8 @@ export type HandleResizeStartProps = { onColumnWidthChange?: (headers: ColumnDef[]) => void; /** Persist the dragged column(s)' final widths as their natural widths (autoExpandColumns). */ onAutoExpandNaturalWidths?: (widths: Map) => void; + /** Drag finished. The columns keep the widths the pointer left them at. */ + onUserColumnResize?: (accessors: Accessor[]) => void; pinnedLeftRef: RefObject; pinnedRightRef: RefObject; reverse: boolean; diff --git a/packages/core/src/types/TableAPI.ts b/packages/core/src/types/TableAPI.ts index 93960f379..e3a5ace54 100644 --- a/packages/core/src/types/TableAPI.ts +++ b/packages/core/src/types/TableAPI.ts @@ -90,4 +90,9 @@ export type TableAPI = { * that are not part of consumer `TData`. */ getPivotedRows: () => Row[]; + /** + * Measure `width: "auto"` columns again. Call this after custom cell content + * has painted if a column is still waiting on its renderer. + */ + refitAutoSizeColumns: () => void; }; diff --git a/packages/core/src/utils/headerCell/resizing.ts b/packages/core/src/utils/headerCell/resizing.ts index fe3500ee7..11551ab22 100644 --- a/packages/core/src/utils/headerCell/resizing.ts +++ b/packages/core/src/utils/headerCell/resizing.ts @@ -205,6 +205,7 @@ export const createResizeHandle = ( mainBodyRef: context.mainBodyRef, onColumnWidthChange: context.onColumnWidthChange, onAutoExpandNaturalWidths: context.onAutoExpandNaturalWidths, + onUserColumnResize: context.onUserColumnResize, pinnedLeftRef: context.pinnedLeftRef, pinnedRightRef: context.pinnedRightRef, reverse: context.reverse, @@ -239,6 +240,7 @@ export const createResizeHandle = ( mainBodyRef: context.mainBodyRef, onColumnWidthChange: context.onColumnWidthChange, onAutoExpandNaturalWidths: context.onAutoExpandNaturalWidths, + onUserColumnResize: context.onUserColumnResize, pinnedLeftRef: context.pinnedLeftRef, pinnedRightRef: context.pinnedRightRef, reverse: context.reverse, diff --git a/packages/core/src/utils/headerCell/types.ts b/packages/core/src/utils/headerCell/types.ts index cf28ee71a..0079aa862 100644 --- a/packages/core/src/utils/headerCell/types.ts +++ b/packages/core/src/utils/headerCell/types.ts @@ -43,6 +43,8 @@ export interface HeaderRenderContext { getShrinkFloors?: () => Map; /** Persist user-set widths (drag / double-click auto-fit) as natural widths. */ onAutoExpandNaturalWidths?: (widths: Map) => void; + /** Drag finished. Those columns keep the widths the pointer left them at. */ + onUserColumnResize?: (accessors: Accessor[]) => void; handleApplyFilter: (filter: FilterCondition) => void; handleClearFilter: (accessor: Accessor) => void; handleSelectAll?: (checked: boolean) => void; diff --git a/packages/core/src/utils/headerWidthUtils.ts b/packages/core/src/utils/headerWidthUtils.ts index 59ed167d7..eb5e7e56e 100644 --- a/packages/core/src/utils/headerWidthUtils.ts +++ b/packages/core/src/utils/headerWidthUtils.ts @@ -609,6 +609,12 @@ const measureHeaderIconWidth = ( return width + AUTO_SIZE_HEADER_ICON_PADDING; }; +/** True when an async renderer host has something other than an empty shell. */ +const rendererHostHasContent = (el: HTMLElement): boolean => { + if (el.querySelector(".st-loading-skeleton")) return false; + return (el.textContent ?? "").trim().length > 0; +}; + const headerContainsAriaLabelPrefix = ( headerCell: HTMLElement | null, prefix: string, @@ -625,9 +631,9 @@ const headerContainsAriaLabelPrefix = ( * measurable yet (async portal / loading skeleton), `settled` is false and * the width is provisional (header / minWidth only — never `valueFormatter` * or plain `label` as a stand-in for renderer output). Callers should apply - * that provisional width once and re-measure later via an explicit refit - * (rows / isLoading / `refitAutoSizeColumns`) — not by leaving the column - * pending across every render. + * that provisional width is not applied. The column stays pending until a later + * pass can measure the renderer output (painted cells, or hosts parked from + * an earlier sample). */ export const calculateHeaderContentWidth = ( accessor: Accessor, @@ -674,6 +680,17 @@ export const calculateHeaderContentWidth = ( * without this, autofit sampling leaks those registrations. */ onRendererHostDiscard?: (host: HTMLElement) => void; + /** + * Hosts produced by an earlier sample of an async renderer. When present, + * they are measured instead of calling `cellRenderer` again. + */ + parkedHosts?: HTMLElement[]; + /** Keep an async renderer host mounted so a later pass can measure it. */ + parkAsyncHost?: (host: HTMLElement) => void; + /** Drop parked hosts after their content has been measured. */ + discardParkedHosts?: () => void; + /** Measure whatever is available and stop waiting for renderer output. */ + forceSettle?: boolean; }, ): { width: number; settled: boolean } => { const { @@ -692,6 +709,10 @@ export const calculateHeaderContentWidth = ( filterIcon, expandIcon, onRendererHostDiscard, + parkedHosts, + parkAsyncHost, + discardParkedHosts, + forceSettle = false, } = options || {}; const headSize = headSampleSize ?? sampleSize ?? AUTO_SIZE_HEAD_SAMPLE_SIZE; const stridedSize = @@ -926,13 +947,37 @@ export const calculateHeaderContentWidth = ( }; const sampleIndices = buildHybridSampleIndices(rows.length, headSize, stridedSize); - // Once a framework adapter returns an async portal/mount host, further - // cellRenderer calls only thrash the bridge (content is never measurable - // off-screen). Skip them and use painted cells instead. + // Async renderer hosts measure as empty until the framework paints them. + // The first pass keeps those hosts; the next pass measures them. Painted + // cells alone miss sampled rows that are outside the viewport. let skipAsyncCellRenderer = false; let sawMeasurableRendererContent = false; + let waitingOnAsyncHosts = false; + let skipSampling = false; + + const parked = header?.cellRenderer ? (parkedHosts ?? []) : []; + if (parked.length > 0) { + const ready = parked.filter(rendererHostHasContent); + if (ready.length === 0 && !forceSettle) { + waitingOnAsyncHosts = true; + cellRendererSettled = false; + } else { + for (const el of parked) { + if (!rendererHostHasContent(el)) continue; + const naturalWidth = measureCellContentClone(el, domQueryRoot); + if (naturalWidth > 0) { + sampleWidths.push(naturalWidth + cellPaddingLeft + cellPaddingRight); + sawMeasurableRendererContent = true; + } + } + discardParkedHosts?.(); + cellRendererSettled = true; + } + skipSampling = true; + } for (const i of sampleIndices) { + if (skipSampling || (skipAsyncCellRenderer && header?.cellRenderer)) break; const row = rows[i]; // Resolve the raw value (valueGetter or nested accessor) @@ -986,14 +1031,18 @@ export const calculateHeaderContentWidth = ( !!rendered.querySelector?.("[data-st-portal-id], [data-st-mount-id]")); // Framework adapters register portals/mounts synchronously but fill - // content asynchronously — off-screen measure is ~0. Dispose the - // registration immediately so autofit sampling cannot leak entries - // into the host bridge, then wait for painted cells. + // content asynchronously. Keep every sampled host for the next + // pass. Without a park callback, drop the host and use painted cells. if (isAsyncRendererHost) { - if (rendered instanceof HTMLElement) { - onRendererHostDiscard?.(rendered); + if (parkAsyncHost && rendered instanceof HTMLElement) { + parkAsyncHost(rendered); + waitingOnAsyncHosts = true; + } else { + if (rendered instanceof HTMLElement) { + onRendererHostDiscard?.(rendered); + } + skipAsyncCellRenderer = true; } - skipAsyncCellRenderer = true; measured = -1; } else { tempDiv.textContent = ""; @@ -1041,7 +1090,9 @@ export const calculateHeaderContentWidth = ( // deterministic off-screen pass; folding in DOM-dependent samples for them // would make the width vary with which rows happen to be rendered // (i.e. with the container size). - if (header?.cellRenderer) { + if (waitingOnAsyncHosts && !forceSettle) { + cellRendererSettled = false; + } else if (header?.cellRenderer && !cellRendererSettled) { const renderedCells = queryAllInTable( domQueryRoot, `.st-cell[data-accessor="${escapeTableAttrValue(String(accessor))}"] .st-cell-content`, diff --git a/packages/core/src/utils/resizeUtils/index.ts b/packages/core/src/utils/resizeUtils/index.ts index 75bc11b1f..350e44d1a 100644 --- a/packages/core/src/utils/resizeUtils/index.ts +++ b/packages/core/src/utils/resizeUtils/index.ts @@ -98,6 +98,7 @@ export const handleResizeStart = ({ mainBodyRef, onColumnWidthChange, onAutoExpandNaturalWidths, + onUserColumnResize, reverse = false, setHeaders, setIsResizing, @@ -374,6 +375,7 @@ export const handleResizeStart = ({ handleMove(lastClientX, true); // true = update React state setIsResizing(false); + onUserColumnResize?.(childrenToResize.map((h) => h.accessor)); // Notify consumer of width change if (onColumnWidthChange) { @@ -402,6 +404,7 @@ export const handleResizeStart = ({ handleMove(lastClientX, true); // true = update React state setIsResizing(false); + onUserColumnResize?.(childrenToResize.map((h) => h.accessor)); // Notify consumer of width change if (onColumnWidthChange) { diff --git a/packages/core/stories/tests/61-AutoSizeCellRendererTests.stories.ts b/packages/core/stories/tests/61-AutoSizeCellRendererTests.stories.ts new file mode 100644 index 000000000..9b5dc63cd --- /dev/null +++ b/packages/core/stories/tests/61-AutoSizeCellRendererTests.stories.ts @@ -0,0 +1,352 @@ +/** + * AUTO-SIZE WITH CELL RENDERERS + * + * Two email columns hold the same address. Email is plain text. Rendered + * paints through an async host (the React portal shape). After the content + * is in the DOM, both columns should land on the same width. + * + * The addresses are short enough to stay under the 500px cap, so a match is + * a content width rather than both columns sitting on the cap. + */ + +import type { ColumnDef, SimpleTableVanilla } from "../../src/index"; +import { expect } from "@storybook/test"; +import { waitForTable, waitUntil } from "./testUtils"; +import { renderVanillaTable } from "../utils"; +import type { Meta } from "@storybook/html"; + +const meta: Meta = { + title: "Tests/61 - Auto-Size Cell Renderer", + parameters: { + layout: "padded", + docs: { + description: { + component: + "Two email columns, one plain and one rendered. width: 'auto' should give them the same content width across hide/show, page changes, appended rows, loading, off-screen samples, and a drag.", + }, + }, + }, +}; + +export default meta; + +const LONG = "alexandra.montgomery@international-shipping.example.com"; +const SHORT = "ada@x.co"; + +type Row = { id: number; email: string; emailRendered: string }; + +type Harness = { + table: SimpleTableVanilla; + fill: () => void; + columns: (hideRendered?: boolean) => ColumnDef[]; +}; + +const pair = (id: number, email: string): Row => ({ id, email, emailRendered: email }); + +const buildContent = (value: string): HTMLElement => { + const span = document.createElement("span"); + span.style.whiteSpace = "nowrap"; + span.textContent = value; + return span; +}; + +/** + * Empty host until fill(), then content on a microtask. Marked as a portal + * host so auto-size waits for the painted output. + */ +const makePortalRenderer = () => { + const pending: Array<{ el: HTMLElement; value: string }> = []; + let mounted = false; + const fillEl = (el: HTMLElement, value: string) => { + if (el.childNodes.length === 0) el.appendChild(buildContent(value)); + }; + return { + cellRenderer: ({ value }: { value: unknown }) => { + const el = document.createElement("div"); + el.setAttribute("data-st-portal-id", "story-auto-size"); + const text = value == null ? "" : String(value); + if (mounted) queueMicrotask(() => fillEl(el, text)); + else pending.push({ el, value: text }); + return el; + }, + fill() { + mounted = true; + for (const item of pending) fillEl(item.el, item.value); + pending.length = 0; + }, + }; +}; + +const widthOf = (root: HTMLElement, label: string): number => { + const headers = Array.from(root.querySelectorAll(".st-header-cell")); + for (const header of headers) { + const text = header.querySelector(".st-header-label-text"); + if (text?.textContent?.trim() === label) return header.getBoundingClientRect().width; + } + return 0; +}; + +const harnessOf = (canvas: HTMLElement): Harness => { + const marked = canvas.querySelector("[data-auto-size-harness]") as + | (HTMLElement & { __harness?: Harness }) + | null; + const harness = marked?.__harness; + if (!harness) throw new Error("auto-size harness missing"); + return harness; +}; + +const mount = ( + rows: Row[], + extras?: { + enablePagination?: boolean; + rowsPerPage?: number; + columnResizing?: boolean; + height?: string; + }, +): HTMLDivElement => { + const renderer = makePortalRenderer(); + const columns = (hideRendered = false): ColumnDef[] => [ + { accessor: "email", label: "Email", width: "auto", type: "string" }, + { + accessor: "emailRendered", + label: "Rendered", + width: "auto", + type: "string", + hide: hideRendered, + cellRenderer: renderer.cellRenderer, + }, + ]; + const { wrapper, table } = renderVanillaTable(columns(), rows, { + getRowId: (params: { row: Row }) => String(params.row.id), + height: extras?.height ?? "280px", + theme: "light", + animations: { enabled: false }, + autoExpandColumns: false, + enablePagination: extras?.enablePagination, + rowsPerPage: extras?.rowsPerPage, + columnResizing: extras?.columnResizing, + }); + wrapper.setAttribute("data-auto-size-harness", ""); + (wrapper as HTMLDivElement & { __harness?: Harness }).__harness = { + table, + fill: renderer.fill, + columns, + }; + return wrapper; +}; + +const pageRows = (): Row[] => [ + ...Array.from({ length: 5 }, (_, i) => pair(i + 1, SHORT)), + ...Array.from({ length: 5 }, (_, i) => pair(i + 6, LONG)), +]; + +/** Fill renderer hosts, re-fit, and wait until both columns share a content width. */ +const settleLong = async (canvas: HTMLElement): Promise => { + const { fill, table } = harnessOf(canvas); + fill(); + table.refitAutoSizeColumns(); + await waitUntil( + () => { + const plain = widthOf(canvas, "Email"); + const rendered = widthOf(canvas, "Rendered"); + return plain > 250 && rendered > 250 && Math.abs(plain - rendered) < 80; + }, + { timeoutMs: 4000 }, + ); +}; + +export const HideShowRestoresRenderedWidth = { + parameters: { tags: ["auto-size-renderer-hide-show"] }, + render: () => mount([pair(1, LONG), pair(2, LONG)]), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + await settleLong(canvasElement); + const fitted = widthOf(canvasElement, "Rendered"); + + const { table, columns } = harnessOf(canvasElement); + table.update({ columns: columns(true) }); + table.update({ columns: columns(false) }); + + await waitUntil( + () => Math.abs(widthOf(canvasElement, "Rendered") - fitted) < 80, + { timeoutMs: 4000 }, + ); + expect(Math.abs(widthOf(canvasElement, "Email") - widthOf(canvasElement, "Rendered"))).toBeLessThan( + 80, + ); + }, +}; + +export const FooterPageChangeRefitsRenderedColumn = { + parameters: { tags: ["auto-size-renderer-footer-page"] }, + render: () => + mount(pageRows(), { enablePagination: true, rowsPerPage: 5 }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + const { fill, table } = harnessOf(canvasElement); + fill(); + table.refitAutoSizeColumns(); + await waitUntil(() => widthOf(canvasElement, "Email") > 0 && widthOf(canvasElement, "Rendered") > 0); + + const plainBefore = widthOf(canvasElement, "Email"); + const renderedBefore = widthOf(canvasElement, "Rendered"); + canvasElement.querySelector('[aria-label="Go to page 2"]')!.click(); + + await waitUntil( + () => + widthOf(canvasElement, "Email") > plainBefore + 80 && + widthOf(canvasElement, "Rendered") > renderedBefore + 80, + { timeoutMs: 4000 }, + ); + expect( + Math.abs(widthOf(canvasElement, "Email") - widthOf(canvasElement, "Rendered")), + ).toBeLessThan(80); + }, +}; + +export const SetPageRefitsBothColumns = { + parameters: { tags: ["auto-size-renderer-set-page"] }, + render: () => mount(pageRows(), { enablePagination: true, rowsPerPage: 5 }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + const { fill, table } = harnessOf(canvasElement); + fill(); + table.refitAutoSizeColumns(); + await waitUntil(() => widthOf(canvasElement, "Email") > 0); + + const plainBefore = widthOf(canvasElement, "Email"); + const renderedBefore = widthOf(canvasElement, "Rendered"); + await table.getAPI().setPage(2); + + await waitUntil( + () => + widthOf(canvasElement, "Email") > plainBefore + 80 && + widthOf(canvasElement, "Rendered") > renderedBefore + 80, + { timeoutMs: 4000 }, + ); + }, +}; + +export const AppendedRowsRefitRenderedColumn = { + parameters: { tags: ["auto-size-renderer-append"] }, + render: () => mount(Array.from({ length: 8 }, (_, i) => pair(i + 1, SHORT))), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + const { fill, table } = harnessOf(canvasElement); + fill(); + table.refitAutoSizeColumns(); + await waitUntil(() => widthOf(canvasElement, "Rendered") > 0); + const before = widthOf(canvasElement, "Rendered"); + + table.update({ + rows: Array.from({ length: 8 }, (_, i) => pair(i + 1, LONG)), + }); + + await waitUntil(() => widthOf(canvasElement, "Rendered") > before + 80, { timeoutMs: 4000 }); + expect(widthOf(canvasElement, "Email")).toBeGreaterThan(250); + }, +}; + +export const LoadingCycleRefitsRenderedColumn = { + parameters: { tags: ["auto-size-renderer-loading"] }, + render: () => mount([pair(1, LONG), pair(2, LONG)]), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + await settleLong(canvasElement); + + const { table } = harnessOf(canvasElement); + const rows = [pair(1, LONG), pair(2, LONG)]; + table.update({ rows, isLoading: true }); + await waitUntil(() => canvasElement.querySelectorAll(".st-loading-skeleton").length > 0); + table.update({ isLoading: false }); + + await waitUntil( + () => { + const plain = widthOf(canvasElement, "Email"); + const rendered = widthOf(canvasElement, "Rendered"); + return plain > 250 && rendered > 250 && Math.abs(plain - rendered) < 80; + }, + { timeoutMs: 4000 }, + ); + }, +}; + +export const OffscreenSampleMatchesPlainColumn = { + parameters: { tags: ["auto-size-renderer-offscreen"] }, + render: () => + mount( + Array.from({ length: 120 }, (_, i) => pair(i + 1, i >= 80 ? LONG : SHORT)), + { height: "220px" }, + ), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + await settleLong(canvasElement); + expect(Math.abs(widthOf(canvasElement, "Email") - widthOf(canvasElement, "Rendered"))).toBeLessThan( + 80, + ); + }, +}; + +export const DraggedWidthSurvivesPageChange = { + parameters: { tags: ["auto-size-renderer-drag"] }, + render: () => + mount(pageRows(), { enablePagination: true, rowsPerPage: 5, columnResizing: true }), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + const { fill, table } = harnessOf(canvasElement); + fill(); + table.refitAutoSizeColumns(); + await waitUntil(() => widthOf(canvasElement, "Email") > 0); + const beforeDrag = widthOf(canvasElement, "Email"); + + const header = Array.from(canvasElement.querySelectorAll(".st-header-cell")).find((cell) => + cell.querySelector(".st-header-label-text")?.textContent?.trim() === "Email", + ); + const handle = header?.querySelector(".st-header-resize-handle-container"); + expect(handle).toBeTruthy(); + const startX = handle!.getBoundingClientRect().left + 5; + const y = handle!.getBoundingClientRect().top + 5; + handle!.dispatchEvent( + new MouseEvent("mousedown", { clientX: startX, clientY: y, bubbles: true, cancelable: true }), + ); + document.dispatchEvent( + new MouseEvent("mousemove", { + clientX: startX + 180, + clientY: y, + bubbles: true, + cancelable: true, + }), + ); + document.dispatchEvent( + new MouseEvent("mouseup", { + clientX: startX + 180, + clientY: y, + bubbles: true, + cancelable: true, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + const dragged = widthOf(canvasElement, "Email"); + expect(dragged).toBeGreaterThan(beforeDrag + 80); + + canvasElement.querySelector('[aria-label="Go to page 2"]')!.click(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(Math.abs(widthOf(canvasElement, "Email") - dragged)).toBeLessThan(8); + }, +}; + +export const TableAPIRefitsAfterPaint = { + parameters: { tags: ["auto-size-renderer-api"] }, + render: () => mount([pair(1, LONG)]), + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await waitForTable(canvasElement); + const { fill, table } = harnessOf(canvasElement); + const before = widthOf(canvasElement, "Rendered"); + fill(); + table.getAPI().refitAutoSizeColumns(); + await waitUntil(() => widthOf(canvasElement, "Rendered") > before + 80, { timeoutMs: 4000 }); + expect(Math.abs(widthOf(canvasElement, "Email") - widthOf(canvasElement, "Rendered"))).toBeLessThan( + 80, + ); + }, +}; diff --git a/packages/examples/react/src/demos/cell-renderer/CellRendererDemo.tsx b/packages/examples/react/src/demos/cell-renderer/CellRendererDemo.tsx index c8cd992c3..b41cd9a55 100644 --- a/packages/examples/react/src/demos/cell-renderer/CellRendererDemo.tsx +++ b/packages/examples/react/src/demos/cell-renderer/CellRendererDemo.tsx @@ -179,6 +179,7 @@ const CellRendererDemo = ({ rows={cellRendererConfig.rows} height={height} theme={theme} + columnResizing selectableCells={cellRendererConfig.tableProps.selectableCells} customTheme={cellRendererConfig.tableProps.customTheme} /> diff --git a/packages/examples/react/src/demos/cell-renderer/cell-renderer.demo-data.ts b/packages/examples/react/src/demos/cell-renderer/cell-renderer.demo-data.ts index 3a07af697..4d00ea2ee 100644 --- a/packages/examples/react/src/demos/cell-renderer/cell-renderer.demo-data.ts +++ b/packages/examples/react/src/demos/cell-renderer/cell-renderer.demo-data.ts @@ -15,13 +15,13 @@ export type CellRendererEmployee = { }; export const cellRendererData: CellRendererEmployee[] = [ - { id: 1, name: "Isabella Romano", website: "isabellaromano.design", status: "active", progress: 92, rating: 4.9, verified: true, tags: ["UI/UX", "Design", "Frontend"], teamMembers: [{ name: "Alice Smith", role: "Designer" }, { name: "Bob Johnson", role: "Developer" }] }, + { id: 1, name: "Isabella Romano", website: "isabellaromano.design", status: "active", progress: 92, rating: 4.9, verified: true, tags: ["UI/UX", "Design", "Frontend"], teamMembers: [{ name: "Alice Smith", role: "Designer" }, { name: "Bob Johnson", role: "Developer" }, { name: "Catherine Moreau", role: "Research" }] }, { id: 2, name: "Ethan McKenzie", website: "ethanmckenzie.dev", status: "active", progress: 87, rating: 4.7, verified: true, tags: ["Web Development", "Backend", "API"], teamMembers: [{ name: "Charlie Brown", role: "Backend Developer" }, { name: "Diana Prince", role: "Frontend Developer" }] }, { id: 3, name: "Zoe Patterson", website: "zoepatterson.com", status: "pending", progress: 34, rating: 4.2, verified: false, tags: ["Branding", "Marketing"], teamMembers: [{ name: "Eve Adams", role: "Marketing Manager" }] }, { id: 4, name: "Felix Chang", website: "felixchang.mobile", status: "active", progress: 95, rating: 4.8, verified: true, tags: ["Mobile App", "UX/UI"], teamMembers: [{ name: "Grace Lee", role: "UX Designer" }, { name: "Hank Johnson", role: "Mobile Developer" }] }, { id: 5, name: "Aria Gonzalez", website: "ariagonzalez.writer", status: "active", progress: 78, rating: 4.6, verified: true, tags: ["Content Writing", "Copywriting"], teamMembers: [{ name: "Ivy White", role: "Content Strategist" }] }, { id: 6, name: "Jasper Flynn", website: "jasperflynn.tech", status: "inactive", progress: 12, rating: 3.8, verified: false, tags: ["Consulting", "Tech Strategy"], teamMembers: [{ name: "Kate Brown", role: "Consultant" }] }, - { id: 7, name: "Nova Sterling", website: "novasterling.marketing", status: "active", progress: 83, rating: 4.5, verified: true, tags: ["Digital Marketing", "SEO"], teamMembers: [{ name: "Leo Wilson", role: "SEO Specialist" }, { name: "Mia Davis", role: "Marketing Analyst" }] }, + { id: 7, name: "Nova Sterling", website: "nova.sterling.international-marketing.example", status: "active", progress: 83, rating: 4.5, verified: true, tags: ["Digital Marketing", "SEO", "Brand Partnerships"], teamMembers: [{ name: "Leo Wilson", role: "SEO Specialist" }, { name: "Mia Davis", role: "Marketing Analyst" }] }, { id: 8, name: "Cruz Martinez", website: "cruzmartinez.photo", status: "active", progress: 71, rating: 4.4, verified: true, tags: ["Photography", "Videography"], teamMembers: [{ name: "Nina Smith", role: "Photographer" }, { name: "Owen Johnson", role: "Videographer" }] }, { id: 9, name: "Sage Thompson", website: "sagethompson.ux", status: "active", progress: 89, rating: 4.7, verified: true, tags: ["UX Design", "UI Design"], teamMembers: [{ name: "Pete White", role: "UX Lead" }, { name: "Quinn Brown", role: "UI Designer" }] }, { id: 10, name: "River Davis", website: "riverdavis.content", status: "pending", progress: 45, rating: 4.1, verified: false, tags: ["Content Strategy", "Copywriting"], teamMembers: [{ name: "Riley Adams", role: "Content Writer" }] }, @@ -31,14 +31,14 @@ export const cellRendererData: CellRendererEmployee[] = [ export const cellRendererHeaders: ReactColumnDef[] = [ { accessor: "id", label: "ID", width: 60, type: "number" }, - { accessor: "name", label: "Name", width: 180, type: "string" }, - { accessor: "teamMembers", label: "Team", width: 280, type: "string" }, - { accessor: "website", label: "Website", width: 180, type: "string" }, - { accessor: "status", label: "Status", width: 120, type: "string" }, + { accessor: "name", label: "Name", width: "auto", type: "string" }, + { accessor: "teamMembers", label: "Team", width: "auto", type: "string" }, + { accessor: "website", label: "Website", width: "auto", type: "string" }, + { accessor: "status", label: "Status", width: "auto", type: "string" }, { accessor: "progress", label: "Progress", width: 150, type: "number" }, - { accessor: "rating", label: "Rating", width: 150, type: "number" }, - { accessor: "verified", label: "Verified", width: 100, type: "boolean" }, - { accessor: "tags", label: "Tags", width: 250, type: "string" }, + { accessor: "rating", label: "Rating", width: "auto", type: "number" }, + { accessor: "verified", label: "Verified", width: "auto", type: "boolean" }, + { accessor: "tags", label: "Tags", width: "auto", type: "string" }, ]; export const cellRendererConfig = { diff --git a/packages/react/package.json b/packages/react/package.json index ec8426270..e694c3f21 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/react", - "version": "4.3.0", + "version": "4.3.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/react/src/SimpleTable.tsx b/packages/react/src/SimpleTable.tsx index e9d11cf64..897cfc3bd 100644 --- a/packages/react/src/SimpleTable.tsx +++ b/packages/react/src/SimpleTable.tsx @@ -168,32 +168,15 @@ function SimpleTableInner 0; - - // Re-fit auto columns from real portal DOM once — not on every portals - // array identity change (that re-enters measure/render and loops easily). - // A second pass runs only when leaving `isLoading`, when skeleton cells - // are replaced by real renderer output. - const didInitialAutoSizeRef = useRef(false); - const wasLoadingRef = useRef(Boolean(reactProps.isLoading)); + + // Measure renderer columns that are still waiting once their portal output + // is in the DOM. Settled columns are not waiting, so later portal updates + // do not measure them again. useLayoutEffect(() => { const instance = instanceRef.current; - if (!instance) return; - - const isLoading = Boolean(reactProps.isLoading); - const leftLoading = wasLoadingRef.current && !isLoading; - wasLoadingRef.current = isLoading; - - if (leftLoading) { - instance.refitAutoSizeColumns?.(); - return; - } - - if (!didInitialAutoSizeRef.current && hasPortals) { - didInitialAutoSizeRef.current = true; - instance.refitAutoSizeColumns?.(); - } - }, [hasPortals, reactProps.isLoading]); + if (!instance?.hasPendingAutoSize?.()) return; + instance.retryPendingAutoSize?.(); + }, [portals, reactProps.isLoading]); return ( <> diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index b5ff974a6..8d1d9ecf3 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -93,6 +93,10 @@ export interface TableInstance { getAPI(): TableAPI; /** Re-measure auto-size columns (used after async React renderers mount). */ refitAutoSizeColumns?(): void; + /** True while a custom renderer column is still waiting to be measured. */ + hasPendingAutoSize?(): boolean; + /** Measure columns that are still waiting on renderer output. */ + retryPendingAutoSize?(): void; } // ─── Icon overrides ────────────────────────────────────────────────────────── diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index caddc5a22..b9ef38ab8 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ globals: true, include: [ "src/__tests__/**/*.{test,spec}.{ts,tsx}", + "../core/src/__tests__/autoSizeCellRenderer.test.ts", "../core/src/__tests__/autoExpandResizePinned.test.ts", "../core/src/__tests__/columnOwnership.test.ts", "../core/src/__tests__/headerCellReuse.test.ts", diff --git a/packages/solid/package.json b/packages/solid/package.json index 8ef5284d6..1871c20d4 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/solid", - "version": "4.3.0", + "version": "4.3.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index f4d860e12..fc9afc46c 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/svelte", - "version": "4.3.0", + "version": "4.3.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts", diff --git a/packages/vue/package.json b/packages/vue/package.json index aeaae4cd4..3e8729c62 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@simple-table/vue", - "version": "4.3.0", + "version": "4.3.1", "main": "dist/cjs/index.js", "module": "dist/index.es.js", "types": "dist/types/index.d.ts",