diff --git a/pages/table/column-groups.page.tsx b/pages/table/column-groups.page.tsx new file mode 100644 index 0000000000..1850a08075 --- /dev/null +++ b/pages/table/column-groups.page.tsx @@ -0,0 +1,443 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useState } from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import { + Box, + FormField, + Header, + Input, + Link, + Pagination, + Select, + SpaceBetween, + Table, + TableProps, + TextFilter, + Toggle, +} from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +// ============================================================================ +// Data +// ============================================================================ + +interface Instance { + id: string; + name: string; + type: string; + az: string; + state: string; + cpu: number; + memory: number; + netIn: number; + netOut: number; + cost: number; +} + +const TYPES = ['t3.medium', 't3.large', 'r5.xlarge', 'c5.large', 'p3.2xlarge']; +const AZS = ['us-east-1a', 'us-east-1b', 'us-east-1c', 'us-east-1d']; +const STATES = ['running', 'stopped', 'pending']; + +const allInstances: Instance[] = Array.from({ length: 15 }, (_, i) => ({ + id: `i-${String(i + 1).padStart(3, '0')}`, + name: `instance-${i + 1}`, + type: TYPES[i % TYPES.length], + az: AZS[i % AZS.length], + state: STATES[i % STATES.length], + cpu: +(Math.random() * 100).toFixed(1), + memory: +(Math.random() * 100).toFixed(1), + netIn: Math.round(Math.random() * 10000), + netOut: Math.round(Math.random() * 10000), + cost: +(Math.random() * 500).toFixed(2), +})); + +// ============================================================================ +// Column & group definitions +// ============================================================================ + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { + id: 'id', + header: 'Instance ID', + cell: item => {item.id}, + sortingField: 'id', + isRowHeader: true, + }, + { 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' }, +]; + +const groupDefinitions: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'performance', header: 'Performance' }, + { id: 'network', header: 'Network' }, + { id: 'metrics', header: 'Metrics' }, +]; + +// ============================================================================ +// Column display presets +// ============================================================================ + +type GroupingPreset = 'flat' | 'single-level' | 'nested' | 'single-child-groups'; + +const columnDisplayPresets: Record = { + flat: [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + { id: 'cost', visible: true }, + ], + 'single-level': [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + { id: 'cost', visible: true }, + ], + nested: [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + ], + }, + ], + }, + { id: 'cost', visible: true }, + ], + 'single-child-groups': [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { type: 'group', id: 'config', visible: true, children: [{ id: 'type', visible: true }] }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }, + { id: 'memory', visible: true }, + { id: 'netIn', visible: true }, + { id: 'netOut', visible: true }, + { id: 'cost', visible: true }, + ], +}; + +const presetOptions = [ + { value: 'single-level', label: 'Single-level groups' }, + { value: 'nested', label: 'Nested groups (3 levels)' }, + { value: 'single-child-groups', label: 'Single-child groups' }, + { value: 'flat', label: 'Without grouping' }, +]; + +// ============================================================================ +// Page component +// ============================================================================ + +type DemoContext = React.Context< + AppContextType<{ + groupingPreset: GroupingPreset; + variant: TableProps.Variant; + selectionType: string; + resizable: boolean; + stickyHeader: boolean; + stickyHeaderOffset: number; + firstSticky: number; + lastSticky: number; + wrapLines: boolean; + stripedRows: boolean; + contentDensity: string; + enableKeyboardNavigation: boolean; + loading: boolean; + empty: boolean; + cellVerticalAlign: string; + sortingDisabled: boolean; + }> +>; + +export default function ColumnGroupsPage() { + const { + urlParams: { + groupingPreset = 'single-level' as GroupingPreset, + variant = 'container' as TableProps.Variant, + selectionType = 'multi', + resizable = true, + stickyHeader = false, + stickyHeaderOffset = 0, + firstSticky = 0, + lastSticky = 0, + wrapLines = false, + stripedRows = false, + contentDensity = 'comfortable', + enableKeyboardNavigation = true, + loading = false, + empty = false, + cellVerticalAlign = 'middle', + sortingDisabled = false, + }, + setUrlParams, + } = useContext(AppContext as DemoContext); + + const [columnDisplay, setColumnDisplay] = useState( + columnDisplayPresets[groupingPreset] + ); + + const tableItems = empty ? [] : allInstances; + + const { items, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection(tableItems, { + filtering: { + empty: No instances, + noMatch: No matches, + }, + pagination: { pageSize: 10 }, + sorting: {}, + selection: {}, + }); + + return ( + +
+ Feature controls +
+ + + + setUrlParams({ variant: detail.selectedOption.value as TableProps.Variant })} + ariaLabel="Table variant" + /> + + + setUrlParams({ cellVerticalAlign: detail.selectedOption.value! })} + ariaLabel="Cell vertical align" + /> + + + + + + setUrlParams({ firstSticky: +detail.value })} + value={String(firstSticky)} + inputMode="numeric" + type="number" + /> + + + setUrlParams({ lastSticky: +detail.value })} + value={String(lastSticky)} + inputMode="numeric" + type="number" + /> + + + setUrlParams({ stickyHeaderOffset: +detail.value })} + value={String(stickyHeaderOffset)} + inputMode="numeric" + type="number" + /> + + + + + setUrlParams({ resizable: detail.checked })}> + Resizable columns + + setUrlParams({ stickyHeader: detail.checked })}> + Sticky header + + setUrlParams({ wrapLines: detail.checked })}> + Wrap lines + + setUrlParams({ stripedRows: detail.checked })}> + Striped rows + + setUrlParams({ contentDensity: detail.checked ? 'compact' : 'comfortable' })} + > + Compact mode + + setUrlParams({ enableKeyboardNavigation: detail.checked })} + > + Keyboard navigation + + setUrlParams({ sortingDisabled: detail.checked })} + > + Sorting disabled + + setUrlParams({ loading: detail.checked })}> + Loading state + + setUrlParams({ empty: detail.checked })}> + Empty state + + + + } + > + {/* Table */} + `${selectedItems.length} items selected`, + itemSelectionLabel: (_, item) => `Select ${item.name}`, + }} + header={
Instances
} + filter={ + + } + pagination={} + empty={No instances} + /> + {/* Spacer for sticky header scroll testing */} +
+ + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 701351b97d..94e7b4388d 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -27166,7 +27166,18 @@ To target individual cells use \`columnDefinitions.verticalAlign\`, that takes p If not set, all columns are displayed and the order is dictated by the \`columnDefinitions\` property. -Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component.", +Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component. + +Each entry is one of the following: +- \`ColumnDisplay\` - Represents a single column. + - \`type\` ('column') - (Optional) Identifies the entry as a column. Defaults to \`'column'\` when omitted. + - \`id\` (string) - The column identifier. Must match a column \`id\` from \`columnDefinitions\`. + - \`visible\` (boolean) - Whether the column is visible. +- \`GroupDisplay\` - Represents a column group. + - \`type\` ('group') - Identifies the entry as a group. + - \`id\` (string) - The group identifier. Must match a group \`id\` from \`groupDefinitions\`. + - \`visible\` (boolean) - Whether the group is visible. + - \`children\` (ReadonlyArray) - The columns or nested groups within this group.", "name": "columnDisplay", "optional": true, "type": "ReadonlyArray", @@ -27388,6 +27399,20 @@ table with \`item=null\` and then for each expanded item. The function result is "optional": true, "type": "TableProps.GetLoadingStatus", }, + { + "description": "Defines the column groups. Each group has an \`id\` and \`header\` used to label the group header cell. + +When using grouped columns, you must also provide the \`columnDisplay\` property with \`{ type: 'group', id, children }\` entries +to assign columns to their respective groups and define the display hierarchy. + +Each group definition contains the following: +- \`id\` (string) - A unique identifier for the group. +- \`header\` (ReactNode) - The content displayed in the group header cell. +- \`ariaLabel\` ((LabelData) => string) - (Optional) A function that provides an \`aria-label\` for the group header.", + "name": "groupDefinitions", + "optional": true, + "type": "ReadonlyArray>", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -42421,8 +42446,22 @@ Returns the current value of the input.", }, }, { + "description": "Returns column header cells from the table's header region. + +By default, returns all leaf-column headers (\`scope="col"\`). +For tables without column grouping this is equivalent to the previous behavior. +For tables with column grouping this excludes group header cells.", "name": "findColumnHeaders", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "Array", @@ -42445,6 +42484,13 @@ Returns the current value of the input.", "name": "columnIndex", "typeName": "number", }, + { + "flags": { + "isOptional": true, + }, + "name": "options", + "typeName": "{ grouped?: boolean | undefined; }", + }, ], "returnType": { "isNullable": true, @@ -42457,15 +42503,24 @@ Returns the current value of the input.", }, }, { + "description": "Returns the clickable sorting area of a column header.", "name": "findColumnSortingArea", "parameters": [ { + "description": "1-based index of the column.", "flags": { "isOptional": false, }, "name": "colIndex", "typeName": "number", }, + { + "flags": { + "isOptional": true, + }, + "name": "options", + "typeName": "{ grouped?: boolean | undefined; }", + }, ], "returnType": { "isNullable": true, @@ -51721,8 +51776,22 @@ In this case, use findContentEditableElement() instead.", }, }, { + "description": "Returns column header cells from the table's header region. + +By default, returns all leaf-column headers (\`scope="col"\`). +For tables without column grouping this is equivalent to the previous behavior. +For tables with column grouping this excludes group header cells.", "name": "findColumnHeaders", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "MultiElementWrapper", @@ -51745,6 +51814,13 @@ In this case, use findContentEditableElement() instead.", "name": "columnIndex", "typeName": "number", }, + { + "flags": { + "isOptional": true, + }, + "name": "options", + "typeName": "{ grouped?: boolean | undefined; }", + }, ], "returnType": { "isNullable": false, @@ -51752,15 +51828,24 @@ In this case, use findContentEditableElement() instead.", }, }, { + "description": "Returns the clickable sorting area of a column header.", "name": "findColumnSortingArea", "parameters": [ { + "description": "1-based index of the column.", "flags": { "isOptional": false, }, "name": "colIndex", "typeName": "number", }, + { + "flags": { + "isOptional": true, + }, + "name": "options", + "typeName": "{ grouped?: boolean | undefined; }", + }, ], "returnType": { "isNullable": false, diff --git a/src/table/__integ__/resizable-columns-grouped.test.ts b/src/table/__integ__/resizable-columns-grouped.test.ts new file mode 100644 index 0000000000..f252f6ae59 --- /dev/null +++ b/src/table/__integ__/resizable-columns-grouped.test.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../lib/components/test-utils/selectors'; + +const tableWrapper = createWrapper().findTable(); +const defaultScreen = { width: 1680, height: 800 }; + +const setupTest = (testFn: (page: BasePageObject) => Promise) => { + return useBrowser(async browser => { + const page = new BasePageObject(browser); + await browser.url('#/light/table/column-groups'); + await page.setWindowSize(defaultScreen); + await testFn(page); + }); +}; + +describe('Table - Grouped column resizing', () => { + test( + 'group resizer changes group width on drag', + setupTest(async page => { + const groupResizerSelector = `${tableWrapper.toSelector()} thead th[scope="colgroup"] button`; + await expect(page.isExisting(groupResizerSelector)).resolves.toBe(true); + await page.dragAndDrop(groupResizerSelector, 50); + }) + ); + + test( + 'leaf column resizer works within grouped table', + setupTest(async page => { + const resizerSelector = tableWrapper.findColumnResizer(3, { grouped: true }).toSelector(); + await expect(page.isExisting(resizerSelector)).resolves.toBe(true); + await page.dragAndDrop(resizerSelector, 30); + }) + ); +}); diff --git a/src/table/__tests__/column-grouping-rendering.test.tsx b/src/table/__tests__/column-grouping-rendering.test.tsx new file mode 100644 index 0000000000..f62ee54754 --- /dev/null +++ b/src/table/__tests__/column-grouping-rendering.test.tsx @@ -0,0 +1,1009 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { PointerEventMock } from '../../../lib/components/internal/utils/pointer-events-mock'; +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +beforeAll(() => { + (window as any).PointerEvent ??= PointerEventMock; +}); + +interface Item { + id: string; + name: string; + type: string; + az: string; + cpu: number; + memory: number; +} + +const items: Item[] = [ + { id: 'i-1', name: 'web', type: 't3.medium', az: 'us-east-1a', cpu: 45, memory: 62 }, + { id: 'i-2', name: 'api', type: 't3.large', az: 'us-east-1b', cpu: 78, memory: 81 }, +]; + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id }, + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'type', header: 'Type', cell: item => item.type }, + { id: 'az', header: 'AZ', cell: item => item.az }, + { id: 'cpu', header: 'CPU', cell: item => `${item.cpu}%` }, + { id: 'memory', header: 'Memory', cell: item => `${item.memory}%` }, +]; + +const groupDefinitions: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'perf', header: 'Performance' }, +]; + +const singleLevelDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { + type: 'group', + id: 'perf', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, +]; + +function renderTable(props: Partial> = {}) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; +} + +describe('Column grouping rendering', () => { + test('renders two header rows for single-level grouping', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); + + test('renders group header cells with correct colspan', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const groupCells = firstRow.findAll('th[scope="colgroup"]'); + + expect(groupCells).toHaveLength(2); + expect(groupCells[0].getElement().getAttribute('colspan')).toBe('2'); + expect(groupCells[1].getElement().getAttribute('colspan')).toBe('2'); + }); + + test('renders group header labels', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const groupCells = firstRow.findAll('th[scope="colgroup"]'); + + expect(groupCells[0].getElement().textContent).toContain('Configuration'); + expect(groupCells[1].getElement().textContent).toContain('Performance'); + }); + + test('ungrouped columns get rowspan=2 in first row', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const leafCells = firstRow.findAll('th[scope="col"]'); + + // id and name are ungrouped, should span both rows + const idCell = leafCells.find(el => el.getElement().textContent?.includes('ID')); + const nameCell = leafCells.find(el => el.getElement().textContent?.includes('Name')); + expect(idCell!.getElement().getAttribute('rowspan')).toBe('2'); + expect(nameCell!.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('leaf columns under groups appear in second row', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const secondRow = thead.findAll('tr')[1]; + const cells = secondRow.findAll('th[scope="col"]'); + + const labels = cells.map(c => c.getElement().textContent?.trim()); + expect(labels).toEqual(expect.arrayContaining(['Type', 'AZ', 'CPU', 'Memory'])); + expect(cells).toHaveLength(4); + }); + + test('leaf columns under groups have data-column-group-id', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + + const configColumns = thead.findAll('th[data-column-group-id="config"]'); + const perfColumns = thead.findAll('th[data-column-group-id="perf"]'); + + expect(configColumns).toHaveLength(2); // type, az + expect(perfColumns).toHaveLength(2); // cpu, memory + }); + + test('leaf columns have data-column-index', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const leafCells = thead.findAll('th[data-column-index]'); + + // All 6 leaf columns should have data-column-index + expect(leafCells).toHaveLength(6); + expect(leafCells[0].getElement().getAttribute('data-column-index')).toBe('1'); + expect(leafCells[5].getElement().getAttribute('data-column-index')).toBe('6'); + }); + + test('group header cells do not have data-column-index', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.getElement().hasAttribute('data-column-index')).toBe(false); + }); + }); + + test('findColumnHeaders returns only leaf columns by default', () => { + const wrapper = renderTable(); + const headers = wrapper.findColumnHeaders(); + + expect(headers.length).toBeGreaterThanOrEqual(6); + const texts = headers.map(h => h.getElement().textContent); + expect(texts).toContain('ID'); + expect(texts.find(t => t?.includes('Memory'))).toBeDefined(); + }); + + test('findColumnHeaders with groupId returns only that group columns', () => { + const wrapper = renderTable(); + const configHeaders = wrapper.findColumnHeaders({ groupId: 'config' }); + const perfHeaders = wrapper.findColumnHeaders({ groupId: 'perf' }); + + expect(configHeaders).toHaveLength(2); + expect(configHeaders[0].getElement().textContent).toContain('Type'); + expect(configHeaders[1].getElement().textContent).toContain('AZ'); + expect(perfHeaders).toHaveLength(2); + }); + + test('renders single row when no groupDefinitions provided', () => { + const wrapper = renderTable({ groupDefinitions: undefined, columnDisplay: undefined }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(1); + }); + + test('renders resizers on group header cells when resizableColumns is true', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.find('[class*="resizer"]')).not.toBeNull(); + }); + }); + + test('renders dividers on non-rightmost cells when resizableColumns is false', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + + // All non-rightmost leaf cells should have a divider + const leafCells = thead.findAll('th[scope="col"]'); + const nonRightmost = leafCells.filter(c => !c.getElement().hasAttribute('data-rightmost')); + nonRightmost.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + + // Rightmost cell should not have a divider (CSS hides it via data-rightmost) + const rightmost = leafCells.find(c => c.getElement().hasAttribute('data-rightmost')); + expect(rightmost).toBeDefined(); + }); + + test('selection cell spans all header rows', () => { + const wrapper = renderTable({ selectionType: 'multi' }); + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + const selectionCell = firstRow.findAll('th')[0]; + + expect(selectionCell.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('hidden columns are excluded from rendering', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: false }, + ], + }, + ]; + const wrapper = renderTable({ columnDisplay: display }); + const headers = wrapper.findColumnHeaders(); + + const labels = headers.map(h => h.getElement().textContent?.trim()); + expect(labels).toContain('ID'); + expect(labels).toContain('Type'); + expect(labels).not.toContain('AZ'); + }); + + test('group is omitted when all children are hidden', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: false }, + { id: 'az', visible: false }, + ], + }, + { type: 'group', id: 'perf', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const wrapper = renderTable({ columnDisplay: display }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Only perf group should render + expect(groupCells).toHaveLength(1); + expect(groupCells[0].getElement().textContent).toContain('Performance'); + }); +}); + +describe('Column grouping with sticky columns', () => { + test('renders correctly with stickyColumns first', () => { + const wrapper = renderTable({ stickyColumns: { first: 1 } }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + + // First column (id) should have sticky styles + const firstCol = thead.find('th[data-column-index="1"]')!; + expect(firstCol.getElement().style.position || firstCol.getElement().className).toBeDefined(); + }); + + test('renders correctly with stickyColumns last', () => { + const wrapper = renderTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const leafCells = thead.findAll('th[scope="col"]'); + expect(leafCells.length).toBe(6); + }); + + test('group spanning sticky-first boundary renders split cells', () => { + // stickyColumns.first = 3 means columns at index 0,1,2 are sticky (id, name, type) + // 'config' group has type(colIndex=2), az(colIndex=3) — straddles boundary + const wrapper = renderTable({ stickyColumns: { first: 3 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // config group split into 2 halves + perf group = 3 group cells + expect(groupCells.length).toBe(3); + + // The split config halves: first half has colspan=1 (type), second has colspan=1 (az) + const configCells = groupCells.filter(c => c.getElement().textContent?.includes('Configuration')); + expect(configCells).toHaveLength(2); + expect(configCells[0].getElement().getAttribute('colspan')).toBe('1'); + expect(configCells[1].getElement().getAttribute('colspan')).toBe('1'); + }); + + test('group spanning sticky-last boundary renders split cells', () => { + // stickyColumns.last = 1 means last column (memory, colIndex=5) is sticky + // 'perf' group has cpu(colIndex=4), memory(colIndex=5) — straddles boundary + const wrapper = renderTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // perf group split into 2 halves + config group = 3 group cells + expect(groupCells.length).toBe(3); + + const perfCells = groupCells.filter(c => c.getElement().textContent?.includes('Performance')); + expect(perfCells).toHaveLength(2); + }); + + test('fully sticky group (all children within boundary) is not split', () => { + // stickyColumns.first = 4 means id, name, type, az are sticky + // 'config' group has type(2), az(3) — both within boundary, no split + const wrapper = renderTable({ stickyColumns: { first: 4 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + const configCells = groupCells.filter(c => c.getElement().textContent?.includes('Configuration')); + expect(configCells).toHaveLength(1); + expect(configCells[0].getElement().getAttribute('colspan')).toBe('2'); + }); + + test('group entirely outside sticky boundary is not split', () => { + // stickyColumns.first = 1 means only id is sticky + // Both groups are entirely outside the boundary + const wrapper = renderTable({ stickyColumns: { first: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + expect(groupCells).toHaveLength(2); + expect(groupCells[0].getElement().getAttribute('colspan')).toBe('2'); + expect(groupCells[1].getElement().getAttribute('colspan')).toBe('2'); + }); +}); + +describe('Column grouping with resizable columns', () => { + test('group header cells have resizers', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + const resizer = cell.find('button[class*="resizer"]'); + expect(resizer).not.toBeNull(); + }); + }); + + test('leaf column cells have resizers', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const leafCells = thead.findAll('th[scope="col"]'); + + leafCells.forEach(cell => { + const resizer = cell.find('button[class*="resizer"]'); + expect(resizer).not.toBeNull(); + }); + }); + + test('findColumnResizer works with grouped columns', () => { + const wrapper = renderTable({ resizableColumns: true }); + // Column index 3 = 'type' (first child of config group) + const resizer = wrapper.findColumnResizer(3, { grouped: true }); + expect(resizer).not.toBeNull(); + }); + + test('group resizer has aria-labelledby pointing to group header', () => { + const wrapper = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const headerId = groupCell.find('[id^="table-group-header"]')!.getElement().id; + const resizer = groupCell.find('button[class*="resizer"]')!; + + expect(resizer.getElement().getAttribute('aria-labelledby')).toBe(headerId); + }); + + test('renders resizable grouped table with onColumnWidthsChange callback', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderTable({ resizableColumns: true, onColumnWidthsChange }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('button[class*="resizer"]').length).toBeGreaterThanOrEqual(2); + }); + + test('columns have width styles when resizable', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const wrapper = renderTable({ resizableColumns: true, columnDefinitions: colDefs }); + const leafCells = wrapper.findColumnHeaders(); + + // At least some cells should have width set + const hasWidth = leafCells.some(cell => cell.getElement().style.width !== ''); + expect(hasWidth).toBe(true); + }); +}); + +describe('Column grouping with sticky header', () => { + test('renders with stickyHeader enabled', () => { + const wrapper = renderTable({ stickyHeader: true }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); + + test('sticky header renders group cells', () => { + const wrapper = renderTable({ stickyHeader: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('sticky header with resizable columns renders correctly', () => { + const wrapper = renderTable({ stickyHeader: true, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + + groupCells.forEach(cell => { + expect(cell.find('button[class*="resizer"]')).not.toBeNull(); + }); + }); + + test('sticky header with sticky columns and groups', () => { + const wrapper = renderTable({ stickyHeader: true, stickyColumns: { first: 2 } }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); + +describe('Column grouping with selection', () => { + test('multi selection with groups renders correctly', () => { + const wrapper = renderTable({ selectionType: 'multi' }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + + // Selection cell in first row spans all header rows + const firstRowCells = rows[0].findAll('th'); + const selectionCell = firstRowCells[0]; + expect(selectionCell.getElement().getAttribute('rowspan')).toBe('2'); + }); + + test('single selection with groups renders correctly', () => { + const wrapper = renderTable({ selectionType: 'single' }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows).toHaveLength(2); + }); +}); + +describe('Column grouping with other features', () => { + test('renders with wrapLines enabled', () => { + const wrapper = renderTable({ wrapLines: true }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with stripedRows enabled', () => { + const wrapper = renderTable({ stripedRows: true }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with contentDensity compact', () => { + const wrapper = renderTable({ contentDensity: 'compact' }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with sortingDisabled', () => { + const wrapper = renderTable({ sortingDisabled: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('renders in loading state', () => { + const wrapper = renderTable({ loading: true, loadingText: 'Loading...' }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + }); + + test('renders with empty items', () => { + const wrapper = renderTable({ items: [] }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr')).toHaveLength(2); + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('renders with variant full-page', () => { + const wrapper = renderTable({ variant: 'full-page' }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); + + test('renders with variant borderless', () => { + const wrapper = renderTable({ variant: 'borderless' }); + const headers = wrapper.findColumnHeaders(); + expect(headers.length).toBeGreaterThanOrEqual(6); + }); + + test('renders with enableKeyboardNavigation', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); + +describe('Column grouping sorting', () => { + test('findColumnSortingArea works with grouped columns', () => { + const sortableColumns: TableProps.ColumnDefinition[] = columnDefinitions.map(col => ({ + ...col, + sortingField: col.id, + })); + const { container } = render( +
+ ); + const tableWrapper = createWrapper(container).findTable()!; + const sortArea = tableWrapper.findColumnSortingArea(3, { grouped: true }); + expect(sortArea).not.toBeNull(); + }); + + test('sorting area is not present on group header cells', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + groupCells.forEach(cell => { + expect(cell.find('[role="button"]')).toBeNull(); + }); + }); +}); + +describe('Column grouping divider positioning', () => { + test('group header cells render dividers', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Non-rightmost groups should have dividers + const nonRightmostGroups = groupCells.filter(c => !c.getElement().hasAttribute('data-rightmost')); + nonRightmostGroups.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + }); + + test('leaf cells under groups render dividers', () => { + const wrapper = renderTable({ resizableColumns: false }); + const thead = wrapper.find('thead')!; + const groupedLeaves = thead.findAll('th[data-column-group-id]'); + + groupedLeaves.forEach(cell => { + expect(cell.find('[class*="divider"]')).not.toBeNull(); + }); + }); + + test('rightmost cell has data-rightmost attribute', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rightmostCells = thead.findAll('th[data-rightmost]'); + expect(rightmostCells.length).toBeGreaterThanOrEqual(1); + }); + + test('non-rightmost cells do not have data-rightmost', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const typeCell = thead.find('th[data-column-index="3"]')!; + expect(typeCell.getElement().hasAttribute('data-rightmost')).toBe(false); + }); +}); + +describe('Column grouping with keyboard navigation', () => { + test('renders with enableKeyboardNavigation', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('group cells have correct aria-colindex', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // config group starts at colIndex 2 (0-based), rendered as aria-colindex 3 (1-based) + const configGroup = groupCells.find(c => c.getElement().textContent?.includes('Configuration')); + const perfGroup = groupCells.find(c => c.getElement().textContent?.includes('Performance')); + + expect(configGroup!.getElement().getAttribute('aria-colindex')).toBeDefined(); + expect(perfGroup!.getElement().getAttribute('aria-colindex')).toBeDefined(); + }); + + test('leaf cells have correct aria-colindex', () => { + const wrapper = renderTable({ enableKeyboardNavigation: true }); + const thead = wrapper.find('thead')!; + + // type is at colIndex 2 (0-based), aria-colindex should be 3 (1-based) + const typeCell = thead.find('th[data-column-index="3"]')!; + expect(typeCell.getElement().getAttribute('aria-colindex')).toBe('3'); + }); +}); + +describe('Column grouping aria attributes', () => { + test('group cells have scope=colgroup', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells).toHaveLength(2); + }); + + test('leaf cells have scope=col', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const leafCells = thead.findAll('th[scope="col"]'); + expect(leafCells).toHaveLength(6); + }); + + test('header rows have aria-rowindex', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + + expect(rows[0].getElement().getAttribute('aria-rowindex')).toBe('1'); + expect(rows[1].getElement().getAttribute('aria-rowindex')).toBe('2'); + }); +}); + +describe('Column grouping sticky split rendering', () => { + test('split group renders two group cells with updateGroupWidth callbacks', () => { + // stickyColumns.first = 3: id(0), name(1), type(2) are sticky + // config group has type(2), az(3) — straddles boundary + const wrapper = renderTable({ stickyColumns: { first: 3 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + // config split into 2 + perf = 3 + expect(groupCells.length).toBe(3); + }); + + test('split group with stickyColumns.last renders correctly', () => { + // stickyColumns.last = 1: memory(5) is sticky + // perf group has cpu(4), memory(5) — straddles boundary + const wrapper = renderTable({ stickyColumns: { last: 1 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + expect(groupCells.length).toBe(3); + }); + + test('fully sticky group gets stickyColumnId from first child', () => { + // stickyColumns.first = 4: id, name, type, az are sticky + // config group (type, az) is fully within sticky boundary + const wrapper = renderTable({ stickyColumns: { first: 4 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + const configGroup = groupCells.find(c => c.getElement().textContent?.includes('Configuration')); + expect(configGroup).toBeDefined(); + }); + + test('fully sticky last group gets stickyColumnId from last child', () => { + // stickyColumns.last = 2: cpu(4), memory(5) are sticky + // perf group (cpu, memory) is fully within sticky-last boundary + const wrapper = renderTable({ stickyColumns: { last: 2 }, resizableColumns: true }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + const perfGroup = groupCells.find(c => c.getElement().textContent?.includes('Performance')); + expect(perfGroup).toBeDefined(); + }); +}); + +describe('Column grouping focus handling', () => { + test('onFocusedComponentChange is called on header focus', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + + // Focus a header cell — verify it has focus tracking wired up + const th = firstRow.findAll('th')[0]; + fireEvent.focus(th.getElement()); + expect(th.getElement().getAttribute('data-focus-id')).toBeTruthy(); + }); + + test('onBlur resets focused component', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + const thead = wrapper.find('thead')!; + const firstRow = thead.findAll('tr')[0]; + + const th = firstRow.findAll('th')[0]; + fireEvent.focus(th.getElement()); + fireEvent.blur(th.getElement()); + // After blur, the focus indicator should be removed + expect(th.getElement().classList.toString()).not.toContain('fake-focus'); + }); +}); + +describe('Column grouping with non-resizable columns', () => { + test('grouped leaf cells get inline styles when not resizable', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150, minWidth: 100 })); + const wrapper = renderTable({ resizableColumns: false, columnDefinitions: colDefs }); + const thead = wrapper.find('thead')!; + const leafCells = thead.findAll('th[scope="col"]'); + // Cells should have width styles applied directly + expect(leafCells.length).toBe(6); + }); + + test('sorting fires onSortingChange for grouped leaf columns', () => { + const onSortingChange = jest.fn(); + const sortableColumns = columnDefinitions.map(col => ({ ...col, sortingField: col.id })); + const { container } = render( +
onSortingChange(event.detail)} + /> + ); + const wrapper = createWrapper(container).findTable()!; + const sortArea = wrapper.findColumnSortingArea(3, { grouped: true }); + sortArea!.click(); + expect(onSortingChange).toHaveBeenCalledWith( + expect.objectContaining({ sortingColumn: expect.objectContaining({ id: 'type' }) }) + ); + }); +}); + +describe('Column grouping resize interactions', () => { + test('grouped resizable table renders with colgroup and col elements', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render( +
+ ); + const colgroup = container.querySelector('colgroup'); + expect(colgroup).not.toBeNull(); + const cols = colgroup!.querySelectorAll('col'); + // 6 leaf columns + expect(cols.length).toBe(6); + }); + + test('col elements have data-column-id attributes', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render( +
+ ); + const cols = container.querySelectorAll('col[data-column-id]'); + expect(cols.length).toBe(6); + expect(cols[0].getAttribute('data-column-id')).toBe('id'); + expect(cols[5].getAttribute('data-column-id')).toBe('memory'); + }); + + test('non-grouped resizable table does not render colgroup', () => { + const colDefs = columnDefinitions.map(col => ({ ...col, width: 150 })); + const { container } = render(
); + const colgroup = container.querySelector('colgroup'); + expect(colgroup).toBeNull(); + }); +}); + +describe('Column grouping keyboard navigation', () => { + test('handles arrow key events across grouped header rows', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + const firstTh = thead.querySelector('th')!; + + // Focus the first header cell + firstTh.focus(); + + // Press arrow down — should move to first body cell + fireEvent.keyDown(table, { key: 'ArrowDown', keyCode: 40 }); + expect(container.querySelector('tbody td')).toBeTruthy(); + }); + + test('handles keyboard events on cells with colspan', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + + // Focus a group header cell (has colspan) + const groupTh = thead.querySelector('th[scope="colgroup"]') as HTMLElement; + groupTh.focus(); + + // Navigate down from group header to leaf row + fireEvent.keyDown(table, { key: 'ArrowDown', keyCode: 40 }); + + // Leaf cells exist in the second header row for navigation targets + const secondRow = thead.querySelectorAll('tr')[1]; + expect(secondRow.querySelector('th')).toBeTruthy(); + }); +}); + +describe('Column grouping vertical navigation with rowspan', () => { + test('handles arrow up from body with rowspan header cells', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + const tbody = container.querySelector('tbody')!; + const firstBodyCell = tbody.querySelector('td') as HTMLElement; + + // Focus a body cell + firstBodyCell.focus(); + + // Navigate up — should go to the header cell in the same column + fireEvent.keyDown(table, { key: 'ArrowUp', keyCode: 38 }); + expect(thead.querySelector('th')).toBeTruthy(); + }); + + test('handles arrow up from leaf header row', () => { + const { container } = render( +
({ ...col, sortingField: col.id }))} + items={items} + groupDefinitions={groupDefinitions} + columnDisplay={singleLevelDisplay} + enableKeyboardNavigation={true} + /> + ); + const table = container.querySelector('table')!; + const thead = container.querySelector('thead')!; + + // Focus a leaf cell in the second header row + const secondRow = thead.querySelectorAll('tr')[1]; + const leafTh = secondRow?.querySelector('th') as HTMLElement; + if (leafTh) { + leafTh.focus(); + // Navigate up — should go to the group header in the first row + fireEvent.keyDown(table, { key: 'ArrowUp', keyCode: 38 }); + expect(thead.querySelector('th[scope="colgroup"]')).toBeTruthy(); + } + }); +}); + +describe('Column grouping with sticky header scrolling', () => { + test('renders with stickyHeader and grouped columns without error', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + expect(wrapper.find('thead')).not.toBeNull(); + // Sticky header with grouped columns renders both header rows + const thead = wrapper.find('thead')!; + expect(thead.findAll('tr').length).toBe(2); + }); +}); +describe('Column grouping group resize callbacks', () => { + const resizableColDefs = columnDefinitions.map(col => ({ ...col, width: 200, minWidth: 100 })); + + function renderResizableGroupedTable(props: Partial> = {}) { + const { container } = render( +
+ ); + return createWrapper(container).findTable()!; + } + + test('group header can be resized with pointer drag', () => { + const wrapper = renderResizableGroupedTable(); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const resizerBtn = groupCell.find('button')!; + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // No error — updateGroup was called + expect(thead.findAll('th[scope="colgroup"]').length).toBe(2); + }); + + test('group resize completes full pointer lifecycle without errors', () => { + const onColumnWidthsChange = jest.fn(); + const wrapper = renderResizableGroupedTable({ onColumnWidthsChange }); + const thead = wrapper.find('thead')!; + const groupCell = thead.findAll('th[scope="colgroup"]')[0]; + const resizerBtn = groupCell.find('button')!; + + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', clientX: 100, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Table structure remains intact after resize lifecycle + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + }); + + test('split group resize works with stickyColumns.first', () => { + const wrapper = renderResizableGroupedTable({ stickyColumns: { first: 3 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + // Split group should have resizers + expect(groupCells.length).toBe(3); + const splitGroupCell = groupCells[0]; + const resizerBtn = splitGroupCell.find('button'); + if (resizerBtn) { + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + } + }); + + test('split group resize works with stickyColumns.last', () => { + const wrapper = renderResizableGroupedTable({ stickyColumns: { last: 1 } }); + const thead = wrapper.find('thead')!; + const groupCells = thead.findAll('th[scope="colgroup"]'); + + expect(groupCells.length).toBe(3); + const lastSplitCell = groupCells[groupCells.length - 1]; + const resizerBtn = lastSplitCell.find('button'); + if (resizerBtn) { + resizerBtn.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + } + }); + + test('leaf column resize completes pointer lifecycle in grouped table', () => { + const wrapper = renderResizableGroupedTable(); + const resizer = wrapper.findColumnResizer(3, { grouped: true }); + expect(resizer).not.toBeNull(); + + resizer!.fireEvent(new PointerEvent('pointerdown', { pointerType: 'mouse', button: 0, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointermove', { pointerType: 'mouse', clientX: 100, bubbles: true })); + document.body.dispatchEvent(new PointerEvent('pointerup', { pointerType: 'mouse', bubbles: true })); + + // Leaf columns and group structure remain intact after resize + const thead = wrapper.find('thead')!; + expect(thead.findAll('th[scope="col"]')).toHaveLength(6); + expect(thead.findAll('th[scope="colgroup"]')).toHaveLength(2); + }); +}); diff --git a/src/table/column-groups/__tests__/fixtures.ts b/src/table/column-groups/__tests__/fixtures.ts new file mode 100644 index 0000000000..ef69a9d552 --- /dev/null +++ b/src/table/column-groups/__tests__/fixtures.ts @@ -0,0 +1,71 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; + +export const COLUMN_DEFS: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + { id: 'networkIn', header: 'Network In', cell: () => 'networkIn' }, + { id: 'type', header: 'Type', cell: () => 'type' }, + { id: 'az', header: 'AZ', cell: () => 'az' }, + { id: 'cost', header: 'Cost', cell: () => 'cost' }, +]; + +export const ALL_IDS = COLUMN_DEFS.map(c => c.id!); + +export const GROUP_DEFS: TableProps.GroupDefinition[] = [ + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Config' }, + { id: 'pricing', header: 'Pricing' }, +]; + +export const FLAT_DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + { id: 'networkIn', visible: true }, + ], + }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { type: 'group', id: 'pricing', visible: true, children: [{ id: 'cost', visible: true }] }, +]; + +export const NESTED_GROUPS: TableProps.GroupDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance' }, +]; + +export const NESTED_DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + ], + }, +]; diff --git a/src/table/column-groups/__tests__/split-utils.test.ts b/src/table/column-groups/__tests__/split-utils.test.ts new file mode 100644 index 0000000000..b04c29b26d --- /dev/null +++ b/src/table/column-groups/__tests__/split-utils.test.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; +import { getGroupColumnIds, getGroupSplit } from '../split-utils'; +import { calculateHierarchyTree } from '../utils'; + +const COLUMN_DEFS: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'type', header: 'Type', cell: () => 'type' }, + { id: 'az', header: 'AZ', cell: () => 'az' }, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, +]; + +const GROUP_DEFS: TableProps.GroupDefinition[] = [ + { id: 'config', header: 'Configuration' }, + { id: 'perf', header: 'Performance' }, +]; + +const DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { + type: 'group', + id: 'perf', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, +]; + +const ALL_IDS = COLUMN_DEFS.map(c => c.id!); + +function buildStructure() { + return calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, DISPLAY); +} + +describe('getGroupColumnIds', () => { + test('returns leaf column IDs for a group', () => { + const structure = buildStructure(); + expect(getGroupColumnIds(structure, 'config')).toEqual(['type', 'az']); + expect(getGroupColumnIds(structure, 'perf')).toEqual(['cpu', 'memory']); + }); + + test('returns empty array for unknown group', () => { + const structure = buildStructure(); + expect(getGroupColumnIds(structure, 'nonexistent')).toEqual([]); + }); +}); + +describe('getGroupSplit', () => { + test('no split when group is fully within sticky-first boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 4, side: 'first', totalLeafColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); + + test('no split when group is fully outside sticky boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 1, side: 'first', totalLeafColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); + + test('detects split by sticky-first boundary', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 3, side: 'first', totalLeafColumns: 6 }); + expect(split).toEqual({ stickyColspan: 1, staticColspan: 1 }); + }); + + test('detects split by sticky-last boundary', () => { + const structure = buildStructure(); + const perfGroup = structure.rows[0].columns.find(c => c.id === 'perf')!; + const split = getGroupSplit({ col: perfGroup, stickyCount: 1, side: 'last', totalLeafColumns: 6 }); + expect(split).toEqual({ stickyColspan: 1, staticColspan: 1 }); + }); + + test('non-group cells return no split', () => { + const structure = buildStructure(); + const leafCol = structure.rows[1].columns[0]; + const split = getGroupSplit({ col: leafCol, stickyCount: 3, side: 'first', totalLeafColumns: 6 }); + expect(split.stickyColspan).toBe(0); + }); + + test('no split when stickyCount is 0', () => { + const structure = buildStructure(); + const configGroup = structure.rows[0].columns.find(c => c.id === 'config')!; + const split = getGroupSplit({ col: configGroup, stickyCount: 0, side: 'first', totalLeafColumns: 6 }); + expect(split.stickyColspan).toBe(0); + expect(split.staticColspan).toBe(0); + }); +}); diff --git a/src/table/column-groups/__tests__/use-column-groups.test.tsx b/src/table/column-groups/__tests__/use-column-groups.test.tsx new file mode 100644 index 0000000000..a54d2a6865 --- /dev/null +++ b/src/table/column-groups/__tests__/use-column-groups.test.tsx @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../../__tests__/render-hook'; +import { TableProps } from '../../interfaces'; +import { useColumnGroups } from '../use-column-groups'; +import { COLUMN_DEFS, FLAT_DISPLAY, GROUP_DEFS, NESTED_DISPLAY, NESTED_GROUPS } from './fixtures'; + +describe('useColumnGroups', () => { + describe('no grouping', () => { + test('returns a single flat row when no groups are defined', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, undefined)); + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + expect(result.current.rows[0].columns).toHaveLength(COLUMN_DEFS.length); + }); + + test('treats empty groups array the same as no groups', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, [])); + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + }); + }); + + describe('grouped columns', () => { + test('creates two rows for flat grouping', () => { + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, GROUP_DEFS, undefined, FLAT_DISPLAY)); + expect(result.current.maxDepth).toBe(2); + expect(result.current.rows).toHaveLength(2); + }); + + test('creates three rows for nested grouping', () => { + const cols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + ]; + const { result } = renderHook(() => useColumnGroups(cols, NESTED_GROUPS, undefined, NESTED_DISPLAY)); + expect(result.current.maxDepth).toBe(3); + expect(result.current.rows).toHaveLength(3); + expect(result.current.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + }); + + describe('visibleColumnIds filtering', () => { + test('excludes hidden columns via visibleColumnIds', () => { + const visibleIds = new Set(['id', 'cpu']); + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, GROUP_DEFS, visibleIds, display)); + const allIds = result.current.rows.flatMap(r => r.columns.map(c => c.id)); + expect(allIds).toContain('cpu'); + expect(allIds).not.toContain('memory'); + expect(allIds).not.toContain('type'); + }); + + test('hides a group entirely when all its children are outside visibleColumnIds', () => { + const visibleIds = new Set(['id', 'name']); + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const { result } = renderHook(() => useColumnGroups(COLUMN_DEFS, GROUP_DEFS, visibleIds, display)); + const groupIds = result.current.rows.flatMap(r => r.columns.filter(c => c.isGroup).map(c => c.id)); + expect(groupIds).not.toContain('performance'); + }); + }); + + describe('edge cases', () => { + test('handles columns without IDs gracefully', () => { + const cols: TableProps.ColumnDefinition[] = [{ header: 'No ID', cell: () => 'x' } as any]; + const { result } = renderHook(() => useColumnGroups(cols, [])); + expect(result.current.rows).toBeDefined(); + }); + + test('warns in dev when a group referenced in columnDisplay is not in groupDefinitions', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'ghost-group', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + renderHook(() => useColumnGroups(COLUMN_DEFS, [], undefined, display)); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ghost-group')); + warnSpy.mockRestore(); + process.env.NODE_ENV = originalEnv; + }); + }); +}); diff --git a/src/table/column-groups/__tests__/utils.test.ts b/src/table/column-groups/__tests__/utils.test.ts new file mode 100644 index 0000000000..007d67837a --- /dev/null +++ b/src/table/column-groups/__tests__/utils.test.ts @@ -0,0 +1,252 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../interfaces'; +import { calculateHierarchyTree, TableHeaderNode } from '../utils'; +import { ALL_IDS, COLUMN_DEFS, FLAT_DISPLAY, GROUP_DEFS, NESTED_DISPLAY, NESTED_GROUPS } from './fixtures'; + +describe('TableHeaderNode', () => { + test('creates node with default properties', () => { + const node = new TableHeaderNode('test-id'); + expect(node.id).toBe('test-id'); + expect(node.colSpan).toBe(1); + expect(node.rowSpan).toBe(1); + expect(node.children).toEqual([]); + expect(node.rowIndex).toBe(-1); + expect(node.colIndex).toBe(-1); + expect(node.isRoot).toBe(false); + expect(node.isLeaf).toBe(true); + expect(node.isGroup).toBe(false); + }); + + test('accepts constructor props and identifies node types', () => { + const colDef: TableProps.ColumnDefinition = { id: 'col', header: 'Col', cell: () => 'col' }; + const groupDef: TableProps.GroupDefinition = { id: 'grp', header: 'Grp' }; + + const colNode = new TableHeaderNode('col', { + columnDefinition: colDef, + colSpan: 2, + rowSpan: 3, + rowIndex: 1, + colIndex: 2, + }); + const groupNode = new TableHeaderNode('grp', { groupDefinition: groupDef }); + const rootNode = new TableHeaderNode('root', { isRoot: true }); + + expect(colNode.colSpan).toBe(2); + expect(colNode.rowSpan).toBe(3); + expect(colNode.columnDefinition).toBe(colDef); + expect(colNode.isGroup).toBe(false); + expect(groupNode.isGroup).toBe(true); + expect(rootNode.isRoot).toBe(true); + expect(rootNode.isLeaf).toBe(false); + }); + + test('manages parent/child relationships', () => { + const parent = new TableHeaderNode('parent'); + const child1 = new TableHeaderNode('child1'); + const child2 = new TableHeaderNode('child2'); + + parent.addChild(child1); + parent.addChild(child2); + + expect(parent.children).toHaveLength(2); + expect(child1.parent).toBe(parent); + expect(child2.parent).toBe(parent); + expect(parent.isLeaf).toBe(false); + expect(child1.isLeaf).toBe(true); + }); +}); + +describe('calculateHierarchyTree', () => { + describe('no grouping', () => { + test('returns a single row with all visible columns', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, []); + + expect(result.maxDepth).toBe(1); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].columns).toHaveLength(COLUMN_DEFS.length); + result.rows[0].columns.forEach((col, i) => { + expect(col.rowSpan).toBe(1); + expect(col.colSpan).toBe(1); + expect(col.isGroup).toBe(false); + expect(col.colIndex).toBe(i); + }); + expect(result.columnToParentIds.size).toBe(0); + }); + }); + + describe('flat grouping', () => { + test('creates two rows with correct structure', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY); + + expect(result.maxDepth).toBe(2); + expect(result.rows).toHaveLength(2); + + // Row 0: ungrouped columns (rowSpan=2) + group headers + const row0 = result.rows[0].columns; + expect(row0.map(c => c.id)).toEqual(['id', 'name', 'performance', 'config', 'pricing']); + expect(row0.find(c => c.id === 'id')).toMatchObject({ rowSpan: 2 }); + expect(row0.find(c => c.id === 'name')).toMatchObject({ rowSpan: 2 }); + expect(row0.find(c => c.id === 'performance')).toMatchObject({ isGroup: true, colSpan: 3, rowSpan: 1 }); + expect(row0.find(c => c.id === 'config')).toMatchObject({ isGroup: true, colSpan: 2 }); + expect(row0.find(c => c.id === 'pricing')).toMatchObject({ isGroup: true, colSpan: 1 }); + + // Row 1: leaf columns under groups + const row1 = result.rows[1].columns; + expect(row1.map(c => c.id)).toEqual(['cpu', 'memory', 'networkIn', 'type', 'az', 'cost']); + expect(row1.every(c => !c.isGroup && c.rowSpan === 1 && c.colSpan === 1)).toBe(true); + }); + + test('tracks parent IDs and colIndex correctly', () => { + const result = calculateHierarchyTree(COLUMN_DEFS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY); + + expect(result.columnToParentIds.get('cpu')).toEqual(['performance']); + expect(result.columnToParentIds.get('type')).toEqual(['config']); + expect(result.columnToParentIds.has('id')).toBe(false); + + const row0 = result.rows[0].columns; + expect(row0.find(c => c.id === 'performance')?.colIndex).toBe(2); + expect(row0.find(c => c.id === 'config')?.colIndex).toBe(5); + }); + }); + + describe('nested grouping', () => { + const nestedCols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + ]; + + test('creates three rows for nested groups', () => { + const result = calculateHierarchyTree(nestedCols, ['cpu', 'memory'], NESTED_GROUPS, NESTED_DISPLAY); + + expect(result.maxDepth).toBe(3); + expect(result.rows).toHaveLength(3); + expect(result.rows[0].columns[0]).toMatchObject({ id: 'metrics', colSpan: 2 }); + expect(result.rows[1].columns[0]).toMatchObject({ id: 'performance', colSpan: 2 }); + expect(result.rows[2].columns.map(c => c.id)).toEqual(['cpu', 'memory']); + expect(result.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + + test('handles 3-level nesting', () => { + const groups: TableProps.GroupDefinition[] = [ + { id: 'l1', header: 'L1' }, + { id: 'l2', header: 'L2' }, + { id: 'l3', header: 'L3' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'l1', + visible: true, + children: [ + { + type: 'group', + id: 'l2', + visible: true, + children: [{ type: 'group', id: 'l3', visible: true, children: [{ id: 'cpu', visible: true }] }], + }, + ], + }, + ]; + const result = calculateHierarchyTree(nestedCols, ['cpu'], groups, display); + expect(result.maxDepth).toBe(4); + expect(result.columnToParentIds.get('cpu')).toEqual(['l1', 'l2', 'l3']); + }); + + test('handles mixed nested and flat groups', () => { + const groups: TableProps.GroupDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Config' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'metrics', + visible: true, + children: [{ type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }], + }, + { type: 'group', id: 'config', visible: true, children: [{ id: 'memory', visible: true }] }, + ]; + const result = calculateHierarchyTree(nestedCols, ['cpu', 'memory'], groups, display); + + expect(result.maxDepth).toBe(3); + const row0 = result.rows[0].columns; + expect(row0.map(c => c.id)).toEqual(['metrics', 'config']); + expect(row0.find(c => c.id === 'config')).toMatchObject({ rowSpan: 2 }); + expect(result.rows[1].columns.map(c => c.id)).toEqual(['performance']); + }); + }); + + describe('visibility filtering', () => { + test('includes only visible columns and adjusts group colSpan', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'g', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: false }, + ], + }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['id', 'cpu'], groups, display); + + const allIds = result.rows.flatMap(r => r.columns.map(c => c.id)); + expect(allIds).toContain('cpu'); + expect(allIds).not.toContain('memory'); + expect(result.rows[0].columns.find(c => c.id === 'g')?.colSpan).toBe(1); + }); + + test('omits a group entirely when all its children are hidden', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['id'], groups, display); + expect(result.rows[0].columns.map(c => c.id)).not.toContain('g'); + }); + }); + + describe('edge cases', () => { + test('returns empty structure for empty column list', () => { + const result = calculateHierarchyTree([], [], []); + expect(result.rows).toHaveLength(0); + expect(result.maxDepth).toBe(0); + }); + + test('skips columns without id', () => { + const cols: TableProps.ColumnDefinition[] = [ + { header: 'No ID', cell: () => 'x' } as any, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + ]; + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const result = calculateHierarchyTree(cols, ['cpu'], groups, display); + expect(result.rows[1].columns[0].id).toBe('cpu'); + }); + + test('skips subtree when group id is not in groupDefinitions', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'nonexistent', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, ['cpu'], [], display); + expect(result.rows).toHaveLength(0); + }); + + test('treats a group with no visible children as absent', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', id: 'g', visible: true, children: [{ id: 'cpu', visible: false }] }, + ]; + const result = calculateHierarchyTree(COLUMN_DEFS, [], groups, display); + expect(result.rows).toHaveLength(0); + }); + }); +}); diff --git a/src/table/column-groups/split-utils.ts b/src/table/column-groups/split-utils.ts new file mode 100644 index 0000000000..c74e5518a5 --- /dev/null +++ b/src/table/column-groups/split-utils.ts @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ColumnGroupsLayout, HeaderRowColumn } from './utils'; + +/** + * Describes how a group header is split by a single sticky column boundary. + * `stickyColspan` is the number of columns on the sticky side. + * `staticColspan` is the number of columns on the scrollable side. + * When both are 0, the group is not affected by this boundary. + */ +export interface StickyGroupSplit { + stickyColspan: number; + staticColspan: number; +} + +/** Returns all leaf column IDs that are descendants of the given group (including nested subgroups). */ +export function getGroupColumnIds(columnGroupsLayout: ColumnGroupsLayout, groupId: string): string[] { + const columnsRow = columnGroupsLayout.rows[columnGroupsLayout.rows.length - 1]; + const childIds: string[] = []; + for (const col of columnsRow.columns) { + if (!col.isGroup && col.parentGroupIds.includes(groupId)) { + childIds.push(col.id); + } + } + return childIds; +} + +/** + * Computes how a group header cell is split by a sticky boundary. + * Call once for sticky-first and once for sticky-last. + * + * @param stickyCount - number of sticky columns from that side (first or last) + * @param side - which boundary to check + */ +export function getGroupSplit({ + col, + stickyCount, + side, + totalLeafColumns, +}: { + col: HeaderRowColumn; + stickyCount: number; + side: 'first' | 'last'; + totalLeafColumns: number; +}): StickyGroupSplit { + if (!col.isGroup || stickyCount === 0) { + return { stickyColspan: 0, staticColspan: 0 }; + } + + const groupStart = col.colIndex; + const groupEnd = col.colIndex + col.colSpan - 1; + + if (side === 'first') { + const lastStickyFirst = stickyCount - 1; + if (groupStart <= lastStickyFirst && groupEnd > lastStickyFirst) { + const stickyColspan = lastStickyFirst - groupStart + 1; + return { stickyColspan, staticColspan: col.colSpan - stickyColspan }; + } + } else { + const firstStickyLast = totalLeafColumns - stickyCount; + if (groupStart < firstStickyLast && groupEnd >= firstStickyLast) { + const staticColspan = firstStickyLast - groupStart; + return { stickyColspan: col.colSpan - staticColspan, staticColspan }; + } + } + + return { stickyColspan: 0, staticColspan: 0 }; +} diff --git a/src/table/column-groups/use-column-groups.tsx b/src/table/column-groups/use-column-groups.tsx new file mode 100644 index 0000000000..ba4e2b2d45 --- /dev/null +++ b/src/table/column-groups/use-column-groups.tsx @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo } from 'react'; + +import { TableProps } from '../interfaces'; +import { getColumnKey } from '../utils'; +import { calculateHierarchyTree } from './utils'; + +export function useColumnGroups( + columnDefinitions: ReadonlyArray>, + groupDefinitions?: ReadonlyArray, + visibleColumns?: Set, + columnDisplay?: ReadonlyArray +) { + return useMemo(() => { + const visibleIds = visibleColumns + ? Array.from(visibleColumns) + : columnDefinitions.map((col, idx) => getColumnKey(col, idx)); + + const layout = calculateHierarchyTree(columnDefinitions, visibleIds, groupDefinitions ?? [], columnDisplay); + + let groupLeafMap: Map | undefined; + if (layout.rows.length > 1) { + groupLeafMap = new Map(); + const columnsRow = layout.rows[layout.rows.length - 1]; + for (const row of layout.rows) { + for (const col of row.columns) { + if (col.isGroup) { + const leafIds = columnsRow.columns + .filter(l => !l.isGroup && l.parentGroupIds.includes(col.id)) + .map(l => l.id); + groupLeafMap.set(col.id, leafIds); + } + } + } + } + + return { ...layout, groupLeafMap }; + }, [columnDefinitions, groupDefinitions, visibleColumns, columnDisplay]); +} diff --git a/src/table/column-groups/utils.ts b/src/table/column-groups/utils.ts new file mode 100644 index 0000000000..7c2c5950a1 --- /dev/null +++ b/src/table/column-groups/utils.ts @@ -0,0 +1,294 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { TableProps } from '../interfaces'; +import { getVisibleColumnDefinitions } from '../utils'; + +export interface HeaderRowColumn { + id: string; + header?: React.ReactNode; + colSpan: number; + rowSpan: number; + isGroup: boolean; + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.GroupDefinition; + parentGroupIds: string[]; + colIndex: number; +} + +export interface HeaderRow { + columns: HeaderRowColumn[]; +} + +export interface ColumnGroupsLayout { + rows: HeaderRow[]; + maxDepth: number; + columnToParentIds: Map; +} + +export interface TableHeaderNodeProps { + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.GroupDefinition; + isRoot?: boolean; + colSpan?: number; + rowSpan?: number; + rowIndex?: number; + colIndex?: number; +} + +/** + * A node in the table header tree. + * - Leaf nodes map to column definitions. + * - Internal nodes map to group definitions. + * - The root is a virtual container (never rendered). + */ +export class TableHeaderNode { + public readonly id: string; + public readonly isRoot: boolean; + public readonly columnDefinition?: TableProps.ColumnDefinition; + public readonly groupDefinition?: TableProps.GroupDefinition; + + public colSpan: number; + public rowSpan: number; + public rowIndex: number; + public colIndex: number; + public subTreeHeight: number = 1; + + public children: TableHeaderNode[] = []; + public parent?: TableHeaderNode; + + constructor(id: string, props: TableHeaderNodeProps = {}) { + this.id = id; + this.isRoot = props.isRoot ?? false; + this.columnDefinition = props.columnDefinition; + this.groupDefinition = props.groupDefinition; + this.colSpan = props.colSpan ?? 1; + this.rowSpan = props.rowSpan ?? 1; + this.rowIndex = props.rowIndex ?? -1; + this.colIndex = props.colIndex ?? -1; + } + + get isGroup(): boolean { + return !!this.groupDefinition; + } + + get isLeaf(): boolean { + return !this.isRoot && this.children.length === 0; + } + + addChild(child: TableHeaderNode): void { + this.children.push(child); + child.parent = this; + } +} + +/** + * Builds the tree from the nested columnDisplay structure. + * Groups are only attached if they contain at least one visible descendant. + */ +function buildTreeFromColumnDisplay( + displayItems: ReadonlyArray, + nodeMap: Map>, + parent: TableHeaderNode +): void { + for (const item of displayItems) { + if (item.type === 'group') { + const groupNode = nodeMap.get(item.id); + if (!groupNode) { + warnOnce('[Table]', `Group "${item.id}" referenced in columnDisplay not found in groupDefinitions. Skipping.`); + continue; + } + buildTreeFromColumnDisplay(item.children, nodeMap, groupNode); + // Only attach group if it has visible descendants. The recursive call above + // only adds children that are either visible columns or nested groups with + // their own visible descendants, so this check handles all nesting levels. + if (groupNode.children.length > 0) { + parent.addChild(groupNode); + } + } else { + if (!item.visible) { + continue; + } + const colNode = nodeMap.get(item.id); + if (colNode) { + parent.addChild(colNode); + } + } + } +} + +/** + * Fallback when no columnDisplay is provided: all visible columns attach directly to root. + */ +function buildTreeFromVisibleColumns( + visibleColumns: Readonly[]>, + nodeMap: Map>, + root: TableHeaderNode +): void { + for (const col of visibleColumns) { + // Columns without IDs cannot participate in grouping, they have no key + // to match against columnDisplay entries or groupDefinitions. + if (!col.id) { + continue; + } + const node = nodeMap.get(col.id); + if (node) { + root.addChild(node); + } + } +} + +function computeSubTreeHeights(node: TableHeaderNode): number { + if (node.isLeaf || node.children.length === 0) { + node.subTreeHeight = 1; + return 1; + } + const maxChildHeight = Math.max(...node.children.map(child => computeSubTreeHeights(child))); + node.subTreeHeight = maxChildHeight + 1; + return node.subTreeHeight; +} + +function computeRowSpansAndIndices(node: TableHeaderNode, treeHeight: number, ancestorRows: number = 0): void { + const maxChildHeight = Math.max(...node.children.map(c => c.subTreeHeight), 0); + node.rowSpan = treeHeight - ancestorRows - maxChildHeight; + + if (node.parent) { + node.rowIndex = node.parent.rowIndex + node.parent.rowSpan; + } + + for (const child of node.children) { + computeRowSpansAndIndices(child, treeHeight, ancestorRows + node.rowSpan); + } +} + +function computeColSpansAndIndices(node: TableHeaderNode, startCol: number = 0): number { + node.colIndex = startCol; + + if (node.isLeaf) { + node.colSpan = 1; + return startCol + 1; + } + + let nextCol = startCol; + for (const child of node.children) { + nextCol = computeColSpansAndIndices(child, nextCol); + } + + node.colSpan = nextCol - startCol; + return nextCol; +} + +export function calculateHierarchyTree( + columnDefinitions: ReadonlyArray>, + visibleColumnIds: readonly (string | number)[], + groupDefinitions: ReadonlyArray, + columnDisplay?: ReadonlyArray +): ColumnGroupsLayout { + const visibleColumns = getVisibleColumnDefinitions({ + columnDisplay, + visibleColumns: visibleColumnIds, + columnDefinitions, + }); + + const nodeMap = new Map>(); + + for (const col of visibleColumns) { + if (col.id) { + nodeMap.set(col.id, new TableHeaderNode(col.id, { columnDefinition: col })); + } + } + + for (const group of groupDefinitions) { + nodeMap.set(group.id, new TableHeaderNode(group.id, { groupDefinition: group })); + } + + const root = new TableHeaderNode('*', { isRoot: true }); + + if (columnDisplay && columnDisplay.length > 0) { + buildTreeFromColumnDisplay(columnDisplay, nodeMap, root); + } else { + buildTreeFromVisibleColumns(visibleColumns, nodeMap, root); + } + + computeSubTreeHeights(root); + + const treeHeight = root.subTreeHeight - 1; + root.rowIndex = -1; + root.rowSpan = 1; + root.colSpan = visibleColumns.length; + + for (const child of root.children) { + computeRowSpansAndIndices(child, treeHeight); + } + + computeColSpansAndIndices(root); + + return buildOutput(root, treeHeight); +} + +function getParentChain(node: TableHeaderNode): string[] { + const chain: string[] = []; + let current = node.parent; + while (current && !current.isRoot) { + chain.unshift(current.id); + current = current.parent; + } + return chain; +} + +function buildOutput(root: TableHeaderNode, maxDepth: number): ColumnGroupsLayout { + const rowsMap = new Map[]>(); + const columnToParentIds = new Map(); + + const queue: TableHeaderNode[] = [...root.children]; + + while (queue.length > 0) { + const node = queue.shift()!; + const parentChain = getParentChain(node); + + const entry: HeaderRowColumn = { + id: node.id, + header: node.groupDefinition?.header ?? node.columnDefinition?.header, + colSpan: node.colSpan, + rowSpan: node.rowSpan, + isGroup: node.isGroup, + columnDefinition: node.columnDefinition, + groupDefinition: node.groupDefinition, + parentGroupIds: parentChain, + colIndex: node.colIndex, + }; + + if (!rowsMap.has(node.rowIndex)) { + rowsMap.set(node.rowIndex, []); + } + rowsMap.get(node.rowIndex)!.push(entry); + + if (node.isLeaf && node.columnDefinition && parentChain.length > 0) { + columnToParentIds.set(node.id, parentChain); + } + + queue.push(...node.children); + } + + // Sort row indices to ensure rows are ordered top-to-bottom, + // then sort columns within each row by their horizontal position. + const rows: HeaderRow[] = Array.from(rowsMap.keys()) + .sort((a, b) => a - b) + .map(key => ({ columns: rowsMap.get(key)!.sort((a, b) => a.colIndex - b.colIndex) })); + + return { rows, maxDepth, columnToParentIds }; +} + +export function getColumnGroupsDepth(columnDisplay?: ReadonlyArray): number { + if (!columnDisplay) { + return 0; + } + let maxDepth = 0; + for (const item of columnDisplay) { + if (item.type === 'group') { + maxDepth = Math.max(maxDepth, 1 + getColumnGroupsDepth(item.children)); + } + } + return maxDepth; +} diff --git a/src/table/header-cell/common-props.ts b/src/table/header-cell/common-props.ts new file mode 100644 index 0000000000..a5eecee09d --- /dev/null +++ b/src/table/header-cell/common-props.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ColumnWidthStyle } from '../column-widths-utils'; +import { TableProps } from '../interfaces'; +import { StickyColumnsModel } from '../sticky-columns'; +import { TableRole } from '../table-role'; + +export interface BaseHeaderCellProps { + tabIndex: number; + colIndex: number; + focusedComponent?: null | string; + resizableColumns?: boolean; + resizableStyle?: ColumnWidthStyle; + onResizeFinish: () => void; + sticky?: boolean; + hidden?: boolean; + stripedRows?: boolean; + stickyState: StickyColumnsModel; + cellRef: React.RefCallback; + tableRole: TableRole; + resizerRoleDescription?: string; + resizerTooltipText?: string; + variant: TableProps.Variant; + tableVariant?: TableProps.Variant; + wrapLines?: boolean; +} diff --git a/src/table/header-cell/group-header-cell.tsx b/src/table/header-cell/group-header-cell.tsx new file mode 100644 index 0000000000..97f1ccf506 --- /dev/null +++ b/src/table/header-cell/group-header-cell.tsx @@ -0,0 +1,146 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef } from 'react'; +import clsx from 'clsx'; + +import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { TableProps } from '../interfaces'; +import { Divider, Resizer } from '../resizer'; +import { useStickyCellStyles } from '../sticky-columns'; +import { DEFAULT_COLUMN_WIDTH, useColumnWidths } from '../use-column-widths'; +import { getStickyClassNames } from '../utils'; +import { BaseHeaderCellProps } from './common-props'; +import { TableThElement } from './th-element'; + +import styles from './styles.css.js'; + +export interface TableGroupHeaderCellProps extends BaseHeaderCellProps { + group: TableProps.GroupDefinition; + colspan: number; + rowspan: number; + groupId: string; + updateGroupWidth: (groupId: PropertyKey, newWidth: number) => void; + childColumnIds: PropertyKey[]; + firstChildColumnId?: PropertyKey; + lastChildColumnId?: PropertyKey; + columnGroupId?: string; + stickyColumnId?: PropertyKey; + stickyBoundaryColumnId?: PropertyKey; + isLast?: boolean; +} + +export function TableGroupHeaderCell({ + group, + colspan, + rowspan, + colIndex, + groupId, + resizableColumns, + resizableStyle, + onResizeFinish, + updateGroupWidth, + childColumnIds, + focusedComponent, + tabIndex, + sticky, + hidden, + stripedRows, + stickyState, + cellRef, + tableRole, + resizerRoleDescription, + resizerTooltipText, + variant, + tableVariant, + columnGroupId, + stickyColumnId, + stickyBoundaryColumnId, + isLast, + wrapLines, +}: TableGroupHeaderCellProps) { + const headerId = useUniqueId('table-group-header-'); + const { columnWidths } = 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 + (columnWidths.get(id) || DEFAULT_COLUMN_WIDTH); + }, 0); + const clickableHeaderRef = useRef(null); + const { tabIndex: clickableHeaderTabIndex } = useSingleTabStopNavigation(clickableHeaderRef, { tabIndex }); + + // Subscribe to the boundary leaf's sticky state to inherit shadow/clip-path classes. + // The offset/position comes from stickyColumnId (first child); this only adds boundary classes. + const boundaryStyles = useStickyCellStyles({ + stickyColumns: stickyState, + columnId: stickyBoundaryColumnId ?? stickyColumnId ?? groupId, + getClassName: props => getStickyClassNames(styles, props), + }); + + // boundaryStyles.className is populated by scroll/intersection observers in the browser. + // In JSDOM these observers don't fire, so this branch is only exercised in integration tests. + /* istanbul ignore next */ + const boundaryClassName = stickyBoundaryColumnId && boundaryStyles.className ? boundaryStyles.className : undefined; + + return ( + + ); +} diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index ff8e92ed41..eb4f6fd553 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -11,46 +11,32 @@ import { useInternalI18n } from '../../i18n/context'; import InternalIcon from '../../icon/internal'; import { KeyCode } from '../../internal/keycode'; import { GeneratedAnalyticsMetadataTableSort } from '../analytics-metadata/interfaces'; -import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { Divider, Resizer } from '../resizer'; -import { StickyColumnsModel } from '../sticky-columns'; -import { TableRole } from '../table-role'; +import { BaseHeaderCellProps } from './common-props'; import { TableThElement } from './th-element'; import { getSortingIconName, getSortingStatus, isSorted } from './utils'; import analyticsSelectors from '../analytics-metadata/styles.css.js'; import styles from './styles.css.js'; -export interface TableHeaderCellProps { - tabIndex: number; +export interface TableHeaderCellProps extends BaseHeaderCellProps { column: TableProps.ColumnDefinition; activeSortingColumn?: TableProps.SortingColumn; sortingDescending?: boolean; sortingDisabled?: boolean; - wrapLines?: boolean; stuck?: boolean; - sticky?: boolean; - hidden?: boolean; - stripedRows?: boolean; onClick(detail: TableProps.SortingState): void; - onResizeFinish: () => void; - colIndex: number; updateColumn: (columnId: PropertyKey, newWidth: number) => void; - resizableColumns?: boolean; - resizableStyle?: ColumnWidthStyle; isEditable?: boolean; columnId: PropertyKey; - stickyState: StickyColumnsModel; - cellRef: React.RefCallback; - focusedComponent?: null | string; - tableRole: TableRole; - resizerRoleDescription?: string; - resizerTooltipText?: string; isExpandable?: boolean; hasDynamicContent?: boolean; - variant: TableProps.Variant; - tableVariant?: TableProps.Variant; + colSpan?: number; + rowSpan?: number; + columnGroupId?: string; + isLastChildOfGroup?: boolean; + isLast?: boolean; } export function TableHeaderCell({ @@ -81,6 +67,11 @@ export function TableHeaderCell({ isExpandable, hasDynamicContent, variant, + colSpan, + rowSpan, + columnGroupId, + isLastChildOfGroup, + isLast, tableVariant, }: TableHeaderCellProps) { const i18n = useInternalI18n('table'); @@ -139,6 +130,10 @@ export function TableHeaderCell({ tableRole={tableRole} variant={variant} tableVariant={tableVariant} + colSpan={colSpan} + rowSpan={rowSpan} + columnGroupId={columnGroupId} + isLast={isLast} {...(sortingDisabled ? {} : getAnalyticsMetadataAttribute({ @@ -214,9 +209,15 @@ export function TableHeaderCell({ // tooltipText={i18n('ariaLabels.resizerTooltipText', resizerTooltipText)} tooltipText={resizerTooltipText} isBorderless={variant === 'full-page' || variant === 'embedded' || variant === 'borderless'} + isLast={isLast} + dividerPosition={isLastChildOfGroup ? 'top' : undefined} /> ) : ( - + 1) ? 'interactive' : 'default'} + /> )} ); diff --git a/src/table/header-cell/styles.scss b/src/table/header-cell/styles.scss index d004215c5e..efaeb5caba 100644 --- a/src/table/header-cell/styles.scss +++ b/src/table/header-cell/styles.scss @@ -53,7 +53,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; position: relative; text-align: start; box-sizing: border-box; - border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-interactive-default; background: awsui.$color-background-table-header; color: awsui.$color-text-column-header; font-weight: awsui.$font-weight-heading-s; @@ -63,6 +63,13 @@ $cell-horizontal-padding: awsui.$space-scaled-l; @include header-cell-focus-outline(awsui.$space-scaled-xxs); + &.header-cell-group, + &.header-cell-grouped, + &.header-cell-spans-rows { + padding-block: awsui.$space-xxxs; + padding-inline: awsui.$space-scaled-xs; + } + &-sticky { border-block-end: awsui.$border-table-sticky-width solid awsui.$color-border-divider-default; } @@ -80,6 +87,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; background: none; } &:last-child, + &[data-rightmost], &.header-cell-sortable { padding-inline-end: awsui.$space-xs; } @@ -143,6 +151,12 @@ $cell-horizontal-padding: awsui.$space-scaled-l; padding-inline-end: awsui.$space-s; @include cell-offset(awsui.$space-s); + .header-cell-group > &, + .header-cell-grouped > &, + .header-cell-spans-rows > & { + padding-block: awsui.$space-xxxs; + } + .header-cell-sortable > & { padding-inline-end: calc(#{awsui.$space-xl} + #{awsui.$space-xxs}); } @@ -160,6 +174,26 @@ $cell-horizontal-padding: awsui.$space-scaled-l; } } +.header-cell-spans-rows { + block-size: 100%; + vertical-align: bottom; + + > .header-cell-content { + block-size: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-end; + + // stylelint-disable-next-line no-descending-specificity + > .sorting-icon { + inset-block-start: auto; + inset-block-end: awsui.$space-scaled-xxs; + transform: none; + } + } +} + .header-cell-sortable:not(.header-cell-disabled) { & > .header-cell-content { cursor: pointer; @@ -206,12 +240,14 @@ settings icon in the pagination slot. &:first-child { @include header-cell-focus-outline-first(awsui.$space-scaled-xxs); } - &:first-child > .header-cell-content { + &:first-child:not(.header-cell-grouped):not(.header-cell-group) > .header-cell-content { @include cell-offset(0px); @include header-cell-focus-outline-first(awsui.$space-table-header-focus-outline-gutter); } - &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start) { + &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( + .header-cell-grouped + ) { @include cell-offset(awsui.$space-xxxs); } @@ -220,11 +256,12 @@ settings icon in the pagination slot. shaded background makes the child content appear too close to the table edge. */ - &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start) { + &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start):not(.header-cell-group):not(.header-cell-grouped) { @include cell-offset(awsui.$space-xxs); } - &:last-child.header-cell-sortable:not(.header-cell-resizable) { + &:last-child.header-cell-sortable:not(.header-cell-resizable), + &[data-rightmost].header-cell-sortable:not(.header-cell-resizable) { padding-inline-end: awsui.$space-xxxs; } diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 55e5739e02..36434de61b 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -38,6 +38,15 @@ export interface TableThElementProps { variant: TableProps.Variant; tableVariant?: TableProps.Variant; ariaLabel?: string; + colSpan?: number; + rowSpan?: number; + scope?: 'col' | 'colgroup'; + columnGroupId?: string; + isLast?: boolean; + /** Additional className to merge (e.g. boundary shadow classes from a secondary sticky subscription). */ + className?: string; + /** Additional ref for boundary sticky subscription (imperatively updates shadow classes). */ + boundaryRef?: React.RefCallback; } export function TableThElement({ @@ -60,6 +69,13 @@ export function TableThElement({ variant, ariaLabel, tableVariant, + colSpan, + rowSpan, + scope, + columnGroupId, + isLast, + className, + boundaryRef, ...props }: TableThElementProps) { const isVisualRefresh = useVisualRefresh(); @@ -71,7 +87,7 @@ export function TableThElement({ }); const cellRefObject = useRef(null); - const mergedRef = useMergeRefs(stickyStyles.ref, cellRef, cellRefObject); + const mergedRef = useMergeRefs(stickyStyles.ref, cellRef, cellRefObject, boundaryRef); const { tabIndex: cellTabIndex } = useSingleTabStopNavigation(cellRefObject); return ( @@ -87,6 +103,7 @@ export function TableThElement({ isVisualRefresh && styles['is-visual-refresh'], isSelection && clsx(tableStyles['selection-control'], tableStyles['selection-control-header']), tableVariant && styles[`table-variant-${tableVariant}`], + scope === 'colgroup' && styles['header-cell-group'], { [styles['header-cell-fake-focus']]: focusedComponent === `header-${String(columnId)}`, [styles['header-cell-sortable']]: sortingStatus, @@ -95,15 +112,24 @@ export function TableThElement({ [styles['header-cell-ascending']]: sortingStatus === 'ascending', [styles['header-cell-descending']]: sortingStatus === 'descending', [styles['header-cell-hidden']]: hidden, + [styles['header-cell-spans-rows']]: (rowSpan ?? 1) > 1, + [styles['header-cell-grouped']]: !!columnGroupId, }, - stickyStyles.className + stickyStyles.className, + className )} + colSpan={colSpan} + rowSpan={rowSpan} style={{ ...resizableStyle, ...stickyStyles.style }} ref={mergedRef} {...getTableColHeaderRoleProps({ tableRole, sortingStatus, colIndex })} + scope={scope ?? 'col'} tabIndex={cellTabIndex === -1 ? undefined : cellTabIndex} {...copyAnalyticsMetadataAttribute(props)} {...(ariaLabel ? { 'aria-label': ariaLabel } : {})} + {...(isLast ? { 'data-rightmost': true } : {})} + {...(scope !== 'colgroup' ? { 'data-column-index': colIndex + 1 } : {})} + {...(columnGroupId ? { 'data-column-group-id': columnGroupId } : {})} > {children} diff --git a/src/table/index.tsx b/src/table/index.tsx index 5bb3cd03a6..418e8ec73b 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -11,6 +11,7 @@ import { CollectionPreferencesMetadata } from '../internal/context/collection-pr import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; +import { getColumnGroupsDepth } from './column-groups/utils'; import { getSortingColumnId } from './header-cell/utils'; import { TableForwardRefType, TableProps } from './interfaces'; import InternalTable, { InternalTableAsSubstep } from './internal'; @@ -32,7 +33,7 @@ const Table = React.forwardRef( const analyticsMetadata = getAnalyticsMetadataProps(props as BasePropsWithAnalyticsMetadata); const hasHiddenColumns = (props.visibleColumns && props.visibleColumns.length < props.columnDefinitions.length) || - props.columnDisplay?.some(col => !col.visible); + props.columnDisplay?.some(col => col.type !== 'group' && !col.visible); const hasStickyColumns = !!props.stickyColumns?.first || !!props.stickyColumns?.last; const baseComponentProps = useBaseComponent( 'Table', @@ -54,6 +55,8 @@ const Table = React.forwardRef( expandableRows: !!props.expandableRows, progressiveLoading: !!props.getLoadingStatus, groupSelection: !!props.expandableRows?.groupSelection, + columnGroups: !!props.groupDefinitions?.length, + columnGroupsDepth: getColumnGroupsDepth(props.columnDisplay), cellCounters: props.columnDefinitions.filter(dev => !!dev.counter).length, loaderCounters: !!props.renderLoaderCounter, inlineEdit: props.columnDefinitions.some(def => !!def.editConfig), diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 1628cf5492..c083d57ba9 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -252,9 +252,33 @@ export interface TableProps extends BaseComponentProps { * If not set, all columns are displayed and the order is dictated by the `columnDefinitions` property. * * Use it in conjunction with the content display preference of the [collection preferences](/components/collection-preferences/) component. + * + * Each entry is one of the following: + * - `ColumnDisplay` - Represents a single column. + * - `type` ('column') - (Optional) Identifies the entry as a column. Defaults to `'column'` when omitted. + * - `id` (string) - The column identifier. Must match a column `id` from `columnDefinitions`. + * - `visible` (boolean) - Whether the column is visible. + * - `GroupDisplay` - Represents a column group. + * - `type` ('group') - Identifies the entry as a group. + * - `id` (string) - The group identifier. Must match a group `id` from `groupDefinitions`. + * - `visible` (boolean) - Whether the group is visible. + * - `children` (ReadonlyArray) - The columns or nested groups within this group. */ columnDisplay?: ReadonlyArray; + /** + * Defines the column groups. Each group has an `id` and `header` used to label the group header cell. + * + * When using grouped columns, you must also provide the `columnDisplay` property with `{ type: 'group', id, children }` entries + * to assign columns to their respective groups and define the display hierarchy. + * + * Each group definition contains the following: + * - `id` (string) - A unique identifier for the group. + * - `header` (ReactNode) - The content displayed in the group header cell. + * - `ariaLabel` ((LabelData) => string) - (Optional) A function that provides an `aria-label` for the group header. + */ + groupDefinitions?: ReadonlyArray>; + /** * Specifies an array containing the `id`s of visible columns. If not set, all columns are displayed. * @@ -507,6 +531,13 @@ export namespace TableProps { cell(item: T): React.ReactNode; } & SortingColumn; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export interface GroupDefinition { + id: string; + header: React.ReactNode; + ariaLabel?: (data: LabelData) => string; + } + export interface ItemCounterData { item: T; itemsCount?: number; @@ -602,11 +633,21 @@ export namespace TableProps { newValue: ValueType ) => Promise | void; - export interface ColumnDisplayProperties { + export interface ColumnDisplay { + type?: 'column'; id: string; visible: boolean; } + export interface GroupDisplay { + type: 'group'; + id: string; + visible: boolean; + children: ReadonlyArray; + } + + export type ColumnDisplayProperties = ColumnDisplay | GroupDisplay; + export interface ExpandableRows { getItemChildren: (item: T) => readonly T[]; isItemExpandable: (item: T) => boolean; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index cf2e4db610..3e82c5c665 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -37,6 +37,7 @@ import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; +import { useColumnGroups } from './column-groups/use-column-groups'; import { checkColumnWidths } from './column-widths-utils'; import { useExpandableTableProps } from './expandable-rows/expandable-rows-utils'; import { TableForwardRefType, TableProps, TableRow } from './interfaces'; @@ -61,7 +62,7 @@ import { import Thead, { TheadProps } from './thead'; import ToolsHeader from './tools-header'; import { useCellEditing } from './use-cell-editing'; -import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH } from './use-column-widths'; +import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH, TableColGroup } from './use-column-widths'; import { usePreventStickyClickScroll } from './use-prevent-sticky-click-scroll'; import { useRowEvents } from './use-row-events'; import useTableFocusNavigation from './use-table-focus-navigation'; @@ -107,6 +108,7 @@ const InternalTable = React.forwardRef( preferences, items, columnDefinitions, + groupDefinitions, trackBy, loading, loadingText, @@ -300,6 +302,15 @@ const InternalTable = React.forwardRef( visibleColumns, }); + const visibleColumnIds = new Set(visibleColumnDefinitions.map((col, idx) => getColumnKey(col, idx) as string)); + + const { groupLeafMap, ...columnGroupsLayout } = useColumnGroups( + columnDefinitions, + groupDefinitions, + visibleColumnIds, + columnDisplay + ); + const selectionProps = { items: allItems, rootItems: items, @@ -394,6 +405,8 @@ const InternalTable = React.forwardRef( selectionType, getSelectAllProps: selection.getSelectAllProps, columnDefinitions: visibleColumnDefinitions, + groupDefinitions, + columnGroupsLayout, variant: computedVariant, tableVariant: computedVariant, wrapLines, @@ -418,6 +431,8 @@ const InternalTable = React.forwardRef( resizerTooltipText: ariaLabels?.resizerTooltipText, stripedRows, stickyState, + stickyColumnsFirst: stickyColumns?.first ?? 0, + stickyColumnsLast: stickyColumns?.last ?? 0, selectionColumnId, tableRole, isExpandable, @@ -452,6 +467,7 @@ const InternalTable = React.forwardRef( const colIndexOffset = selectionType ? 1 : 0; const totalColumnsCount = visibleColumnDefinitions.length + colIndexOffset; + const headerRowCount = columnGroupsLayout?.rows.length || 1; return ( @@ -460,6 +476,7 @@ const InternalTable = React.forwardRef( visibleColumns={visibleColumnWidthsWithSelection} resizableColumns={resizableColumns} containerRef={wrapperMeasureRefObject} + groupLeafMap={groupLeafMap} > 1} + columnDefinitions={visibleColumnDefinitions} + hasSelection={hasSelection} /> )} @@ -560,16 +580,21 @@ const InternalTable = React.forwardRef( className={clsx( styles.table, resizableColumns && styles['table-layout-fixed'], + columnGroupsLayout && columnGroupsLayout.rows.length > 1 && styles['has-grouped-header'], contentDensity === 'compact' && getVisualContextClassname('compact-table') )} {...getTableRoleProps({ tableRole, totalItemsCount, totalColumnsCount: totalColumnsCount, + headerRowCount, ariaLabel: ariaLabels?.tableLabel, ariaLabelledby, })} > + {resizableColumns && columnGroupsLayout && columnGroupsLayout.rows.length > 1 && ( + + )} ; +export type DividerPosition = 'default' | 'top' | 'bottom' | 'full'; + +export function Divider({ + className, + position, + variant, +}: { + className?: string; + position?: DividerPosition; + variant?: 'default' | 'interactive'; +}) { + return ( + + ); } export function Resizer({ @@ -52,6 +73,8 @@ export function Resizer({ roleDescription, tooltipText, isBorderless, + isLast, + dividerPosition, }: ResizerProps) { onWidthUpdate = useStableCallback(onWidthUpdate); onWidthUpdateCommit = useStableCallback(onWidthUpdateCommit); @@ -330,7 +353,8 @@ export function Resizer({ className={clsx( styles['resizer-wrapper'], isVisualRefresh && styles['visual-refresh'], - (!isVisualRefresh || isBorderless) && styles['is-borderless'] + (!isVisualRefresh || isBorderless) && styles['is-borderless'], + isLast && styles['is-last'] )} ref={positioningWrapperRef} > @@ -411,7 +435,11 @@ export function Resizer({ data-focus-id={focusId} /> .divider, +th:not([data-rightmost]) > .divider, .divider-interactive { position: absolute; outline: none; @@ -46,11 +46,30 @@ th:not(:last-child) > .divider, max-block-size: calc(100% - #{$block-gap}); margin-block: auto; margin-inline: auto; - border-inline-start: awsui.$border-item-width solid awsui.$color-border-divider-interactive-default; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-interactive-default; box-sizing: border-box; + + // Position variants for grouped column headers. + // All leaf dividers maintain the same bottom gap ($block-gap / 2) as the default. + &.divider-position-top { + // Leaf column under a group: extends upward, same bottom gap as default. + margin-block-start: 0; + margin-block-end: auto; + max-block-size: calc(100% - #{$block-gap} / 2); + } + &.divider-position-bottom { + // Group header: extends downward to meet the horizontal border below. + margin-block-start: auto; + margin-block-end: 0; + max-block-size: calc(100% - #{$block-gap} / 2); + } + &.divider-position-full { + margin-block: 0; + max-block-size: 100%; + } } -th:not(:last-child) > .divider-disabled { +th:not([data-rightmost]) > .divider-disabled { border-inline-start-color: awsui.$color-border-divider-default; } @@ -58,13 +77,14 @@ th:not(:last-child) > .divider-disabled { inset-inline-end: calc(#{$handle-width} / 2); } -// stylelint-disable-next-line selector-combinator-disallowed-list -th:last-child > .resizer-wrapper.visual-refresh.is-borderless .divider-interactive { - inset-inline-end: 0; +.divider-active { + /* used in test-utils */ } -.divider-active { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; +.resizer-wrapper.visual-refresh.is-borderless.is-last { + > .divider-interactive { + inset-inline-end: 0; + } } .resizer { @@ -84,9 +104,6 @@ th:last-child > .resizer-wrapper.visual-refresh.is-borderless .divider-interacti .resize-active & { pointer-events: none; } - &:hover + .divider { - border-inline-start: $active-separator-width solid awsui.$color-border-divider-active; - } &.has-focus { @include focus-visible.when-visible-unfocused { @include styles.focus-highlight(calc(#{awsui.$space-table-header-focus-outline-gutter} - 2px)); diff --git a/src/table/selection/selection-cell.tsx b/src/table/selection/selection-cell.tsx index f2b894af81..170e51e3ee 100644 --- a/src/table/selection/selection-cell.tsx +++ b/src/table/selection/selection-cell.tsx @@ -52,11 +52,15 @@ export function TableHeaderSelectionCell({ focusedComponent={focusedComponent} {...selectAllProps} {...(props.sticky ? { tabIndex: -1 } : {})} + spansRows={!!props.rowSpan && props.rowSpan > 1} /> ) : ( {singleSelectionHeaderAriaLabel} )} - + 1 ? 'interactive' : 'default'} + /> ); } diff --git a/src/table/selection/selection-control.tsx b/src/table/selection/selection-control.tsx index 7d6b7458f5..d61d992006 100644 --- a/src/table/selection/selection-control.tsx +++ b/src/table/selection/selection-control.tsx @@ -23,6 +23,8 @@ export interface SelectionControlProps extends ItemSelectionProps { rowIndex?: number; itemKey?: string; verticalAlign?: 'middle' | 'top'; + /** Whether this control spans multiple header rows (grouped column header). */ + spansRows?: boolean; } export function SelectionControl({ @@ -38,6 +40,7 @@ export function SelectionControl({ rowIndex, itemKey, verticalAlign = 'middle', + spansRows, onChange, ...sharedProps }: SelectionControlProps) { @@ -123,7 +126,12 @@ export function SelectionControl({ onMouseUp={setShiftState} onClick={handleClick} htmlFor={controlId} - className={clsx(styles.label, styles.root, verticalAlign === 'top' && styles['label-top'])} + className={clsx( + styles.label, + styles.root, + verticalAlign === 'top' && !spansRows && styles['label-top'], + spansRows && styles['label-bottom'] + )} aria-label={ariaLabel} title={ariaLabel} {...(rowIndex !== undefined && !sharedProps.disabled diff --git a/src/table/selection/styles.scss b/src/table/selection/styles.scss index 0e5218042f..d348ca3554 100644 --- a/src/table/selection/styles.scss +++ b/src/table/selection/styles.scss @@ -29,6 +29,12 @@ padding-block-start: awsui.$space-xs; } +.label-bottom { + align-items: end; + padding-block-start: awsui.$space-xs; + padding-block-end: calc(awsui.$space-xxs + awsui.$space-xxs); +} + .stud { visibility: hidden; } diff --git a/src/table/sticky-header.tsx b/src/table/sticky-header.tsx index ae5ddf4fcb..37d12bda9c 100644 --- a/src/table/sticky-header.tsx +++ b/src/table/sticky-header.tsx @@ -8,6 +8,7 @@ import { getVisualContextClassname } from '../internal/components/visual-context import { TableProps } from './interfaces'; import { getTableRoleProps, TableRole } from './table-role'; import Thead, { TheadProps } from './thead'; +import { TableColGroup } from './use-column-widths'; import { useStickyHeader } from './use-sticky-header'; import styles from './styles.css.js'; @@ -29,6 +30,9 @@ interface StickyHeaderProps { contentDensity?: 'comfortable' | 'compact'; tableHasHeader?: boolean; tableRole: TableRole; + hasGroupedColumns?: boolean; + columnDefinitions?: ReadonlyArray>; + hasSelection?: boolean; } export default forwardRef(StickyHeader); @@ -40,11 +44,14 @@ function StickyHeader( wrapperRef, theadRef, secondaryWrapperRef, - onScroll, tableRef, + onScroll, tableHasHeader, contentDensity, tableRole, + hasGroupedColumns, + columnDefinitions, + hasSelection, }: StickyHeaderProps, ref: React.Ref ) { @@ -67,6 +74,10 @@ function StickyHeader( setFocus: setFocusedComponent, })); + // For grouped columns, the secondary table needs a to define leaf column + // widths. Without it, table-layout:fixed uses the first row (which has colspan group + // headers) to determine widths — giving wrong results. + return (
+ {hasGroupedColumns && columnDefinitions && ( + + )}
rows, stickyRef points to the first . + // Use the full bottom so we account for all header rows. + /* istanbul ignore next: requires DOM scroll measurements */ + const stickyEl = stickyRef.current.closest('thead') ?? stickyRef.current; + const stickyBottom = getLogicalBoundingClientRect(stickyEl).insetBlockEnd; const scrollingOffset = stickyBottom - getLogicalBoundingClientRect(item).insetBlockStart; if (scrollingOffset > 0) { scrollUpBy(scrollingOffset, containerRef.current); diff --git a/src/table/styles.scss b/src/table/styles.scss index 186090c9bd..941b1ea3bc 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -142,6 +142,15 @@ filter search icon. padding-inline: awsui.$space-scaled-l; border-inline-start: awsui.$border-width-item-selected solid transparent; } + + // When the selection cell spans multiple header rows, use flex to push the + // checkbox to the bottom of the cell, matching bottom-aligned leaf column headers. + &-content-spans-rows { + display: flex; + flex-direction: column; + justify-content: flex-end; + block-size: 100%; + } } .header-secondary { diff --git a/src/table/table-role/grid-navigation.tsx b/src/table/table-role/grid-navigation.tsx index f655d4aec6..e419d2e3fd 100644 --- a/src/table/table-role/grid-navigation.tsx +++ b/src/table/table-role/grid-navigation.tsx @@ -17,8 +17,8 @@ import { nodeBelongs } from '../../internal/utils/node-belongs'; import { FocusedCell, GridNavigationProps } from './interfaces'; import { defaultIsSuppressed, + findNextCell, findTableRowByAriaRowIndex, - findTableRowCellByAriaColIndex, focusNextElement, getClosestCell, isElementDisabled, @@ -330,15 +330,13 @@ export class GridNavigationProcessor { return cellFocusables[nextElementIndex]; } - // Find next cell to focus or move focus into (can be null if the left/right edge is reached). + // Find next cell to focus or move focus into. const targetAriaColIndex = from.colIndex + delta.x; - const targetCell = findTableRowCellByAriaColIndex(targetRow, targetAriaColIndex, delta.x); - if (!targetCell) { - return null; - } - // When target cell matches the current cell it means we reached the left or right boundary. - if (targetCell === cellElement && delta.x !== 0) { + const targetCell = this.table + ? findNextCell(this.table, targetRow, targetAriaColIndex, delta, cellElement as HTMLTableCellElement | null) + : null; + if (!targetCell) { return null; } diff --git a/src/table/table-role/table-role-helper.ts b/src/table/table-role/table-role-helper.ts index f28752e3fd..ac0748421b 100644 --- a/src/table/table-role/table-role-helper.ts +++ b/src/table/table-role/table-role-helper.ts @@ -22,6 +22,7 @@ export function getTableRoleProps(options: { ariaLabelledby?: string; totalItemsCount?: number; totalColumnsCount?: number; + headerRowCount?: number; }): React.TableHTMLAttributes { const nativeProps: React.TableHTMLAttributes = {}; @@ -33,8 +34,9 @@ export function getTableRoleProps(options: { nativeProps['aria-labelledby'] = options.ariaLabelledby; // Incrementing the total count by one to account for the header row. + const headerRows = options.headerRowCount ?? 1; if (typeof options.totalItemsCount === 'number' && options.totalItemsCount > 0) { - nativeProps['aria-rowcount'] = options.totalItemsCount + 1; + nativeProps['aria-rowcount'] = options.totalItemsCount + headerRows; } if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { @@ -68,12 +70,12 @@ export function getTableWrapperRoleProps(options: { return nativeProps; } -export function getTableHeaderRowRoleProps(options: { tableRole: TableRole }) { +export function getTableHeaderRowRoleProps(options: { tableRole: TableRole; rowIndex?: number }) { const nativeProps: React.HTMLAttributes = {}; // For grids headers are treated similar to data rows and are indexed accordingly. if (options.tableRole === 'grid' || options.tableRole === 'grid-default' || options.tableRole === 'treegrid') { - nativeProps['aria-rowindex'] = 1; + nativeProps['aria-rowindex'] = (options.rowIndex ?? 0) + 1; } return nativeProps; @@ -83,6 +85,7 @@ export function getTableRowRoleProps(options: { tableRole: TableRole; rowIndex: number; firstIndex?: number; + headerRowCount?: number; level?: number; setSize?: number; posInSet?: number; @@ -90,12 +93,13 @@ export function getTableRowRoleProps(options: { const nativeProps: React.HTMLAttributes = {}; // The data cell indices are incremented by 1 to account for the header cells. + const headerRows = options.headerRowCount ?? 1; if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { - nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + 1; + nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + headerRows; } // For tables indices are only added when the first index is not 0 (not the first page/frame). else if (options.firstIndex !== undefined) { - nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + 1; + nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + headerRows; } if (options.tableRole === 'treegrid' && options.level && options.level !== 0) { nativeProps['aria-level'] = options.level; diff --git a/src/table/table-role/utils.ts b/src/table/table-role/utils.ts index c39809a50d..b0057f70e3 100644 --- a/src/table/table-role/utils.ts +++ b/src/table/table-role/utils.ts @@ -68,14 +68,71 @@ export function findTableRowCellByAriaColIndex( targetAriaColIndex: number, delta: number ) { + const cellElements = Array.from( + tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]') + ); + return findClosestCellByAriaColIndex(cellElements, targetAriaColIndex, delta); +} + +/** + * Collects all cells visually present in a row, including cells from earlier rows + * that span into this row via rowspan. This is needed because cells with rowspan > 1 + * are only in one in the DOM but visually occupy multiple rows. + */ +export function getAllCellsInRow(table: HTMLTableElement, targetAriaRowIndex: number): HTMLTableCellElement[] { + const cells: HTMLTableCellElement[] = []; + const rows = table.querySelectorAll('tr[aria-rowindex]'); + + for (const row of Array.from(rows)) { + const rowIndex = parseInt(row.getAttribute('aria-rowindex') ?? ''); + if (isNaN(rowIndex) || rowIndex > targetAriaRowIndex) { + continue; + } + + const rowCells = row.querySelectorAll('td[aria-colindex],th[aria-colindex]'); + for (const cell of Array.from(rowCells)) { + const rowspan = cell.rowSpan || 1; + // Cell is visible in target row if: rowIndex <= targetAriaRowIndex < rowIndex + rowspan + if (rowIndex + rowspan > targetAriaRowIndex) { + cells.push(cell); + } + } + } + + return cells; +} + +/** + * From a list of cell elements, find the closest one to targetAriaColIndex in the direction of delta. + * Accounts for colspan: a cell with colindex=2 and colspan=4 covers columns 2,3,4,5. + */ +export function findClosestCellByAriaColIndex( + cellElements: HTMLTableCellElement[], + targetAriaColIndex: number, + delta: number +): HTMLTableCellElement | null { + // First check if any cell's colspan range covers the target exactly. + for (const element of cellElements) { + const colIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); + const colspan = element.colSpan || 1; + if (colIndex <= targetAriaColIndex && targetAriaColIndex < colIndex + colspan) { + return element; + } + } + + // Otherwise find the closest cell in the direction of delta. let targetCell: null | HTMLTableCellElement = null; - const cellElements = Array.from(tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]')); + const sorted = [...cellElements].sort((a, b) => { + const aIdx = parseInt(a.getAttribute('aria-colindex') ?? '0'); + const bIdx = parseInt(b.getAttribute('aria-colindex') ?? '0'); + return aIdx - bIdx; + }); if (delta < 0) { - cellElements.reverse(); + sorted.reverse(); } - for (const element of cellElements) { + for (const element of sorted) { const columnIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); - targetCell = element as HTMLTableCellElement; + targetCell = element; if (columnIndex === targetAriaColIndex) { break; @@ -90,6 +147,50 @@ export function findTableRowCellByAriaColIndex( return targetCell; } +/** + * Finds the next cell to navigate to, handling colspan and rowspan for grouped columns. + * Skips past the current cell when movement lands on it due to span attributes. + */ +export function findNextCell( + table: HTMLTableElement, + targetRow: HTMLTableRowElement, + targetAriaColIndex: number, + delta: { x: number; y: number }, + currentCell: HTMLTableCellElement | null +): HTMLTableCellElement | null { + const targetRowAriaIndex = parseInt(targetRow.getAttribute('aria-rowindex') ?? ''); + let allVisibleCells = getAllCellsInRow(table, targetRowAriaIndex); + let targetCell = findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x); + + // When vertical movement lands on the same cell (due to rowspan), skip past it. + if (targetCell === currentCell && delta.y !== 0 && currentCell) { + const cellRow = currentCell.closest('tr'); + const cellRowIndex = parseInt(cellRow?.getAttribute('aria-rowindex') ?? '0'); + const cellRowSpan = currentCell.rowSpan || 1; + const skipToRowIndex = delta.y > 0 ? cellRowIndex + cellRowSpan : cellRowIndex - 1; + const skipRow = findTableRowByAriaRowIndex(table, skipToRowIndex, delta.y); + if (!skipRow) { + return null; + } + const skipRowAriaIndex = parseInt(skipRow.getAttribute('aria-rowindex') ?? ''); + allVisibleCells = getAllCellsInRow(table, skipRowAriaIndex); + targetCell = findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x); + } + + // When horizontal movement lands on the same cell (due to colspan), skip past it. + if (targetCell === currentCell && delta.x !== 0 && currentCell) { + const cellColIndex = parseInt(currentCell.getAttribute('aria-colindex') ?? '0'); + const cellColSpan = currentCell.colSpan || 1; + const skipToColIndex = delta.x > 0 ? cellColIndex + cellColSpan : cellColIndex - 1; + targetCell = findClosestCellByAriaColIndex(allVisibleCells, skipToColIndex, delta.x); + if (!targetCell || targetCell === currentCell) { + return null; + } + } + + return targetCell; +} + export function isTableCell(element: Element) { return element.tagName === 'TD' || element.tagName === 'TH'; } diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 10024c014e..91df16450c 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -6,13 +6,16 @@ import clsx from 'clsx'; import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; import { fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events'; +import { getGroupColumnIds, getGroupSplit } from './column-groups/split-utils'; +import { ColumnGroupsLayout } from './column-groups/utils'; import { TableHeaderCell } from './header-cell'; +import { TableGroupHeaderCell } from './header-cell/group-header-cell'; import { InternalSelectionType, TableProps } from './interfaces'; import { focusMarkers, ItemSelectionProps } from './selection'; import { TableHeaderSelectionCell } from './selection/selection-cell'; import { StickyColumnsModel } from './sticky-columns'; import { getTableHeaderRowRoleProps, TableRole } from './table-role'; -import { useColumnWidths } from './use-column-widths'; +import { DEFAULT_COLUMN_WIDTH, useColumnWidths } from './use-column-widths'; import { getColumnKey } from './utils'; import styles from './styles.css.js'; @@ -20,6 +23,8 @@ import styles from './styles.css.js'; export interface TheadProps { selectionType: undefined | InternalSelectionType; columnDefinitions: ReadonlyArray>; + groupDefinitions?: ReadonlyArray; + columnGroupsLayout?: ColumnGroupsLayout; sortingColumn: TableProps.SortingColumn | undefined; sortingDescending: boolean | undefined; sortingDisabled: boolean | undefined; @@ -39,6 +44,8 @@ export interface TheadProps { resizerTooltipText?: string; stripedRows?: boolean; stickyState: StickyColumnsModel; + stickyColumnsFirst: number; + stickyColumnsLast: number; selectionColumnId: PropertyKey; focusedComponent?: null | string; onFocusedComponentChange?: (focusId: null | string) => void; @@ -53,6 +60,7 @@ const Thead = React.forwardRef( selectionType, getSelectAllProps, columnDefinitions, + columnGroupsLayout, sortingColumn, sortingDisabled, sortingDescending, @@ -69,6 +77,8 @@ const Thead = React.forwardRef( hidden = false, stuck = false, stickyState, + stickyColumnsFirst, + stickyColumnsLast, selectionColumnId, focusedComponent, onFocusedComponentChange, @@ -80,7 +90,17 @@ const Thead = React.forwardRef( }: TheadProps, outerRef: React.Ref ) => { - const { getColumnStyles, columnWidths, updateColumn, setCell } = useColumnWidths(); + const { getColumnStyles, columnWidths, updateColumn, updateGroup, setCell } = useColumnWidths(); + + const handleSplitGroupResize = (leafIds: string[], newWidth: number) => { + const lastLeaf = leafIds[leafIds.length - 1]; + if (lastLeaf) { + const currentGroupWidth = leafIds.reduce((sum, id) => sum + (columnWidths.get(id) || DEFAULT_COLUMN_WIDTH), 0); + const delta = newWidth - currentGroupWidth; + const currentLeafWidth = columnWidths.get(lastLeaf) || DEFAULT_COLUMN_WIDTH; + updateColumn(lastLeaf, currentLeafWidth + delta); + } + }; const commonCellProps = { stuck, @@ -91,69 +111,336 @@ const Thead = React.forwardRef( variant, tableVariant, stickyState, + wrapLines, }; + // No grouping - render single row + if (!columnGroupsLayout || columnGroupsLayout.rows.length <= 1) { + return ( + + { + const focusControlElement = findUpUntil(event.target, element => !!element.getAttribute('data-focus-id')); + const focusId = focusControlElement?.getAttribute('data-focus-id') ?? null; + onFocusedComponentChange?.(focusId); + }} + onBlur={() => onFocusedComponentChange?.(null)} + > + {selectionType ? ( + setCell(sticky, selectionColumnId, node)} + getSelectAllProps={getSelectAllProps} + onFocusMove={onFocusMove} + singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel} + /> + ) : null} + + {columnDefinitions.map((column, colIndex) => { + const columnId = getColumnKey(column, colIndex); + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={getColumnStyles(sticky, columnId)} + onClick={detail => { + setLastUserAction('sorting'); + fireNonCancelableEvent(onSortingChange, detail); + }} + isEditable={!!column.editConfig} + cellRef={node => setCell(sticky, columnId, node)} + tableRole={tableRole} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isExpandable={colIndex === 0 && isExpandable} + hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + isLast={colIndex === columnDefinitions.length - 1} + /> + ); + })} + + + ); + } + + // Grouped columns + const totalLeafColumns = columnDefinitions.length; return ( - { - const focusControlElement = findUpUntil(event.target, element => !!element.getAttribute('data-focus-id')); - const focusId = focusControlElement?.getAttribute('data-focus-id') ?? null; - onFocusedComponentChange?.(focusId); - }} - onBlur={() => onFocusedComponentChange?.(null)} - > - {selectionType ? ( - - ) : null} - - {columnDefinitions.map((column, colIndex) => { - const columnId = getColumnKey(column, colIndex); - return ( - ( + { + const focusControlElement = findUpUntil(event.target, element => !!element.getAttribute('data-focus-id')); + const focusId = focusControlElement?.getAttribute('data-focus-id') ?? null; + onFocusedComponentChange?.(focusId); + }} + onBlur={() => onFocusedComponentChange?.(null)} + > + {/* Selection column — render once in the first row with rowSpan covering all header rows */} + {selectionType && rowIndex === 0 ? ( + onResizeFinish(columnWidths)} - resizableColumns={resizableColumns} - resizableStyle={getColumnStyles(sticky, columnId)} - onClick={detail => { - setLastUserAction('sorting'); - fireNonCancelableEvent(onSortingChange, detail); - }} - isEditable={!!column.editConfig} - cellRef={node => setCell(sticky, columnId, node)} - tableRole={tableRole} - resizerRoleDescription={resizerRoleDescription} - resizerTooltipText={resizerTooltipText} - // Expandable option is only applicable to the first data column of the table. - // When present, the header content receives extra padding to match the first offset in the data cells. - isExpandable={colIndex === 0 && isExpandable} - hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + columnId={selectionColumnId} + cellRef={node => setCell(sticky, selectionColumnId, node)} + getSelectAllProps={getSelectAllProps} + onFocusMove={onFocusMove} + singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel} + rowSpan={columnGroupsLayout.rows.length} /> - ); - })} - + ) : null} + + {row.columns.map((col, colIndexInRow) => { + // A cell is the last child of its parent group when the next rendered cell + // in the same row belongs to a different top-level parent, i.e. they don't + // share the same immediate parent group. + const nextCol = row.columns[colIndexInRow + 1]; + const thisParent = col.parentGroupIds[col.parentGroupIds.length - 1] ?? null; + const nextParent = nextCol ? (nextCol.parentGroupIds[nextCol.parentGroupIds.length - 1] ?? null) : null; + // A leaf is also considered last-child-of-group when the sticky boundary + // bisects its parent group just after this leaf — visually it's the rightmost + // leaf of the sticky half, so its resizer should span full-height like a + // normal last-child-of-group. + const isLeafAtStickyFirstBoundary = + !col.isGroup && + thisParent !== null && + stickyColumnsFirst > 0 && + col.colIndex === stickyColumnsFirst - 1; + const isLeafAtStickyLastBoundary = + !col.isGroup && + thisParent !== null && + stickyColumnsLast > 0 && + col.colIndex === columnDefinitions.length - stickyColumnsLast - 1; + const isLastChildOfGroup = + (thisParent !== null && thisParent !== nextParent) || + isLeafAtStickyFirstBoundary || + isLeafAtStickyLastBoundary; + + if (col.isGroup) { + // Group header cell + const groupDefinition = col.groupDefinition!; + const childIds = getGroupColumnIds(columnGroupsLayout!, col.id); + const splitFirst = getGroupSplit({ + col, + stickyCount: stickyColumnsFirst, + side: 'first', + totalLeafColumns, + }); + const splitLast = getGroupSplit({ + col, + stickyCount: stickyColumnsLast, + side: 'last', + totalLeafColumns, + }); + const split = splitFirst.stickyColspan > 0 ? splitFirst : splitLast; + const isSplit = split.stickyColspan > 0; + + if (isSplit) { + // Group is bisected by the sticky boundary — render two + ))} ); } diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index 694972aa12..d378cd6f05 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -6,6 +6,8 @@ import { useResizeObserver, useStableCallback } from '@cloudscape-design/compone import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal'; import { ColumnWidthStyle, setElementWidths } from './column-widths-utils'; +import { TableProps } from './interfaces'; +import { getColumnKey } from './utils'; export const DEFAULT_COLUMN_WIDTH = 120; @@ -39,7 +41,7 @@ function updateWidths( oldWidths: Map, newWidth: number, 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) { @@ -61,14 +63,19 @@ interface WidthsContext { getColumnStyles(sticky: boolean, columnId: PropertyKey): ColumnWidthStyle; columnWidths: Map; 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; } +/* istanbul ignore next */ const WidthsContext = createContext({ getColumnStyles: () => ({}), columnWidths: new Map(), updateColumn: () => {}, + updateGroup: () => {}, setCell: () => {}, + setCol: () => {}, }); interface WidthProviderProps { @@ -76,15 +83,24 @@ interface WidthProviderProps { resizableColumns: boolean | undefined; containerRef: React.RefObject; children: React.ReactNode; + groupLeafMap?: Map; } -export function ColumnWidthsProvider({ visibleColumns, resizableColumns, containerRef, children }: WidthProviderProps) { +export function ColumnWidthsProvider({ + visibleColumns, + resizableColumns, + containerRef, + groupLeafMap, + children, +}: WidthProviderProps) { const visibleColumnsRef = useRef(null); const containerWidthRef = useRef(0); const [columnWidths, setColumnWidths] = useState>(null); const cellsRef = useRef(new Map()); const stickyCellsRef = useRef(new Map()); + const colsRef = 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) => { const ref = sticky ? stickyCellsRef : cellsRef; @@ -94,19 +110,32 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain ref.current.delete(columnId); } }; + const setCol = (columnId: PropertyKey, node: null | HTMLElement) => { + if (node) { + colsRef.current.set(columnId, node); + hasColElements.current = true; + } else { + colsRef.current.delete(columnId); + hasColElements.current = colsRef.current.size > 0; + } + }; const getColumnStyles = (sticky: boolean, columnId: PropertyKey): ColumnWidthStyle => { - const column = visibleColumns.find(column => column.id === columnId); - if (!column) { - return {}; - } + const column = visibleColumns.find(col => col.id === columnId); if (sticky) { - return { - width: - cellsRef.current.get(column.id)?.getBoundingClientRect().width || - (columnWidths?.get(column.id) ?? column.width), - }; + // For sticky headers, mirror the primary cell's width. + // Try DOM measurement first (handles columns not in visibleColumns like selection). + const measured = cellsRef.current.get(columnId)?.getBoundingClientRect().width; + /* istanbul ignore next: getBoundingClientRect returns 0 in JSDOM */ + if (measured) { + return { width: measured }; + } + return { width: columnWidths?.get(columnId) ?? column?.width }; + } + + if (!column) { + return {}; } if (resizableColumns && columnWidths) { @@ -116,11 +145,11 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain 0 ); if (isLastColumn && containerWidthRef.current > totalWidth) { - return { width: 'auto', minWidth: column?.minWidth }; - } else { - return { width: columnWidths.get(column.id), minWidth: column?.minWidth }; + return { width: 'auto', minWidth: column.minWidth }; } + return { width: columnWidths.get(column.id), minWidth: column.minWidth }; } + return { width: column.width, minWidth: column.minWidth, @@ -131,13 +160,29 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain // Imperatively sets width style for a cell avoiding React state. // This allows setting the style as soon container's size change is observed. const updateColumnWidths = useStableCallback(() => { - for (const { id } of visibleColumns) { - const element = cellsRef.current.get(id); - if (element) { - setElementWidths(element, getColumnStyles(false, id)); + // When col elements exist (grouped columns), apply widths to elements. + // With table-layout:fixed, widths control the actual column widths. + if (hasColElements.current) { + for (const { id } of visibleColumns) { + const colElement = colsRef.current.get(id); + if (colElement) { + setElementWidths(colElement, getColumnStyles(false, id)); + } + const element = cellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(false, id)); + } + } + } else { + for (const { id } of visibleColumns) { + const element = cellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(false, id)); + } } } - // Sticky column widths must be synchronized once all real column widths are assigned. + + // Sticky column widths must always be synchronized regardless of columnWidths state. for (const { id } of visibleColumns) { const element = stickyCellsRef.current.get(id); if (element) { @@ -195,13 +240,73 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths ?? new Map(), newWidth, columnId)); } + /* istanbul ignore next: covered by integration tests, requires real DOM measurements */ + function updateGroup(groupId: PropertyKey, newGroupWidth: number) { + if (!columnWidths || !groupLeafMap) { + return; + } + + const leafIds = groupLeafMap.get(String(groupId)) ?? []; + const rightmostLeaf = leafIds[leafIds.length - 1]; + if (!rightmostLeaf) { + return; + } + + let currentGroupWidth = 0; + for (const id of leafIds) { + currentGroupWidth += columnWidths.get(id) || DEFAULT_COLUMN_WIDTH; + } + + const delta = newGroupWidth - currentGroupWidth; + const currentLeafWidth = columnWidths.get(rightmostLeaf) || DEFAULT_COLUMN_WIDTH; + updateColumn(rightmostLeaf, currentLeafWidth + delta); + } + return ( - + {children} ); } +/* + * Renders a with elements for each leaf column. + * With table-layout:fixed, widths control actual column widths, + * which makes colspan headers automatically span the correct width. + * Must be rendered inside ColumnWidthsProvider. + */ +export function TableColGroup({ + visibleColumnDefinitions, + hasSelection, + sticky = false, + selectionColumnId, +}: { + visibleColumnDefinitions: ReadonlyArray>; + hasSelection: boolean; + sticky?: boolean; + selectionColumnId?: PropertyKey; +}) { + const { getColumnStyles, setCol } = useColumnWidths(); + return ( + + {hasSelection && ( + + )} + {visibleColumnDefinitions.map((column, colIndex) => { + const columnId = getColumnKey(column, colIndex); + if (sticky) { + return ; + } + return setCol(columnId, node)} />; + })} + + ); +} + export function useColumnWidths() { return useContext(WidthsContext); } diff --git a/src/table/use-sticky-header.ts b/src/table/use-sticky-header.ts index 952ccb6b49..bbf516ece3 100644 --- a/src/table/use-sticky-header.ts +++ b/src/table/use-sticky-header.ts @@ -24,7 +24,9 @@ export const useStickyHeader = ( secondaryTableRef.current && tableWrapperRef.current ) { - tableWrapperRef.current.style.marginBlockStart = `-${theadRef.current.getBoundingClientRect().height}px`; + // Use the full thead height to account for multi-row headers (grouped columns). + const thead = theadRef.current.closest('thead') ?? theadRef.current; + tableWrapperRef.current.style.marginBlockStart = `-${thead.getBoundingClientRect().height}px`; } }, [theadRef, secondaryTheadRef, secondaryTableRef, tableWrapperRef, tableRef]); useLayoutEffect(() => { diff --git a/src/table/utils.ts b/src/table/utils.ts index 6822e581e4..1a707a1274 100644 --- a/src/table/utils.ts +++ b/src/table/utils.ts @@ -55,7 +55,7 @@ export function getVisibleColumnDefinitions({ columnDefinitions, }: { columnDisplay?: ReadonlyArray; - visibleColumns?: ReadonlyArray; + visibleColumns?: ReadonlyArray; columnDefinitions: ReadonlyArray>; }) { // columnsDisplay has a precedence over visibleColumns. @@ -79,17 +79,15 @@ function getVisibleColumnDefinitionsFromColumnDisplay({ (accumulator, item) => (item.id === undefined ? accumulator : { ...accumulator, [item.id]: item }), {} ); - return columnDisplay - .filter(item => item.visible) - .map(item => columnDefinitionsById[item.id]) - .filter(Boolean); + const visibleIds = flattenVisibleColumnIds(columnDisplay); + return visibleIds.map(id => columnDefinitionsById[id]).filter(Boolean); } function getVisibleColumnDefinitionsFromVisibleColumns({ visibleColumns, columnDefinitions, }: { - visibleColumns: ReadonlyArray; + visibleColumns: ReadonlyArray; columnDefinitions: ReadonlyArray>; }) { const ids = new Set(visibleColumns); @@ -104,3 +102,17 @@ export function getStickyClassNames(styles: Record, props: Stick [styles['sticky-cell-last-inline-end']]: !!props?.lastInsetInlineEnd, }; } + +function flattenVisibleColumnIds(items: ReadonlyArray): string[] { + const ids: string[] = []; + for (const item of items) { + if (item.type === 'group') { + // ColumnDisplayGroup — recurse into children + ids.push(...flattenVisibleColumnIds(item.children)); + } else if (item.visible) { + // ColumnDisplayItem — include if visible + ids.push(item.id); + } + } + return ids; +} diff --git a/src/test-utils/dom/table/index.ts b/src/test-utils/dom/table/index.ts index 4858341902..107d5d1536 100644 --- a/src/test-utils/dom/table/index.ts +++ b/src/test-utils/dom/table/index.ts @@ -46,7 +46,25 @@ export default class TableWrapper extends ComponentWrapper { return this.containerWrapper.findFooter(); } - findColumnHeaders(): Array { + /** + * Returns column header cells from the table's header region. + * + * By default, returns all leaf-column headers (`scope="col"`). + * For tables without column grouping this is equivalent to the previous behavior. + * For tables with column grouping this excludes group header cells. + * + * @param option.groupId When provided, returns only leaf columns belonging to this group + * (matched via `data-column-group-id` attribute). + */ + findColumnHeaders( + option: { + groupId?: string; + } = {} + ): Array { + const { groupId } = option; + if (groupId !== undefined) { + return this.findActiveTHead().findAll(`th[data-column-group-id="${groupId}"]`); + } return this.findActiveTHead().findAll('tr > *'); } @@ -55,7 +73,10 @@ export default class TableWrapper extends ComponentWrapper { * * @param columnIndex 1-based index of the column containing the resizer. */ - findColumnResizer(columnIndex: number): ElementWrapper | null { + findColumnResizer(columnIndex: number, options?: { grouped?: boolean }): ElementWrapper | null { + if (options?.grouped) { + return this.findActiveTHead().find(`th[data-column-index="${columnIndex}"] .${resizerStyles.resizer}`); + } return this.findActiveTHead().find(`th:nth-child(${columnIndex}) .${resizerStyles.resizer}`); } @@ -105,7 +126,15 @@ export default class TableWrapper extends ComponentWrapper { return this.findByClassName(styles.loading); } - findColumnSortingArea(colIndex: number): ElementWrapper | null { + /** + * Returns the clickable sorting area of a column header. + * + * @param colIndex 1-based index of the column. + */ + findColumnSortingArea(colIndex: number, options?: { grouped?: boolean }): ElementWrapper | null { + if (options?.grouped) { + return this.findActiveTHead().find(`th[data-column-index="${colIndex}"] [role=button]`); + } return this.findActiveTHead().find(`tr > *:nth-child(${colIndex}) [role=button]`); }
elements. + // Both halves get resizers. Each resizes its own rightmost column child. + const isSplitFirst = splitFirst.stickyColspan > 0; + + // Left half is sticky for 'first', non-sticky for 'last' + const leftColspan = isSplitFirst ? split.stickyColspan : split.staticColspan; + const leftColIndex = col.colIndex; + const leftGroupId = isSplitFirst ? col.id : `${col.id}__split`; + // Left half's child IDs for resize + const leftChildIds = childIds.filter((_, i) => col.colIndex + i < leftColIndex + leftColspan); + + // Right half is non-sticky for 'first', sticky for 'last' + const rightColspan = isSplitFirst ? split.staticColspan : split.stickyColspan; + const rightColIndex = col.colIndex + leftColspan; + const rightGroupId = isSplitFirst ? `${col.id}__split` : col.id; + const rightChildIds = childIds.filter((_, i) => col.colIndex + i >= rightColIndex); + + return ( + + {/* Left half */} + onResizeFinish(columnWidths)} + updateGroupWidth={(_, newWidth) => { + handleSplitGroupResize(leftChildIds, newWidth); + }} + childColumnIds={leftChildIds} + firstChildColumnId={leftChildIds[0]} + lastChildColumnId={leftChildIds[leftChildIds.length - 1]} + cellRef={isSplitFirst ? node => setCell(sticky, col.id, node) : () => {}} + isLast={false} + stickyColumnId={isSplitFirst ? childIds[0] : undefined} + stickyBoundaryColumnId={isSplitFirst ? leftChildIds[leftChildIds.length - 1] : undefined} + columnGroupId={ + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined + } + /> + + {/* Right half */} + onResizeFinish(columnWidths)} + updateGroupWidth={(_, newWidth) => { + handleSplitGroupResize(rightChildIds, newWidth); + }} + childColumnIds={rightChildIds} + firstChildColumnId={rightChildIds[0]} + lastChildColumnId={rightChildIds[rightChildIds.length - 1]} + cellRef={!isSplitFirst ? node => setCell(sticky, col.id, node) : () => {}} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isLast={rightColIndex + rightColspan === totalLeafColumns} + stickyColumnId={!isSplitFirst ? childIds[childIds.length - 1] : undefined} + columnGroupId={ + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined + } + /> + + ); + } + + // Determine if the entire group is sticky (all children on one side) + const isFullyStickyFirst = + stickyColumnsFirst > 0 && col.colIndex + col.colSpan - 1 < stickyColumnsFirst; + const isFullyStickyLast = + stickyColumnsLast > 0 && col.colIndex >= columnDefinitions.length - stickyColumnsLast; + const fullyStickyColumnId = isFullyStickyFirst + ? childIds[0] + : isFullyStickyLast + ? childIds[childIds.length - 1] + : undefined; + + // When the group's last child is the sticky-first boundary, the group + // needs the shadow from that child (but offset from the first child). + const isAtStickyFirstBoundary = + isFullyStickyFirst && col.colIndex + col.colSpan - 1 === stickyColumnsFirst - 1; + const isAtStickyLastBoundary = + isFullyStickyLast && col.colIndex === columnDefinitions.length - stickyColumnsLast; + const fullyStickyBoundaryColumnId = isAtStickyFirstBoundary + ? childIds[childIds.length - 1] + : isAtStickyLastBoundary + ? childIds[0] + : undefined; + + return ( + onResizeFinish(columnWidths)} + updateGroupWidth={(groupId, newWidth) => { + updateGroup(groupId, newWidth); + }} + childColumnIds={childIds} + firstChildColumnId={childIds[0]} + lastChildColumnId={childIds[childIds.length - 1]} + cellRef={node => setCell(sticky, col.id, node)} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isLast={col.colIndex + col.colSpan === totalLeafColumns} + stickyColumnId={fullyStickyColumnId} + stickyBoundaryColumnId={fullyStickyBoundaryColumnId} + columnGroupId={ + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined + } + /> + ); + } else { + // Regular column cell + const column = col.columnDefinition!; + const columnId = col.id; + const colIndex = col.colIndex; + + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={getColumnStyles(sticky, columnId)} + onClick={detail => { + setLastUserAction('sorting'); + fireNonCancelableEvent(onSortingChange, detail); + }} + isEditable={!!column.editConfig} + cellRef={node => { + setCell(sticky, columnId, node); + }} + tableRole={tableRole} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isExpandable={colIndex === 0 && isExpandable} + hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + colSpan={col.colSpan} + rowSpan={col.rowSpan} + isLastChildOfGroup={isLastChildOfGroup} + isLast={col.colIndex + col.colSpan === totalLeafColumns} + columnGroupId={ + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined + } + /> + ); + } + })} +