Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions pages/table/column-groups.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,30 @@ const columnDefinitions: TableProps.ColumnDefinition<Instance>[] = [
cell: item => <Link href="#">{item.id}</Link>,
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[] = [
Expand Down
9 changes: 7 additions & 2 deletions src/table/__tests__/columns-width.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -54,10 +58,11 @@ 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();
expect(() => ctx.setCol('x', null)).not.toThrow();
expect(() => ctx.setCol(false, 'x', null)).not.toThrow();
return null;
}
render(<Bare />);
Expand Down
79 changes: 79 additions & 0 deletions src/table/__tests__/resizable-columns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -699,3 +700,81 @@ describe('UAP buttons', () => {
await waitFor(() => expect(onChange).toHaveBeenCalledWith({ widths: [170] }));
});
});

describe('with column groups', () => {
const columnDefinitions: TableProps.ColumnDefinition<Item>[] = [
{ 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<Item>[] = [{ 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(
<Table
{...defaultProps}
columnDefinitions={columnDefinitions}
groupDefinitions={groupDefinitions}
columnDisplay={columnDisplay}
/>
);

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(
<Table
{...defaultProps}
columnDefinitions={columnDefinitions}
groupDefinitions={groupDefinitions}
columnDisplay={columnDisplay}
stickyHeader={true}
/>
);
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']));
});
});
15 changes: 11 additions & 4 deletions src/table/column-groups/col-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,29 @@ export function TableColGroup({
visibleColumnDefinitions: ReadonlyArray<TableProps.ColumnDefinition<any>>;
hasSelection: boolean;
sticky?: boolean;
selectionColumnId?: PropertyKey;
selectionColumnId: PropertyKey;
}) {
const { getColumnStyles, setCol } = useColumnWidths();
return (
<colgroup>
{hasSelection && (
<col
style={sticky && selectionColumnId ? { width: getColumnStyles(true, selectionColumnId).width } : undefined}
style={sticky ? { width: getColumnStyles(true, selectionColumnId).width } : undefined}
ref={node => setCol(sticky, selectionColumnId, node)}
/>
)}
{visibleColumnDefinitions.map((column, colIndex) => {
const columnId = getColumnKey(column, colIndex);
if (sticky) {
return <col key={String(columnId)} style={{ width: getColumnStyles(true, columnId).width }} />;
return (
<col
key={String(columnId)}
style={{ width: getColumnStyles(true, columnId).width }}
ref={node => setCol(true, columnId, node)}
/>
);
}
return <col key={columnId} data-column-id={String(columnId)} ref={node => setCol(columnId, node)} />;
return <col key={columnId} data-column-id={String(columnId)} ref={node => setCol(false, columnId, node)} />;
})}
</colgroup>
);
Expand Down
4 changes: 2 additions & 2 deletions src/table/header-cell/group-header-cell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>((sum, id) => {
if (id === lastChild) {
return sum + DEFAULT_COLUMN_WIDTH;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the bug 2, this used to use the 120 default width every time. That's what made the tracker and the column edge drift apart. using the actual min width instead.

return sum + getColumnMinWidth(id);
}
return sum + (columnWidths.get(id) || DEFAULT_COLUMN_WIDTH);
}, 0);
Expand Down
6 changes: 5 additions & 1 deletion src/table/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -655,7 +655,11 @@ const InternalTable = React.forwardRef(
})}
>
{resizableColumns && columnGroupsLayout && columnGroupsLayout.rows.length > 1 && (
<TableColGroup visibleColumnDefinitions={visibleColumnDefinitions} hasSelection={hasSelection} />
<TableColGroup
visibleColumnDefinitions={visibleColumnDefinitions}
hasSelection={hasSelection}
selectionColumnId={selectionColumnId}
/>
)}
<Thead
ref={theadRef}
Expand Down
57 changes: 45 additions & 12 deletions src/table/use-column-widths.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,24 @@ 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<PropertyKey, number>,
newWidth: number,
columnId: PropertyKey
): Map<PropertyKey, number> {
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;
}
Expand All @@ -60,15 +63,17 @@ function updateWidths(
interface WidthsContext {
getColumnStyles(sticky: boolean, columnId: PropertyKey): ColumnWidthStyle;
columnWidths: Map<PropertyKey, number>;
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<WidthsContext>({
getColumnStyles: () => ({}),
columnWidths: new Map(),
getColumnMinWidth: () => DEFAULT_COLUMN_WIDTH,
updateColumn: () => {},
updateGroup: () => {},
setCell: () => {},
Expand Down Expand Up @@ -97,6 +102,8 @@ export function ColumnWidthsProvider({
const cellsRef = useRef(new Map<PropertyKey, HTMLElement>());
const stickyCellsRef = useRef(new Map<PropertyKey, HTMLElement>());
const colsRef = useRef(new Map<PropertyKey, HTMLElement>());
// Only filled with column groups, where the sticky <col> widths take priority over the sticky cell widths.
const stickyColsRef = useRef(new Map<PropertyKey, HTMLElement>());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this different from stickyCellsRef we already have?

@NathanZlion NathanZlion Oct 27, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stickyCellsRef holds the sticky header cells, and this holds the sticky s. When s exist, the browser takes column widths from them, not from the cells. We were only updating the cells, so the s kept their first-render width, and that's the bug.

we're using col for grouped ones: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/col

https://github.com/cloudscape-design/components/blob/37fbce9334fcf726c6679780682a0c898b347958/src/table/column-groups/col-group.tsx

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, when we have grouped columns the widths measurement should be done differently - with respect to the groups. When grouping is not used - then technically stickyCellsRef and stickyColsRef should be identical. If thats the case, case we remove the stickyCellsRef then?

If we cannot do this optimisation and we have to keep both - let's add code comments to explain why is that needed.

@NathanZlion NathanZlion Oct 28, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, I'll add a comment. They're not quite the identical, the sticky table only renders a <colgroup> when there are column groups, one of the ways we used back then to make column groups work well with the resizing feature and all. so without groups stickyColsRef stays empty and the cells are what carry the widths.

const hasColElements = useRef(false);
const getCell = (columnId: PropertyKey): null | HTMLElement => cellsRef.current.get(columnId) ?? null;
const setCell = (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => {
Expand All @@ -107,7 +114,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;
Expand Down Expand Up @@ -158,8 +173,16 @@ export function ColumnWidthsProvider({
const syncStickyColumnWidths = useStableCallback(() => {
for (const { id } of visibleColumns) {
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, getColumnStyles(true, id));
setElementWidths(element, styles);
}
if (colElement) {
setElementWidths(colElement, { width: styles.width });
}
}
});
Expand Down Expand Up @@ -255,6 +278,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));
}
Expand Down Expand Up @@ -282,7 +307,15 @@ export function ColumnWidthsProvider({

return (
<WidthsContext.Provider
value={{ getColumnStyles, columnWidths: columnWidths ?? new Map(), updateColumn, updateGroup, setCell, setCol }}
value={{
getColumnStyles,
columnWidths: columnWidths ?? new Map(),
getColumnMinWidth,
updateColumn,
updateGroup,
setCell,
setCol,
}}
>
{children}
</WidthsContext.Provider>
Expand Down
Loading