diff --git a/package-lock.json b/package-lock.json
index d663110273..8a29a2d85a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,7 @@
"": {
"name": "@cloudscape-design/components",
"version": "3.0.0",
+ "hasInstallScript": true,
"dependencies": {
"@cloudscape-design/collection-hooks": "^1.0.0",
"@cloudscape-design/component-toolkit": "^1.0.0-beta",
@@ -21,7 +22,7 @@
"date-fns": "^2.25.0",
"intl-messageformat": "^10.3.1",
"mnth": "^2.0.0",
- "react-keyed-flatten-children": "^2.2.1",
+ "react-is": "^18.2.0",
"react-transition-group": "^4.4.2",
"tslib": "^2.4.0",
"weekstart": "^1.1.0"
@@ -52,6 +53,7 @@
"@types/node": "^20.17.14",
"@types/react": "^16.14.20",
"@types/react-dom": "^16.9.14",
+ "@types/react-is": "^18.2.0",
"@types/react-router": "^5.1.18",
"@types/react-router-dom": "^5.3.2",
"@types/react-test-renderer": "^16.9.12",
@@ -2396,11 +2398,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/@jest/core/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@jest/core/node_modules/strip-ansi": {
"version": "6.0.1",
"dev": true,
@@ -4503,11 +4500,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/@types/jest/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/jsdom": {
"version": "20.0.1",
"dev": true,
@@ -4590,6 +4582,16 @@
"@types/react": "^16"
}
},
+ "node_modules/@types/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-zts4lhQn5ia0cF/y2+3V6Riu0MAfez9/LJYavdM8TvcVl+S91A/7VWxyBT8hbRuWspmuCaiGI0F41OJYGrKhRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "^18"
+ }
+ },
"node_modules/@types/react-router": {
"version": "5.1.20",
"dev": true,
@@ -9937,14 +9939,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/expect-webdriverio/node_modules/react-is": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
- "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -12817,11 +12811,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/jest-circus/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-cli": {
"version": "29.7.0",
"dev": true,
@@ -13039,11 +13028,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/jest-config/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-diff": {
"version": "29.7.0",
"dev": true,
@@ -13097,11 +13081,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/jest-diff/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-docblock": {
"version": "29.7.0",
"dev": true,
@@ -13167,11 +13146,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/jest-each/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-environment-jsdom": {
"version": "29.7.0",
"dev": true,
@@ -13310,11 +13284,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
- "node_modules/jest-leak-detector/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-matcher-utils": {
"version": "29.7.0",
"dev": true,
@@ -13368,11 +13337,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/jest-matcher-utils/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-message-util": {
"version": "29.7.0",
"dev": true,
@@ -13431,11 +13395,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/jest-message-util/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-mock": {
"version": "29.7.0",
"dev": true,
@@ -13739,11 +13698,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/jest-snapshot/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-snapshot/node_modules/semver": {
"version": "7.7.2",
"dev": true,
@@ -13863,11 +13817,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
- "node_modules/jest-validate/node_modules/react-is": {
- "version": "18.3.1",
- "dev": true,
- "license": "MIT"
- },
"node_modules/jest-watcher": {
"version": "29.7.0",
"dev": true,
@@ -16997,6 +16946,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/process": {
"version": "0.11.10",
"dev": true,
@@ -17411,22 +17367,9 @@
}
},
"node_modules/react-is": {
- "version": "17.0.2",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/react-keyed-flatten-children": {
- "version": "2.2.1",
- "license": "MIT",
- "dependencies": {
- "react-is": "^18.2.0"
- },
- "peerDependencies": {
- "react": ">=15.0.0"
- }
- },
- "node_modules/react-keyed-flatten-children/node_modules/react-is": {
"version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-router": {
diff --git a/pages/collection-preferences/multi-level-reorder.page.tsx b/pages/collection-preferences/multi-level-reorder.page.tsx
new file mode 100644
index 0000000000..f689c1b7e9
--- /dev/null
+++ b/pages/collection-preferences/multi-level-reorder.page.tsx
@@ -0,0 +1,70 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React from 'react';
+
+import CollectionPreferences, { CollectionPreferencesProps } from '~components/collection-preferences';
+
+import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings';
+import {
+ baseProperties,
+ contentDensityPreference,
+ customPreference,
+ pageSizePreference,
+ wrapLinesPreference,
+} from './shared-configs';
+
+const columnOptions: CollectionPreferencesProps.ContentDisplayOption[] = [
+ // ungroupdd
+ { id: 'name', label: 'Name', alwaysVisible: true },
+ { id: 'status', label: 'Status' },
+
+ // performance
+ { id: 'cpuUtilization', label: 'CPU (%)', groupId: 'performance' },
+ { id: 'memoryUtilization', label: 'Memory (%)', groupId: 'performance' },
+ { id: 'networkIn', label: 'Network In (MB/s)', groupId: 'performance' },
+ { id: 'networkOut', label: 'Network Out (MB/s)', groupId: 'performance' },
+
+ // config
+ { id: 'instanceType', label: 'Instance Type', groupId: 'configuration' },
+ { id: 'availabilityZone', label: 'Availability Zone', groupId: 'configuration' },
+ { id: 'region', label: 'Region', groupId: 'configuration' },
+
+ // cost
+ { id: 'monthlyCost', label: 'Monthly Cost ($)', groupId: 'cost' },
+ { id: 'spotPrice', label: 'Spot Price ($/hr)', groupId: 'cost' },
+ {
+ id: 'reservedCost',
+ label:
+ 'Reserved Instance Cost - Long text to verify wrapping behavior and ensure the reordering feature works correctly with extended content',
+ groupId: 'cost',
+ },
+];
+
+const columnGroups: CollectionPreferencesProps.ContentDisplayOptionGroup[] = [
+ { id: 'metrics', label: 'Metrics' },
+ { id: 'performance', label: 'Performance', groupId: 'metrics' },
+ { id: 'configuration', label: 'Configuration' },
+ { id: 'cost', label: 'Cost' },
+];
+
+export default function App() {
+ return (
+ <>
+
Multi-level Reorder Preferences
+
+ >
+ );
+}
diff --git a/pages/list/nested-sortable.page.tsx b/pages/list/nested-sortable.page.tsx
new file mode 100644
index 0000000000..902c35b1d3
--- /dev/null
+++ b/pages/list/nested-sortable.page.tsx
@@ -0,0 +1,155 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { useState } from 'react';
+
+import { Box, Container, Header, SpaceBetween } from '~components';
+import List from '~components/list';
+
+interface TreeItem {
+ id: string;
+ content: string;
+ children?: TreeItem[];
+}
+
+const initialData: TreeItem[] = [
+ {
+ id: 'item-1',
+ content: 'Group 1',
+ children: [
+ { id: 'item-1-1', content: 'Item 1.1' },
+ {
+ id: 'item-1-2',
+ content: 'Item 1.2',
+ children: [
+ { id: 'item-1-2-1', content: 'Item 1.2.1' },
+ {
+ id: 'item-1-2-2',
+ content: 'Item 1.2.2',
+ children: [
+ { id: 'item-1-2-2-1', content: 'Item 1.2.2.1' },
+ { id: 'item-1-2-2-2', content: 'Item 1.2.2.2' },
+ ],
+ },
+ { id: 'item-1-2-3', content: 'Item 1.2.3' },
+ ],
+ },
+ { id: 'item-1-3', content: 'Item 1.3' },
+ ],
+ },
+ {
+ id: 'item-2',
+ content: 'Group 2',
+ children: [
+ { id: 'item-2-1', content: 'Item 2.1' },
+ {
+ id: 'item-2-2',
+ content: 'Item 2.2',
+ children: [
+ { id: 'item-2-2-1', content: 'Item 2.2.1' },
+ { id: 'item-2-2-2', content: 'Item 2.2.2' },
+ ],
+ },
+ ],
+ },
+ {
+ id: 'item-3',
+ content: 'Group 3',
+ children: [
+ { id: 'item-3-1', content: 'Item 3.1' },
+ { id: 'item-3-2', content: 'Item 3.2' },
+ { id: 'item-3-3', content: 'Item 3.3' },
+ ],
+ },
+];
+
+export default function NestedSortableListPage() {
+ const [items, setItems] = useState(initialData);
+
+ const updateItemChildren = (items: TreeItem[], targetId: string, newChildren: TreeItem[]): TreeItem[] => {
+ return items.map(item => {
+ if (item.id === targetId) {
+ return { ...item, children: newChildren };
+ }
+ if (item.children) {
+ return {
+ ...item,
+ children: updateItemChildren(item.children, targetId, newChildren),
+ };
+ }
+ return item;
+ });
+ };
+
+ const handleChildSort = (itemId: string, newChildren: TreeItem[]) => {
+ setItems(prev => updateItemChildren(prev, itemId, newChildren));
+ };
+
+ return (
+
+
+ Recursive Nested Sortable Lists
+
+ Tree Structure with Recursive Sorting}>
+ setItems(newItems)}
+ depth={0}
+ onChildSort={handleChildSort}
+ />
+
+
+ Debug: Current Data Structure}>
+ {JSON.stringify(items, null, 2)}
+
+
+
+ );
+}
+
+interface RecursiveListProps {
+ items: TreeItem[];
+ onSortingChange: (items: TreeItem[]) => void;
+ depth: number;
+ onChildSort: (itemId: string, newChildren: TreeItem[]) => void;
+}
+
+function RecursiveList({ items, onSortingChange, depth, onChildSort }: RecursiveListProps) {
+ const [localItems, setLocalItems] = useState(items);
+
+ React.useEffect(() => {
+ setLocalItems(items);
+ }, [items]);
+
+ const handleSort = (e: { detail: { items: ReadonlyArray } }) => {
+ const newItems = [...e.detail.items];
+ setLocalItems(newItems);
+ onSortingChange(newItems);
+ };
+
+ return (
+ ({
+ id: item.id,
+ content: (
+
+ {item.content}
+ {item.children && item.children.length > 0 && (
+
+ onChildSort(item.id, newChildren)}
+ depth={depth + 1}
+ onChildSort={onChildSort}
+ />
+
+ )}
+
+ ),
+ })}
+ />
+ );
+}
diff --git a/pages/table/grouped-column-with-preference.page.tsx b/pages/table/grouped-column-with-preference.page.tsx
new file mode 100644
index 0000000000..63c42e2b26
--- /dev/null
+++ b/pages/table/grouped-column-with-preference.page.tsx
@@ -0,0 +1,397 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import React, { useState } from 'react';
+
+import { useCollection } from '@cloudscape-design/collection-hooks';
+
+import {
+ Box,
+ Button,
+ CollectionPreferences,
+ CollectionPreferencesProps,
+ FormField,
+ Header,
+ Input,
+ Pagination,
+ SpaceBetween,
+ Table,
+ TableProps,
+ TextFilter,
+ Toggle,
+} from '~components';
+
+import { SimplePage } from '../app/templates';
+
+interface EC2Instance {
+ id: string;
+ name: string;
+ cpuUtilization: number;
+ memoryUtilization: number;
+ networkIn: number;
+ networkOut: number;
+ instanceType: string;
+ az: string;
+ state: string;
+ monthlyCost: number;
+ spotPrice: number;
+}
+
+const allInstances: EC2Instance[] = [
+ {
+ id: 'i-1234567890abcdef0',
+ name: 'web-server-1',
+ cpuUtilization: 45.2,
+ memoryUtilization: 62.8,
+ networkIn: 1250,
+ networkOut: 890,
+ instanceType: 't3.medium',
+ az: 'us-east-1a',
+ state: 'running',
+ monthlyCost: 30.4,
+ spotPrice: 0.0416,
+ },
+ {
+ id: 'i-0987654321fedcba0',
+ name: 'api-server-1',
+ cpuUtilization: 78.5,
+ memoryUtilization: 81.2,
+ networkIn: 3420,
+ networkOut: 2890,
+ instanceType: 't3.large',
+ az: 'us-east-1b',
+ state: 'running',
+ monthlyCost: 60.8,
+ spotPrice: 0.0832,
+ },
+ {
+ id: 'i-abcdef1234567890a',
+ name: 'db-server-1',
+ cpuUtilization: 23.1,
+ memoryUtilization: 45.6,
+ networkIn: 890,
+ networkOut: 450,
+ instanceType: 'r5.xlarge',
+ az: 'us-east-1c',
+ state: 'running',
+ monthlyCost: 201.6,
+ spotPrice: 0.252,
+ },
+ {
+ id: 'i-fedcba0987654321b',
+ name: 'cache-server-1',
+ cpuUtilization: 12.4,
+ memoryUtilization: 34.2,
+ networkIn: 560,
+ networkOut: 320,
+ instanceType: 'r5.large',
+ az: 'us-east-1a',
+ state: 'stopped',
+ monthlyCost: 100.8,
+ spotPrice: 0.126,
+ },
+ {
+ id: 'i-1122334455667788c',
+ name: 'worker-1',
+ cpuUtilization: 91.3,
+ memoryUtilization: 88.7,
+ networkIn: 4560,
+ networkOut: 3210,
+ instanceType: 'c5.2xlarge',
+ az: 'us-east-1d',
+ state: 'running',
+ monthlyCost: 248.0,
+ spotPrice: 0.34,
+ },
+];
+
+const columnDefinitions: TableProps['columnDefinitions'] = [
+ {
+ id: 'id',
+ header: 'Instance ID',
+ cell: (item: EC2Instance) => item.id,
+ sortingField: 'id',
+ isRowHeader: true,
+ groupId: 'metrics',
+ },
+ {
+ id: 'name',
+ header: 'Name',
+ cell: (item: EC2Instance) => item.name,
+ sortingField: 'name',
+ },
+ {
+ id: 'cpuUtilization',
+ header: 'CPU (%)',
+ cell: (item: EC2Instance) => `${item.cpuUtilization.toFixed(1)}%`,
+ sortingField: 'cpuUtilization',
+ groupId: 'performance',
+ },
+ {
+ id: 'memoryUtilization',
+ header: 'Memory (%)',
+ cell: (item: EC2Instance) => `${item.memoryUtilization.toFixed(1)}%`,
+ sortingField: 'memoryUtilization',
+ groupId: 'performance',
+ },
+ {
+ id: 'networkIn',
+ header: 'Network In (MB/s)',
+ cell: (item: EC2Instance) => item.networkIn.toString(),
+ sortingField: 'networkIn',
+ groupId: 'performance',
+ },
+ {
+ id: 'networkOut',
+ header: 'Network Out (MB/s)',
+ cell: (item: EC2Instance) => item.networkOut.toString(),
+ sortingField: 'networkOut',
+ groupId: 'performance',
+ },
+ {
+ id: 'instanceType',
+ header: 'Instance Type',
+ cell: (item: EC2Instance) => item.instanceType,
+ sortingField: 'instanceType',
+ groupId: 'configuration',
+ },
+ {
+ id: 'az',
+ header: 'Availability Zone',
+ cell: (item: EC2Instance) => item.az,
+ sortingField: 'az',
+ groupId: 'configuration',
+ },
+ {
+ id: 'state',
+ header: 'State',
+ cell: (item: EC2Instance) => item.state,
+ sortingField: 'state',
+ groupId: 'configuration',
+ },
+ {
+ id: 'monthlyCost',
+ header: 'Monthly Cost ($)',
+ cell: (item: EC2Instance) => `$${item.monthlyCost.toFixed(2)}`,
+ sortingField: 'monthlyCost',
+ groupId: 'cost',
+ },
+ {
+ id: 'spotPrice',
+ header: 'Spot Price ($/hr)',
+ cell: (item: EC2Instance) => `$${item.spotPrice.toFixed(4)}`,
+ sortingField: 'spotPrice',
+ groupId: 'cost',
+ },
+];
+
+const columnGroupingDefinitions: TableProps['columnGroupingDefinitions'] = [
+ {
+ id: 'cost',
+ header: 'Cost',
+ },
+ {
+ id: 'configuration',
+ header: 'Configuration',
+ },
+ {
+ id: 'performance',
+ header: 'Performance',
+ groupId: 'metrics',
+ },
+ {
+ id: 'metrics',
+ header: 'Metrics',
+ },
+];
+
+const collectionPreferencesProps: CollectionPreferencesProps = {
+ title: 'Preferences',
+ confirmLabel: 'Confirm',
+ cancelLabel: 'Cancel',
+ pageSizePreference: {
+ title: 'Page size',
+ options: [
+ { value: 2, label: '2 instances' },
+ { value: 10, label: '10 instances' },
+ { value: 30, label: '30 instances' },
+ ],
+ },
+ contentDisplayPreference: {
+ title: 'Column preferences',
+ description: 'Customize the columns visibility and order.',
+ options: [
+ { id: 'name', label: 'Name' },
+ { id: 'cpuUtilization', label: 'CPU (%)', groupId: 'performance' },
+ { id: 'memoryUtilization', label: 'Memory (%)', groupId: 'performance' },
+ { id: 'networkIn', label: 'Network In (MB/s)', groupId: 'performance' },
+ { id: 'networkOut', label: 'Network Out (MB/s)', groupId: 'performance' },
+ { id: 'instanceType', label: 'Instance Type', groupId: 'configuration' },
+ { id: 'id', label: 'Instance ID', alwaysVisible: true, groupId: 'metrics' },
+ { id: 'az', label: 'Availability Zone', groupId: 'configuration' },
+ { id: 'state', label: 'State', groupId: 'configuration' },
+ { id: 'monthlyCost', label: 'Monthly Cost ($)', groupId: 'cost' },
+ { id: 'spotPrice', label: 'Spot Price ($/hr)', groupId: 'cost' },
+ ],
+ groups: [
+ { id: 'cost', label: 'Cost' },
+ { id: 'configuration', label: 'Configuration' },
+ { id: 'performance', label: 'Performance', groupId: 'metrics' },
+ { id: 'metrics', label: 'Metrics' },
+ ],
+ },
+};
+
+function EmptyState({ title, subtitle, action }: { title: string; subtitle?: string; action?: React.ReactNode }) {
+ return (
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+ {action}
+
+ );
+}
+
+export default function EC2TableDemo() {
+ const [preferences, setPreferences] = useState({
+ pageSize: 10,
+ contentDisplay: [
+ { id: 'cpuUtilization', visible: true },
+ { id: 'memoryUtilization', visible: true },
+ { id: 'networkIn', visible: true },
+ { id: 'networkOut', visible: true },
+ { id: 'id', visible: true },
+ { id: 'name', visible: true },
+ { id: 'instanceType', visible: true },
+ { id: 'az', visible: true },
+ { id: 'state', visible: true },
+ { id: 'monthlyCost', visible: false },
+ { id: 'spotPrice', visible: false },
+ ],
+ });
+
+ const ariaLabels: TableProps['ariaLabels'] = {
+ selectionGroupLabel: 'EC2 instances selection',
+ allItemsSelectionLabel: ({ selectedItems }) =>
+ `${selectedItems.length} ${selectedItems.length === 1 ? 'instance' : 'instances'} selected`,
+ itemSelectionLabel: ({ selectedItems }, item) => {
+ const isItemSelected = selectedItems.includes(item);
+ return `${item.name} is ${isItemSelected ? '' : 'not '}selected`;
+ },
+ tableLabel: 'EC2 Instances',
+ };
+
+ const { items, actions, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection(
+ allInstances,
+ {
+ filtering: {
+ empty: (
+ Launch instance}
+ />
+ ),
+ noMatch: (
+ actions.setFiltering('')}>Clear filter}
+ />
+ ),
+ },
+ pagination: { pageSize: preferences?.pageSize },
+ sorting: {},
+ selection: {},
+ }
+ );
+
+ const { selectedItems } = collectionProps;
+
+ const [firstSticky, setFirstSticky] = useState('0');
+ const [lastSticky, setLastSticky] = useState('0');
+ const [resizable, setResizable] = useState(true);
+
+ return (
+
+ EC2 Instances Table
+
+
+ setResizable(detail.checked)} checked={resizable}>
+ Resizable
+
+
+ {/* first */}
+
+ setFirstSticky(detail.value)}
+ value={firstSticky}
+ name="first"
+ inputMode="numeric"
+ type="number"
+ />
+
+
+ setLastSticky(detail.value)}
+ value={lastSticky}
+ name="last"
+ inputMode="numeric"
+ type="number"
+ />
+
+
+
+
+ EC2 Instances
+
+ }
+ columnDefinitions={columnDefinitions}
+ columnGroupingDefinitions={columnGroupingDefinitions}
+ columnDisplay={preferences?.contentDisplay}
+ items={items}
+ pagination={}
+ filter={
+
+ }
+ preferences={
+ setPreferences(detail)}
+ />
+ }
+ />
+
+ );
+}
diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
index 01fbd65f7e..93d0775298 100644
--- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap
@@ -8777,6 +8777,11 @@ You must provide an ordered list of the items to display in the \`preferences.co
"optional": true,
"type": "boolean",
},
+ {
+ "name": "groups",
+ "optional": true,
+ "type": "ReadonlyArray",
+ },
{
"inlineType": {
"name": "CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings",
@@ -25702,6 +25707,13 @@ Use it in conjunction with the content display preference of the [collection pre
"optional": true,
"type": "ReadonlyArray",
},
+ {
+ "description": "Add grouping for the columns define groups for columns to be under and also nested groups for which
+other groups will be nested under.",
+ "name": "columnGroupingDefinitions",
+ "optional": true,
+ "type": "ReadonlyArray>",
+ },
{
"defaultValue": "'comfortable'",
"description": "Toggles the content density of the table. Defaults to \`'comfortable'\`.",
diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx
index 173522ee6d..02f8f90d3f 100644
--- a/src/collection-preferences/content-display/index.tsx
+++ b/src/collection-preferences/content-display/index.tsx
@@ -18,7 +18,14 @@ import InternalTextFilter from '../../text-filter/internal';
import { getAnalyticsInnerContextAttribute } from '../analytics-metadata/utils';
import { CollectionPreferencesProps } from '../interfaces';
import ContentDisplayOption from './content-display-option';
-import { getFilteredOptions, getSortedOptions, OptionWithVisibility } from './utils';
+import {
+ buildOptionTree,
+ flattenOptionTree,
+ getFilteredOptions,
+ getSortedOptions,
+ OptionTreeNode,
+ OptionWithVisibility,
+} from './utils';
import styles from '../styles.css.js';
@@ -31,10 +38,73 @@ interface ContentDisplayPreferenceProps extends CollectionPreferencesProps.Conte
value?: ReadonlyArray;
}
+interface HierarchicalContentDisplayProps {
+ tree: OptionTreeNode[];
+ onToggle: (option: OptionWithVisibility) => void;
+ onTreeChange: (newTree: OptionTreeNode[]) => void;
+ ariaLabelledby?: string;
+ ariaDescribedby?: string;
+ i18nStrings: React.ComponentProps['i18nStrings'];
+ depth?: number;
+}
+
+function HierarchicalContentDisplay({
+ tree,
+ onToggle,
+ onTreeChange,
+ ariaLabelledby,
+ ariaDescribedby,
+ i18nStrings,
+ depth = 0,
+}: HierarchicalContentDisplayProps) {
+ return (
+ ({
+ id: node.id,
+ announcementLabel: node.label,
+ content: node.isGroup ? (
+
+ {/* Group header — no toggle */}
+
+ {node.label}
+
+ {/* Recursively render children (sub-groups or leaf columns) */}
+ {node.children.length > 0 && (
+
+
+ onTreeChange(tree.map(n => (n.id === node.id && n.isGroup ? { ...n, children: newChildren } : n)))
+ }
+ i18nStrings={i18nStrings}
+ depth={depth + 1}
+ />
+
+ )}
+
+ ) : (
+ // node is OptionLeafNode — has all OptionWithVisibility fields
+
+ ),
+ })}
+ disableItemPaddings={true}
+ sortable={true}
+ onSortingChange={({ detail: { items } }) => {
+ onTreeChange([...items]);
+ }}
+ {...(depth === 0 ? { ariaLabelledby, ariaDescribedby } : {})}
+ i18nStrings={i18nStrings}
+ />
+ );
+}
+
export default function ContentDisplayPreference({
title,
description,
options,
+ groups,
value = options.map(({ id }) => ({
id,
visible: true,
@@ -56,11 +126,12 @@ export default function ContentDisplayPreference({
const titleId = `${idPrefix}-title`;
const descriptionId = `${idPrefix}-description`;
- const [sortedOptions, sortedAndFilteredOptions] = useMemo(() => {
+ const [sortedOptions, sortedAndFilteredOptions, optionTree] = useMemo(() => {
const sorted = getSortedOptions({ options, contentDisplay: value });
const filtered = getFilteredOptions(sorted, columnFilteringText);
- return [sorted, filtered];
- }, [columnFilteringText, options, value]);
+ const tree = groups && groups.length > 0 ? buildOptionTree(sorted, groups) : null;
+ return [sorted, filtered, tree];
+ }, [columnFilteringText, groups, options, value]);
const onToggle = (option: OptionWithVisibility) => {
// We use sortedOptions as base and not value because there might be options that
@@ -126,48 +197,85 @@ export default function ContentDisplayPreference({
)}
- ({
- id: item.id,
- content: ,
- announcementLabel: item.label,
- })}
- disableItemPaddings={true}
- sortable={true}
- sortDisabled={columnFilteringText.trim().length > 0}
- onSortingChange={({ detail: { items } }) => {
- onChange(items);
- }}
- ariaDescribedby={descriptionId}
- ariaLabelledby={titleId}
- i18nStrings={{
- liveAnnouncementDndStarted: i18n(
- 'contentDisplayPreference.liveAnnouncementDndStarted',
- liveAnnouncementDndStarted,
- formatDndStarted
- ),
- liveAnnouncementDndItemReordered: i18n(
- 'contentDisplayPreference.liveAnnouncementDndItemReordered',
- liveAnnouncementDndItemReordered,
- formatDndItemReordered
- ),
- liveAnnouncementDndItemCommitted: i18n(
- 'contentDisplayPreference.liveAnnouncementDndItemCommitted',
- liveAnnouncementDndItemCommitted,
- formatDndItemCommitted
- ),
- liveAnnouncementDndDiscarded: i18n(
- 'contentDisplayPreference.liveAnnouncementDndDiscarded',
- liveAnnouncementDndDiscarded
- ),
- dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel),
- dragHandleAriaDescription: i18n(
- 'contentDisplayPreference.dragHandleAriaDescription',
- dragHandleAriaDescription
- ),
- }}
- />
+ {/* Grouped hierarchical view */}
+ {optionTree && columnFilteringText.trim().length === 0 ? (
+ onChange(flattenOptionTree(newTree))}
+ ariaDescribedby={descriptionId}
+ ariaLabelledby={titleId}
+ i18nStrings={{
+ liveAnnouncementDndStarted: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndStarted',
+ liveAnnouncementDndStarted,
+ formatDndStarted
+ ),
+ liveAnnouncementDndItemReordered: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndItemReordered',
+ liveAnnouncementDndItemReordered,
+ formatDndItemReordered
+ ),
+ liveAnnouncementDndItemCommitted: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndItemCommitted',
+ liveAnnouncementDndItemCommitted,
+ formatDndItemCommitted
+ ),
+ liveAnnouncementDndDiscarded: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndDiscarded',
+ liveAnnouncementDndDiscarded
+ ),
+ dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel),
+ dragHandleAriaDescription: i18n(
+ 'contentDisplayPreference.dragHandleAriaDescription',
+ dragHandleAriaDescription
+ ),
+ }}
+ />
+ ) : (
+ ({
+ id: item.id,
+ content: ,
+ announcementLabel: item.label,
+ })}
+ disableItemPaddings={true}
+ sortable={true}
+ sortDisabled={columnFilteringText.trim().length > 0}
+ onSortingChange={({ detail: { items } }) => {
+ onChange(items);
+ }}
+ ariaDescribedby={descriptionId}
+ ariaLabelledby={titleId}
+ i18nStrings={{
+ liveAnnouncementDndStarted: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndStarted',
+ liveAnnouncementDndStarted,
+ formatDndStarted
+ ),
+ liveAnnouncementDndItemReordered: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndItemReordered',
+ liveAnnouncementDndItemReordered,
+ formatDndItemReordered
+ ),
+ liveAnnouncementDndItemCommitted: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndItemCommitted',
+ liveAnnouncementDndItemCommitted,
+ formatDndItemCommitted
+ ),
+ liveAnnouncementDndDiscarded: i18n(
+ 'contentDisplayPreference.liveAnnouncementDndDiscarded',
+ liveAnnouncementDndDiscarded
+ ),
+ dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel),
+ dragHandleAriaDescription: i18n(
+ 'contentDisplayPreference.dragHandleAriaDescription',
+ dragHandleAriaDescription
+ ),
+ }}
+ />
+ )}
);
}
diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts
index 9877ce3ed6..433e1243f5 100644
--- a/src/collection-preferences/content-display/utils.ts
+++ b/src/collection-preferences/content-display/utils.ts
@@ -6,6 +6,25 @@ export interface OptionWithVisibility extends CollectionPreferencesProps.Content
visible: boolean;
}
+/**
+ * A group header node in the hierarchical tree.
+ * children can be either nested sub-group nodes OR leaf column nodes — supporting N-level nesting.
+ */
+export interface OptionGroupNode {
+ id: string;
+ label: string;
+ groupId?: string;
+ isGroup: true;
+ children: OptionTreeNode[];
+}
+
+/** A flat leaf column node (not a group header). */
+export interface OptionLeafNode extends OptionWithVisibility {
+ isGroup: false;
+}
+
+export type OptionTreeNode = OptionGroupNode | OptionLeafNode;
+
export function getSortedOptions({
options,
contentDisplay,
@@ -28,6 +47,204 @@ export function getSortedOptions({
return Array.from(optionsById.values());
}
+export function buildOptionTree(
+ sortedOptions: ReadonlyArray,
+ groups: ReadonlyArray
+): OptionTreeNode[] {
+ if (!groups || groups.length === 0) {
+ return sortedOptions.map(opt => ({ ...opt, isGroup: false as const }));
+ }
+
+ // Map group id → group definition
+ const groupById = new Map();
+ for (const group of groups) {
+ groupById.set(group.id, group);
+ }
+
+ // insertion-order tracking so we preserve the sorted options order.
+ const childrenMap = new Map();
+ childrenMap.set(null, []); // null = root level
+ for (const group of groups) {
+ childrenMap.set(group.id, []);
+ }
+
+ // Track which node IDs have already been placed to avoid duplicates
+ const placed = new Set();
+
+ /**
+ * Resolve the effective parent for a node:
+ * - If node.groupId exists and that group exists → parent is that group
+ * - Otherwise → parent is root (null)
+ */
+ const resolveParent = (groupId: string | undefined): string | null => {
+ if (groupId && groupById.has(groupId)) {
+ return groupId;
+ }
+ return null;
+ };
+
+ /**
+ * Find the earliest leaf option index that is a descendant of a given group.
+ * Used to determine insertion order of groups relative to their sibling leaf columns.
+ */
+ const firstLeafIndex = new Map();
+ // Pre-compute for each leaf option its index in sortedOptions
+ const optionIndex = new Map();
+ sortedOptions.forEach((opt, i) => optionIndex.set(opt.id, i));
+
+ // For each group, find the minimum index among its direct and indirect leaf descendants
+ const computeFirstLeafIndex = (groupId: string): number => {
+ if (firstLeafIndex.has(groupId)) {
+ return firstLeafIndex.get(groupId)!;
+ }
+ let min = Infinity;
+ // Direct leaf children
+ for (const opt of sortedOptions) {
+ if (opt.groupId === groupId) {
+ const idx = optionIndex.get(opt.id) ?? Infinity;
+ if (idx < min) {
+ min = idx;
+ }
+ }
+ }
+ // Indirect children via sub-groups
+ for (const group of groups) {
+ if (group.groupId === groupId) {
+ const sub = computeFirstLeafIndex(group.id);
+ if (sub < min) {
+ min = sub;
+ }
+ }
+ }
+ firstLeafIndex.set(groupId, min);
+ return min;
+ };
+ for (const group of groups) {
+ computeFirstLeafIndex(group.id);
+ }
+
+ // We build children lists by processing sortedOptions (leaf columns) in order,
+ // and inserting group nodes at the position of their first leaf descendant.
+ // We use an insertion-order approach: for each parent, track which children
+ // have been added and in what order.
+
+ const parentOrder = new Map(); // parent → ordered child IDs
+ const parentOrderSet = new Map>(); // for O(1) membership
+ parentOrder.set(null, []);
+ parentOrderSet.set(null, new Set());
+ for (const group of groups) {
+ parentOrder.set(group.id, []);
+ parentOrderSet.set(group.id, new Set());
+ }
+
+ const ensureAncestorsPlaced = (groupId: string) => {
+ // Walk up the ancestor chain and ensure each ancestor is registered with its parent
+ const chain: string[] = [];
+ let current: string | undefined = groupId;
+ while (current) {
+ chain.unshift(current);
+ const parentGroupId: string | undefined = groupById.get(current)?.groupId;
+ if (!parentGroupId || !groupById.has(parentGroupId)) {
+ break;
+ }
+ current = parentGroupId;
+ }
+ // Now place from top down
+ for (const gid of chain) {
+ const parentId = resolveParent(groupById.get(gid)?.groupId);
+ const order = parentOrder.get(parentId)!;
+ const orderSet = parentOrderSet.get(parentId)!;
+ if (!orderSet.has(gid)) {
+ orderSet.add(gid);
+ order.push(gid);
+ }
+ }
+ };
+
+ // Process sorted leaf options to establish ordering
+ for (const option of sortedOptions) {
+ const directParentId = resolveParent(option.groupId);
+
+ if (directParentId !== null) {
+ // Ensure the full ancestor chain is placed first
+ ensureAncestorsPlaced(directParentId);
+ }
+
+ // Place the leaf option itself
+ const order = parentOrder.get(directParentId)!;
+ const orderSet = parentOrderSet.get(directParentId)!;
+ const leafKey = `leaf:${option.id}`;
+ if (!orderSet.has(leafKey)) {
+ orderSet.add(leafKey);
+ order.push(leafKey);
+ }
+ }
+
+ // Build node map for groups
+ const groupNodes = new Map();
+ for (const group of groups) {
+ groupNodes.set(group.id, {
+ id: group.id,
+ label: group.label,
+ groupId: group.groupId,
+ isGroup: true,
+ children: [],
+ });
+ }
+
+ // Leaf option map for fast lookup
+ const optionByLeafKey = new Map();
+ for (const opt of sortedOptions) {
+ optionByLeafKey.set(`leaf:${opt.id}`, opt);
+ }
+
+ // Recursive builder: given a parent, build its ordered children array
+ const buildChildren = (parentId: string | null): OptionTreeNode[] => {
+ const order = parentOrder.get(parentId) ?? [];
+ const result: OptionTreeNode[] = [];
+ for (const key of order) {
+ if (key.startsWith('leaf:')) {
+ const opt = optionByLeafKey.get(key);
+ if (opt) {
+ result.push({ ...opt, isGroup: false as const });
+ }
+ } else {
+ // It's a group ID
+ const groupNode = groupNodes.get(key);
+ if (groupNode && !placed.has(key)) {
+ placed.add(key);
+ groupNode.children = buildChildren(key);
+ result.push(groupNode);
+ }
+ }
+ }
+ return result;
+ };
+
+ return buildChildren(null);
+}
+
+/**
+ * Recursively flattens an N-level tree back to a flat ContentDisplayItem array.
+ * Only leaf columns are emitted (depth-first order, group children follow the group).
+ */
+export function flattenOptionTree(
+ tree: OptionTreeNode[]
+): ReadonlyArray {
+ const result: CollectionPreferencesProps.ContentDisplayItem[] = [];
+ const walk = (nodes: OptionTreeNode[]) => {
+ for (const node of nodes) {
+ if (node.isGroup) {
+ walk(node.children); // FIXED: recurse into children instead of treating them as leaves
+ } else {
+ result.push({ id: node.id, visible: node.visible });
+ }
+ }
+ };
+ walk(tree);
+ return result;
+}
+
export function getFilteredOptions(options: ReadonlyArray, filterText: string) {
filterText = filterText.trim().toLowerCase();
diff --git a/src/collection-preferences/interfaces.ts b/src/collection-preferences/interfaces.ts
index 5768238b0a..fa8d52ee60 100644
--- a/src/collection-preferences/interfaces.ts
+++ b/src/collection-preferences/interfaces.ts
@@ -229,16 +229,20 @@ export namespace CollectionPreferencesProps {
title?: string;
description?: string;
options: ReadonlyArray;
+ groups?: ReadonlyArray;
enableColumnFiltering?: boolean;
i18nStrings?: ContentDisplayPreferenceI18nStrings;
}
export interface ContentDisplayOption {
id: string;
+ groupId?: string;
label: string;
alwaysVisible?: boolean;
}
+ export type ContentDisplayOptionGroup = Omit;
+
export interface ContentDisplayItem {
id: string;
visible: boolean;
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..ea23fd7d5b
--- /dev/null
+++ b/src/table/__tests__/column-grouping-rendering.test.tsx
@@ -0,0 +1,233 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import * as React from 'react';
+import { render } from '@testing-library/react';
+
+import Table, { TableProps } from '../../../lib/components/table';
+import createWrapper from '../../../lib/components/test-utils/dom';
+
+interface Item {
+ id: number;
+ name: string;
+ cpu: number;
+ memory: number;
+ networkIn: number;
+ type: string;
+ az: string;
+ cost: number;
+}
+
+const items: Item[] = [
+ { id: 1, name: 'web-1', cpu: 45, memory: 62, networkIn: 1250, type: 't3.medium', az: 'us-east-1a', cost: 30 },
+ { id: 2, name: 'api-1', cpu: 78, memory: 81, networkIn: 3420, type: 't3.large', az: 'us-east-1b', cost: 60 },
+];
+
+const columnDefinitions: TableProps.ColumnDefinition- [] = [
+ { id: 'id', header: 'ID', cell: item => item.id, isRowHeader: true },
+ { id: 'name', header: 'Name', cell: item => item.name },
+ { id: 'cpu', header: 'CPU', cell: item => item.cpu, groupId: 'performance' },
+ { id: 'memory', header: 'Memory', cell: item => item.memory, groupId: 'performance' },
+ { id: 'networkIn', header: 'Network In', cell: item => item.networkIn, groupId: 'performance' },
+ { id: 'type', header: 'Type', cell: item => item.type, groupId: 'config' },
+ { id: 'az', header: 'AZ', cell: item => item.az, groupId: 'config' },
+ { id: 'cost', header: 'Cost', cell: item => `$${item.cost}`, groupId: 'pricing' },
+];
+
+const columnGroupingDefinitions: TableProps.ColumnGroupsDefinition
- [] = [
+ { id: 'performance', header: 'Performance' },
+ { id: 'config', header: 'Configuration' },
+ { id: 'pricing', header: 'Pricing' },
+];
+
+function renderTable(props: Partial> = {}) {
+ const { container } = render(
+
+ );
+ const wrapper = createWrapper(container).findTable()!;
+ return { wrapper, container };
+}
+
+describe('Table with column grouping', () => {
+ test('renders multiple header rows when columnGroupingDefinitions are provided', () => {
+ const { wrapper } = renderTable();
+ const thead = wrapper.find('thead')!;
+ const rows = thead.findAll('tr');
+ expect(rows.length).toBeGreaterThan(1);
+ });
+
+ test('renders group header cells with correct text', () => {
+ const { wrapper } = renderTable();
+ const thead = wrapper.find('thead')!;
+ const thElements = thead.findAll('th');
+ const headerTexts = thElements.map(th => th.getElement().textContent?.trim());
+ expect(headerTexts).toContain('Performance');
+ expect(headerTexts).toContain('Configuration');
+ expect(headerTexts).toContain('Pricing');
+ });
+
+ test('renders leaf column headers', () => {
+ const { wrapper } = renderTable();
+ const thead = wrapper.find('thead')!;
+ const thElements = thead.findAll('th');
+ const headerTexts = thElements.map(th => th.getElement().textContent?.trim());
+ expect(headerTexts).toContain('ID');
+ expect(headerTexts).toContain('Name');
+ expect(headerTexts).toContain('CPU');
+ expect(headerTexts).toContain('Memory');
+ expect(headerTexts).toContain('Cost');
+ });
+
+ test('group header cells have correct colspan', () => {
+ const { wrapper } = renderTable();
+ const thead = wrapper.find('thead')!;
+ const thElements = thead.findAll('th');
+
+ // Find the Performance group header
+ const perfHeader = thElements.find(th => th.getElement().textContent?.trim() === 'Performance');
+ expect(perfHeader).toBeDefined();
+ const colspan = perfHeader!.getElement().getAttribute('colspan');
+ // Performance group has cpu, memory, networkIn = 3 columns
+ expect(colspan).toBe('3');
+
+ // Find the Configuration group header
+ const configHeader = thElements.find(th => th.getElement().textContent?.trim() === 'Configuration');
+ expect(configHeader).toBeDefined();
+ const configColspan = configHeader!.getElement().getAttribute('colspan');
+ // Configuration has type, az = 2 columns
+ expect(configColspan).toBe('2');
+ });
+
+ test('renders correct number of body rows', () => {
+ const { wrapper } = renderTable();
+ const rows = wrapper.findAll('tr').filter(row => !row.getElement().closest('thead'));
+ // 2 items
+ expect(rows.length).toBe(2);
+ });
+
+ test('renders correct number of body cells per row', () => {
+ const { wrapper } = renderTable();
+ const bodyRows = wrapper.findAll('tr').filter(row => !row.getElement().closest('thead'));
+ // Each row should have cells for all visible columns
+ bodyRows.forEach(row => {
+ const cells = row.findAll('td');
+ expect(cells.length).toBeGreaterThan(0);
+ });
+ });
+
+ test('aria-rowindex accounts for multiple header rows', () => {
+ const { wrapper } = renderTable();
+ const thead = wrapper.find('thead')!;
+ const headerRows = thead.findAll('tr');
+
+ headerRows.forEach((row, idx) => {
+ expect(row.getElement().getAttribute('aria-rowindex')).toBe(`${idx + 1}`);
+ });
+ });
+
+ test('works with selectionType', () => {
+ const { wrapper } = renderTable({
+ selectionType: 'multi',
+ selectedItems: [],
+ onSelectionChange: () => {},
+ ariaLabels: {
+ selectionGroupLabel: 'Items selection',
+ allItemsSelectionLabel: () => 'Select all',
+ itemSelectionLabel: (_, item) => `Select ${item.name}`,
+ },
+ });
+ const thead = wrapper.find('thead')!;
+ const rows = thead.findAll('tr');
+ expect(rows.length).toBeGreaterThan(1);
+ // Selection checkbox should be in the first header row
+ const firstRowThs = rows[0].findAll('th');
+ expect(firstRowThs.length).toBeGreaterThan(0);
+ });
+
+ test('renders single header row when no columnGroupingDefinitions', () => {
+ const { wrapper } = renderTable({ columnGroupingDefinitions: undefined });
+ const thead = wrapper.find('thead')!;
+ const rows = thead.findAll('tr');
+ expect(rows.length).toBe(1);
+ });
+
+ test('renders with resizableColumns', () => {
+ const { wrapper } = renderTable({ resizableColumns: true });
+ const thead = wrapper.find('thead')!;
+ const rows = thead.findAll('tr');
+ expect(rows.length).toBeGreaterThan(1);
+ });
+
+ test('renders with columnDisplay to control visibility', () => {
+ const { wrapper } = renderTable({
+ columnDisplay: [
+ { id: 'id', visible: true },
+ { id: 'name', visible: true },
+ { id: 'cpu', visible: true },
+ { id: 'memory', visible: false },
+ { id: 'networkIn', visible: false },
+ { id: 'type', visible: false },
+ { id: 'az', visible: false },
+ { id: 'cost', visible: false },
+ ],
+ });
+ const thead = wrapper.find('thead')!;
+ const thElements = thead.findAll('th');
+ const headerTexts = thElements.map(th => th.getElement().textContent?.trim());
+ expect(headerTexts).toContain('ID');
+ expect(headerTexts).toContain('Name');
+ expect(headerTexts).toContain('CPU');
+ // Hidden columns should not be present
+ expect(headerTexts).not.toContain('Memory');
+ expect(headerTexts).not.toContain('Cost');
+ });
+
+ test('renders with nested column groups', () => {
+ const nestedGroupDefs: TableProps.ColumnGroupsDefinition
- [] = [
+ { id: 'performance', header: 'Performance', groupId: 'metrics' },
+ { id: 'metrics', header: 'Metrics' },
+ { id: 'config', header: 'Configuration' },
+ { id: 'pricing', header: 'Pricing' },
+ ];
+ const { wrapper } = renderTable({ columnGroupingDefinitions: nestedGroupDefs });
+ const thead = wrapper.find('thead')!;
+ const thElements = thead.findAll('th');
+ const headerTexts = thElements.map(th => th.getElement().textContent?.trim());
+ expect(headerTexts).toContain('Metrics');
+ expect(headerTexts).toContain('Performance');
+ });
+
+ test('renders colgroup when resizableColumns and grouped', () => {
+ const { container } = renderTable({ resizableColumns: true });
+ const colgroup = container.querySelector('colgroup');
+ expect(colgroup).toBeTruthy();
+ });
+});
+
+describe('Table with column grouping and resizable columns', () => {
+ test('renders with resizable columns enabled', () => {
+ const { wrapper } = renderTable({ resizableColumns: true });
+ const thead = wrapper.find('thead')!;
+ const rows = thead.findAll('tr');
+ // Should still have multiple header rows
+ expect(rows.length).toBeGreaterThan(1);
+ // All group + leaf header cells should be rendered
+ const thElements = thead.findAll('th');
+ expect(thElements.length).toBeGreaterThan(0);
+ });
+
+ test('group headers have scope="colgroup"', () => {
+ const { wrapper } = renderTable();
+ const thead = wrapper.find('thead')!;
+ const thElements = thead.findAll('th');
+
+ const perfHeader = thElements.find(th => th.getElement().textContent?.trim() === 'Performance');
+ if (perfHeader) {
+ expect(perfHeader.getElement().getAttribute('scope')).toBe('colgroup');
+ }
+ });
+});
diff --git a/src/table/__tests__/column-grouping-utils.test.tsx b/src/table/__tests__/column-grouping-utils.test.tsx
new file mode 100644
index 0000000000..f813e90c7b
--- /dev/null
+++ b/src/table/__tests__/column-grouping-utils.test.tsx
@@ -0,0 +1,634 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { CalculateHierarchyTree, TableHeaderNode } from '../column-grouping-utils';
+import { TableProps } from '../interfaces';
+
+describe('column-grouping-utils', () => {
+ describe('TableHeaderNode', () => {
+ it('creates node with basic 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.subtreeHeight).toBe(1);
+ expect(node.children).toEqual([]);
+ expect(node.rowIndex).toBe(-1);
+ expect(node.colIndex).toBe(-1);
+ expect(node.isRoot).toBe(false);
+ });
+
+ it('creates node with options', () => {
+ const columnDef: TableProps.ColumnDefinition = {
+ id: 'test',
+ header: 'Test',
+ cell: () => 'test',
+ };
+
+ const node = new TableHeaderNode('test', {
+ colspan: 2,
+ rowspan: 3,
+ columnDefinition: columnDef,
+ rowIndex: 1,
+ colIndex: 2,
+ });
+
+ expect(node.colspan).toBe(2);
+ expect(node.rowspan).toBe(3);
+ expect(node.columnDefinition).toBe(columnDef);
+ expect(node.rowIndex).toBe(1);
+ expect(node.colIndex).toBe(2);
+ });
+
+ it('creates root node', () => {
+ const node = new TableHeaderNode('root', { isRoot: true });
+
+ expect(node.isRoot).toBe(true);
+ expect(node.isRootNode).toBe(true);
+ });
+
+ it('identifies group nodes correctly', () => {
+ const groupNode = new TableHeaderNode('group', {
+ groupDefinition: { id: 'group', header: 'Group' },
+ });
+ const colNode = new TableHeaderNode('col', {
+ columnDefinition: { id: 'col', header: 'Col', cell: () => 'col' },
+ });
+
+ expect(groupNode.isGroup).toBe(true);
+ expect(colNode.isGroup).toBe(false);
+ });
+
+ it('identifies leaf nodes correctly', () => {
+ const parent = new TableHeaderNode('parent');
+ const child = new TableHeaderNode('child');
+ const root = new TableHeaderNode('root', { isRoot: true });
+
+ parent.addChild(child);
+
+ expect(parent.isLeaf).toBe(false);
+ expect(child.isLeaf).toBe(true);
+ expect(root.isLeaf).toBe(false); // root is never a leaf
+ });
+
+ it('adds child and sets parent relationship', () => {
+ const parent = new TableHeaderNode('parent');
+ const child = new TableHeaderNode('child');
+
+ parent.addChild(child);
+
+ expect(parent.children).toHaveLength(1);
+ expect(parent.children[0]).toBe(child);
+ expect(child.parentNode).toBe(parent);
+ });
+
+ it('maintains children order', () => {
+ const parent = new TableHeaderNode('parent');
+ const child1 = new TableHeaderNode('child1');
+ const child2 = new TableHeaderNode('child2');
+ const child3 = new TableHeaderNode('child3');
+
+ parent.addChild(child1);
+ parent.addChild(child2);
+ parent.addChild(child3);
+
+ expect(parent.children[0]).toBe(child1);
+ expect(parent.children[1]).toBe(child2);
+ expect(parent.children[2]).toBe(child3);
+ });
+ });
+
+ describe('CalculateHierarchyTree', () => {
+ describe('no grouping', () => {
+ it('returns single row with all visible columns', () => {
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'col1', header: 'Col 1', cell: () => 'col1' },
+ { id: 'col2', header: 'Col 2', cell: () => 'col2' },
+ { id: 'col3', header: 'Col 3', cell: () => 'col3' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['col1', 'col2', 'col3'], []);
+
+ expect(result?.maxDepth).toBe(1);
+ expect(result?.rows).toHaveLength(1);
+ expect(result?.rows[0].columns).toHaveLength(3);
+ expect(result?.rows[0].columns.map(c => c.id)).toEqual(['col1', 'col2', 'col3']);
+ });
+
+ it('all columns have rowspan=1, colspan=1', () => {
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'col1', header: 'Col 1', cell: () => 'col1' },
+ { id: 'col2', header: 'Col 2', cell: () => 'col2' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['col1', 'col2'], []);
+
+ result?.rows[0].columns.forEach(col => {
+ expect(col.rowspan).toBe(1);
+ expect(col.colspan).toBe(1);
+ expect(col.isGroup).toBe(false);
+ });
+ });
+
+ it('assigns sequential colIndex values', () => {
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'a', header: 'A', cell: () => 'a' },
+ { id: 'b', header: 'B', cell: () => 'b' },
+ { id: 'c', header: 'C', cell: () => 'c' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['a', 'b', 'c'], []);
+
+ expect(result?.rows[0].columns[0].colIndex).toBe(0);
+ expect(result?.rows[0].columns[1].colIndex).toBe(1);
+ expect(result?.rows[0].columns[2].colIndex).toBe(2);
+ });
+
+ it('columnToParentIds is empty for ungrouped columns', () => {
+ const columns: TableProps.ColumnDefinition[] = [{ id: 'col1', header: 'Col 1', cell: () => 'col1' }];
+
+ const result = CalculateHierarchyTree(columns, ['col1'], []);
+
+ expect(result?.columnToParentIds.size).toBe(0);
+ });
+ });
+
+ describe('flat grouping', () => {
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'id', header: 'ID', cell: () => 'id' },
+ { id: 'name', header: 'Name', cell: () => 'name' },
+ { id: 'cpu', header: 'CPU', groupId: 'perf', cell: () => 'cpu' },
+ { id: 'memory', header: 'Memory', groupId: 'perf', cell: () => 'memory' },
+ { id: 'type', header: 'Type', groupId: 'config', cell: () => 'type' },
+ ];
+
+ const groups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'perf', header: 'Performance' },
+ { id: 'config', header: 'Configuration' },
+ ];
+
+ it('creates two rows', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ expect(result?.maxDepth).toBe(2);
+ expect(result?.rows).toHaveLength(2);
+ });
+
+ it('row 0 has ungrouped columns with rowspan=2', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ const row0 = result?.rows[0].columns;
+ const idCol = row0?.find(c => c.id === 'id');
+ const nameCol = row0?.find(c => c.id === 'name');
+
+ expect(idCol?.rowspan).toBe(2);
+ expect(idCol?.colspan).toBe(1);
+ expect(nameCol?.rowspan).toBe(2);
+ expect(nameCol?.colspan).toBe(1);
+ });
+
+ it('row 0 has group headers with rowspan=1 and correct colspan', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ const row0 = result?.rows[0].columns;
+ const perfGroup = row0?.find(c => c.id === 'perf');
+ const configGroup = row0?.find(c => c.id === 'config');
+
+ expect(perfGroup?.isGroup).toBe(true);
+ expect(perfGroup?.rowspan).toBe(1);
+ expect(perfGroup?.colspan).toBe(2); // cpu + memory
+
+ expect(configGroup?.isGroup).toBe(true);
+ expect(configGroup?.rowspan).toBe(1);
+ expect(configGroup?.colspan).toBe(1); // type only
+ });
+
+ it('row 1 has only grouped columns', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ const row1 = result?.rows[1].columns;
+
+ expect(row1?.length).toBe(3); // cpu, memory, type
+ expect(row1?.map(c => c.id)).toEqual(['cpu', 'memory', 'type']);
+ row1?.forEach(col => {
+ expect(col.isGroup).toBe(false);
+ expect(col.rowspan).toBe(1);
+ expect(col.colspan).toBe(1);
+ });
+ });
+
+ it('maintains correct column order', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ const row0 = result?.rows[0].columns;
+ expect(row0?.map(c => c.id)).toEqual(['id', 'name', 'perf', 'config']);
+ });
+
+ it('assigns correct colIndex across rows', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ const row0 = result?.rows[0].columns;
+ expect(row0?.find(c => c.id === 'id')?.colIndex).toBe(0);
+ expect(row0?.find(c => c.id === 'name')?.colIndex).toBe(1);
+ expect(row0?.find(c => c.id === 'perf')?.colIndex).toBe(2);
+ expect(row0?.find(c => c.id === 'config')?.colIndex).toBe(4);
+
+ const row1 = result?.rows[1].columns;
+ expect(row1?.find(c => c.id === 'cpu')?.colIndex).toBe(2);
+ expect(row1?.find(c => c.id === 'memory')?.colIndex).toBe(3);
+ expect(row1?.find(c => c.id === 'type')?.colIndex).toBe(4);
+ });
+
+ it('tracks parent IDs for grouped columns', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ expect(result?.columnToParentIds.get('cpu')).toEqual(['perf']);
+ expect(result?.columnToParentIds.get('memory')).toEqual(['perf']);
+ expect(result?.columnToParentIds.get('type')).toEqual(['config']);
+ expect(result?.columnToParentIds.has('id')).toBe(false);
+ expect(result?.columnToParentIds.has('name')).toBe(false);
+ });
+
+ it('includes parentGroupIds in column metadata', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ const row1 = result?.rows[1].columns;
+ const cpuCol = row1?.find(c => c.id === 'cpu');
+
+ expect(cpuCol?.parentGroupIds).toEqual(['perf']);
+ });
+ });
+
+ describe('nested grouping', () => {
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'cpu', header: 'CPU', groupId: 'perf', cell: () => 'cpu' },
+ { id: 'memory', header: 'Memory', groupId: 'perf', cell: () => 'memory' },
+ ];
+
+ const groups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'metrics', header: 'Metrics' },
+ { id: 'perf', header: 'Performance', groupId: 'metrics' },
+ ];
+
+ it('creates correct number of rows for 2-level nesting', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['cpu', 'memory'],
+ groups
+ // []
+ );
+
+ expect(result?.maxDepth).toBe(3);
+ expect(result?.rows).toHaveLength(3);
+ });
+
+ it('places groups in correct rows', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['cpu', 'memory'],
+ groups
+ // []
+ );
+
+ // Row 0: metrics (top-level group)
+ expect(result?.rows[0].columns).toHaveLength(1);
+ expect(result?.rows[0].columns[0].id).toBe('metrics');
+ expect(result?.rows[0].columns[0].rowIndex).toBe(0);
+
+ // Row 1: perf (nested group)
+ expect(result?.rows[1].columns).toHaveLength(1);
+ expect(result?.rows[1].columns[0].id).toBe('perf');
+ expect(result?.rows[1].columns[0].rowIndex).toBe(1);
+
+ // Row 2: leaf columns
+ expect(result?.rows[2].columns).toHaveLength(2);
+ expect(result?.rows[2].columns.map(c => c.id)).toEqual(['cpu', 'memory']);
+ });
+
+ it('calculates correct colspan for nested groups', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['cpu', 'memory'],
+ groups
+ // []
+ );
+
+ expect(result?.rows[0].columns[0].colspan).toBe(2); // metrics spans both columns
+ expect(result?.rows[1].columns[0].colspan).toBe(2); // perf spans both columns
+ expect(result?.rows[2].columns[0].colspan).toBe(1); // cpu
+ expect(result?.rows[2].columns[1].colspan).toBe(1); // memory
+ });
+
+ it('tracks full parent chain for nested groups', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['cpu', 'memory'],
+ groups
+ // []
+ );
+
+ expect(result?.columnToParentIds.get('cpu')).toEqual(['metrics', 'perf']);
+ expect(result?.columnToParentIds.get('memory')).toEqual(['metrics', 'perf']);
+ });
+
+ it('handles 3-level nesting', () => {
+ const deepGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'level1', header: 'Level 1' },
+ { id: 'level2', header: 'Level 2', groupId: 'level1' },
+ { id: 'level3', header: 'Level 3', groupId: 'level2' },
+ ];
+ const deepCols: TableProps.ColumnDefinition[] = [
+ { id: 'col', header: 'Col', groupId: 'level3', cell: () => 'col' },
+ ];
+
+ const result = CalculateHierarchyTree(deepCols, ['col'], deepGroups);
+
+ expect(result?.maxDepth).toBe(4);
+ expect(result?.rows).toHaveLength(4);
+ expect(result?.columnToParentIds.get('col')).toEqual(['level1', 'level2', 'level3']);
+ });
+
+ it('handles mixed nested and flat groups', () => {
+ const mixedGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'metrics', header: 'Metrics' },
+ { id: 'perf', header: 'Performance', groupId: 'metrics' },
+ { id: 'config', header: 'Config' }, // flat
+ ];
+ const mixedCols: TableProps.ColumnDefinition[] = [
+ { id: 'cpu', header: 'CPU', groupId: 'perf', cell: () => 'cpu' },
+ { id: 'type', header: 'Type', groupId: 'config', cell: () => 'type' },
+ ];
+
+ const result = CalculateHierarchyTree(mixedCols, ['cpu', 'type'], mixedGroups);
+
+ expect(result?.maxDepth).toBe(3);
+
+ console.log(JSON.stringify(result?.rows));
+
+ // Row 0: metrics and config
+ expect(result?.rows[0].columns.map(c => c.id)).toEqual(['metrics', 'config']);
+
+ // Row 1: perf only (config's children in row 2)
+ expect(result?.rows[1].columns.map(c => c.id)).toEqual(['perf']);
+
+ // Row 2: both leaf columns
+ expect(result?.rows[2].columns.map(c => c.id)).toEqual(['cpu', 'type']);
+ });
+
+ it('handles multiple trees at same level', () => {
+ const parallelGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'tree1', header: 'Tree 1' },
+ { id: 'tree1child', header: 'Tree 1 Child', groupId: 'tree1' },
+ { id: 'tree2', header: 'Tree 2' },
+ { id: 'tree2child', header: 'Tree 2 Child', groupId: 'tree2' },
+ ];
+ const parallelCols: TableProps.ColumnDefinition[] = [
+ { id: 'col1', header: 'Col 1', groupId: 'tree1child', cell: () => 'col1' },
+ { id: 'col2', header: 'Col 2', groupId: 'tree2child', cell: () => 'col2' },
+ ];
+
+ const result = CalculateHierarchyTree(parallelCols, ['col1', 'col2'], parallelGroups);
+
+ expect(result?.maxDepth).toBe(3);
+ expect(result?.rows[0].columns.map(c => c.id)).toEqual(['tree1', 'tree2']);
+ expect(result?.rows[1].columns.map(c => c.id)).toEqual(['tree1child', 'tree2child']);
+ expect(result?.rows[2].columns.map(c => c.id)).toEqual(['col1', 'col2']);
+ });
+ });
+
+ describe('mixed ungrouped and grouped columns', () => {
+ it('ungrouped columns span all rows', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }];
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'ungrouped', header: 'Ungrouped', cell: () => 'ungrouped' },
+ { id: 'grouped', header: 'Grouped', groupId: 'group1', cell: () => 'grouped' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['ungrouped', 'grouped'], groups);
+
+ const ungroupedCol = result?.rows[0].columns.find(c => c.id === 'ungrouped');
+ expect(ungroupedCol?.rowspan).toBe(2); // spans both rows
+ expect(ungroupedCol?.rowIndex).toBe(0);
+ });
+
+ it('maintains correct column order with mixed types', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }];
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'a', header: 'A', cell: () => 'a' },
+ { id: 'b', header: 'B', groupId: 'group1', cell: () => 'b' },
+ { id: 'c', header: 'C', cell: () => 'c' },
+ { id: 'd', header: 'D', groupId: 'group1', cell: () => 'd' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['a', 'b', 'c', 'd'], groups);
+
+ expect(result?.rows[0].columns.map(c => c.id)).toEqual(['a', 'group1', 'c']);
+ expect(result?.rows[1].columns.map(c => c.id)).toEqual(['b', 'd']);
+ });
+ });
+
+ describe('visibility filtering', () => {
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'col1', header: 'Col 1', cell: () => 'col1' },
+ { id: 'col2', header: 'Col 2', cell: () => 'col2' },
+ { id: 'col3', header: 'Col 3', groupId: 'group1', cell: () => 'col3' },
+ { id: 'col4', header: 'Col 4', groupId: 'group1', cell: () => 'col4' },
+ ];
+
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }];
+
+ it('only includes visible columns', () => {
+ const result = CalculateHierarchyTree(columns, ['col1', 'col3'], groups);
+
+ const allColumnIds = result?.rows.flatMap(row => row.columns.map(c => c.id));
+ expect(allColumnIds).toContain('col1');
+ expect(allColumnIds).toContain('col3');
+ expect(allColumnIds).toContain('group1');
+ expect(allColumnIds).not.toContain('col2');
+ expect(allColumnIds).not.toContain('col4');
+ });
+
+ it('adjusts group colspan when some children hidden', () => {
+ const result = CalculateHierarchyTree(columns, ['col1', 'col3'], groups);
+
+ const group = result?.rows[0].columns.find(c => c.id === 'group1');
+ expect(group?.colspan).toBe(1); // only col3 visible
+ });
+
+ it('hides group when all children hidden', () => {
+ const result = CalculateHierarchyTree(columns, ['col1', 'col2'], groups);
+
+ const allIds = result?.rows[0].columns.map(c => c.id);
+ expect(allIds).not.toContain('group1');
+ });
+
+ it('respects columnDisplay visibility settings', () => {
+ const columnDisplay: TableProps.ColumnDisplayProperties[] = [
+ { id: 'col1', visible: true },
+ { id: 'col2', visible: false },
+ { id: 'col3', visible: true },
+ { id: 'col4', visible: true },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['col1', 'col2', 'col3', 'col4'], groups, columnDisplay);
+
+ const leafColumns = result?.rows[result.rows.length - 1].columns;
+ expect(leafColumns?.map(c => c.id)).not.toContain('col2');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles empty column list', () => {
+ const result = CalculateHierarchyTree([], [], []);
+
+ expect(result?.rows).toHaveLength(0);
+ expect(result?.maxDepth).toBe(0);
+ // expect(result?.rows[0].columns).toHaveLength(0);
+ });
+
+ it('handles column without id', () => {
+ const columns: TableProps.ColumnDefinition[] = [
+ { header: 'No ID', cell: () => 'noid' } as any,
+ { id: 'withid', header: 'With ID', cell: () => 'withid' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['withid'], []);
+
+ // Column without id should be skipped
+ expect(result?.rows[0].columns).toHaveLength(1);
+ expect(result?.rows[0].columns[0].id).toBe('withid');
+ });
+
+ it('handles group without id', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [
+ { header: 'No ID' } as any,
+ { id: 'valid', header: 'Valid' },
+ ];
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'col', header: 'Col', groupId: 'valid', cell: () => 'col' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['col'], groups);
+
+ // Group without id should be skipped
+ const groupIds = result?.rows[0].columns.filter(c => c.isGroup).map(c => c.id);
+ expect(groupIds).toEqual(['valid']);
+ });
+
+ it('handles column referencing non-existent group', () => {
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'orphan', header: 'Orphan', groupId: 'nonexistent', cell: () => 'orphan' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['orphan'], []);
+
+ // Should treat as ungrouped
+ expect(result?.rows).toHaveLength(1);
+ expect(result?.rows[0].columns[0]).toMatchObject({
+ id: 'orphan',
+ rowspan: 1,
+ isGroup: false,
+ });
+ expect(result?.columnToParentIds.has('orphan')).toBe(false);
+ });
+
+ it('handles group referencing non-existent parent', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'orphan', header: 'Orphan', groupId: 'nonexistent' },
+ ];
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'col', header: 'Col', groupId: 'orphan', cell: () => 'col' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['col'], groups);
+
+ // Orphan group should be top-level
+ expect(result?.rows[0].columns.find(c => c.id === 'orphan')).toBeDefined();
+ expect(result?.columnToParentIds.get('col')).toEqual(['orphan']);
+ });
+
+ it('handles all columns in one group', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'all', header: 'All' }];
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'a', header: 'A', groupId: 'all', cell: () => 'a' },
+ { id: 'b', header: 'B', groupId: 'all', cell: () => 'b' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, ['a', 'b'], groups);
+
+ expect(result?.rows).toHaveLength(2);
+ expect(result?.rows[0].columns).toHaveLength(1); // only group header
+ expect(result?.rows[1].columns).toHaveLength(2); // both columns
+ });
+
+ it('handles single column', () => {
+ const columns: TableProps.ColumnDefinition[] = [{ id: 'only', header: 'Only', cell: () => 'only' }];
+
+ const result = CalculateHierarchyTree(columns, ['only'], []);
+
+ expect(result?.maxDepth).toBe(1);
+ expect(result?.rows).toHaveLength(1);
+ expect(result?.rows[0].columns).toHaveLength(1);
+ expect(result?.rows[0].columns[0]).toMatchObject({
+ id: 'only',
+ colspan: 1,
+ rowspan: 1,
+ colIndex: 0,
+ });
+ });
+
+ it('handles group with no visible children', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'empty', header: 'Empty' }];
+ const columns: TableProps.ColumnDefinition[] = [
+ { id: 'hidden', header: 'Hidden', groupId: 'empty', cell: () => 'hidden' },
+ ];
+
+ const result = CalculateHierarchyTree(columns, [], groups);
+
+ // Group with no visible children should not appear
+ expect(result?.rows.length).toEqual(0);
+ // expect(result?.rows[0].columns.find(c => c.id === 'empty')).toBeUndefined();
+ });
+ });
+ });
+});
diff --git a/src/table/__tests__/use-column-grouping.test.tsx b/src/table/__tests__/use-column-grouping.test.tsx
new file mode 100644
index 0000000000..dd128ae3ad
--- /dev/null
+++ b/src/table/__tests__/use-column-grouping.test.tsx
@@ -0,0 +1,561 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { renderHook } from '../../__tests__/render-hook';
+import { CalculateHierarchyTree } from '../column-grouping-utils';
+import { TableProps } from '../interfaces';
+import { useColumnGrouping } from '../use-column-grouping';
+
+describe('useColumnGrouping', () => {
+ const mockColumns: TableProps.ColumnDefinition[] = [
+ { id: 'id', header: 'ID', cell: () => 'id' },
+ { id: 'name', header: 'Name', cell: () => 'name' },
+ { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' },
+ { id: 'memory', header: 'Memory', groupId: 'performance', cell: () => 'memory' },
+ { id: 'type', header: 'Type', groupId: 'config', cell: () => 'type' },
+ { id: 'az', header: 'AZ', groupId: 'config', cell: () => 'az' },
+ ];
+
+ const mockGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'performance', header: 'Performance' },
+ { id: 'config', header: 'Configuration' },
+ ];
+
+ describe('no grouping', () => {
+ it('returns single row when no groups defined', () => {
+ const { result } = renderHook(() => useColumnGrouping(undefined, mockColumns));
+
+ expect(result.current.maxDepth).toBe(1);
+ expect(result.current.rows).toHaveLength(1);
+ expect(result.current.rows[0].columns).toHaveLength(6);
+ expect(result.current.rows[0].columns[0]).toMatchObject({
+ id: 'id',
+ colspan: 1,
+ rowspan: 1,
+ isGroup: false,
+ });
+ });
+
+ it('returns single row when groups array is empty', () => {
+ const { result } = renderHook(() => useColumnGrouping([], mockColumns));
+
+ expect(result.current.maxDepth).toBe(1);
+ expect(result.current.rows).toHaveLength(1);
+ });
+ });
+
+ describe('flat grouping', () => {
+ it('creates two rows with grouped and ungrouped columns', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ expect(result.current.maxDepth).toBe(2);
+ expect(result.current.rows).toHaveLength(2);
+ });
+
+ it('row 0 contains ungrouped columns with rowspan and group headers', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ const row0 = result.current.rows[0].columns;
+
+ // Ungrouped columns
+ expect(row0[0]).toMatchObject({
+ id: 'id',
+ header: 'ID',
+ colspan: 1,
+ rowspan: 2,
+ isGroup: false,
+ });
+ expect(row0[1]).toMatchObject({
+ id: 'name',
+ header: 'Name',
+ colspan: 1,
+ rowspan: 2,
+ isGroup: false,
+ });
+
+ // Group headers
+ expect(row0[2]).toMatchObject({
+ id: 'performance',
+ header: 'Performance',
+ colspan: 2,
+ rowspan: 1,
+ isGroup: true,
+ });
+ expect(row0[3]).toMatchObject({
+ id: 'config',
+ header: 'Configuration',
+ colspan: 2,
+ rowspan: 1,
+ isGroup: true,
+ });
+ });
+
+ it('row 1 contains only grouped columns', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ const row1 = result.current.rows[1].columns;
+
+ expect(row1).toHaveLength(4);
+ expect(row1[0]).toMatchObject({
+ id: 'cpu',
+ header: 'CPU',
+ colspan: 1,
+ rowspan: 1,
+ isGroup: false,
+ });
+ expect(row1[1]).toMatchObject({
+ id: 'memory',
+ header: 'Memory',
+ colspan: 1,
+ rowspan: 1,
+ isGroup: false,
+ });
+ });
+
+ it('maintains column order from columnDefinitions', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ const row0 = result.current.rows[0].columns;
+ expect(row0.map((c: any) => c.id)).toEqual(['id', 'name', 'performance', 'config']);
+
+ const row1 = result.current.rows[1].columns;
+ expect(row1.map((c: any) => c.id)).toEqual(['cpu', 'memory', 'type', 'az']);
+ });
+ });
+
+ describe('visibility filtering', () => {
+ it('filters columns by visibleColumnIds', () => {
+ const visibleIds = new Set(['id', 'cpu', 'memory']);
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns, visibleIds));
+
+ const row0 = result.current.rows[0].columns;
+ expect(row0).toHaveLength(2); // id + performance group
+ expect(row0[0].id).toBe('id');
+ expect(row0[1].id).toBe('performance');
+
+ const row1 = result.current.rows[1].columns;
+ expect(row1).toHaveLength(2); // cpu + memory
+ });
+
+ it('hides group when all children are hidden', () => {
+ const visibleIds = new Set(['id', 'name', 'type', 'az']);
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns, visibleIds));
+
+ const row0 = result.current.rows[0].columns;
+ const groupIds = row0.filter((c: any) => c.isGroup).map((c: any) => c.id);
+ expect(groupIds).toEqual(['config']); // performance group hidden
+ });
+
+ it('adjusts colspan when some children are hidden', () => {
+ const visibleIds = new Set(['id', 'cpu', 'type', 'az']);
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns, visibleIds));
+
+ const row0 = result.current.rows[0].columns;
+ const perfGroup = row0.find((c: any) => c.id === 'performance');
+ expect(perfGroup?.colspan).toBe(1); // only cpu visible
+ });
+ });
+
+ describe('parent tracking', () => {
+ it('tracks parent group IDs for grouped columns', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ expect(result.current.columnToParentIds.get('cpu')).toEqual(['performance']);
+ expect(result.current.columnToParentIds.get('memory')).toEqual(['performance']);
+ expect(result.current.columnToParentIds.get('type')).toEqual(['config']);
+ });
+
+ it('returns empty array for ungrouped columns', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ expect(result.current.columnToParentIds.get('id')).toBeUndefined();
+ expect(result.current.columnToParentIds.get('name')).toBeUndefined();
+ });
+ });
+
+ describe('column indices', () => {
+ it('assigns sequential colIndex values', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ const row0 = result.current.rows[0].columns;
+ expect(row0[0].colIndex).toBe(0); // id
+ expect(row0[1].colIndex).toBe(1); // name
+ expect(row0[2].colIndex).toBe(2); // performance group starts at 2
+ expect(row0[3].colIndex).toBe(4); // config group starts at 4
+
+ const row1 = result.current.rows[1].columns;
+ // Row 1 colIndex accounts for ungrouped columns from row 0
+ expect(row1[0].colIndex).toBe(2); // cpu (after id=0, name=1)
+ expect(row1[1].colIndex).toBe(3); // memory
+ expect(row1[2].colIndex).toBe(4); // type
+ expect(row1[3].colIndex).toBe(5); // az
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles columns without IDs', () => {
+ const columnsNoIds: TableProps.ColumnDefinition[] = [
+ { header: 'Col1', cell: () => 'col1' },
+ { header: 'Col2', groupId: 'group1', cell: () => 'col2' },
+ ];
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }];
+
+ const { result } = renderHook(() => useColumnGrouping(groups, columnsNoIds));
+
+ // Columns without IDs are skipped by CalculateHierarchyTree, resulting in empty rows
+ expect(result.current.rows).toBeDefined();
+ expect(result.current.rows.length).toBe(0);
+ });
+
+ it('handles group without header', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'performance', header: undefined }];
+
+ const { result } = renderHook(() => useColumnGrouping(groups, mockColumns));
+
+ const perfGroup = result.current.rows[0].columns.find((c: any) => c.id === 'performance');
+ // When header is undefined, it stays undefined (no fallback to id)
+ expect(perfGroup?.header).toBeUndefined();
+ });
+
+ it('handles all columns ungrouped', () => {
+ const ungroupedCols: TableProps.ColumnDefinition[] = [
+ { id: 'a', header: 'A', cell: () => 'a' },
+ { id: 'b', header: 'B', cell: () => 'b' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, ungroupedCols));
+
+ // When groups are defined but no columns use them, we should have only 1 row
+ expect(result.current.rows).toHaveLength(1);
+ expect(result.current.rows[0].columns).toHaveLength(2);
+ expect(result.current.rows[0].columns[0].rowspan).toBe(1);
+ });
+
+ it('handles all columns grouped', () => {
+ const allGrouped: TableProps.ColumnDefinition[] = [
+ { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' },
+ { id: 'memory', header: 'Memory', groupId: 'performance', cell: () => 'memory' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, allGrouped));
+
+ expect(result.current.rows).toHaveLength(2);
+ expect(result.current.rows[0].columns).toHaveLength(1); // only group header
+ expect(result.current.rows[1].columns).toHaveLength(2); // both columns
+ });
+ });
+
+ describe('nested groups', () => {
+ it('handles nested group definitions', () => {
+ const nestedGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'metrics', header: 'Metrics' },
+ { id: 'performance', header: 'Performance', groupId: 'metrics' },
+ ];
+ const nestedCols: TableProps.ColumnDefinition[] = [
+ { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(nestedGroups, nestedCols));
+
+ expect(result.current.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']);
+ });
+
+ it('creates correct number of rows for nested groups', () => {
+ const nestedGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'metrics', header: 'Metrics' },
+ { id: 'performance', header: 'Performance', groupId: 'metrics' },
+ ];
+ const nestedCols: TableProps.ColumnDefinition[] = [
+ { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' },
+ { id: 'memory', header: 'Memory', groupId: 'performance', cell: () => 'memory' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(nestedGroups, nestedCols));
+
+ // Should have 3 rows: row 0 (metrics), row 1 (performance), row 2 (leaf columns)
+ expect(result.current.maxDepth).toBe(3);
+ expect(result.current.rows).toHaveLength(3);
+ });
+
+ it('places nested groups in correct rows', () => {
+ const nestedGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'metrics', header: 'Metrics' },
+ { id: 'performance', header: 'Performance', groupId: 'metrics' },
+ ];
+ const nestedCols: TableProps.ColumnDefinition[] = [
+ { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' },
+ { id: 'memory', header: 'Memory', groupId: 'performance', cell: () => 'memory' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(nestedGroups, nestedCols));
+
+ // Row 0 should have metrics group
+ expect(result.current.rows[0].columns).toHaveLength(1);
+ expect(result.current.rows[0].columns[0]).toMatchObject({
+ id: 'metrics',
+ isGroup: true,
+ colspan: 2,
+ });
+
+ // Row 1 should have performance group
+ expect(result.current.rows[1].columns).toHaveLength(1);
+ expect(result.current.rows[1].columns[0]).toMatchObject({
+ id: 'performance',
+ isGroup: true,
+ colspan: 2,
+ });
+
+ // Row 2 should have leaf columns
+ expect(result.current.rows[2].columns).toHaveLength(2);
+ expect(result.current.rows[2].columns[0].id).toBe('cpu');
+ expect(result.current.rows[2].columns[1].id).toBe('memory');
+ });
+
+ it('handles 3-level nesting', () => {
+ const deepGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'level1', header: 'Level 1' },
+ { id: 'level2', header: 'Level 2', groupId: 'level1' },
+ { id: 'level3', header: 'Level 3', groupId: 'level2' },
+ ];
+ const deepCols: TableProps.ColumnDefinition[] = [
+ { id: 'col1', header: 'Col 1', groupId: 'level3', cell: () => 'col1' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(deepGroups, deepCols));
+
+ expect(result.current.maxDepth).toBe(4);
+ expect(result.current.rows).toHaveLength(4);
+ expect(result.current.columnToParentIds.get('col1')).toEqual(['level1', 'level2', 'level3']);
+ });
+
+ it('handles mixed nested and flat groups', () => {
+ const mixedGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'metrics', header: 'Metrics' },
+ { id: 'performance', header: 'Performance', groupId: 'metrics' },
+ { id: 'config', header: 'Configuration' }, // flat group
+ ];
+ const mixedCols: TableProps.ColumnDefinition[] = [
+ { id: 'id', header: 'ID', cell: () => 'id' }, // ungrouped
+ { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' },
+ { id: 'type', header: 'Type', groupId: 'config', cell: () => 'type' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(mixedGroups, mixedCols));
+
+ expect(result.current.maxDepth).toBe(3);
+ expect(result.current.rows).toHaveLength(3);
+
+ // Row 0: ungrouped column (id), metrics group, config group
+ expect(result.current.rows[0].columns).toHaveLength(3);
+ expect(result.current.rows[0].columns[0]).toMatchObject({
+ id: 'id',
+ rowspan: 3,
+ isGroup: false,
+ });
+ expect(result.current.rows[0].columns[1]).toMatchObject({
+ id: 'metrics',
+ isGroup: true,
+ });
+ expect(result.current.rows[0].columns[2]).toMatchObject({
+ id: 'config',
+ isGroup: true,
+ });
+
+ // Row 1: performance group
+ expect(result.current.rows[1].columns).toHaveLength(1);
+ expect(result.current.rows[1].columns[0]).toMatchObject({
+ id: 'performance',
+ isGroup: true,
+ });
+
+ // Row 2: leaf columns (cpu, type)
+ expect(result.current.rows[2].columns).toHaveLength(2);
+ });
+
+ it('prevents circular references in nested groups', () => {
+ const circularGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'a', header: 'A', groupId: 'b' },
+ { id: 'b', header: 'B', groupId: 'a' },
+ ];
+ const cols: TableProps.ColumnDefinition[] = [{ id: 'col', header: 'Col', groupId: 'a', cell: () => 'col' }];
+
+ const { result } = renderHook(() => useColumnGrouping(circularGroups, cols));
+
+ // Should not crash and should handle gracefully
+ expect(result.current.rows).toBeDefined();
+ // Circular groups should be detected and one will be marked as circular
+ // The column referencing the circular group will be treated as ungrouped
+ const allGroupIds = result.current.rows.flatMap(row => row.columns.filter(c => c.isGroup).map(c => c.id));
+ // At least one group should be excluded or treated specially
+ expect(allGroupIds.length).toBeLessThanOrEqual(1);
+ });
+
+ it('handles non-existent parent group reference', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'child', header: 'Child', groupId: 'nonexistent' },
+ ];
+ const cols: TableProps.ColumnDefinition[] = [
+ { id: 'col', header: 'Col', groupId: 'child', cell: () => 'col' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(groups, cols));
+
+ // Should treat child as top-level group
+ expect(result.current.rows[0].columns.find(c => c.id === 'child')).toBeDefined();
+ // Parent chain should only include 'child', not the non-existent parent
+ expect(result.current.columnToParentIds.get('col')).toEqual(['child']);
+ });
+
+ it('handles column referencing non-existent group', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }];
+ const cols: TableProps.ColumnDefinition[] = [
+ { id: 'col1', header: 'Col 1', groupId: 'nonexistent', cell: () => 'col1' },
+ { id: 'col2', header: 'Col 2', groupId: 'group1', cell: () => 'col2' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(groups, cols));
+
+ // col1 should be treated as ungrouped (no entry in columnToParentIds)
+ expect(result.current.columnToParentIds.get('col1')).toBeUndefined();
+ // col1 should appear in row 0 with rowspan
+ const col1InRow0 = result.current.rows[0].columns.find(c => c.id === 'col1');
+ expect(col1InRow0).toBeDefined();
+ expect(col1InRow0?.rowspan).toBe(result.current.maxDepth);
+ });
+
+ it('handles group without id (should be skipped)', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [
+ { id: '', header: 'Invalid Group' } as any,
+ { id: 'valid', header: 'Valid Group' },
+ ];
+ const cols: TableProps.ColumnDefinition[] = [
+ { id: 'col', header: 'Col', groupId: 'valid', cell: () => 'col' },
+ ];
+
+ const { result } = renderHook(() => useColumnGrouping(groups, cols));
+
+ // Should only have valid group
+ const groupIds = result.current.rows[0].columns.filter(c => c.isGroup).map(c => c.id);
+ expect(groupIds).toEqual(['valid']);
+ });
+ });
+
+ describe('CalculateHierarchyTree direct tests for edge cases', () => {
+ it('skips columns with undefined id in visibleLeafColumns', () => {
+ // Column has no id → line 62 early return in createNodeConnections
+ const cols: TableProps.ColumnDefinition[] = [{ header: 'No ID', cell: () => 'x' }];
+ const result = CalculateHierarchyTree(cols, ['col-0'], [], undefined);
+ // No columns with ids → empty rows
+ expect(result.rows).toHaveLength(0);
+ });
+
+ it('skips columns whose id is not in the node map (not visible)', () => {
+ // Column has id but is not in visibleColumnIds → getVisibleColumnDefinitions filters it out
+ // Then createNodeConnections iterates visibleColumns but the node is not in idToNodeMap → line 67
+ const cols: TableProps.ColumnDefinition[] = [
+ { id: 'a', header: 'A', cell: () => 'a' },
+ { id: 'b', header: 'B', cell: () => 'b' },
+ ];
+ // Only 'a' is visible, so 'b' is filtered out before createNodeConnections
+ const result = CalculateHierarchyTree(cols, ['a'], [], undefined);
+ expect(result.rows).toHaveLength(1);
+ expect(result.rows[0].columns).toHaveLength(1);
+ expect(result.rows[0].columns[0].id).toBe('a');
+ });
+
+ it('skips group definitions with undefined id', () => {
+ // Group definition has undefined id → line 209 early return
+ const cols: TableProps.ColumnDefinition[] = [{ id: 'a', header: 'A', cell: () => 'a' }];
+ const groups = [{ id: undefined, header: 'Bad Group' } as any];
+ const result = CalculateHierarchyTree(cols, ['a'], groups, undefined);
+ // Should just have column 'a', no group
+ expect(result.rows).toHaveLength(1);
+ expect(result.rows[0].columns[0].id).toBe('a');
+ });
+
+ it('handles circular reference with already-visited node connecting to root', () => {
+ // Two groups referencing each other: a→b, b→a
+ // When traversing from leaf "col" → group "a" → group "b" → detects cycle at "a"
+ // The circular node gets connected to root (lines 79-80)
+ const cols: TableProps.ColumnDefinition[] = [
+ { id: 'col-circ', header: 'Col', groupId: 'grp-a-circ', cell: () => 'x' },
+ ];
+ const groups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'grp-a-circ', header: 'A', groupId: 'grp-b-circ' },
+ { id: 'grp-b-circ', header: 'B', groupId: 'grp-a-circ' },
+ ];
+ const result = CalculateHierarchyTree(cols, ['col-circ'], groups, undefined);
+ // Should not crash; produces some result
+ expect(result.rows).toBeDefined();
+ expect(result.maxDepth).toBeGreaterThanOrEqual(0);
+ });
+ });
+
+ describe('error handling and warnings', () => {
+ let consoleWarnSpy: jest.SpyInstance;
+ let originalNodeEnv: string | undefined;
+
+ beforeEach(() => {
+ originalNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'development';
+ consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ });
+
+ afterEach(() => {
+ consoleWarnSpy.mockRestore();
+ process.env.NODE_ENV = originalNodeEnv;
+ });
+
+ // Note: warnOnce from the toolkit has global deduplication.
+ // Warnings may already be consumed by earlier tests in the same run.
+ // These tests verify that warnings are called at least once across the suite
+ // by checking console.warn was called with a matching pattern.
+
+ it('warns about circular references in development mode', () => {
+ const circularGroups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'circ-x', header: 'X', groupId: 'circ-y' },
+ { id: 'circ-y', header: 'Y', groupId: 'circ-x' },
+ ];
+ const cols: TableProps.ColumnDefinition[] = [
+ { id: 'circ-col', header: 'Col', groupId: 'circ-x', cell: () => 'col' },
+ ];
+
+ renderHook(() => useColumnGrouping(circularGroups, cols));
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Circular reference detected'));
+ });
+
+ it('warns about non-existent parent group', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [
+ { id: 'warn-child', header: 'Child', groupId: 'warn-nonexistent-parent' },
+ ];
+ const cols: TableProps.ColumnDefinition[] = [
+ { id: 'warn-col', header: 'Col', groupId: 'warn-child', cell: () => 'col' },
+ ];
+
+ renderHook(() => useColumnGrouping(groups, cols));
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('references non-existent parent group'));
+ });
+
+ it('warns about column referencing non-existent group', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [];
+ const cols: TableProps.ColumnDefinition[] = [
+ { id: 'warn-col2', header: 'Col', groupId: 'warn-nonexistent-group', cell: () => 'col' },
+ ];
+
+ renderHook(() => useColumnGrouping(groups, cols));
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('references non-existent parent group'));
+ });
+
+ it('handles group without id gracefully', () => {
+ const groups: TableProps.ColumnGroupsDefinition[] = [{ id: '', header: 'Invalid' } as any];
+ const cols: TableProps.ColumnDefinition[] = [];
+
+ const { result } = renderHook(() => useColumnGrouping(groups, cols));
+
+ // Group with empty id is skipped; no crash
+ expect(result.current.rows).toBeDefined();
+ });
+ });
+});
diff --git a/src/table/column-grouping-utils.ts b/src/table/column-grouping-utils.ts
new file mode 100644
index 0000000000..05c9a5c823
--- /dev/null
+++ b/src/table/column-grouping-utils.ts
@@ -0,0 +1,368 @@
+// 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 { isDevelopment } from '../internal/is-development';
+import { TableProps } from './interfaces';
+import { getVisibleColumnDefinitions } from './utils';
+
+export namespace TableGroupedTypes {
+ export interface ColumnInRow {
+ id: string;
+ header?: React.ReactNode;
+ colspan: number;
+ rowspan: number;
+ isGroup: boolean;
+ // TODO: I could find a better way to make this modular instead of 2 props
+ // for column and column-group
+ columnDefinition?: TableProps.ColumnDefinition;
+ groupDefinition?: TableProps.ColumnGroupsDefinition;
+ parentGroupIds: string[]; // Chain of parent group IDs for ARIA headers attribute
+ rowIndex: number;
+ colIndex: number;
+ }
+
+ export interface HeaderRow {
+ columns: ColumnInRow[];
+ }
+
+ export interface HierarchicalStructure {
+ rows: HeaderRow[];
+ maxDepth: number;
+ columnToParentIds: Map; // Maps leaf column IDs to their parent group IDs
+ }
+}
+
+// namespace TableTreeTypes {
+// export interface TableHeaderNode {
+// id: string;
+// header?: React.ReactNode;
+// colspan?: number; // -1 for phantom
+// rowspan?: number; /// same -1 for phantom
+// columnDefinition?: TableProps.ColumnDefinition;
+// subtreeHeight?: number;
+// groupDefinition?: TableProps.ColumnGroupsDefinition;
+// parentNode?: TableHeaderNode;
+// children?: TableHeaderNode[]; // order here implies actual render order
+// colIndex?: number; // Absolute column index for aria-colindex
+// }
+// }
+
+// creates the connection between nodes going from child to root
+function createNodeConnections(
+ visibleLeafColumns: Readonly[]>,
+ idToNodeMap: Map>,
+ rootNode: TableHeaderNode
+): void {
+ const visitedNodesIdSet = new Set();
+
+ visibleLeafColumns.forEach(column => {
+ if (!column.id) {
+ return;
+ }
+
+ let currentNode = idToNodeMap.get(column.id);
+ if (!currentNode) {
+ return;
+ }
+
+ // Track nodes visited in this single leaf-to-root path to detect cycles
+ const pathVisited = new Set();
+
+ while (currentNode) {
+ // Cycle detection: if we've seen this node in the current path, we have a circular reference
+ if (pathVisited.has(currentNode.id)) {
+ warnOnceInDev(`Circular reference detected in column group definitions involving "${currentNode.id}".`);
+ // Connect the node to root to prevent orphaning
+ if (!visitedNodesIdSet.has(currentNode.id)) {
+ rootNode.addChild(currentNode);
+ visitedNodesIdSet.add(currentNode.id);
+ }
+ break;
+ }
+ pathVisited.add(currentNode.id);
+
+ const groupDef = currentNode.groupDefinition;
+ const colDef = currentNode.columnDefinition;
+
+ // Find parent ID from either groupDefinition or columnDefinition
+ const parentId = groupDef?.groupId || colDef?.groupId;
+
+ if (!parentId) {
+ // No parent means this connects to root
+ if (!visitedNodesIdSet.has(currentNode.id)) {
+ rootNode.addChild(currentNode);
+ visitedNodesIdSet.add(currentNode.id);
+ }
+ break;
+ }
+
+ const parentNode = idToNodeMap.get(parentId);
+ if (!parentNode) {
+ // Parent not found, connect to root
+ warnOnceInDev(
+ `Group "${currentNode.id}" references non-existent parent group "${parentId}". Treating as top-level.`
+ );
+ if (!visitedNodesIdSet.has(currentNode.id)) {
+ rootNode.addChild(currentNode);
+ visitedNodesIdSet.add(currentNode.id);
+ }
+ break;
+ }
+
+ // Connect child to parent (only if not already connected)
+ if (!visitedNodesIdSet.has(currentNode.id)) {
+ parentNode.addChild(currentNode);
+ visitedNodesIdSet.add(currentNode.id);
+ }
+
+ // Move up the tree
+ currentNode = parentNode;
+ }
+ });
+}
+
+export class TableHeaderNode {
+ id: string;
+ colspan: number = 1;
+ rowspan: number = 1;
+ subtreeHeight: number = 1;
+ columnDefinition?: TableProps.ColumnDefinition;
+ groupDefinition?: TableProps.ColumnGroupsDefinition;
+ parentNode?: TableHeaderNode;
+ children: TableHeaderNode[] = [];
+ rowIndex: number = -1;
+ colIndex: number = -1;
+ isRoot: boolean = false;
+
+ constructor(
+ id: string,
+ options?: {
+ colspan?: number;
+ rowspan?: number;
+ columnDefinition?: TableProps.ColumnDefinition;
+ groupDefinition?: TableProps.ColumnGroupsDefinition;
+ parentNode?: TableHeaderNode;
+ rowIndex?: number;
+ colIndex?: number;
+ isRoot?: boolean;
+ }
+ ) {
+ this.id = id;
+ Object.assign(this, options);
+ this.children = [];
+ }
+
+ get isGroup(): boolean {
+ return !!this.groupDefinition;
+ }
+
+ get isRootNode(): boolean {
+ return this.isRoot;
+ }
+
+ public addChild(child: TableHeaderNode): void {
+ this.children.push(child);
+ child.parentNode = this;
+ }
+
+ get isLeaf(): boolean {
+ return !this.isRoot && this.children.length === 0;
+ }
+}
+
+export function warnOnceInDev(message: string): void {
+ if (isDevelopment) {
+ warnOnce(`[Table]`, message);
+ }
+}
+
+// function to evaluate validity of the grouping and if the ordering via column display splits it
+// should give dev warning ...
+
+// from
+// column defininitions
+// column grouping definitions
+// columnDisplay : array of colprops (id: string; visible: boolean;) - only child columns
+
+// output the hierarchical tree:
+
+export function CalculateHierarchyTree(
+ columnDefinitions: TableProps.ColumnDefinition[],
+ visibleColumnIds: string[],
+ columnGroupingDefinitions: TableProps.ColumnGroupsDefinition[],
+ columnDisplayProperties?: TableProps.ColumnDisplayProperties[]
+): TableGroupedTypes.HierarchicalStructure {
+ // filtering by visible columns
+ const visibleColumns: Readonly[]> = getVisibleColumnDefinitions({
+ columnDisplay: columnDisplayProperties,
+ visibleColumns: visibleColumnIds,
+ columnDefinitions: columnDefinitions,
+ });
+
+ // creating hashmap from id to node
+ const idToNodeMap: Map> = new Map();
+
+ visibleColumns.forEach((columnDefinition: TableProps.ColumnDefinition) => {
+ if (columnDefinition.id === undefined) {
+ return;
+ }
+
+ idToNodeMap.set(
+ columnDefinition.id,
+ new TableHeaderNode(columnDefinition.id, { columnDefinition: columnDefinition })
+ );
+ });
+
+ columnGroupingDefinitions.forEach((groupDefinition: TableProps.ColumnGroupsDefinition) => {
+ if (groupDefinition.id === undefined) {
+ return;
+ }
+
+ idToNodeMap.set(
+ groupDefinition.id,
+ new TableHeaderNode(groupDefinition.id, { groupDefinition: groupDefinition })
+ );
+ });
+
+ // traverse from root to parent to create a
+
+ const rootNode = new TableHeaderNode('*', { isRoot: true });
+
+ // create connection 0(N)ish pass leaf to root
+ createNodeConnections(visibleColumns, idToNodeMap, rootNode);
+
+ // traversal for SubTreeHeight
+ traverseForSubtreeHeight(rootNode);
+
+ rootNode.colspan = visibleColumnIds.length;
+ rootNode.rowIndex = -1; // Root starts at -1 so children start at 0
+ rootNode.rowspan = 1; // Root takes one row (not rendered)
+
+ // bfs for row span and row index
+ rootNode.children.forEach(node => {
+ traverseForRowSpanAndRowIndex(node, rootNode.subtreeHeight - 1);
+ });
+
+ // dfs for colspan and col index
+ traverseForColSpanAndColIndex(rootNode);
+
+ return buildHierarchicalStructure(rootNode);
+}
+
+function traverseForSubtreeHeight(node: TableHeaderNode) {
+ node.subtreeHeight = 1;
+
+ for (const child of node.children) {
+ node.subtreeHeight = Math.max(node.subtreeHeight, traverseForSubtreeHeight(child) + 1);
+ }
+
+ return node.subtreeHeight;
+}
+
+function traverseForRowSpanAndRowIndex(
+ node: TableHeaderNode,
+ allTreesMaxHeight: number,
+ rowsTakenByAncestors: number = 0
+) {
+ // formula: rowSpan = totalTreeHeight - rowsTakenByAncestors - maxSubtreeHeight
+ const maxSubtreeHeight = Math.max(...node.children.map(child => child.subtreeHeight as number), 0);
+ node.rowspan = allTreesMaxHeight - rowsTakenByAncestors - maxSubtreeHeight;
+
+ // rowIndex = parentRowIndex + parentRowSpan
+ if (node.parentNode) {
+ node.rowIndex = node.parentNode.rowIndex + node.parentNode.rowspan;
+ }
+
+ node.children.forEach(childNode => {
+ traverseForRowSpanAndRowIndex(childNode, allTreesMaxHeight, rowsTakenByAncestors + node.rowspan);
+ });
+}
+
+// takes currColIndex from where to start from
+// and returns the starting colindex for next node
+function traverseForColSpanAndColIndex(node: TableHeaderNode, currColIndex = 0): number {
+ node.colIndex = currColIndex;
+
+ if (node.isLeaf) {
+ return currColIndex + 1;
+ }
+
+ let runningColIndex = currColIndex;
+ for (const childNode of node.children) {
+ runningColIndex = traverseForColSpanAndColIndex(childNode, runningColIndex);
+ }
+
+ node.colspan = runningColIndex - currColIndex;
+ return runningColIndex;
+}
+
+function buildParentChain(node: TableHeaderNode): string[] {
+ const chain: string[] = [];
+ let current = node.parentNode;
+
+ while (current && !current.isRoot) {
+ chain.push(current.id);
+ current = current.parentNode;
+ }
+
+ // Already in order: immediate parent to root
+ return chain.reverse();
+}
+
+function buildHierarchicalStructure(rootNode: TableHeaderNode): TableGroupedTypes.HierarchicalStructure {
+ const maxDepth = rootNode.subtreeHeight - 1;
+ const rowsMap = new Map[]>();
+ const columnToParentIds = new Map();
+
+ // BFS traversal - naturally gives column order per row
+ const queue: TableHeaderNode[] = [...rootNode.children]; // Skip root
+
+ while (queue.length > 0) {
+ const node = queue.shift()!;
+
+ // Create ColumnInRow from node
+ const columnInRow: TableGroupedTypes.ColumnInRow = {
+ 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: buildParentChain(node),
+ rowIndex: node.rowIndex,
+ colIndex: node.colIndex,
+ };
+
+ // Add to appropriate row
+ if (!rowsMap.has(node.rowIndex)) {
+ rowsMap.set(node.rowIndex, []);
+ }
+ rowsMap.get(node.rowIndex)!.push(columnInRow);
+
+ // Track parent chain for leaf columns
+ // if (node.isLeaf && node.columnDefinition) {
+ // columnToParentIds.set(node.id, buildParentChain(node));
+ // }
+
+ if (node.isLeaf && node.columnDefinition) {
+ const parentChain = buildParentChain(node);
+ if (parentChain.length > 0) {
+ columnToParentIds.set(node.id, parentChain);
+ }
+ }
+
+ // Add children to queue (already in correct order)
+ queue.push(...node.children);
+ }
+
+ // Convert map to sorted array
+ const rows: TableGroupedTypes.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 };
+}
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..5ff4cf0d7b
--- /dev/null
+++ b/src/table/header-cell/group-header-cell.tsx
@@ -0,0 +1,131 @@
+// 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 { useMergeRefs } from '@cloudscape-design/component-toolkit/internal';
+import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal';
+import { useUniqueId } from '@cloudscape-design/component-toolkit/internal';
+
+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 { TableThElement } from './th-element';
+
+import styles from './styles.css.js';
+
+export interface TableGroupHeaderCellProps {
+ group: TableProps.ColumnGroupsDefinition;
+ colspan: number;
+ rowspan: number;
+ colIndex: number;
+ groupId: string;
+ resizableColumns?: boolean;
+ resizableStyle?: ColumnWidthStyle;
+ onResizeFinish: () => void;
+ updateGroupWidth: (groupId: PropertyKey, newWidth: number) => void;
+ childColumnIds: PropertyKey[];
+ childColumnMinWidths: Map;
+ focusedComponent?: null | string;
+ tabIndex: number;
+ stuck?: boolean;
+ sticky?: boolean;
+ hidden?: boolean;
+ stripedRows?: boolean;
+ stickyState: StickyColumnsModel;
+ cellRef: React.RefCallback;
+ tableRole: TableRole;
+ resizerRoleDescription?: string;
+ resizerTooltipText?: string;
+ variant: TableProps.Variant;
+ tableVariant?: TableProps.Variant;
+}
+
+export function TableGroupHeaderCell({
+ group,
+ colspan,
+ rowspan,
+ colIndex,
+ groupId,
+ resizableColumns,
+ resizableStyle,
+ onResizeFinish,
+ updateGroupWidth,
+ // childColumnIds,
+ // childColumnMinWidths,
+ focusedComponent,
+ tabIndex,
+ stuck,
+ sticky,
+ hidden,
+ stripedRows,
+ stickyState,
+ cellRef,
+ tableRole,
+ resizerRoleDescription,
+ resizerTooltipText,
+ variant,
+ tableVariant,
+}: TableGroupHeaderCellProps) {
+ const headerId = useUniqueId('table-group-header-');
+
+ const clickableHeaderRef = useRef(null);
+ const { tabIndex: clickableHeaderTabIndex } = useSingleTabStopNavigation(clickableHeaderRef, { tabIndex });
+
+ const cellRefObject = useRef(null);
+ const cellRefCombined = useMergeRefs(cellRef, cellRefObject);
+
+ return (
+
+
+
+
+ {resizableColumns ? (
+ updateGroupWidth(groupId, newWidth)}
+ onWidthUpdateCommit={onResizeFinish}
+ ariaLabelledby={headerId}
+ minWidth={undefined}
+ roleDescription={resizerRoleDescription}
+ tooltipText={resizerTooltipText}
+ isBorderless={variant === 'full-page' || variant === 'embedded' || variant === 'borderless'}
+ />
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx
index ff8e92ed41..dd8cd62376 100644
--- a/src/table/header-cell/index.tsx
+++ b/src/table/header-cell/index.tsx
@@ -51,6 +51,8 @@ export interface TableHeaderCellProps {
hasDynamicContent?: boolean;
variant: TableProps.Variant;
tableVariant?: TableProps.Variant;
+ colSpan?: number;
+ rowSpan?: number;
}
export function TableHeaderCell({
@@ -82,6 +84,8 @@ export function TableHeaderCell({
hasDynamicContent,
variant,
tableVariant,
+ colSpan,
+ rowSpan,
}: TableHeaderCellProps) {
const i18n = useInternalI18n('table');
const sortable = !!column.sortingComparator || !!column.sortingField;
@@ -139,6 +143,8 @@ export function TableHeaderCell({
tableRole={tableRole}
variant={variant}
tableVariant={tableVariant}
+ colSpan={colSpan}
+ rowSpan={rowSpan}
{...(sortingDisabled
? {}
: getAnalyticsMetadataAttribute({
diff --git a/src/table/header-cell/styles.scss b/src/table/header-cell/styles.scss
index d004215c5e..007121def3 100644
--- a/src/table/header-cell/styles.scss
+++ b/src/table/header-cell/styles.scss
@@ -207,7 +207,7 @@ settings icon in the pagination slot.
@include header-cell-focus-outline-first(awsui.$space-scaled-xxs);
}
&:first-child > .header-cell-content {
- @include cell-offset(0px);
+ // @include cell-offset(0px);
@include header-cell-focus-outline-first(awsui.$space-table-header-focus-outline-gutter);
}
diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx
index 55e5739e02..0a883d5eb1 100644
--- a/src/table/header-cell/th-element.tsx
+++ b/src/table/header-cell/th-element.tsx
@@ -38,6 +38,9 @@ export interface TableThElementProps {
variant: TableProps.Variant;
tableVariant?: TableProps.Variant;
ariaLabel?: string;
+ colSpan?: number;
+ rowSpan?: number;
+ scope?: 'col' | 'colgroup';
}
export function TableThElement({
@@ -60,6 +63,9 @@ export function TableThElement({
variant,
ariaLabel,
tableVariant,
+ colSpan,
+ rowSpan,
+ scope,
...props
}: TableThElementProps) {
const isVisualRefresh = useVisualRefresh();
@@ -104,6 +110,9 @@ export function TableThElement({
tabIndex={cellTabIndex === -1 ? undefined : cellTabIndex}
{...copyAnalyticsMetadataAttribute(props)}
{...(ariaLabel ? { 'aria-label': ariaLabel } : {})}
+ {...(scope ? { scope } : {})}
+ {...(colSpan && colSpan > 1 ? { colSpan } : {})}
+ {...(rowSpan && rowSpan > 1 ? { rowSpan } : {})}
>
{children}
diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx
index 1628cf5492..b57a3d09c9 100644
--- a/src/table/interfaces.tsx
+++ b/src/table/interfaces.tsx
@@ -255,6 +255,12 @@ export interface TableProps extends BaseComponentProps {
*/
columnDisplay?: ReadonlyArray;
+ /**
+ * Add grouping for the columns define groups for columns to be under and also nested groups for which
+ * other groups will be nested under.
+ */
+ columnGroupingDefinitions?: ReadonlyArray>;
+
/**
* Specifies an array containing the `id`s of visible columns. If not set, all columns are displayed.
*
@@ -494,6 +500,7 @@ export namespace TableProps {
export type ColumnDefinition = {
id?: string;
+ groupId?: string;
header: React.ReactNode;
ariaLabel?(data: LabelData): string;
width?: number | string;
@@ -513,6 +520,11 @@ export namespace TableProps {
selectedItemsCount?: number;
}
+ export type ColumnGroupsDefinition = Pick<
+ ColumnDefinition,
+ 'id' | 'header' | 'groupId' | 'ariaLabel'
+ >;
+
export interface StickyColumns {
first?: number;
last?: number;
diff --git a/src/table/internal.tsx b/src/table/internal.tsx
index cf2e4db610..7500cd5c52 100644
--- a/src/table/internal.tsx
+++ b/src/table/internal.tsx
@@ -61,7 +61,13 @@ 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 { useColumnGrouping } from './use-column-grouping';
+import {
+ ColumnWidthDefinition,
+ ColumnWidthsProvider,
+ DEFAULT_COLUMN_WIDTH,
+ useColumnWidths,
+} 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';
@@ -75,6 +81,33 @@ const GRID_NAVIGATION_PAGE_SIZE = 10;
const SELECTION_COLUMN_WIDTH = 54;
const selectionColumnId = Symbol('selection-column-id');
+/**
+ * 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.
+ */
+function TableColGroup({
+ visibleColumnDefinitions,
+ hasSelection,
+ selectionColumnWidth,
+}: {
+ visibleColumnDefinitions: ReadonlyArray>;
+ hasSelection: boolean;
+ selectionColumnWidth: number;
+}) {
+ const { setCol } = useColumnWidths();
+ return (
+
+ {hasSelection && }
+ {visibleColumnDefinitions.map((column, colIndex) => {
+ const columnId = getColumnKey(column, colIndex);
+ return setCol(columnId, node)} />;
+ })}
+
+ );
+}
+
type InternalTableProps = SomeRequired<
TableProps,
'items' | 'selectedItems' | 'variant' | 'firstIndex' | 'cellVerticalAlign'
@@ -107,6 +140,7 @@ const InternalTable = React.forwardRef(
preferences,
items,
columnDefinitions,
+ columnGroupingDefinitions,
trackBy,
loading,
loadingText,
@@ -300,6 +334,16 @@ const InternalTable = React.forwardRef(
visibleColumns,
});
+ // Build visible column IDs set for grouping
+ const visibleColumnIds = new Set(visibleColumnDefinitions.map((col, idx) => col.id || `column-${idx}`));
+
+ const hierarchicalStructure = useColumnGrouping(
+ columnGroupingDefinitions,
+ columnDefinitions,
+ visibleColumnIds,
+ columnDisplay
+ );
+
const selectionProps = {
items: allItems,
rootItems: items,
@@ -394,6 +438,8 @@ const InternalTable = React.forwardRef(
selectionType,
getSelectAllProps: selection.getSelectAllProps,
columnDefinitions: visibleColumnDefinitions,
+ columnGroupingDefinitions,
+ hierarchicalStructure,
variant: computedVariant,
tableVariant: computedVariant,
wrapLines,
@@ -452,6 +498,7 @@ const InternalTable = React.forwardRef(
const colIndexOffset = selectionType ? 1 : 0;
const totalColumnsCount = visibleColumnDefinitions.length + colIndexOffset;
+ const headerRowCount = hierarchicalStructure?.rows.length || 1;
return (
@@ -460,6 +507,7 @@ const InternalTable = React.forwardRef(
visibleColumns={visibleColumnWidthsWithSelection}
resizableColumns={resizableColumns}
containerRef={wrapperMeasureRefObject}
+ hierarchicalStructure={hierarchicalStructure}
>
+ {resizableColumns && hierarchicalStructure && hierarchicalStructure.rows.length > 1 && (
+
+ )}
1) {
+ cell.rowSpan = cellDef.rowspan;
+ }
+ if (cellDef.colspan && cellDef.colspan > 1) {
+ cell.colSpan = cellDef.colspan;
+ }
+ tr.appendChild(cell);
+ }
+ table.appendChild(tr);
+ }
+ return table;
+}
+
+describe('getAllCellsInRow', () => {
+ test('returns empty array for null table', () => {
+ expect(getAllCellsInRow(null, 1)).toEqual([]);
+ });
+
+ test('returns cells from a simple row', () => {
+ const table = buildTable([
+ {
+ rowindex: 1,
+ cells: [
+ { tag: 'th', colindex: 1, text: 'Col 1' },
+ { tag: 'th', colindex: 2, text: 'Col 2' },
+ ],
+ },
+ {
+ rowindex: 2,
+ cells: [
+ { tag: 'td', colindex: 1, text: 'A' },
+ { tag: 'td', colindex: 2, text: 'B' },
+ ],
+ },
+ ]);
+ const cells = getAllCellsInRow(table, 2);
+ expect(cells.length).toBe(2);
+ expect(cells[0].textContent).toBe('A');
+ expect(cells[1].textContent).toBe('B');
+ });
+
+ test('includes cells with rowspan that span into the target row', () => {
+ const table = buildTable([
+ {
+ rowindex: 1,
+ cells: [
+ { tag: 'th', colindex: 1, text: 'Selection', rowspan: 3 },
+ { tag: 'th', colindex: 2, text: 'Group' },
+ ],
+ },
+ { rowindex: 2, cells: [{ tag: 'th', colindex: 2, text: 'Leaf Col' }] },
+ {
+ rowindex: 3,
+ cells: [
+ { tag: 'td', colindex: 1, text: 'Data A' },
+ { tag: 'td', colindex: 2, text: 'Data B' },
+ ],
+ },
+ ]);
+
+ // Row 2: should include Selection (rowspan=3 from row 1) + Leaf Col
+ const row2Cells = getAllCellsInRow(table, 2);
+ expect(row2Cells.length).toBe(2);
+ expect(row2Cells[0].textContent).toBe('Selection');
+ expect(row2Cells[1].textContent).toBe('Leaf Col');
+
+ // Row 3: should include Selection (still spanning) + both data cells
+ const row3Cells = getAllCellsInRow(table, 3);
+ expect(row3Cells.length).toBe(3);
+ });
+
+ test('excludes cells whose rowspan does not reach the target row', () => {
+ const table = buildTable([
+ {
+ rowindex: 1,
+ cells: [
+ { tag: 'th', colindex: 1, text: 'Group', rowspan: 2 },
+ { tag: 'th', colindex: 2, text: 'Other' },
+ ],
+ },
+ { rowindex: 2, cells: [{ tag: 'th', colindex: 2, text: 'Under Other' }] },
+ {
+ rowindex: 3,
+ cells: [
+ { tag: 'td', colindex: 1, text: 'Data' },
+ { tag: 'td', colindex: 2, text: 'Data' },
+ ],
+ },
+ ]);
+
+ // Row 3: Group (rowspan=2, from row1) does NOT reach row 3
+ const row3Cells = getAllCellsInRow(table, 3);
+ expect(row3Cells.length).toBe(2);
+ expect(row3Cells[0].textContent).toBe('Data');
+ });
+
+ test('skips rows with aria-rowindex greater than target', () => {
+ const table = buildTable([
+ { rowindex: 1, cells: [{ tag: 'td', colindex: 1, text: 'R1' }] },
+ { rowindex: 5, cells: [{ tag: 'td', colindex: 1, text: 'R5' }] },
+ ]);
+ const cells = getAllCellsInRow(table, 1);
+ expect(cells.length).toBe(1);
+ expect(cells[0].textContent).toBe('R1');
+ });
+});
+
+describe('findClosestCellByAriaColIndex', () => {
+ function createCells(
+ colConfigs: Array<{ colindex: number; colspan?: number; text: string }>
+ ): HTMLTableCellElement[] {
+ return colConfigs.map(({ colindex, colspan, text }) => {
+ const td = document.createElement('td');
+ td.setAttribute('aria-colindex', String(colindex));
+ if (colspan && colspan > 1) {
+ td.colSpan = colspan;
+ }
+ td.textContent = text;
+ return td;
+ });
+ }
+
+ test('returns exact match by colspan range', () => {
+ const cells = createCells([
+ { colindex: 1, text: 'A' },
+ { colindex: 2, colspan: 3, text: 'B-span' },
+ { colindex: 5, text: 'C' },
+ ]);
+
+ // Target colindex 3 falls within B-span (colindex=2, colspan=3 -> covers 2,3,4)
+ const result = findClosestCellByAriaColIndex(cells, 3, 1);
+ expect(result?.textContent).toBe('B-span');
+ });
+
+ test('returns exact match for single column', () => {
+ const cells = createCells([
+ { colindex: 1, text: 'A' },
+ { colindex: 2, text: 'B' },
+ { colindex: 3, text: 'C' },
+ ]);
+
+ const result = findClosestCellByAriaColIndex(cells, 2, 1);
+ expect(result?.textContent).toBe('B');
+ });
+
+ test('returns closest cell in positive direction when no exact match', () => {
+ const cells = createCells([
+ { colindex: 1, text: 'A' },
+ { colindex: 5, text: 'E' },
+ ]);
+
+ // Looking for colindex 3, delta > 0 -> should return colindex 5
+ const result = findClosestCellByAriaColIndex(cells, 3, 1);
+ expect(result?.textContent).toBe('E');
+ });
+
+ test('returns closest cell in negative direction when no exact match', () => {
+ const cells = createCells([
+ { colindex: 1, text: 'A' },
+ { colindex: 5, text: 'E' },
+ ]);
+
+ // Looking for colindex 3, delta < 0 -> should return colindex 1
+ const result = findClosestCellByAriaColIndex(cells, 3, -1);
+ expect(result?.textContent).toBe('A');
+ });
+
+ test('returns null for empty cells array', () => {
+ const result = findClosestCellByAriaColIndex([], 1, 1);
+ expect(result).toBe(null);
+ });
+});
diff --git a/src/table/table-role/grid-navigation.tsx b/src/table/table-role/grid-navigation.tsx
index f655d4aec6..c3880e755f 100644
--- a/src/table/table-role/grid-navigation.tsx
+++ b/src/table/table-role/grid-navigation.tsx
@@ -17,9 +17,11 @@ import { nodeBelongs } from '../../internal/utils/node-belongs';
import { FocusedCell, GridNavigationProps } from './interfaces';
import {
defaultIsSuppressed,
+ findClosestCellByAriaColIndex,
findTableRowByAriaRowIndex,
findTableRowCellByAriaColIndex,
focusNextElement,
+ getAllCellsInRow,
getClosestCell,
isElementDisabled,
isTableCell,
@@ -330,16 +332,48 @@ 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.
+ // Use getAllCellsInRow to include cells from earlier rows that span into the target row via rowspan.
const targetAriaColIndex = from.colIndex + delta.x;
- const targetCell = findTableRowCellByAriaColIndex(targetRow, targetAriaColIndex, delta.x);
+ const targetRowAriaIndex = parseInt(targetRow.getAttribute('aria-rowindex') ?? '');
+ let allVisibleCells = getAllCellsInRow(this.table, targetRowAriaIndex);
+ let targetCell =
+ allVisibleCells.length > 0
+ ? findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x)
+ : findTableRowCellByAriaColIndex(targetRow, targetAriaColIndex, delta.x);
+
+ // When vertical movement lands on the same cell (due to rowspan), skip past it.
+ if (targetCell === cellElement && delta.y !== 0 && cellElement) {
+ const cellRow = cellElement.closest('tr');
+ const cellRowIndex = parseInt(cellRow?.getAttribute('aria-rowindex') ?? '0');
+ const cellRowSpan = (cellElement as HTMLTableCellElement).rowSpan || 1;
+ // Jump to the first row after this cell's span (↓) or one row before the cell's start (↑).
+ const skipToRowIndex = delta.y > 0 ? cellRowIndex + cellRowSpan : cellRowIndex - 1;
+ const skipRow = findTableRowByAriaRowIndex(this.table, skipToRowIndex, delta.y);
+ if (!skipRow) {
+ return null;
+ }
+ const skipRowAriaIndex = parseInt(skipRow.getAttribute('aria-rowindex') ?? '');
+ allVisibleCells = getAllCellsInRow(this.table, skipRowAriaIndex);
+ targetCell =
+ allVisibleCells.length > 0
+ ? findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x)
+ : findTableRowCellByAriaColIndex(skipRow, 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) {
- return null;
+ // When horizontal movement lands on the same cell (due to colspan), skip past it.
+ if (targetCell === cellElement && delta.x !== 0 && cellElement) {
+ const cellColIndex = parseInt(cellElement.getAttribute('aria-colindex') ?? '0');
+ const cellColSpan = (cellElement as HTMLTableCellElement).colSpan || 1;
+ const skipToColIndex = delta.x > 0 ? cellColIndex + cellColSpan : cellColIndex - 1;
+ targetCell = findClosestCellByAriaColIndex(allVisibleCells, skipToColIndex, delta.x);
+ if (!targetCell || targetCell === cellElement) {
+ return null;
+ }
}
const targetCellFocusables = this.getFocusablesFrom(targetCell);
diff --git a/src/table/table-role/table-role-helper.ts b/src/table/table-role/table-role-helper.ts
index f28752e3fd..8b9b894ebd 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 = {};
@@ -32,9 +33,10 @@ export function getTableRoleProps(options: {
nativeProps['aria-label'] = options.ariaLabel;
nativeProps['aria-labelledby'] = options.ariaLabelledby;
- // Incrementing the total count by one to account for the header row.
+ // Incrementing the total count to account for the header row(s).
+ 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,13 @@ 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.
+ // With grouped columns there can be multiple header rows (rowIndex 0, 1, 2, ...).
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,19 +86,21 @@ export function getTableRowRoleProps(options: {
tableRole: TableRole;
rowIndex: number;
firstIndex?: number;
+ headerRowCount?: number;
level?: number;
setSize?: number;
posInSet?: number;
}) {
const nativeProps: React.HTMLAttributes = {};
- // The data cell indices are incremented by 1 to account for the header cells.
+ // The data cell indices are incremented by headerRowCount to account for the header row(s).
+ 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..bc533bfb42 100644
--- a/src/table/table-role/utils.ts
+++ b/src/table/table-role/utils.ts
@@ -68,14 +68,75 @@ 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: null | HTMLTableElement, targetAriaRowIndex: number): HTMLTableCellElement[] {
+ if (!table) {
+ return [];
+ }
+
+ 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;
diff --git a/src/table/thead.tsx b/src/table/thead.tsx
index 10024c014e..7afbbacef0 100644
--- a/src/table/thead.tsx
+++ b/src/table/thead.tsx
@@ -6,7 +6,9 @@ import clsx from 'clsx';
import { findUpUntil } from '@cloudscape-design/component-toolkit/dom';
import { fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events';
+import { TableGroupedTypes } from './column-grouping-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';
@@ -20,6 +22,8 @@ import styles from './styles.css.js';
export interface TheadProps {
selectionType: undefined | InternalSelectionType;
columnDefinitions: ReadonlyArray>;
+ columnGroupingDefinitions?: ReadonlyArray>;
+ hierarchicalStructure?: TableGroupedTypes.HierarchicalStructure;
sortingColumn: TableProps.SortingColumn | undefined;
sortingDescending: boolean | undefined;
sortingDisabled: boolean | undefined;
@@ -53,6 +57,7 @@ const Thead = React.forwardRef(
selectionType,
getSelectAllProps,
columnDefinitions,
+ hierarchicalStructure: h,
sortingColumn,
sortingDisabled,
sortingDescending,
@@ -80,7 +85,42 @@ const Thead = React.forwardRef(
}: TheadProps,
outerRef: React.Ref
) => {
- const { getColumnStyles, columnWidths, updateColumn, setCell } = useColumnWidths();
+ const { getColumnStyles, columnWidths, updateColumn, updateGroup, setCell } = useColumnWidths();
+
+ const hierarchicalStructure: TableGroupedTypes.HierarchicalStructure | undefined = h;
+
+ // Helper to get child column IDs for a group (for getting minWidths)
+ const getChildColumnIds = (groupId: string): string[] => {
+ if (!hierarchicalStructure) {
+ return [];
+ }
+
+ const childIds: string[] = [];
+ const leafRow = hierarchicalStructure.rows[hierarchicalStructure.rows.length - 1];
+
+ leafRow.columns.forEach(col => {
+ if (!col.isGroup && col.parentGroupIds.includes(groupId)) {
+ childIds.push(col.id);
+ }
+ });
+
+ return childIds;
+ };
+
+ // Helper to get minWidth for columns
+ const getColumnMinWidths = (columnIds: string[]): Map => {
+ const minWidths = new Map();
+
+ columnIds.forEach(colId => {
+ const col = columnDefinitions.find((c, idx) => (c.id || `column-${idx}`) === colId);
+ if (col && col.minWidth) {
+ const minWidth = typeof col.minWidth === 'string' ? parseInt(col.minWidth) : col.minWidth;
+ minWidths.set(colId, minWidth);
+ }
+ });
+
+ return minWidths;
+ };
const commonCellProps = {
stuck,
@@ -93,67 +133,182 @@ const Thead = React.forwardRef(
stickyState,
};
+ // No grouping - render single row
+ if (!hierarchicalStructure || hierarchicalStructure.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 ? (
+
+ ) : 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}
+ />
+ );
+ })}
+
+
+ );
+ }
+
+ // Grouped columns
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);
+ }
+ : undefined
+ }
+ onBlur={rowIndex === 0 ? () => onFocusedComponentChange?.(null) : undefined}
+ >
+ {/* Selection column only in first row */}
+ {rowIndex === 0 && selectionType ? (
+ 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}
+ getSelectAllProps={getSelectAllProps}
+ onFocusMove={onFocusMove}
+ singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel}
+ rowSpan={hierarchicalStructure.maxDepth}
/>
- );
- })}
-
+ ) : null}
+
+ {row.columns.map(col => {
+ if (col.isGroup) {
+ // Group header cell
+ const groupDefinition = col.groupDefinition!;
+ const childIds = getChildColumnIds(col.id);
+
+ return (
+ onResizeFinish(columnWidths)}
+ updateGroupWidth={(groupId, newWidth) => {
+ updateGroup(groupId, newWidth);
+ }}
+ childColumnIds={childIds}
+ childColumnMinWidths={getColumnMinWidths(childIds)}
+ cellRef={node => setCell(sticky, col.id, node)}
+ resizerRoleDescription={resizerRoleDescription}
+ resizerTooltipText={resizerTooltipText}
+ />
+ );
+ } else {
+ // Regular column cell
+ const column = col.columnDefinition!;
+ const columnId = col.id;
+ const colIndex = col.colIndex;
+
+ return (
+ onResizeFinish(columnWidths)}
+ resizableColumns={resizableColumns}
+ resizableStyle={resizableColumns ? {} : 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}
+ />
+ );
+ }
+ })}
+
+ ))}
);
}
diff --git a/src/table/use-column-grouping.ts b/src/table/use-column-grouping.ts
new file mode 100644
index 0000000000..9d67cedecd
--- /dev/null
+++ b/src/table/use-column-grouping.ts
@@ -0,0 +1,36 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { useMemo } from 'react';
+
+import { CalculateHierarchyTree, TableGroupedTypes } from './column-grouping-utils';
+import { TableProps } from './interfaces';
+
+/**
+ * Processes flat group definitions and column definitions to create a hierarchical
+ * structure that represents multiple header rows for grouped columns.
+ *
+ * @param columnGroupingDefinitions - Optional flat array of group definitions
+ * @param columnDefinitions - Array of column definitions (with optional groupId)
+ * @param visibleColumnIds - Optional set of visible column IDs for filtering
+ */
+export function useColumnGrouping(
+ columnGroupingDefinitions: ReadonlyArray> | undefined,
+ columnDefinitions: ReadonlyArray>,
+ visibleColumnIds?: Set,
+ columnDisplay?: ReadonlyArray
+): TableGroupedTypes.HierarchicalStructure {
+ return useMemo(() => {
+ // Convert Set to Array for CalculateHierarchyTree
+ const visibleIds = visibleColumnIds
+ ? Array.from(visibleColumnIds)
+ : columnDefinitions.map((col, idx) => col.id || `column-${idx}`);
+
+ // Convert readonly arrays to mutable for CalculateHierarchyTree
+ const groups = columnGroupingDefinitions ? [...columnGroupingDefinitions] : [];
+ const columns = [...columnDefinitions];
+ const columnDisplayMutable = columnDisplay ? [...columnDisplay] : undefined;
+
+ // Call the CalculateHierarchyTree function
+ return CalculateHierarchyTree(columns, visibleIds, groups, columnDisplayMutable);
+ }, [columnGroupingDefinitions, columnDefinitions, visibleColumnIds, columnDisplay]);
+}
diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx
index db41a79c8c..16a6639143 100644
--- a/src/table/use-column-widths.tsx
+++ b/src/table/use-column-widths.tsx
@@ -5,6 +5,7 @@ import React, { createContext, useContext, useEffect, useRef, useState } from 'r
import { useResizeObserver, useStableCallback } from '@cloudscape-design/component-toolkit/internal';
import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal';
+import { TableGroupedTypes } from './column-grouping-utils';
import { ColumnWidthStyle, setElementWidths } from './column-widths-utils';
export const DEFAULT_COLUMN_WIDTH = 120;
@@ -39,7 +40,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 +62,18 @@ 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;
}
const WidthsContext = createContext({
getColumnStyles: () => ({}),
columnWidths: new Map(),
updateColumn: () => {},
+ updateGroup: () => {},
setCell: () => {},
+ setCol: () => {},
});
interface WidthProviderProps {
@@ -76,15 +81,24 @@ interface WidthProviderProps {
resizableColumns: boolean | undefined;
containerRef: React.RefObject;
children: React.ReactNode;
+ hierarchicalStructure: TableGroupedTypes.HierarchicalStructure;
}
-export function ColumnWidthsProvider({ visibleColumns, resizableColumns, containerRef, children }: WidthProviderProps) {
+export function ColumnWidthsProvider({
+ visibleColumns,
+ resizableColumns,
+ containerRef,
+ hierarchicalStructure,
+ 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,6 +108,102 @@ 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;
+ }
+ };
+
+ // Helper: Get all child column IDs for a group (only direct children)
+ const getDirectChildColumnIds = (groupId: string): string[] => {
+ if (!hierarchicalStructure) {
+ return [];
+ }
+
+ const childIds: string[] = [];
+
+ // Find the group in the hierarchy
+ for (const row of hierarchicalStructure.rows) {
+ for (const col of row.columns) {
+ if (col.id === groupId && col.isGroup) {
+ // Look in the next row for direct children
+ const rowIndex = hierarchicalStructure.rows.indexOf(row);
+ if (rowIndex < hierarchicalStructure.rows.length - 1) {
+ const nextRow = hierarchicalStructure.rows[rowIndex + 1];
+ nextRow.columns.forEach(childCol => {
+ // Check if this column has the group as immediate parent
+ if (childCol.parentGroupIds && childCol.parentGroupIds[childCol.parentGroupIds.length - 1] === groupId) {
+ childIds.push(childCol.id);
+ }
+ });
+ }
+ break;
+ }
+ }
+ }
+
+ return childIds;
+ };
+
+ // Helper: Find the rightmost leaf descendant of a group
+ const findRightmostLeaf = (groupId: string, widths: Map): string | null => {
+ if (!hierarchicalStructure) {
+ return null;
+ }
+
+ // Get direct children
+ const childIds = getDirectChildColumnIds(groupId);
+ if (childIds.length === 0) {
+ return null;
+ }
+
+ // Start from the rightmost child
+ for (let i = childIds.length - 1; i >= 0; i--) {
+ const childId = childIds[i];
+
+ // Check if this child is a leaf (not a group)
+ const isLeaf = !hierarchicalStructure.rows.some(row =>
+ row.columns.some(col => col.id === childId && col.isGroup)
+ );
+
+ if (isLeaf) {
+ return childId;
+ } else {
+ // It's a group, recurse into it
+ const leaf = findRightmostLeaf(childId, widths);
+ if (leaf) {
+ return leaf;
+ }
+ }
+ }
+
+ return null;
+ };
+
+ // Helper: Calculate group width as sum of direct children
+ const calculateGroupWidth = (groupId: string, widths: Map): number => {
+ const childIds = getDirectChildColumnIds(groupId);
+ let totalWidth = 0;
+
+ childIds.forEach(childId => {
+ // If child is a group, calculate its width recursively
+ const isGroup = hierarchicalStructure?.rows.some(row =>
+ row.columns.some(col => col.id === childId && col.isGroup)
+ );
+
+ if (isGroup) {
+ totalWidth += calculateGroupWidth(childId, widths);
+ } else {
+ totalWidth += widths.get(childId) || DEFAULT_COLUMN_WIDTH;
+ }
+ });
+
+ return totalWidth;
+ };
const getColumnStyles = (sticky: boolean, columnId: PropertyKey): ColumnWidthStyle => {
const column = visibleColumns.find(column => column.id === columnId);
@@ -131,12 +241,35 @@ 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));
+ if (!columnWidths) {
+ return;
+ }
+
+ // 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) {
+ const styles = getColumnStyles(false, id);
+ setElementWidths(colElement, styles);
+ }
+ // Still update th cells for non-width styles (but width comes from col)
+ const element = cellsRef.current.get(id);
+ if (element) {
+ setElementWidths(element, getColumnStyles(false, id));
+ }
+ }
+ } else {
+ // No col elements - apply widths directly to th cells (single-row headers)
+ 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.
for (const { id } of visibleColumns) {
const element = stickyCellsRef.current.get(id);
@@ -193,8 +326,33 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain
setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths ?? new Map(), newWidth, columnId));
}
+ function updateGroup(groupId: PropertyKey, newGroupWidth: number) {
+ if (!columnWidths) {
+ return;
+ }
+
+ // Calculate current group width
+ const currentGroupWidth = calculateGroupWidth(String(groupId), columnWidths);
+ const delta = newGroupWidth - currentGroupWidth;
+
+ // Find the rightmost leaf descendant
+ const rightmostLeaf = findRightmostLeaf(String(groupId), columnWidths);
+ if (!rightmostLeaf) {
+ return;
+ }
+
+ // Apply the delta to the rightmost leaf column
+ const currentLeafWidth = columnWidths.get(rightmostLeaf) || DEFAULT_COLUMN_WIDTH;
+ const newLeafWidth = currentLeafWidth + delta;
+
+ // Use updateColumn to handle the leaf resize (which will propagate to parents automatically)
+ updateColumn(rightmostLeaf, newLeafWidth);
+ }
+
return (
-
+
{children}
);