From 1b802075e23d58133fd134e22b8e9d4d08196e14 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 27 Oct 2026 08:39:31 +0100 Subject: [PATCH 1/7] fix: Keep sticky header cols in sync with grouped table columns --- src/table/__tests__/columns-width.test.tsx | 2 +- .../__tests__/resizable-columns.test.tsx | 79 +++++++++++++++++++ src/table/column-groups/col-group.tsx | 11 ++- src/table/header-cell/group-header-cell.tsx | 4 +- src/table/use-column-widths.tsx | 53 ++++++++++--- 5 files changed, 132 insertions(+), 17 deletions(-) diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index 208a975f7e7..7363222029a 100644 --- a/src/table/__tests__/columns-width.test.tsx +++ b/src/table/__tests__/columns-width.test.tsx @@ -57,7 +57,7 @@ test('useColumnWidths returns safe defaults without provider', () => { expect(() => ctx.updateColumn('x', 100)).not.toThrow(); expect(() => ctx.updateGroup('x', 100)).not.toThrow(); expect(() => ctx.setCell(false, 'x', null)).not.toThrow(); - expect(() => ctx.setCol('x', null)).not.toThrow(); + expect(() => ctx.setCol(false, 'x', null)).not.toThrow(); return null; } render(); diff --git a/src/table/__tests__/resizable-columns.test.tsx b/src/table/__tests__/resizable-columns.test.tsx index 4813e8d1b4b..d50f5b261c2 100644 --- a/src/table/__tests__/resizable-columns.test.tsx +++ b/src/table/__tests__/resizable-columns.test.tsx @@ -14,6 +14,7 @@ import InternalDragHandleWrapper from '../../../lib/components/test-utils/dom/in import { fakeBoundingClientRect, firePointerdown, firePointermove, firePointerup } from './utils/resize-actions'; import resizerStyles from '../../../lib/components/table/resizer/styles.css.js'; +import tableStyles from '../../../lib/components/table/styles.css.js'; jest.mock('../../../lib/components/internal/utils/scrollable-containers', () => ({ ...jest.requireActual('../../../lib/components/internal/utils/scrollable-containers'), @@ -699,3 +700,81 @@ describe('UAP buttons', () => { await waitFor(() => expect(onChange).toHaveBeenCalledWith({ widths: [170] })); }); }); + +describe('with column groups', () => { + const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'Id', cell: item => item.id, width: 100 }, + { id: 'description', header: 'Description', cell: item => item.description, width: 100, minWidth: 50 }, + ]; + const groupDefinitions: TableProps.GroupDefinition[] = [{ id: 'group', header: 'Group' }]; + const columnDisplay: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'group', + visible: true, + children: [ + { id: 'id', visible: true }, + { id: 'description', visible: true }, + ], + }, + ]; + + let columnWidth = 100; + const originalBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; + beforeEach(() => { + HTMLElement.prototype.getBoundingClientRect = function () { + const rect = originalBoundingClientRect.apply(this); + if (this.tagName === 'TH') { + rect.width = this.getAttribute('scope') === 'colgroup' ? columnWidth * 2 : columnWidth; + } + return rect; + }; + }); + + afterEach(() => { + columnWidth = 100; + HTMLElement.prototype.getBoundingClientRect = originalBoundingClientRect; + }); + + test('uses the rightmost column min width for the group min width', () => { + const { wrapper } = renderTable( + + ); + + expect(wrapper.find('th[scope="colgroup"] [role="slider"]')!.getElement()).toHaveAttribute('aria-valuemin', '150'); + }); + + test('keeps sticky header columns in sync with the table when the container resizes', async () => { + const containerCallbacks: ((entry: ContainerQueryEntry) => void)[] = []; + jest.mocked(useResizeObserver).mockImplementation((target, cb) => { + const element = typeof target === 'function' ? target() : target?.current; + if (element?.classList.contains(tableStyles['wrapper-content-measure'])) { + containerCallbacks.push(cb); + } + }); + const { wrapper } = renderTable( +
+ ); + const getStickyColWidths = () => + wrapper + .findAllByClassName(tableStyles['header-secondary'])[0] + .findAll('col') + .map(col => col.getElement().style.width); + expect(getStickyColWidths()).toEqual(['100px', '100px']); + + columnWidth = 150; + containerCallbacks.forEach(cb => cb({ contentBoxWidth: 1000 } as unknown as ContainerQueryEntry)); + await waitFor(() => expect(getStickyColWidths()).toEqual(['150px', '150px'])); + }); +}); diff --git a/src/table/column-groups/col-group.tsx b/src/table/column-groups/col-group.tsx index 002e8cbffc0..ab9f7de05f6 100644 --- a/src/table/column-groups/col-group.tsx +++ b/src/table/column-groups/col-group.tsx @@ -29,14 +29,21 @@ export function TableColGroup({ {hasSelection && ( setCol(true, selectionColumnId, node) : undefined} /> )} {visibleColumnDefinitions.map((column, colIndex) => { const columnId = getColumnKey(column, colIndex); if (sticky) { - return ; + return ( + setCol(true, columnId, node)} + /> + ); } - return setCol(columnId, node)} />; + return setCol(false, columnId, node)} />; })} ); diff --git a/src/table/header-cell/group-header-cell.tsx b/src/table/header-cell/group-header-cell.tsx index 6405b46de54..4014d33cf12 100644 --- a/src/table/header-cell/group-header-cell.tsx +++ b/src/table/header-cell/group-header-cell.tsx @@ -59,13 +59,13 @@ export function TableGroupHeaderCell({ wrapLines, }: TableGroupHeaderCellProps) { const headerId = useUniqueId('table-group-header-'); - const { columnWidths } = useColumnWidths(); + const { columnWidths, getColumnMinWidth } = useColumnWidths(); // Effective min = sum of non-rightmost children's current widths (fixed) + rightmost child's minWidth const lastChild = childColumnIds[childColumnIds.length - 1]; const groupMinWidth = childColumnIds.reduce((sum, id) => { if (id === lastChild) { - return sum + DEFAULT_COLUMN_WIDTH; + return sum + getColumnMinWidth(id); } return sum + (columnWidths.get(id) || DEFAULT_COLUMN_WIDTH); }, 0); diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index 29fa001036d..b04120bf198 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -34,6 +34,16 @@ function readWidths( return result; } +function getMinWidth(column: ColumnWidthDefinition | undefined) { + if (typeof column?.minWidth === 'number') { + return column.minWidth; + } + if (typeof column?.width === 'number' && column.width < DEFAULT_COLUMN_WIDTH) { + return column.width; + } + return DEFAULT_COLUMN_WIDTH; +} + function updateWidths( visibleColumns: readonly ColumnWidthDefinition[], oldWidths: Map, @@ -41,14 +51,7 @@ function updateWidths( columnId: PropertyKey ): Map { const column = visibleColumns.find(column => column.id === columnId); - let minWidth = DEFAULT_COLUMN_WIDTH; - if (typeof column?.width === 'number' && column.width < DEFAULT_COLUMN_WIDTH) { - minWidth = column?.width; - } - if (typeof column?.minWidth === 'number') { - minWidth = column?.minWidth; - } - newWidth = Math.max(newWidth, minWidth); + newWidth = Math.max(newWidth, getMinWidth(column)); if (oldWidths.get(columnId) === newWidth) { return oldWidths; } @@ -60,15 +63,17 @@ function updateWidths( interface WidthsContext { getColumnStyles(sticky: boolean, columnId: PropertyKey): ColumnWidthStyle; columnWidths: Map; + getColumnMinWidth: (columnId: PropertyKey) => number; updateColumn: (columnId: PropertyKey, newWidth: number) => void; updateGroup: (groupId: PropertyKey, newWidth: number) => void; setCell: (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => void; - setCol: (columnId: PropertyKey, node: null | HTMLElement) => void; + setCol: (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => void; } const WidthsContext = createContext({ getColumnStyles: () => ({}), columnWidths: new Map(), + getColumnMinWidth: () => DEFAULT_COLUMN_WIDTH, updateColumn: () => {}, updateGroup: () => {}, setCell: () => {}, @@ -97,6 +102,7 @@ export function ColumnWidthsProvider({ const cellsRef = useRef(new Map()); const stickyCellsRef = useRef(new Map()); const colsRef = useRef(new Map()); + const stickyColsRef = useRef(new Map()); const hasColElements = useRef(false); const getCell = (columnId: PropertyKey): null | HTMLElement => cellsRef.current.get(columnId) ?? null; const setCell = (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => { @@ -107,7 +113,15 @@ export function ColumnWidthsProvider({ ref.current.delete(columnId); } }; - const setCol = (columnId: PropertyKey, node: null | HTMLElement) => { + const setCol = (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => { + if (sticky) { + if (node) { + stickyColsRef.current.set(columnId, node); + } else { + stickyColsRef.current.delete(columnId); + } + return; + } if (node) { colsRef.current.set(columnId, node); hasColElements.current = true; @@ -157,9 +171,14 @@ export function ColumnWidthsProvider({ // Mirrors the current primary cell widths onto the sticky header copies. const syncStickyColumnWidths = useStableCallback(() => { for (const { id } of visibleColumns) { + const styles = getColumnStyles(true, id); const element = stickyCellsRef.current.get(id); if (element) { - setElementWidths(element, getColumnStyles(true, id)); + setElementWidths(element, styles); + } + const colElement = stickyColsRef.current.get(id); + if (colElement) { + setElementWidths(colElement, { width: styles.width }); } } }); @@ -255,6 +274,8 @@ export function ColumnWidthsProvider({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const getColumnMinWidth = (columnId: PropertyKey) => getMinWidth(visibleColumns.find(col => col.id === columnId)); + function updateColumn(columnId: PropertyKey, newWidth: number) { setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths ?? new Map(), newWidth, columnId)); } @@ -282,7 +303,15 @@ export function ColumnWidthsProvider({ return ( {children} From e6f093f50631aec00a4723eb25883311243c2632 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 27 Oct 2026 10:06:19 +0100 Subject: [PATCH 2/7] chore: Set column widths on column groups dev page Explicit widths let the grouped sticky header alignment fix be checked by widening the window after load, and a low minWidth on Memory lets the group resize minimum fix be checked by shrinking the Performance group. --- pages/table/column-groups.page.tsx | 32 +++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/pages/table/column-groups.page.tsx b/pages/table/column-groups.page.tsx index fcaa93311c4..9cdef27b0a8 100644 --- a/pages/table/column-groups.page.tsx +++ b/pages/table/column-groups.page.tsx @@ -60,16 +60,30 @@ const columnDefinitions: TableProps.ColumnDefinition[] = [ cell: item => {item.id}, sortingField: 'id', isRowHeader: true, + width: 140, }, - { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, - { id: 'type', header: 'Type', cell: item => item.type, sortingField: 'type' }, - { id: 'az', header: 'AZ', cell: item => item.az, sortingField: 'az' }, - { id: 'state', header: 'State', cell: item => item.state, sortingField: 'state' }, - { id: 'cpu', header: 'CPU (%)', cell: item => `${item.cpu}%`, sortingField: 'cpu' }, - { id: 'memory', header: 'Memory (%)', cell: item => `${item.memory}%`, sortingField: 'memory' }, - { id: 'netIn', header: 'Network in', cell: item => item.netIn.toLocaleString(), sortingField: 'netIn' }, - { id: 'netOut', header: 'Network out', cell: item => item.netOut.toLocaleString(), sortingField: 'netOut' }, - { id: 'cost', header: 'Cost ($)', cell: item => `$${item.cost}`, sortingField: 'cost' }, + { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name', width: 140 }, + { id: 'type', header: 'Type', cell: item => item.type, sortingField: 'type', width: 140 }, + { id: 'az', header: 'AZ', cell: item => item.az, sortingField: 'az', width: 140 }, + { id: 'state', header: 'State', cell: item => item.state, sortingField: 'state', width: 140 }, + { id: 'cpu', header: 'CPU (%)', cell: item => `${item.cpu}%`, sortingField: 'cpu', width: 140 }, + { + id: 'memory', + header: 'Memory (%)', + cell: item => `${item.memory}%`, + sortingField: 'memory', + width: 140, + minWidth: 60, + }, + { id: 'netIn', header: 'Network in', cell: item => item.netIn.toLocaleString(), sortingField: 'netIn', width: 140 }, + { + id: 'netOut', + header: 'Network out', + cell: item => item.netOut.toLocaleString(), + sortingField: 'netOut', + width: 140, + }, + { id: 'cost', header: 'Cost ($)', cell: item => `$${item.cost}`, sortingField: 'cost', width: 140 }, ]; const groupDefinitions: TableProps.GroupDefinition[] = [ From 995fc4410f9f08978e8c921b17cf61ffc043c83e Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 27 Oct 2026 10:06:22 +0100 Subject: [PATCH 3/7] test: Cover default column min width in column widths context --- src/table/__tests__/columns-width.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index 7363222029a..155e23b6faa 100644 --- a/src/table/__tests__/columns-width.test.tsx +++ b/src/table/__tests__/columns-width.test.tsx @@ -6,7 +6,11 @@ import { render } from '@testing-library/react'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import Table, { TableProps } from '../../../lib/components/table'; -import { ColumnWidthsProvider, useColumnWidths } from '../../../lib/components/table/use-column-widths'; +import { + ColumnWidthsProvider, + DEFAULT_COLUMN_WIDTH, + useColumnWidths, +} from '../../../lib/components/table/use-column-widths'; import createWrapper, { ElementWrapper } from '../../../lib/components/test-utils/dom'; import { fakeBoundingClientRect, firePointerdown, firePointermove, firePointerup } from './utils/resize-actions'; @@ -54,6 +58,7 @@ test('useColumnWidths returns safe defaults without provider', () => { const ctx = useColumnWidths(); expect(ctx.getColumnStyles(false, 'x')).toEqual({}); expect(ctx.columnWidths).toEqual(new Map()); + expect(ctx.getColumnMinWidth('x')).toBe(DEFAULT_COLUMN_WIDTH); expect(() => ctx.updateColumn('x', 100)).not.toThrow(); expect(() => ctx.updateGroup('x', 100)).not.toThrow(); expect(() => ctx.setCell(false, 'x', null)).not.toThrow(); From 37fbce9334fcf726c6679780682a0c898b347958 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Tue, 27 Oct 2026 10:37:35 +0100 Subject: [PATCH 4/7] perf: Skip sticky width measurement when no sticky target exists syncStickyColumnWidths measured every primary column before checking for a sticky cell or , adding layout reads on resize even without a sticky header. Only measure when there is something to update. --- src/table/use-column-widths.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index b04120bf198..6b5d4b66791 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -171,12 +171,15 @@ export function ColumnWidthsProvider({ // Mirrors the current primary cell widths onto the sticky header copies. const syncStickyColumnWidths = useStableCallback(() => { for (const { id } of visibleColumns) { - const styles = getColumnStyles(true, id); const element = stickyCellsRef.current.get(id); + const colElement = stickyColsRef.current.get(id); + if (!element && !colElement) { + continue; + } + const styles = getColumnStyles(true, id); if (element) { setElementWidths(element, styles); } - const colElement = stickyColsRef.current.get(id); if (colElement) { setElementWidths(colElement, { width: styles.width }); } From df4a5882996dd35186e7e8b246ef5b77220e02cb Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Wed, 28 Oct 2026 15:23:01 +0100 Subject: [PATCH 5/7] chore: Add describing comment for sticky col ref --- src/table/use-column-widths.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index 6b5d4b66791..bc515df887f 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -102,6 +102,7 @@ export function ColumnWidthsProvider({ const cellsRef = useRef(new Map()); const stickyCellsRef = useRef(new Map()); const colsRef = useRef(new Map()); + // Only filled with column groups, where the sticky widths take priority over the sticky cell widths. const stickyColsRef = useRef(new Map()); const hasColElements = useRef(false); const getCell = (columnId: PropertyKey): null | HTMLElement => cellsRef.current.get(columnId) ?? null; From d5912d3fed30d0565c49a1137c4c66821934bdff Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Wed, 28 Oct 2026 15:37:34 +0100 Subject: [PATCH 6/7] fix: simplify by making optional prop to required since the selectionColumnId is always there --- src/table/column-groups/col-group.tsx | 6 +++--- src/table/internal.tsx | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/table/column-groups/col-group.tsx b/src/table/column-groups/col-group.tsx index ab9f7de05f6..985546ff41f 100644 --- a/src/table/column-groups/col-group.tsx +++ b/src/table/column-groups/col-group.tsx @@ -21,15 +21,15 @@ export function TableColGroup({ visibleColumnDefinitions: ReadonlyArray>; hasSelection: boolean; sticky?: boolean; - selectionColumnId?: PropertyKey; + selectionColumnId: PropertyKey; }) { const { getColumnStyles, setCol } = useColumnWidths(); return ( {hasSelection && ( setCol(true, selectionColumnId, node) : undefined} + style={sticky ? { width: getColumnStyles(true, selectionColumnId).width } : undefined} + ref={sticky ? node => setCol(true, selectionColumnId, node) : undefined} /> )} {visibleColumnDefinitions.map((column, colIndex) => { diff --git a/src/table/internal.tsx b/src/table/internal.tsx index e3ffe489457..b211e3eb42b 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -655,7 +655,11 @@ const InternalTable = React.forwardRef( })} > {resizableColumns && columnGroupsLayout && columnGroupsLayout.rows.length > 1 && ( - + )} Date: Thu, 29 Oct 2026 11:56:18 +0100 Subject: [PATCH 7/7] fix: remove the sticky check for ref --- src/table/column-groups/col-group.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/table/column-groups/col-group.tsx b/src/table/column-groups/col-group.tsx index 985546ff41f..a7784946a88 100644 --- a/src/table/column-groups/col-group.tsx +++ b/src/table/column-groups/col-group.tsx @@ -29,7 +29,7 @@ export function TableColGroup({ {hasSelection && ( setCol(true, selectionColumnId, node) : undefined} + ref={node => setCol(sticky, selectionColumnId, node)} /> )} {visibleColumnDefinitions.map((column, colIndex) => {