+ 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..9a0b4b9848
--- /dev/null
+++ b/src/table/__tests__/column-grouping-utils.test.tsx
@@ -0,0 +1,671 @@
+// 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 expanded into hidden placeholder cells (rowspan=1, isHidden=true)', () => {
+ 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');
+
+ // Ungrouped leaf columns are expanded: a hidden placeholder appears in row 0
+ // and the real cell appears in row 1 (aligned with leaf columns of groups)
+ expect(idCol?.rowspan).toBe(1);
+ expect(idCol?.isHidden).toBe(true);
+ expect(idCol?.colspan).toBe(1);
+ expect(nameCol?.rowspan).toBe(1);
+ expect(nameCol?.isHidden).toBe(true);
+ 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 grouped columns plus real ungrouped columns (expanded from row 0)', () => {
+ const result = CalculateHierarchyTree(
+ columns,
+ ['id', 'name', 'cpu', 'memory', 'type'],
+ groups
+ // []
+ );
+
+ const row1 = result?.rows[1].columns;
+
+ // Row 1 contains the real ungrouped columns (id, name) plus the grouped leaf columns
+ expect(row1?.length).toBe(5); // id, name, cpu, memory, type
+ expect(row1?.map(c => c.id)).toEqual(['id', 'name', 'cpu', 'memory', 'type']);
+
+ // id and name are the real (non-hidden) cells
+ const idCol = row1?.find(c => c.id === 'id');
+ const nameCol = row1?.find(c => c.id === 'name');
+ expect(idCol?.isHidden).toBe(false);
+ expect(nameCol?.isHidden).toBe(false);
+
+ // grouped leaf columns are normal
+ ['cpu', 'memory', 'type'].forEach(id => {
+ const col = row1?.find(c => c.id === id);
+ expect(col?.isGroup).toBe(false);
+ expect(col?.rowspan).toBe(1);
+ expect(col?.colspan).toBe(1);
+ expect(col?.isHidden).toBe(false);
+ });
+ });
+
+ 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);
+
+ // Row 0: metrics and a hidden placeholder for config (config had rowspan=2, now expanded)
+ expect(result?.rows[0].columns.map(c => c.id)).toEqual(['metrics', 'config']);
+ const configRow0 = result?.rows[0].columns.find(c => c.id === 'config');
+ expect(configRow0?.isHidden).toBe(true);
+ expect(configRow0?.rowspan).toBe(1);
+
+ // Row 1: perf and the real config (dropped down from row 0)
+ expect(result?.rows[1].columns.map(c => c.id)).toEqual(['perf', 'config']);
+ const configRow1 = result?.rows[1].columns.find(c => c.id === 'config');
+ expect(configRow1?.isHidden).toBe(false);
+ expect(configRow1?.isGroup).toBe(true);
+ expect(configRow1?.rowspan).toBe(1);
+
+ // 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 are expanded into hidden placeholder in row 0, real cell in row 1', () => {
+ 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);
+
+ // Row 0 has a hidden placeholder for 'ungrouped'
+ const ungroupedRow0 = result?.rows[0].columns.find(c => c.id === 'ungrouped');
+ expect(ungroupedRow0?.rowspan).toBe(1);
+ expect(ungroupedRow0?.isHidden).toBe(true);
+ expect(ungroupedRow0?.rowIndex).toBe(0);
+
+ // Row 1 has the real 'ungrouped' cell
+ const ungroupedRow1 = result?.rows[1].columns.find(c => c.id === 'ungrouped');
+ expect(ungroupedRow1?.rowspan).toBe(1);
+ expect(ungroupedRow1?.isHidden).toBe(false);
+ expect(ungroupedRow1?.rowIndex).toBe(1);
+ });
+
+ 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);
+
+ // Row 0: hidden placeholders for a and c, plus the group header
+ expect(result?.rows[0].columns.map(c => c.id)).toEqual(['a', 'group1', 'c']);
+ const row0Hidden = result?.rows[0].columns.filter(c => c.isHidden).map(c => c.id);
+ expect(row0Hidden).toEqual(['a', 'c']);
+
+ // Row 1: real a and c cells plus grouped columns b and d
+ // b and d appear in the order determined by the tree traversal (b before d),
+ // with c inserted after d based on its colIndex
+ expect(result?.rows[1].columns.map(c => c.id)).toEqual(['a', 'b', 'd', 'c']);
+ const row1Hidden = result?.rows[1].columns.filter(c => c.isHidden).map(c => c.id);
+ expect(row1Hidden).toEqual([]); // no hidden cells in row 1
+ });
+ });
+
+ 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..fd0ace060c
--- /dev/null
+++ b/src/table/__tests__/use-column-grouping.test.tsx
@@ -0,0 +1,571 @@
+// 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 hidden placeholders for ungrouped columns and group headers', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ const row0 = result.current.rows[0].columns;
+
+ // Hidden placeholders at top for ungrouped columns
+ expect(row0[0]).toMatchObject({ id: 'id', isHidden: true, rowspan: 1 });
+ expect(row0[1]).toMatchObject({ id: 'name', isHidden: true, rowspan: 1 });
+
+ // 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 0 has hidden placeholders at top for ungrouped columns, row 1 has visible cells at bottom', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ // Row 0: hidden placeholders for id, name (at top) + group headers
+ const row0 = result.current.rows[0].columns;
+ expect(row0[0]).toMatchObject({ id: 'id', isHidden: true, rowspan: 1 });
+ expect(row0[1]).toMatchObject({ id: 'name', isHidden: true, rowspan: 1 });
+
+ const row1 = result.current.rows[1].columns;
+
+ // Row 1: visible id + visible name (at bottom, aligned with leaf row) + 4 leaf columns
+ const visibleId = row1.find(c => c.id === 'id' && !c.isHidden);
+ const visibleName = row1.find(c => c.id === 'name' && !c.isHidden);
+ expect(visibleId).toBeDefined();
+ expect(visibleName).toBeDefined();
+
+ // Leaf columns
+ expect(row1.find(c => c.id === 'cpu' && !c.isHidden)).toMatchObject({
+ id: 'cpu',
+ header: 'CPU',
+ colspan: 1,
+ rowspan: 1,
+ isGroup: false,
+ });
+ expect(row1.find(c => c.id === 'memory' && !c.isHidden)).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;
+ // Visible id and name now in row 1 (bottom) alongside leaf columns
+ expect(row1.map((c: any) => c.id)).toEqual(['id', 'name', '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;
+ // Row 0: hidden placeholder for id (top) + performance group
+ expect(row0).toHaveLength(2);
+ expect(row0[0]).toMatchObject({ id: 'id', isHidden: true });
+ expect(row0[1].id).toBe('performance');
+
+ const row1 = result.current.rows[1].columns;
+ // Row 1: visible id (bottom) + cpu + memory
+ expect(row1).toHaveLength(3);
+ const visibleId = row1.find(c => c.id === 'id' && !c.isHidden);
+ expect(visibleId).toBeDefined();
+ });
+
+ 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('ungrouped columns do not have parent group entries', () => {
+ const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns));
+
+ // Ungrouped columns may have entries due to hidden node chain, but should not have group IDs
+ const idParents = result.current.columnToParentIds.get('id');
+ const nameParents = result.current.columnToParentIds.get('name');
+ // Either undefined or empty — no group parents
+ expect(!idParents || idParents.length === 0).toBe(true);
+ expect(!nameParents || nameParents.length === 0).toBe(true);
+ });
+ });
+
+ 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); // hidden id placeholder
+ expect(row0[1].colIndex).toBe(1); // hidden name placeholder
+ 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: visible id + name (at bottom) + leaf columns
+ expect(row1[0].colIndex).toBe(0); // visible id
+ expect(row1[1].colIndex).toBe(1); // visible name
+ expect(row1[2].colIndex).toBe(2); // cpu
+ expect(row1[3].colIndex).toBe(3); // memory
+ expect(row1[4].colIndex).toBe(4); // type
+ expect(row1[5].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: hidden placeholder for id (top), metrics group, config group
+ expect(result.current.rows[0].columns).toHaveLength(3);
+ expect(result.current.rows[0].columns[0]).toMatchObject({
+ id: 'id',
+ rowspan: 1,
+ isHidden: true,
+ });
+ 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: hidden placeholder for id, performance group, hidden placeholder for config's type
+ const row1Ids = result.current.rows[1].columns.map(c => c.id);
+ expect(row1Ids).toContain('id'); // hidden placeholder
+ expect(row1Ids).toContain('performance');
+
+ // Row 2: leaf columns (hidden placeholder for id, cpu, type)
+ const row2 = result.current.rows[2].columns;
+ const row2NonHidden = row2.filter(c => !c.isHidden);
+ expect(row2NonHidden.length).toBeGreaterThanOrEqual(2); // at least cpu and type
+ });
+
+ 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 appear with rowspan=1 in the bottom row (visible, not hidden)
+ const allRows = result.current.rows;
+ const lastRow = allRows[allRows.length - 1];
+ const col1Visible = lastRow.columns.find(c => c.id === 'col1' && !c.isHidden);
+ expect(col1Visible).toBeDefined();
+ expect(col1Visible?.rowspan).toBe(1);
+ });
+
+ 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..a3161f772c
--- /dev/null
+++ b/src/table/column-grouping-utils.ts
@@ -0,0 +1,437 @@
+// 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;
+ isHidden: boolean; // True for placeholder cells that fill gaps where rowspan > 1 would have been
+ // 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;
+ isHidden: boolean = false;
+
+ constructor(
+ id: string,
+ options?: {
+ colspan?: number;
+ rowspan?: number;
+ columnDefinition?: TableProps.ColumnDefinition;
+ groupDefinition?: TableProps.ColumnGroupsDefinition;
+ parentNode?: TableHeaderNode;
+ rowIndex?: number;
+ colIndex?: number;
+ isRoot?: boolean;
+ isHidden?: 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);
+ });
+
+ // Expand nodes with rowspan > 1 into chains of hidden nodes in the tree
+ expandRowspansToHiddenNodes(rootNode);
+
+ // Re-compute subtree heights after hidden node insertion
+ traverseForSubtreeHeight(rootNode);
+
+ // 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);
+ });
+}
+
+/**
+ * Expands nodes with rowspan > 1 into a chain of hidden placeholder nodes ABOVE
+ * the real node, so the visible cell appears at the bottom (aligned with leaf columns).
+ *
+ * For a node at rowIndex=R with rowspan=N, we:
+ * 1. Create (N-1) hidden placeholder nodes at rows R, R+1, ..., R+N-2
+ * 2. Move the real node to row R+N-1 (the bottom of its span) with rowspan=1
+ * 3. The hidden nodes form a parent chain: parent→hidden(R)→hidden(R+1)→...→realNode(R+N-1)
+ * 4. The real node keeps its original children.
+ */
+function expandRowspansToHiddenNodes(node: TableHeaderNode): void {
+ // Process children first (bottom-up so we don't interfere with our own expansion)
+ for (const child of [...node.children]) {
+ expandRowspansToHiddenNodes(child);
+ }
+
+ if (node.isRoot) {
+ return;
+ }
+
+ if (node.rowspan <= 1) {
+ return;
+ }
+
+ const originalRowspan = node.rowspan;
+ const originalRowIndex = node.rowIndex;
+ const parentNode = node.parentNode!;
+
+ // Remove this node from its parent's children
+ const indexInParent = parentNode.children.indexOf(node);
+ parentNode.children.splice(indexInParent, 1);
+
+ // Build a chain of hidden nodes at the top rows
+ let currentParent = parentNode;
+ for (let r = 0; r < originalRowspan - 1; r++) {
+ const hiddenNode = new TableHeaderNode(node.id, {
+ rowIndex: originalRowIndex + r,
+ rowspan: 1,
+ colspan: node.colspan,
+ columnDefinition: node.columnDefinition,
+ groupDefinition: node.groupDefinition,
+ isHidden: true,
+ });
+ // Insert at the same position in parent to maintain column order
+ if (currentParent === parentNode) {
+ currentParent.children.splice(indexInParent, 0, hiddenNode);
+ } else {
+ currentParent.addChild(hiddenNode);
+ }
+ hiddenNode.parentNode = currentParent;
+ currentParent = hiddenNode;
+ }
+
+ // Move the real node to the bottom row
+ node.rowIndex = originalRowIndex + originalRowspan - 1;
+ node.rowspan = 1;
+ currentParent.addChild(node);
+ node.parentNode = currentParent;
+}
+
+// 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) {
+ // Skip hidden placeholder nodes — they are not real group parents
+ if (!current.isHidden) {
+ 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()!;
+ const parentChain = buildParentChain(node);
+
+ const columnInRow: TableGroupedTypes.ColumnInRow = {
+ id: node.id,
+ header: node.groupDefinition?.header || node.columnDefinition?.header,
+ colspan: node.colspan,
+ rowspan: node.rowspan,
+ isGroup: node.isGroup,
+ isHidden: node.isHidden,
+ columnDefinition: node.columnDefinition,
+ groupDefinition: node.groupDefinition,
+ parentGroupIds: parentChain,
+ 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 (skip hidden placeholder nodes)
+ if (node.isLeaf && node.columnDefinition && !node.isHidden) {
+ const parentChainForTracking = buildParentChain(node);
+ if (parentChainForTracking.length > 0) {
+ columnToParentIds.set(node.id, parentChainForTracking);
+ }
+ }
+
+ // 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..a2404878ec
--- /dev/null
+++ b/src/table/header-cell/group-header-cell.tsx
@@ -0,0 +1,308 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { useEffect, 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 { StickyColumnsCellState, 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;
+ /** First visible leaf column ID among this group's children — used for sticky offset. */
+ firstChildColumnId?: PropertyKey;
+ /** Last visible leaf column ID among this group's children — used for clip-path computation. */
+ lastChildColumnId?: PropertyKey;
+ 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,
+ firstChildColumnId,
+ lastChildColumnId,
+ 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);
+
+ // Cached wrapper element and cleanup for scroll listener
+ const wrapperRef = useRef(null);
+ const removeScrollListenerRef = useRef<(() => void) | null>(null);
+
+ /**
+ * Dynamically constrains the group
's width as child columns scroll out of view.
+ *
+ * Approach (same as ag-grid): directly set `style.width` on the sticky
to
+ * max(0, lastChild.getBoundingClientRect().right - groupTh.getBoundingClientRect().left)
+ *
+ * As the last child column scrolls left, its `.right` decreases, shrinking the group width.
+ * The right border moves with the last child's right edge — no clip-path needed.
+ * When all children are off-screen, width collapses to 0.
+ * When all children are fully visible, width is cleared so colspan drives layout.
+ */
+ const computeWidth = () => {
+ const groupEl = cellRefObject.current;
+ if (!groupEl) {
+ return;
+ }
+
+ // Find the last child's
via data-focus-id attribute, scoped to thead
+ let lastChildEl: Element | null = null;
+ if (lastChildColumnId) {
+ const thead = groupEl.closest('thead');
+ if (thead) {
+ lastChildEl = thead.querySelector(`[data-focus-id="header-${String(lastChildColumnId)}"]`);
+ }
+ }
+
+ if (!lastChildEl) {
+ groupEl.style.maxWidth = '';
+ return;
+ }
+
+ const groupRect = groupEl.getBoundingClientRect();
+ const lastChildRect = lastChildEl.getBoundingClientRect();
+ // Width from our stuck left edge to the last child's current right edge
+ const constrainedWidth = lastChildRect.right - groupRect.left;
+
+ if (constrainedWidth >= groupEl.offsetWidth) {
+ // All children fully visible — release constraint
+ groupEl.style.maxWidth = '';
+ } else {
+ // Constrain right edge: overflow:hidden on the th clips the border/content
+ groupEl.style.maxWidth = `${Math.max(0, constrainedWidth)}px`;
+ }
+ };
+
+ /**
+ * Finds the scrollable wrapper ancestor and attaches the scroll listener.
+ * Called after the ref is populated (from within the sticky store subscription
+ * or a deferred effect).
+ */
+ const attachScrollListener = () => {
+ const el = cellRefObject.current;
+ if (!el) {
+ return;
+ }
+
+ // Remove any existing listener before re-attaching
+ removeScrollListenerRef.current?.();
+ removeScrollListenerRef.current = null;
+
+ // Find scrollable wrapper ancestor
+ let wrapper: HTMLElement | null = el.parentElement;
+ while (
+ wrapper &&
+ getComputedStyle(wrapper).overflowX !== 'auto' &&
+ getComputedStyle(wrapper).overflowX !== 'scroll'
+ ) {
+ wrapper = wrapper.parentElement;
+ }
+
+ if (!wrapper) {
+ return;
+ }
+ wrapperRef.current = wrapper;
+
+ const listener = () => computeWidth();
+ wrapper.addEventListener('scroll', listener, { passive: true });
+ removeScrollListenerRef.current = () => wrapper!.removeEventListener('scroll', listener);
+
+ // Run immediately to set initial width state
+ computeWidth();
+ };
+
+ // Attach scroll listener after React has committed the DOM and populated cellRefObject.
+ // We use setTimeout(0) to defer past the synchronous ref-assignment phase.
+ useEffect(() => {
+ const id = setTimeout(attachScrollListener, 0);
+ return () => {
+ clearTimeout(id);
+ removeScrollListenerRef.current?.();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [lastChildColumnId]);
+
+ // Subscribe to the sticky store for the first child column's state.
+ // When sticky columns are active, we apply the first child's insetInlineStart offset
+ // to the group
and trigger a clip recompute.
+ // We also apply the shadow class when the last child is at the sticky boundary.
+ useEffect(() => {
+ if (!firstChildColumnId) {
+ return;
+ }
+
+ const selector = (
+ state: Parameters[0] extends (s: infer S) => any ? S : never
+ ) => state.cellState.get(firstChildColumnId) ?? null;
+
+ const lastSelector = lastChildColumnId
+ ? (state: Parameters[0] extends (s: infer S) => any ? S : never) =>
+ state.cellState.get(lastChildColumnId) ?? null
+ : null;
+
+ const applyStyles = (firstState: StickyColumnsCellState | null, lastState: StickyColumnsCellState | null) => {
+ const thEl = cellRefObject.current;
+ if (!thEl) {
+ return;
+ }
+
+ // Apply sticky offset to the
itself
+ if (firstState?.offset.insetInlineStart !== undefined) {
+ thEl.style.insetInlineStart = `${firstState.offset.insetInlineStart}px`;
+ } else {
+ thEl.style.insetInlineStart = '';
+ }
+
+ // Shadow class on the