From 0b0ff08ba8dafed40ad48615db2f3d69a7d19a17 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 8 Oct 2026 09:57:22 +0200 Subject: [PATCH 1/3] fix(AnalyticalTable): shrink Smart-mode columns to fit --- .../hooks/useDynamicColumnWidths.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts b/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts index 6cb0abb334a..2447913bde0 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts @@ -452,6 +452,58 @@ const calculateSmartAndGrowColumns = ( return column; }); } + + // Step 3 (Smart only): shrink flexible columns to fit when the computed widths overflow the table. + // Grow mode intentionally sizes columns to their full content (capped at `MAX_WIDTH`) and may overflow by + // design, so it is excluded here. Each column is floored at its header width so header text is never + // clipped: when the headers alone don't fit, nothing shrinks and horizontal scrolling kicks in (Smart contract). + if (remainingWidth < 0 && !isGrow) { + // Flexible columns: no external `width` (so not fixed/internal columns), with their no-clip floor (headerPx). + const shrinkableColumns = visibleColumnsAdaptedPrio2 + .filter((column) => { + const columnIdOrAccessor = (column.id ?? column.accessor) as string; + const meta = columnMeta[columnIdOrAccessor]; + const isInternalColumn = typeof column.id === 'string' && column.id.startsWith('__ui5wcr__internal_'); + return !isInternalColumn && !meta?.width && typeof column.width === 'number'; + }) + .map((column) => { + const columnIdOrAccessor = (column.id ?? column.accessor) as string; + const floor = Math.max(columnMeta[columnIdOrAccessor].headerPx, column.minWidth ?? 0); + return { column, floor }; + }); + + let deficit = -remainingWidth; + while (deficit > 0) { + let shrinkableCount = 0; + for (const { column, floor } of shrinkableColumns) { + if (column.width > floor) { + shrinkableCount++; + } + } + if (shrinkableCount === 0) { + break; + } + const reduction = deficit / shrinkableCount; + let used = 0; + for (const { column, floor } of shrinkableColumns) { + if (column.width > floor) { + const potential = column.width - reduction; + if (potential < floor) { + used += column.width - floor; + column.width = floor; + } else { + column.width = potential; + used += reduction; + } + } + } + deficit -= used; + if (used === 0) { + break; + } + } + } + return visibleColumnsAdaptedPrio2; }; From 14458ec6ca1924fb523af6937daeda5017014013 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 8 Oct 2026 14:15:12 +0200 Subject: [PATCH 2/3] add test --- .../test/AnalyticalTable.spec.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/main/src/components/AnalyticalTable/test/AnalyticalTable.spec.tsx b/packages/main/src/components/AnalyticalTable/test/AnalyticalTable.spec.tsx index fb43d109fca..f3f50b2fe59 100644 --- a/packages/main/src/components/AnalyticalTable/test/AnalyticalTable.spec.tsx +++ b/packages/main/src/components/AnalyticalTable/test/AnalyticalTable.spec.tsx @@ -432,3 +432,67 @@ test.describe('AnalyticalTable — last header cell end border', () => { expect(await borderInlineEnd(lastHeader)).toBe(await borderInlineEnd(lastCell)); }); }); + +test.describe('AnalyticalTable Smart scaleWidthMode — shrink to fit', () => { + // Total width of all rendered column headers (horizontal virtualization is off in Smart mode, so all are in DOM). + const sumColumnWidths = (page: Page) => + page + .locator('[role="columnheader"]') + .evaluateAll((els) => els.reduce((acc, el) => acc + el.getBoundingClientRect().width, 0)); + const tableClientWidth = (page: Page) => + page.locator('[data-component-name="AnalyticalTableContainer"]').evaluate((el) => el.clientWidth); + + // One wide content column ('name') plus three columns whose fair share is narrower than their header (so Smart + // bumps them up to their header width and the row overflows), and a fixed 100px column — all in a 540px container. + // The shrink pass must take the whole overflow out of the content column; the header-bumped and fixed columns stay. + const columns: AnalyticalTableColumnDefinition[] = [ + { Header: 'Name', accessor: 'name', scaleWidthModeOptions: { headerString: 'Name', cellString: 'x'.repeat(70) } }, + { + Header: 'Age', + accessor: 'age', + scaleWidthModeOptions: { headerString: 'Wide Header1', cellString: 'content valueX' }, + }, + { + Header: 'Friend Name', + accessor: 'friend.name', + scaleWidthModeOptions: { headerString: 'Wide Header2', cellString: 'content valueX' }, + }, + { + Header: 'Friend Age', + accessor: 'friend.age', + scaleWidthModeOptions: { headerString: 'Wide Header3', cellString: 'content valueX' }, + }, + { Header: 'Fixed', accessor: 'status', width: 100 }, + ]; + const smartTableProps = { + columns, + withHook: false, + scaleWidthMode: AnalyticalTableScaleWidthMode.Smart, + containerWidth: '540px', + } as const; + + // Resolves once Smart has settled the widths so the columns fit the container (the width calc runs async, after + // fonts are ready, so the pre-shrink overflow is briefly visible). + const expectColumnsToFit = async (page: Page) => + expect.poll(async () => (await sumColumnWidths(page)) - (await tableClientWidth(page))).toBeLessThanOrEqual(5); + + test('shrinks overflowing columns to fit', async ({ mount, page }) => { + await mount(STORY, smartTableProps); + await expect(columnHeader(page, 'name')).toBeVisible(); + // Columns fit the table → no horizontal overflow (without the shrink pass the sum stays well above the width). + await expectColumnsToFit(page); + // The fixed-width column keeps its width. + expect(Math.abs((await boxWidth(columnHeader(page, 'status'))) - 100)).toBeLessThanOrEqual(5); + }); + + test('does not shrink below header width', async ({ mount, page }) => { + await mount(STORY, smartTableProps); + await expect(columnHeader(page, 'friend.name')).toBeVisible(); + await expectColumnsToFit(page); + // The Smart calc keeps adjusting after the first fit, so let the widths fully settle before reading them. + await page.waitForTimeout(1500); + // 'Friend Name' must stay at its header width: the shrink floors there, not at the 60px default, so only the wide + // 'name' column absorbs the overflow instead of every column collapsing toward 60px. + expect(await boxWidth(columnHeader(page, 'friend.name'))).toBeGreaterThanOrEqual(100); + }); +}); From 9aa68d32dcbbd09098cefd47438b3ad3927b905c Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 8 Oct 2026 15:18:38 +0200 Subject: [PATCH 3/3] Update useDynamicColumnWidths.ts --- .../hooks/useDynamicColumnWidths.ts | 126 +++++++----------- 1 file changed, 47 insertions(+), 79 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts b/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts index 2447913bde0..1f2cb72c98a 100644 --- a/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts +++ b/packages/main/src/components/AnalyticalTable/hooks/useDynamicColumnWidths.ts @@ -106,6 +106,41 @@ function getContentPxAvg(rowSample: RowType[], columnIdOrAccessor: string, compu ); } +// Absorbs `deficit` px of overflow by shrinking `items` toward each one's floor (react-table's distribute-and-floor). +// Mutates `item.width` in place. +function shrinkColumnsToFit(items: T[], deficit: number, getFloor: (item: T) => number) { + while (deficit > 0) { + let shrinkableCount = 0; + for (const item of items) { + if (item.width > getFloor(item)) { + shrinkableCount++; + } + } + if (shrinkableCount === 0) { + break; + } + const reduction = deficit / shrinkableCount; + let used = 0; + for (const item of items) { + const floor = getFloor(item); + if (item.width > floor) { + const potential = item.width - reduction; + if (potential < floor) { + used += item.width - floor; + item.width = floor; + } else { + item.width = potential; + used += reduction; + } + } + } + deficit -= used; + if (used === 0) { + break; + } + } +} + function calculateDefaultColumnWidths(tableWidth: number, columns: AnalyticalTableColumnDefinition[]) { // Columns w/ external width property const fixed = []; @@ -211,37 +246,7 @@ function calculateDefaultColumnWidths(tableWidth: number, columns: AnalyticalTab } // Shrink columns - while (remainingSpace < 0) { - let shrinkableCount = 0; - for (const { col, width } of dynamic) { - const min = col.minWidth ?? 0; - if (width > min) { - shrinkableCount++; - } - } - if (shrinkableCount === 0) { - break; - } - const reduction = Math.abs(remainingSpace) / shrinkableCount; - let used = 0; - for (const dc of dynamic) { - const min = dc.col.minWidth ?? 0; - if (dc.width > min) { - const potential = dc.width - reduction; - if (potential < min) { - used += dc.width - min; - dc.width = min; - } else { - dc.width = potential; - used += reduction; - } - } - } - remainingSpace += used; - if (used === 0) { - break; - } - } + shrinkColumnsToFit(dynamic, -remainingSpace, ({ col }) => col.minWidth ?? 0); } const result = {}; @@ -453,55 +458,18 @@ const calculateSmartAndGrowColumns = ( }); } - // Step 3 (Smart only): shrink flexible columns to fit when the computed widths overflow the table. - // Grow mode intentionally sizes columns to their full content (capped at `MAX_WIDTH`) and may overflow by - // design, so it is excluded here. Each column is floored at its header width so header text is never - // clipped: when the headers alone don't fit, nothing shrinks and horizontal scrolling kicks in (Smart contract). + // Step 3 (Smart only): shrink flexible columns to fit when they overflow, floored at each header width so header + // text is never clipped. Grow sizes to full content and overflows by design, so it is excluded. if (remainingWidth < 0 && !isGrow) { - // Flexible columns: no external `width` (so not fixed/internal columns), with their no-clip floor (headerPx). - const shrinkableColumns = visibleColumnsAdaptedPrio2 - .filter((column) => { - const columnIdOrAccessor = (column.id ?? column.accessor) as string; - const meta = columnMeta[columnIdOrAccessor]; - const isInternalColumn = typeof column.id === 'string' && column.id.startsWith('__ui5wcr__internal_'); - return !isInternalColumn && !meta?.width && typeof column.width === 'number'; - }) - .map((column) => { - const columnIdOrAccessor = (column.id ?? column.accessor) as string; - const floor = Math.max(columnMeta[columnIdOrAccessor].headerPx, column.minWidth ?? 0); - return { column, floor }; - }); - - let deficit = -remainingWidth; - while (deficit > 0) { - let shrinkableCount = 0; - for (const { column, floor } of shrinkableColumns) { - if (column.width > floor) { - shrinkableCount++; - } - } - if (shrinkableCount === 0) { - break; - } - const reduction = deficit / shrinkableCount; - let used = 0; - for (const { column, floor } of shrinkableColumns) { - if (column.width > floor) { - const potential = column.width - reduction; - if (potential < floor) { - used += column.width - floor; - column.width = floor; - } else { - column.width = potential; - used += reduction; - } - } - } - deficit -= used; - if (used === 0) { - break; - } - } + // Flexible columns: no external `width`, so not fixed/internal columns. + const shrinkableColumns = visibleColumnsAdaptedPrio2.filter((column) => { + const meta = columnMeta[(column.id ?? column.accessor) as string]; + const isInternalColumn = typeof column.id === 'string' && column.id.startsWith('__ui5wcr__internal_'); + return !isInternalColumn && !meta?.width; + }); + shrinkColumnsToFit(shrinkableColumns, -remainingWidth, (column) => + Math.max(columnMeta[(column.id ?? column.accessor) as string].headerPx, column.minWidth ?? 0), + ); } return visibleColumnsAdaptedPrio2;