From 1365ded183c2b0596e74d5d77dd1bf42a6c9e725 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 19 Feb 2026 17:03:26 +0100 Subject: [PATCH 01/14] feat: Modify interfaces --- src/collection-preferences/interfaces.ts | 4 ++++ src/table/interfaces.tsx | 12 ++++++++++++ 2 files changed, 16 insertions(+) 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/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; From 9729e224cb8f7db19ed9bcb608261c2577a9facc Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 19 Feb 2026 17:04:23 +0100 Subject: [PATCH 02/14] feat: Add Test Pages --- .../multi-level-reorder.page.tsx | 96 +++++ pages/list/nested-sortable.page.tsx | 155 ++++++++ .../grouped-column-with-preference.page.tsx | 340 ++++++++++++++++++ 3 files changed, 591 insertions(+) create mode 100644 pages/collection-preferences/multi-level-reorder.page.tsx create mode 100644 pages/list/nested-sortable.page.tsx create mode 100644 pages/table/grouped-column-with-preference.page.tsx 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..c9d4a95154 --- /dev/null +++ b/pages/collection-preferences/multi-level-reorder.page.tsx @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import CollectionPreferences from '~components/collection-preferences'; + +import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings'; +import { + baseProperties, + contentDensityPreference, + customPreference, + pageSizePreference, + wrapLinesPreference, +} from './shared-configs'; + +const shortOptionsList = [ + { + id: 'root1', + label: 'Root Item 1', + }, + { + id: 'root2', + label: 'Root Item 2', + }, + { + id: 'child1', + label: 'Child 1 of Root 1', + parentId: 'root1', + }, + { + id: 'child2', + label: 'Child 2 of Root 1', + parentId: 'root1', + }, + { + id: 'grandchild1', + label: 'Grandchild 1 of Child 1', + parentId: 'child1', + }, + { + id: 'grandchild2', + label: 'Grandchild 2 of Child 1', + parentId: 'child1', + }, + { + id: 'greatgrandchild1', + label: 'Great-Grandchild 1 (Level 4)', + parentId: 'grandchild1', + }, + { + id: 'root3', + label: 'Root Item 3', + }, + { + id: 'child3', + label: 'Child of Root 3', + parentId: 'root3', + }, + { + id: 'root4', + label: + 'Root Item 4 - Long text to verify wrapping behavior and ensure that the reordering feature works correctly with extended content', + }, + { + id: 'child4', + label: 'Child of Root 4', + parentId: 'root4', + }, + { + id: 'grandchild3', + label: 'Grandchild of Root 4', + parentId: 'child4', + }, + { + id: 'root5', + label: 'ExtremelyLongLabelTextWithoutSpacesToVerifyThatItWrapsToTheNextLine', + }, +]; + +export default function App() { + return ( + + ); +} 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..29f75e0775 --- /dev/null +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -0,0 +1,340 @@ +// 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, + Container, + Header, + Pagination, + Table, + TableProps, + TextFilter, +} from '~components'; + +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, + }, + { + 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: 'id', label: 'Instance ID', alwaysVisible: true }, + { 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: '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' }, + ], + }, +}; + +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: 'id', visible: true }, + { id: 'name', visible: true }, + { id: 'cpuUtilization', visible: true }, + { id: 'memoryUtilization', visible: true }, + { id: 'networkIn', visible: true }, + { id: 'networkOut', visible: true }, + { id: 'instanceType', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + { id: 'monthlyCost', visible: false }, + { id: 'spotPrice', visible: false }, + ], + }); + + 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; + + return ( + + + EC2 Instances + + } + columnDefinitions={columnDefinitions} + columnGroupingDefinitions={columnGroupingDefinitions} + columnDisplay={preferences?.contentDisplay} + items={items} + pagination={} + filter={ + + } + preferences={ + setPreferences(detail)} + /> + } + /> + + ); +} From 646b48db23dfee3fb80b3a55b54260b7d3e4afd2 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 19 Feb 2026 21:48:25 +0100 Subject: [PATCH 03/14] feat: Add column grouping util functions to create tree --- .../__tests__/column-grouping-utils.test.tsx | 634 ++++++++++++++++++ src/table/column-grouping-utils.ts | 349 ++++++++++ 2 files changed, 983 insertions(+) create mode 100644 src/table/__tests__/column-grouping-utils.test.tsx create mode 100644 src/table/column-grouping-utils.ts 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/column-grouping-utils.ts b/src/table/column-grouping-utils.ts new file mode 100644 index 0000000000..f2893caa63 --- /dev/null +++ b/src/table/column-grouping-utils.ts @@ -0,0 +1,349 @@ +// 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 { Optional } from '../internal/types'; +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; + 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; + } + + while (currentNode) { + 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 + 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[] +): Optional> { + // 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 }; +} From 791d85bd4b7eeea62a2775ae370faae8fee5d780 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Sat, 21 Feb 2026 14:55:44 +0100 Subject: [PATCH 04/14] chore: Update snapshots --- .../__snapshots__/documenter.test.ts.snap | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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'\`.", From 1dcf9f5bc355dd91b6891d0cb6f2b4b5f15f2e2d Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Mon, 23 Feb 2026 16:04:52 +0100 Subject: [PATCH 05/14] chore: Add colSpan, rowSpan, and scope to th-element --- src/table/header-cell/th-element.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) 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} From 6fe44df547a37697de6e9c4894b4f518c25a18af Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Mon, 23 Feb 2026 16:07:24 +0100 Subject: [PATCH 06/14] fix: Add SimplePage for test page --- .../grouped-column-with-preference.page.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/pages/table/grouped-column-with-preference.page.tsx b/pages/table/grouped-column-with-preference.page.tsx index 29f75e0775..7357b281ba 100644 --- a/pages/table/grouped-column-with-preference.page.tsx +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -10,7 +10,6 @@ import { Button, CollectionPreferences, CollectionPreferencesProps, - Container, Header, Pagination, Table, @@ -18,6 +17,8 @@ import { TextFilter, } from '~components'; +import { SimplePage } from '../app/templates'; + interface EC2Instance { id: string; name: string; @@ -255,12 +256,12 @@ export default function EC2TableDemo() { const [preferences, setPreferences] = useState({ pageSize: 10, contentDisplay: [ - { id: 'id', visible: true }, - { id: 'name', visible: true }, { 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 }, @@ -297,12 +298,15 @@ export default function EC2TableDemo() { const { selectedItems } = collectionProps; return ( - +
} /> - + ); } From b376653ba0453e0ced25f0e1a94963d41cae7a4b Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Mon, 23 Feb 2026 17:45:22 +0100 Subject: [PATCH 07/14] fix: Fix Axe violation for heading one availability, and AriaLabel violations --- pages/table/grouped-column-with-preference.page.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pages/table/grouped-column-with-preference.page.tsx b/pages/table/grouped-column-with-preference.page.tsx index 7357b281ba..7109b9d440 100644 --- a/pages/table/grouped-column-with-preference.page.tsx +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -270,6 +270,17 @@ export default function EC2TableDemo() { ], }); + 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, { @@ -299,6 +310,7 @@ export default function EC2TableDemo() { return ( +

EC2 Instances Table

Date: Wed, 25 Feb 2026 16:39:36 +0100 Subject: [PATCH 08/14] chore: Add fields for first and last for sticky --- .../grouped-column-with-preference.page.tsx | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/pages/table/grouped-column-with-preference.page.tsx b/pages/table/grouped-column-with-preference.page.tsx index 7109b9d440..a44b8ead71 100644 --- a/pages/table/grouped-column-with-preference.page.tsx +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -11,7 +11,9 @@ import { CollectionPreferences, CollectionPreferencesProps, Header, + Input, Pagination, + SpaceBetween, Table, TableProps, TextFilter, @@ -108,6 +110,7 @@ const columnDefinitions: TableProps['columnDefinitions'] = [ cell: (item: EC2Instance) => item.id, sortingField: 'id', isRowHeader: true, + groupId: 'metrics', }, { id: 'name', @@ -308,6 +311,9 @@ export default function EC2TableDemo() { const { selectedItems } = collectionProps; + const [firstSticky, setFirstSticky] = useState('0'); + const [lastSticky, setLastSticky] = useState('0'); + return (

EC2 Instances Table

@@ -316,7 +322,8 @@ export default function EC2TableDemo() { selectionType="multi" resizableColumns={true} stickyColumns={{ - first: 1, + first: +firstSticky, + last: +lastSticky, }} // variant="stacked" enableKeyboardNavigation={true} @@ -338,11 +345,29 @@ export default function EC2TableDemo() { items={items} pagination={} filter={ - + + {/* first */} + setFirstSticky(detail.value)} + value={firstSticky} + name="first" + inputMode="numeric" + type="number" + /> + setLastSticky(detail.value)} + value={lastSticky} + name="last" + inputMode="numeric" + type="number" + /> + + + } preferences={ Date: Wed, 25 Feb 2026 18:39:24 +0100 Subject: [PATCH 09/14] feat: Add column grouping support with nested headers, colspan/rowspan navigation, and group resizing --- .../__tests__/use-column-grouping.test.tsx | 509 ++++++++++++++++++ src/table/column-grouping-utils.ts | 20 + src/table/header-cell/group-header-cell.tsx | 131 +++++ src/table/header-cell/index.tsx | 6 + src/table/header-cell/styles.scss | 2 +- src/table/internal.tsx | 59 +- src/table/table-role/grid-navigation.tsx | 44 +- src/table/table-role/table-role-helper.ts | 19 +- src/table/table-role/utils.ts | 69 ++- src/table/thead.tsx | 277 ++++++++-- src/table/use-column-grouping.ts | 68 +++ src/table/use-column-widths.tsx | 178 +++++- 12 files changed, 1300 insertions(+), 82 deletions(-) create mode 100644 src/table/__tests__/use-column-grouping.test.tsx create mode 100644 src/table/header-cell/group-header-cell.tsx create mode 100644 src/table/use-column-grouping.ts 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..efedc31c6b --- /dev/null +++ b/src/table/__tests__/use-column-grouping.test.tsx @@ -0,0 +1,509 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../__tests__/render-hook'; +import { TableProps } from '../interfaces'; +import { 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('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 index f2893caa63..07a4823cec 100644 --- a/src/table/column-grouping-utils.ts +++ b/src/table/column-grouping-utils.ts @@ -14,6 +14,8 @@ export namespace TableGroupedTypes { 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 @@ -65,7 +67,22 @@ function createNodeConnections( 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; @@ -84,6 +101,9 @@ function createNodeConnections( 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); 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 ( + + ); +} 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/internal.tsx b/src/table/internal.tsx index cf2e4db610..dabe6a5472 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 && ( + + )} 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..9e65080cdd 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,8 @@ const Thead = React.forwardRef( selectionType, getSelectAllProps, columnDefinitions, + // columnGroupingDefinitions, + hierarchicalStructure: h, // TODO: change to normal later, no convert to h sortingColumn, sortingDisabled, sortingDescending, @@ -80,7 +86,49 @@ const Thead = React.forwardRef( }: TheadProps, outerRef: React.Ref ) => { - const { getColumnStyles, columnWidths, updateColumn, setCell } = useColumnWidths(); + const { getColumnStyles, columnWidths, updateColumn, updateGroup, setCell } = useColumnWidths(); + + /// TODO: Remove this nati later, JUST TESTING :) + const hierarchicalStructure: TableGroupedTypes.HierarchicalStructure = { + // maxDepth: 1, + maxDepth: h?.maxDepth ?? 1, + columnToParentIds: h?.columnToParentIds ?? new Map(), + // rows: h?.rows?.slice(-1) ?? [], // - shows only last column + rows: h?.rows ?? [], + }; + + // 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 +141,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={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} + /> + ); + })} + + + ); + } + + // 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..6edc30f3e6 --- /dev/null +++ b/src/table/use-column-grouping.ts @@ -0,0 +1,68 @@ +// 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 new CalculateHierarchyTree function + const result = CalculateHierarchyTree(columns, visibleIds, groups, columnDisplayMutable); + + // Return result or fallback to single row if undefined + if (!result) { + const fallbackColumns: TableGroupedTypes.ColumnInRow[] = []; + let colIndex = 0; + + columnDefinitions.forEach((col, index) => { + const id = col.id || `column-${index}`; + if (!visibleColumnIds || visibleColumnIds.has(id)) { + fallbackColumns.push({ + id, + header: col.header, + colspan: 1, + rowspan: 1, + isGroup: false, + columnDefinition: col, + groupDefinition: undefined, + parentGroupIds: [], + rowIndex: 0, + colIndex: colIndex++, + }); + } + }); + + return { + rows: [{ columns: fallbackColumns }], + maxDepth: 1, + columnToParentIds: new Map(), + }; + } + + return result; + }, [columnGroupingDefinitions, columnDefinitions, visibleColumnIds, columnDisplay]); +} diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index db41a79c8c..9f9b2eff01 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) { @@ -54,6 +55,7 @@ function updateWidths( } const newWidths = new Map(oldWidths); newWidths.set(columnId, newWidth); + console.log(newWidths); return newWidths; } @@ -61,14 +63,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 +82,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 +109,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 +242,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 +327,38 @@ 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) { + console.warn(`No rightmost leaf found for group ${String(groupId)}`); + return; + } + + // Apply the delta to the rightmost leaf column + const currentLeafWidth = columnWidths.get(rightmostLeaf) || DEFAULT_COLUMN_WIDTH; + const newLeafWidth = currentLeafWidth + delta; + + console.log( + `Group resize: ${String(groupId)} delta=${delta}px -> applying to leaf ${rightmostLeaf} (${currentLeafWidth}px -> ${newLeafWidth}px)` + ); + + // Use updateColumn to handle the leaf resize (which will propagate to parents automatically) + updateColumn(rightmostLeaf, newLeafWidth); + } + return ( - + {children} ); From 512fc797f887fb722729bedf69190baec4cc356e Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 26 Feb 2026 07:15:49 +0100 Subject: [PATCH 10/14] fix: Fix failing unit tests for row count and single row render condition --- src/table/internal.tsx | 2 +- src/table/thead.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/table/internal.tsx b/src/table/internal.tsx index dabe6a5472..7500cd5c52 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -498,7 +498,7 @@ const InternalTable = React.forwardRef( const colIndexOffset = selectionType ? 1 : 0; const totalColumnsCount = visibleColumnDefinitions.length + colIndexOffset; - const headerRowCount = hierarchicalStructure?.rows.length ?? 1; + const headerRowCount = hierarchicalStructure?.rows.length || 1; return ( diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 9e65080cdd..3fc6e69089 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -142,7 +142,7 @@ const Thead = React.forwardRef( }; // No grouping - render single row - if (!hierarchicalStructure || hierarchicalStructure.rows.length === 1) { + if (!hierarchicalStructure || hierarchicalStructure.rows.length <= 1) { return ( Date: Thu, 26 Feb 2026 08:22:42 +0100 Subject: [PATCH 11/14] fix: Address some A11Y and codecov issues --- package-lock.json | 101 ++------ .../multi-level-reorder.page.tsx | 29 ++- .../grouped-column-with-preference.page.tsx | 2 + .../column-grouping-rendering.test.tsx | 233 ++++++++++++++++++ .../__tests__/use-column-grouping.test.tsx | 52 ++++ src/table/column-grouping-utils.ts | 3 +- src/table/table-role/__tests__/utils.test.ts | 199 +++++++++++++++ src/table/thead.tsx | 14 +- src/table/use-column-grouping.ts | 36 +-- src/table/use-column-widths.tsx | 6 - 10 files changed, 530 insertions(+), 145 deletions(-) create mode 100644 src/table/__tests__/column-grouping-rendering.test.tsx create mode 100644 src/table/table-role/__tests__/utils.test.ts 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 index c9d4a95154..2830f9bc39 100644 --- a/pages/collection-preferences/multi-level-reorder.page.tsx +++ b/pages/collection-preferences/multi-level-reorder.page.tsx @@ -79,18 +79,21 @@ const shortOptionsList = [ export default function App() { return ( - + <> +

Multi-level Reorder Preferences

+ + ); } diff --git a/pages/table/grouped-column-with-preference.page.tsx b/pages/table/grouped-column-with-preference.page.tsx index a44b8ead71..ce79d6dc8f 100644 --- a/pages/table/grouped-column-with-preference.page.tsx +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -348,6 +348,7 @@ export default function EC2TableDemo() { {/* first */} setFirstSticky(detail.value)} value={firstSticky} name="first" @@ -355,6 +356,7 @@ export default function EC2TableDemo() { type="number" /> setLastSticky(detail.value)} value={lastSticky} name="last" 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__/use-column-grouping.test.tsx b/src/table/__tests__/use-column-grouping.test.tsx index efedc31c6b..dd128ae3ad 100644 --- a/src/table/__tests__/use-column-grouping.test.tsx +++ b/src/table/__tests__/use-column-grouping.test.tsx @@ -1,6 +1,7 @@ // 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'; @@ -438,6 +439,57 @@ describe('useColumnGrouping', () => { }); }); + 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; diff --git a/src/table/column-grouping-utils.ts b/src/table/column-grouping-utils.ts index 07a4823cec..05c9a5c823 100644 --- a/src/table/column-grouping-utils.ts +++ b/src/table/column-grouping-utils.ts @@ -3,7 +3,6 @@ import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; import { isDevelopment } from '../internal/is-development'; -import { Optional } from '../internal/types'; import { TableProps } from './interfaces'; import { getVisibleColumnDefinitions } from './utils'; @@ -193,7 +192,7 @@ export function CalculateHierarchyTree( visibleColumnIds: string[], columnGroupingDefinitions: TableProps.ColumnGroupsDefinition[], columnDisplayProperties?: TableProps.ColumnDisplayProperties[] -): Optional> { +): TableGroupedTypes.HierarchicalStructure { // filtering by visible columns const visibleColumns: Readonly[]> = getVisibleColumnDefinitions({ columnDisplay: columnDisplayProperties, diff --git a/src/table/table-role/__tests__/utils.test.ts b/src/table/table-role/__tests__/utils.test.ts new file mode 100644 index 0000000000..42b81a93de --- /dev/null +++ b/src/table/table-role/__tests__/utils.test.ts @@ -0,0 +1,199 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { findClosestCellByAriaColIndex, getAllCellsInRow } from '../../../../lib/components/table/table-role/utils'; + +interface CellDef { + tag: 'th' | 'td'; + colindex: number; + text: string; + rowspan?: number; + colspan?: number; +} + +interface RowDef { + rowindex: number; + cells: CellDef[]; +} + +function buildTable(rows: RowDef[]): HTMLTableElement { + const table = document.createElement('table'); + for (const rowDef of rows) { + const tr = document.createElement('tr'); + tr.setAttribute('aria-rowindex', String(rowDef.rowindex)); + for (const cellDef of rowDef.cells) { + const cell = document.createElement(cellDef.tag); + cell.setAttribute('aria-colindex', String(cellDef.colindex)); + cell.textContent = cellDef.text; + if (cellDef.rowspan && cellDef.rowspan > 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/thead.tsx b/src/table/thead.tsx index 3fc6e69089..7afbbacef0 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -57,8 +57,7 @@ const Thead = React.forwardRef( selectionType, getSelectAllProps, columnDefinitions, - // columnGroupingDefinitions, - hierarchicalStructure: h, // TODO: change to normal later, no convert to h + hierarchicalStructure: h, sortingColumn, sortingDisabled, sortingDescending, @@ -88,14 +87,7 @@ const Thead = React.forwardRef( ) => { const { getColumnStyles, columnWidths, updateColumn, updateGroup, setCell } = useColumnWidths(); - /// TODO: Remove this nati later, JUST TESTING :) - const hierarchicalStructure: TableGroupedTypes.HierarchicalStructure = { - // maxDepth: 1, - maxDepth: h?.maxDepth ?? 1, - columnToParentIds: h?.columnToParentIds ?? new Map(), - // rows: h?.rows?.slice(-1) ?? [], // - shows only last column - rows: h?.rows ?? [], - }; + const hierarchicalStructure: TableGroupedTypes.HierarchicalStructure | undefined = h; // Helper to get child column IDs for a group (for getting minWidths) const getChildColumnIds = (groupId: string): string[] => { @@ -186,7 +178,7 @@ const Thead = React.forwardRef( updateColumn={updateColumn} onResizeFinish={() => onResizeFinish(columnWidths)} resizableColumns={resizableColumns} - resizableStyle={resizableColumns ? {} : getColumnStyles(sticky, columnId)} + resizableStyle={getColumnStyles(sticky, columnId)} onClick={detail => { setLastUserAction('sorting'); fireNonCancelableEvent(onSortingChange, detail); diff --git a/src/table/use-column-grouping.ts b/src/table/use-column-grouping.ts index 6edc30f3e6..9d67cedecd 100644 --- a/src/table/use-column-grouping.ts +++ b/src/table/use-column-grouping.ts @@ -30,39 +30,7 @@ export function useColumnGrouping( const columns = [...columnDefinitions]; const columnDisplayMutable = columnDisplay ? [...columnDisplay] : undefined; - // Call the new CalculateHierarchyTree function - const result = CalculateHierarchyTree(columns, visibleIds, groups, columnDisplayMutable); - - // Return result or fallback to single row if undefined - if (!result) { - const fallbackColumns: TableGroupedTypes.ColumnInRow[] = []; - let colIndex = 0; - - columnDefinitions.forEach((col, index) => { - const id = col.id || `column-${index}`; - if (!visibleColumnIds || visibleColumnIds.has(id)) { - fallbackColumns.push({ - id, - header: col.header, - colspan: 1, - rowspan: 1, - isGroup: false, - columnDefinition: col, - groupDefinition: undefined, - parentGroupIds: [], - rowIndex: 0, - colIndex: colIndex++, - }); - } - }); - - return { - rows: [{ columns: fallbackColumns }], - maxDepth: 1, - columnToParentIds: new Map(), - }; - } - - return result; + // 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 9f9b2eff01..16a6639143 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -55,7 +55,6 @@ function updateWidths( } const newWidths = new Map(oldWidths); newWidths.set(columnId, newWidth); - console.log(newWidths); return newWidths; } @@ -339,7 +338,6 @@ export function ColumnWidthsProvider({ // Find the rightmost leaf descendant const rightmostLeaf = findRightmostLeaf(String(groupId), columnWidths); if (!rightmostLeaf) { - console.warn(`No rightmost leaf found for group ${String(groupId)}`); return; } @@ -347,10 +345,6 @@ export function ColumnWidthsProvider({ const currentLeafWidth = columnWidths.get(rightmostLeaf) || DEFAULT_COLUMN_WIDTH; const newLeafWidth = currentLeafWidth + delta; - console.log( - `Group resize: ${String(groupId)} delta=${delta}px -> applying to leaf ${rightmostLeaf} (${currentLeafWidth}px -> ${newLeafWidth}px)` - ); - // Use updateColumn to handle the leaf resize (which will propagate to parents automatically) updateColumn(rightmostLeaf, newLeafWidth); } From 68fff9e5c6f1d8434cb12cc7e5f312da0fe28600 Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 26 Feb 2026 19:01:32 +0100 Subject: [PATCH 12/14] chore: Make page easy to test --- .../grouped-column-with-preference.page.tsx | 64 +++++++++++-------- 1 file changed, 38 insertions(+), 26 deletions(-) diff --git a/pages/table/grouped-column-with-preference.page.tsx b/pages/table/grouped-column-with-preference.page.tsx index ce79d6dc8f..c8645fc305 100644 --- a/pages/table/grouped-column-with-preference.page.tsx +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -10,6 +10,7 @@ import { Button, CollectionPreferences, CollectionPreferencesProps, + FormField, Header, Input, Pagination, @@ -17,6 +18,7 @@ import { Table, TableProps, TextFilter, + Toggle, } from '~components'; import { SimplePage } from '../app/templates'; @@ -313,14 +315,44 @@ export default function EC2TableDemo() { 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" + /> + + +
} filter={ - - {/* first */} - setFirstSticky(detail.value)} - value={firstSticky} - name="first" - inputMode="numeric" - type="number" - /> - setLastSticky(detail.value)} - value={lastSticky} - name="last" - inputMode="numeric" - type="number" - /> - - - + } preferences={ Date: Thu, 26 Feb 2026 20:25:16 +0100 Subject: [PATCH 13/14] feat: Implement Collection Preference for hierarchical lists --- .../multi-level-reorder.page.tsx | 92 +++----- .../content-display/index.tsx | 203 ++++++++++++++---- .../content-display/utils.ts | 120 +++++++++++ 3 files changed, 308 insertions(+), 107 deletions(-) diff --git a/pages/collection-preferences/multi-level-reorder.page.tsx b/pages/collection-preferences/multi-level-reorder.page.tsx index 2830f9bc39..557e9123d4 100644 --- a/pages/collection-preferences/multi-level-reorder.page.tsx +++ b/pages/collection-preferences/multi-level-reorder.page.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import CollectionPreferences from '~components/collection-preferences'; +import CollectionPreferences, { CollectionPreferencesProps } from '~components/collection-preferences'; import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings'; import { @@ -13,70 +13,39 @@ import { wrapLinesPreference, } from './shared-configs'; -const shortOptionsList = [ - { - id: 'root1', - label: 'Root Item 1', - }, - { - id: 'root2', - label: 'Root Item 2', - }, - { - id: 'child1', - label: 'Child 1 of Root 1', - parentId: 'root1', - }, - { - id: 'child2', - label: 'Child 2 of Root 1', - parentId: 'root1', - }, - { - id: 'grandchild1', - label: 'Grandchild 1 of Child 1', - parentId: 'child1', - }, - { - id: 'grandchild2', - label: 'Grandchild 2 of Child 1', - parentId: 'child1', - }, - { - id: 'greatgrandchild1', - label: 'Great-Grandchild 1 (Level 4)', - parentId: 'grandchild1', - }, - { - id: 'root3', - label: 'Root Item 3', - }, - { - id: 'child3', - label: 'Child of Root 3', - parentId: 'root3', - }, +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: 'root4', + id: 'reservedCost', label: - 'Root Item 4 - Long text to verify wrapping behavior and ensure that the reordering feature works correctly with extended content', - }, - { - id: 'child4', - label: 'Child of Root 4', - parentId: 'root4', - }, - { - id: 'grandchild3', - label: 'Grandchild of Root 4', - parentId: 'child4', - }, - { - id: 'root5', - label: 'ExtremelyLongLabelTextWithoutSpacesToVerifyThatItWrapsToTheNextLine', + '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: 'performance', label: 'Performance' }, + { id: 'configuration', label: 'Configuration' }, + { id: 'cost', label: 'Cost' }, +]; + export default function App() { return ( <> @@ -90,7 +59,8 @@ export default function App() { contentDisplayPreference={{ title: 'Column preferences', description: 'Customize the columns visibility and order.', - options: shortOptionsList, + options: columnOptions, + groups: columnGroups, ...contentDisplayPreferenceI18nStrings, }} /> diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index 173522ee6d..7108f3d89f 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,76 @@ 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']; +} + +function HierarchicalContentDisplay({ + tree, + onToggle, + onTreeChange, + ariaLabelledby, + ariaDescribedby, + i18nStrings, +}: HierarchicalContentDisplayProps) { + return ( + ({ + id: node.id, + announcementLabel: node.label, + content: node.isGroup ? ( + + {/* Group label row — no toggle, just the header text */} + + {node.label} + + {/* Nested sortable list for children */} + {node.children.length > 0 && ( + + ({ + id: child.id, + announcementLabel: child.label, + content: , + })} + disableItemPaddings={true} + sortable={true} + onSortingChange={({ detail: { items } }) => { + onTreeChange(tree.map(n => (n.id === node.id && n.isGroup ? { ...n, children: [...items] } : n))); + }} + i18nStrings={i18nStrings} + /> + + )} + + ) : ( + + ), + })} + disableItemPaddings={true} + sortable={true} + onSortingChange={({ detail: { items } }) => { + onTreeChange([...items]); + }} + ariaLabelledby={ariaLabelledby} + ariaDescribedby={ariaDescribedby} + i18nStrings={i18nStrings} + /> + ); +} + export default function ContentDisplayPreference({ title, description, options, + groups, value = options.map(({ id }) => ({ id, visible: true, @@ -56,11 +129,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 +200,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..31f7e32183 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, containing ordered leaf column children. + */ +export interface OptionGroupNode { + id: string; + label: string; + groupId?: string; // For nested groups (group-of-groups) + isGroup: true; + children: OptionWithVisibility[]; +} + +/** A flat leaf column node (not a group header). */ +export interface OptionLeafNode extends OptionWithVisibility { + isGroup: false; +} + +/** A node in the top-level option tree — either a group header or a standalone leaf column. */ +export type OptionTreeNode = OptionGroupNode | OptionLeafNode; + export function getSortedOptions({ options, contentDisplay, @@ -28,6 +47,107 @@ export function getSortedOptions({ return Array.from(optionsById.values()); } +/** + * Builds the top-level tree of option nodes from the sorted flat options and group definitions. + * + * Each group collects its leaf children in sorted order. Ungrouped leaf columns appear + * at the top level between groups, preserving overall column order. + * + * The order of top-level nodes is determined by the first appearance of each group/column + * in the sorted options list. + */ +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); + } + + // Accumulate children per group, in the order options appear + const groupChildren = new Map(); + for (const group of groups) { + groupChildren.set(group.id, []); + } + + // Track the insertion order of top-level nodes using a key: groupId or `leaf:${id}` + const topLevelOrder: string[] = []; + const seenTopLevel = new Set(); + + for (const option of sortedOptions) { + const gid = option.groupId; + if (gid && groupById.has(gid)) { + // This option belongs to a group — add to that group's children + groupChildren.get(gid)!.push(option); + // Register the group as a top-level entry the first time we see one of its children + if (!seenTopLevel.has(gid)) { + seenTopLevel.add(gid); + topLevelOrder.push(gid); + } + } else { + // Ungrouped leaf column — top-level entry + const key = `leaf:${option.id}`; + if (!seenTopLevel.has(key)) { + seenTopLevel.add(key); + topLevelOrder.push(key); + } + } + } + + // Build result in top-level order + const result: OptionTreeNode[] = []; + // Keep a map from leaf-key to its option for fast lookup + const optionByLeafKey = new Map(); + for (const opt of sortedOptions) { + optionByLeafKey.set(`leaf:${opt.id}`, opt); + } + + for (const key of topLevelOrder) { + if (key.startsWith('leaf:')) { + const opt = optionByLeafKey.get(key)!; + result.push({ ...opt, isGroup: false as const }); + } else { + const group = groupById.get(key)!; + result.push({ + id: group.id, + label: group.label, + groupId: group.groupId, + isGroup: true as const, + children: groupChildren.get(key)!, + }); + } + } + + return result; +} + +/** + * Flattens a tree of OptionTreeNodes back to a flat ContentDisplayItem array, + * depth-first: group's children immediately follow the group node. + * Group headers themselves are NOT emitted — only leaf columns. + */ +export function flattenOptionTree( + tree: OptionTreeNode[] +): ReadonlyArray { + const result: CollectionPreferencesProps.ContentDisplayItem[] = []; + for (const node of tree) { + if (node.isGroup) { + for (const child of node.children) { + result.push({ id: child.id, visible: child.visible }); + } + } else { + result.push({ id: node.id, visible: node.visible }); + } + } + return result; +} + export function getFilteredOptions(options: ReadonlyArray, filterText: string) { filterText = filterText.trim().toLowerCase(); From d852c4eede1585ff7f683597708ada9105ff9f7f Mon Sep 17 00:00:00 2001 From: Nathnael Dereje Date: Thu, 26 Feb 2026 21:29:04 +0100 Subject: [PATCH 14/14] fix: Ensure collection preference works for multi-depth --- .../multi-level-reorder.page.tsx | 3 +- .../grouped-column-with-preference.page.tsx | 5 +- .../content-display/index.tsx | 33 ++- .../content-display/utils.ts | 223 +++++++++++++----- 4 files changed, 180 insertions(+), 84 deletions(-) diff --git a/pages/collection-preferences/multi-level-reorder.page.tsx b/pages/collection-preferences/multi-level-reorder.page.tsx index 557e9123d4..f689c1b7e9 100644 --- a/pages/collection-preferences/multi-level-reorder.page.tsx +++ b/pages/collection-preferences/multi-level-reorder.page.tsx @@ -41,7 +41,8 @@ const columnOptions: CollectionPreferencesProps.ContentDisplayOption[] = [ ]; const columnGroups: CollectionPreferencesProps.ContentDisplayOptionGroup[] = [ - { id: 'performance', label: 'Performance' }, + { id: 'metrics', label: 'Metrics' }, + { id: 'performance', label: 'Performance', groupId: 'metrics' }, { id: 'configuration', label: 'Configuration' }, { id: 'cost', label: 'Cost' }, ]; diff --git a/pages/table/grouped-column-with-preference.page.tsx b/pages/table/grouped-column-with-preference.page.tsx index c8645fc305..63c42e2b26 100644 --- a/pages/table/grouped-column-with-preference.page.tsx +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -221,13 +221,13 @@ const collectionPreferencesProps: CollectionPreferencesProps = { title: 'Column preferences', description: 'Customize the columns visibility and order.', options: [ - { id: 'id', label: 'Instance ID', alwaysVisible: true }, { 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' }, @@ -236,7 +236,8 @@ const collectionPreferencesProps: CollectionPreferencesProps = { groups: [ { id: 'cost', label: 'Cost' }, { id: 'configuration', label: 'Configuration' }, - { id: 'performance', label: 'Performance' }, + { id: 'performance', label: 'Performance', groupId: 'metrics' }, + { id: 'metrics', label: 'Metrics' }, ], }, }; diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index 7108f3d89f..02f8f90d3f 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -42,9 +42,10 @@ interface HierarchicalContentDisplayProps { tree: OptionTreeNode[]; onToggle: (option: OptionWithVisibility) => void; onTreeChange: (newTree: OptionTreeNode[]) => void; - ariaLabelledby: string; - ariaDescribedby: string; + ariaLabelledby?: string; + ariaDescribedby?: string; i18nStrings: React.ComponentProps['i18nStrings']; + depth?: number; } function HierarchicalContentDisplay({ @@ -54,6 +55,7 @@ function HierarchicalContentDisplay({ ariaLabelledby, ariaDescribedby, i18nStrings, + depth = 0, }: HierarchicalContentDisplayProps) { return ( - {/* Group label row — no toggle, just the header text */} + {/* Group header — no toggle */} {node.label} - {/* Nested sortable list for children */} + {/* Recursively render children (sub-groups or leaf columns) */} {node.children.length > 0 && ( - ({ - id: child.id, - announcementLabel: child.label, - content: , - })} - disableItemPaddings={true} - sortable={true} - onSortingChange={({ detail: { items } }) => { - onTreeChange(tree.map(n => (n.id === node.id && n.isGroup ? { ...n, children: [...items] } : n))); - }} + + 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 ), })} @@ -96,8 +94,7 @@ function HierarchicalContentDisplay({ onSortingChange={({ detail: { items } }) => { onTreeChange([...items]); }} - ariaLabelledby={ariaLabelledby} - ariaDescribedby={ariaDescribedby} + {...(depth === 0 ? { ariaLabelledby, ariaDescribedby } : {})} i18nStrings={i18nStrings} /> ); diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts index 31f7e32183..433e1243f5 100644 --- a/src/collection-preferences/content-display/utils.ts +++ b/src/collection-preferences/content-display/utils.ts @@ -7,14 +7,15 @@ export interface OptionWithVisibility extends CollectionPreferencesProps.Content } /** - * A group header node in the hierarchical tree, containing ordered leaf column children. + * 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; // For nested groups (group-of-groups) + groupId?: string; isGroup: true; - children: OptionWithVisibility[]; + children: OptionTreeNode[]; } /** A flat leaf column node (not a group header). */ @@ -22,7 +23,6 @@ export interface OptionLeafNode extends OptionWithVisibility { isGroup: false; } -/** A node in the top-level option tree — either a group header or a standalone leaf column. */ export type OptionTreeNode = OptionGroupNode | OptionLeafNode; export function getSortedOptions({ @@ -47,15 +47,6 @@ export function getSortedOptions({ return Array.from(optionsById.values()); } -/** - * Builds the top-level tree of option nodes from the sorted flat options and group definitions. - * - * Each group collects its leaf children in sorted order. Ungrouped leaf columns appear - * at the top level between groups, preserving overall column order. - * - * The order of top-level nodes is determined by the first appearance of each group/column - * in the sorted options list. - */ export function buildOptionTree( sortedOptions: ReadonlyArray, groups: ReadonlyArray @@ -64,87 +55,193 @@ export function buildOptionTree( return sortedOptions.map(opt => ({ ...opt, isGroup: false as const })); } - // Map group id -> group definition + // Map group id → group definition const groupById = new Map(); for (const group of groups) { groupById.set(group.id, group); } - // Accumulate children per group, in the order options appear - const groupChildren = new Map(); + // 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) { - groupChildren.set(group.id, []); + childrenMap.set(group.id, []); } - // Track the insertion order of top-level nodes using a key: groupId or `leaf:${id}` - const topLevelOrder: string[] = []; - const seenTopLevel = new Set(); + // Track which node IDs have already been placed to avoid duplicates + const placed = new Set(); - for (const option of sortedOptions) { - const gid = option.groupId; - if (gid && groupById.has(gid)) { - // This option belongs to a group — add to that group's children - groupChildren.get(gid)!.push(option); - // Register the group as a top-level entry the first time we see one of its children - if (!seenTopLevel.has(gid)) { - seenTopLevel.add(gid); - topLevelOrder.push(gid); + /** + * 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; } - } else { - // Ungrouped leaf column — top-level entry - const key = `leaf:${option.id}`; - if (!seenTopLevel.has(key)) { - seenTopLevel.add(key); - topLevelOrder.push(key); + 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: [], + }); } - // Build result in top-level order - const result: OptionTreeNode[] = []; - // Keep a map from leaf-key to its option for fast lookup + // Leaf option map for fast lookup const optionByLeafKey = new Map(); for (const opt of sortedOptions) { optionByLeafKey.set(`leaf:${opt.id}`, opt); } - for (const key of topLevelOrder) { - if (key.startsWith('leaf:')) { - const opt = optionByLeafKey.get(key)!; - result.push({ ...opt, isGroup: false as const }); - } else { - const group = groupById.get(key)!; - result.push({ - id: group.id, - label: group.label, - groupId: group.groupId, - isGroup: true as const, - children: groupChildren.get(key)!, - }); + // 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 result; + return buildChildren(null); } /** - * Flattens a tree of OptionTreeNodes back to a flat ContentDisplayItem array, - * depth-first: group's children immediately follow the group node. - * Group headers themselves are NOT emitted — only leaf columns. + * 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[] = []; - for (const node of tree) { - if (node.isGroup) { - for (const child of node.children) { - result.push({ id: child.id, visible: child.visible }); + 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 }); } - } else { - result.push({ id: node.id, visible: node.visible }); } - } + }; + walk(tree); return result; }