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[] = [ diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index 208a975f7e7..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,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(); 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..a7784946a88 100644 --- a/src/table/column-groups/col-group.tsx +++ b/src/table/column-groups/col-group.tsx @@ -21,22 +21,29 @@ export function TableColGroup({ visibleColumnDefinitions: ReadonlyArray>; hasSelection: boolean; sticky?: boolean; - selectionColumnId?: PropertyKey; + selectionColumnId: PropertyKey; }) { const { getColumnStyles, setCol } = useColumnWidths(); return ( {hasSelection && ( setCol(sticky, selectionColumnId, node)} /> )} {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/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 && ( - + )} , @@ -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,8 @@ 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; const setCell = (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => { @@ -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; @@ -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 }); } } }); @@ -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)); } @@ -282,7 +307,15 @@ export function ColumnWidthsProvider({ return ( {children}