diff --git a/package-lock.json b/package-lock.json index d663110273..8a29a2d85a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "@cloudscape-design/components", "version": "3.0.0", + "hasInstallScript": true, "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", "@cloudscape-design/component-toolkit": "^1.0.0-beta", @@ -21,7 +22,7 @@ "date-fns": "^2.25.0", "intl-messageformat": "^10.3.1", "mnth": "^2.0.0", - "react-keyed-flatten-children": "^2.2.1", + "react-is": "^18.2.0", "react-transition-group": "^4.4.2", "tslib": "^2.4.0", "weekstart": "^1.1.0" @@ -52,6 +53,7 @@ "@types/node": "^20.17.14", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", + "@types/react-is": "^18.2.0", "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.3.2", "@types/react-test-renderer": "^16.9.12", @@ -2396,11 +2398,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jest/core/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/@jest/core/node_modules/strip-ansi": { "version": "6.0.1", "dev": true, @@ -4503,11 +4500,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@types/jest/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/@types/jsdom": { "version": "20.0.1", "dev": true, @@ -4590,6 +4582,16 @@ "@types/react": "^16" } }, + "node_modules/@types/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-zts4lhQn5ia0cF/y2+3V6Riu0MAfez9/LJYavdM8TvcVl+S91A/7VWxyBT8hbRuWspmuCaiGI0F41OJYGrKhRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "^18" + } + }, "node_modules/@types/react-router": { "version": "5.1.20", "dev": true, @@ -9937,14 +9939,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/expect-webdriverio/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -12817,11 +12811,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-circus/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-cli": { "version": "29.7.0", "dev": true, @@ -13039,11 +13028,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-config/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-diff": { "version": "29.7.0", "dev": true, @@ -13097,11 +13081,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-diff/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-docblock": { "version": "29.7.0", "dev": true, @@ -13167,11 +13146,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-each/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-environment-jsdom": { "version": "29.7.0", "dev": true, @@ -13310,11 +13284,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-leak-detector/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-matcher-utils": { "version": "29.7.0", "dev": true, @@ -13368,11 +13337,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-matcher-utils/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-message-util": { "version": "29.7.0", "dev": true, @@ -13431,11 +13395,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-message-util/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-mock": { "version": "29.7.0", "dev": true, @@ -13739,11 +13698,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-snapshot/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-snapshot/node_modules/semver": { "version": "7.7.2", "dev": true, @@ -13863,11 +13817,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-validate/node_modules/react-is": { - "version": "18.3.1", - "dev": true, - "license": "MIT" - }, "node_modules/jest-watcher": { "version": "29.7.0", "dev": true, @@ -16997,6 +16946,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/process": { "version": "0.11.10", "dev": true, @@ -17411,22 +17367,9 @@ } }, "node_modules/react-is": { - "version": "17.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/react-keyed-flatten-children": { - "version": "2.2.1", - "license": "MIT", - "dependencies": { - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=15.0.0" - } - }, - "node_modules/react-keyed-flatten-children/node_modules/react-is": { "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, "node_modules/react-router": { diff --git a/pages/collection-preferences/multi-level-reorder.page.tsx b/pages/collection-preferences/multi-level-reorder.page.tsx new file mode 100644 index 0000000000..f689c1b7e9 --- /dev/null +++ b/pages/collection-preferences/multi-level-reorder.page.tsx @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import CollectionPreferences, { CollectionPreferencesProps } from '~components/collection-preferences'; + +import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings'; +import { + baseProperties, + contentDensityPreference, + customPreference, + pageSizePreference, + wrapLinesPreference, +} from './shared-configs'; + +const columnOptions: CollectionPreferencesProps.ContentDisplayOption[] = [ + // ungroupdd + { id: 'name', label: 'Name', alwaysVisible: true }, + { id: 'status', label: 'Status' }, + + // performance + { id: 'cpuUtilization', label: 'CPU (%)', groupId: 'performance' }, + { id: 'memoryUtilization', label: 'Memory (%)', groupId: 'performance' }, + { id: 'networkIn', label: 'Network In (MB/s)', groupId: 'performance' }, + { id: 'networkOut', label: 'Network Out (MB/s)', groupId: 'performance' }, + + // config + { id: 'instanceType', label: 'Instance Type', groupId: 'configuration' }, + { id: 'availabilityZone', label: 'Availability Zone', groupId: 'configuration' }, + { id: 'region', label: 'Region', groupId: 'configuration' }, + + // cost + { id: 'monthlyCost', label: 'Monthly Cost ($)', groupId: 'cost' }, + { id: 'spotPrice', label: 'Spot Price ($/hr)', groupId: 'cost' }, + { + id: 'reservedCost', + label: + 'Reserved Instance Cost - Long text to verify wrapping behavior and ensure the reordering feature works correctly with extended content', + groupId: 'cost', + }, +]; + +const columnGroups: CollectionPreferencesProps.ContentDisplayOptionGroup[] = [ + { id: 'metrics', label: 'Metrics' }, + { id: 'performance', label: 'Performance', groupId: 'metrics' }, + { id: 'configuration', label: 'Configuration' }, + { id: 'cost', label: 'Cost' }, +]; + +export default function App() { + return ( + <> +

Multi-level Reorder Preferences

+ + + ); +} diff --git a/pages/list/nested-sortable.page.tsx b/pages/list/nested-sortable.page.tsx new file mode 100644 index 0000000000..902c35b1d3 --- /dev/null +++ b/pages/list/nested-sortable.page.tsx @@ -0,0 +1,155 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Box, Container, Header, SpaceBetween } from '~components'; +import List from '~components/list'; + +interface TreeItem { + id: string; + content: string; + children?: TreeItem[]; +} + +const initialData: TreeItem[] = [ + { + id: 'item-1', + content: 'Group 1', + children: [ + { id: 'item-1-1', content: 'Item 1.1' }, + { + id: 'item-1-2', + content: 'Item 1.2', + children: [ + { id: 'item-1-2-1', content: 'Item 1.2.1' }, + { + id: 'item-1-2-2', + content: 'Item 1.2.2', + children: [ + { id: 'item-1-2-2-1', content: 'Item 1.2.2.1' }, + { id: 'item-1-2-2-2', content: 'Item 1.2.2.2' }, + ], + }, + { id: 'item-1-2-3', content: 'Item 1.2.3' }, + ], + }, + { id: 'item-1-3', content: 'Item 1.3' }, + ], + }, + { + id: 'item-2', + content: 'Group 2', + children: [ + { id: 'item-2-1', content: 'Item 2.1' }, + { + id: 'item-2-2', + content: 'Item 2.2', + children: [ + { id: 'item-2-2-1', content: 'Item 2.2.1' }, + { id: 'item-2-2-2', content: 'Item 2.2.2' }, + ], + }, + ], + }, + { + id: 'item-3', + content: 'Group 3', + children: [ + { id: 'item-3-1', content: 'Item 3.1' }, + { id: 'item-3-2', content: 'Item 3.2' }, + { id: 'item-3-3', content: 'Item 3.3' }, + ], + }, +]; + +export default function NestedSortableListPage() { + const [items, setItems] = useState(initialData); + + const updateItemChildren = (items: TreeItem[], targetId: string, newChildren: TreeItem[]): TreeItem[] => { + return items.map(item => { + if (item.id === targetId) { + return { ...item, children: newChildren }; + } + if (item.children) { + return { + ...item, + children: updateItemChildren(item.children, targetId, newChildren), + }; + } + return item; + }); + }; + + const handleChildSort = (itemId: string, newChildren: TreeItem[]) => { + setItems(prev => updateItemChildren(prev, itemId, newChildren)); + }; + + return ( + + +
Recursive Nested Sortable Lists
+ + Tree Structure with Recursive Sorting}> + setItems(newItems)} + depth={0} + onChildSort={handleChildSort} + /> + + + Debug: Current Data Structure}> +
{JSON.stringify(items, null, 2)}
+
+
+
+ ); +} + +interface RecursiveListProps { + items: TreeItem[]; + onSortingChange: (items: TreeItem[]) => void; + depth: number; + onChildSort: (itemId: string, newChildren: TreeItem[]) => void; +} + +function RecursiveList({ items, onSortingChange, depth, onChildSort }: RecursiveListProps) { + const [localItems, setLocalItems] = useState(items); + + React.useEffect(() => { + setLocalItems(items); + }, [items]); + + const handleSort = (e: { detail: { items: ReadonlyArray } }) => { + const newItems = [...e.detail.items]; + setLocalItems(newItems); + onSortingChange(newItems); + }; + + return ( + ({ + id: item.id, + content: ( + + {item.content} + {item.children && item.children.length > 0 && ( + + onChildSort(item.id, newChildren)} + depth={depth + 1} + onChildSort={onChildSort} + /> + + )} + + ), + })} + /> + ); +} diff --git a/pages/table/grouped-column-customer-pages.page.tsx b/pages/table/grouped-column-customer-pages.page.tsx new file mode 100644 index 0000000000..8f9311cc38 --- /dev/null +++ b/pages/table/grouped-column-customer-pages.page.tsx @@ -0,0 +1,848 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext } from 'react'; + +import { FormField, Header, Input, SpaceBetween, StatusIndicator, Table, TableProps, Toggle } from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +type DemoContext = React.Context< + AppContextType<{ + resizable: boolean; + firstSticky: number; + lastSticky: number; + customGap: boolean; + gap: number; + }> +>; + +// ============================================================================ +// 1. Patching Findings Summary (Mirador Team) +// 2 levels, 3-9 sub-columns, sorting, resizing, sticky columns +// ============================================================================ + +interface PatchingHost { + owner: string; + totalHosts: number; + hostsNotReporting: number; + redHosts: number; + yellowHosts: number; + greenHosts: number; + compliancePercent: number; +} + +const patchingData: PatchingHost[] = [ + { + owner: "Divya Gaur's team", + totalHosts: 10, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 10, + compliancePercent: 100, + }, + { + owner: 'Divya Gaur (gaurdiv)', + totalHosts: 1, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 1, + compliancePercent: 100, + }, + { + owner: 'Akshay Bapat (aksbapat)', + totalHosts: 1, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 1, + compliancePercent: 100, + }, + { + owner: 'Alex Kochurov (alexko)', + totalHosts: 1, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 1, + compliancePercent: 100, + }, + { + owner: 'Ayden Carter (aycart)', + totalHosts: 1, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 1, + compliancePercent: 100, + }, + { + owner: 'Jannik Altenhofer (jhofr)', + totalHosts: 1, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 1, + compliancePercent: 100, + }, + { + owner: 'Moon Lee (moonlee)', + totalHosts: 1, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 1, + compliancePercent: 100, + }, + { + owner: 'Mihir Pavuskar (pavuskar)', + totalHosts: 1, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 1, + compliancePercent: 100, + }, + { + owner: 'Jeffrey Rohlman (rohlmanj)', + totalHosts: 2, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 2, + compliancePercent: 100, + }, + { + owner: 'Tushar Jain (tusjain)', + totalHosts: 1, + hostsNotReporting: 0, + redHosts: 0, + yellowHosts: 0, + greenHosts: 1, + compliancePercent: 100, + }, +]; + +const patchingColumns: TableProps.ColumnDefinition[] = [ + { id: 'owner', header: 'Owners', cell: item => item.owner, sortingField: 'owner', isRowHeader: true }, + { id: 'totalHosts', header: 'Total hosts', cell: item => item.totalHosts, sortingField: 'totalHosts' }, + { + id: 'hostsNotReporting', + header: 'Hosts not reporting', + cell: item => (item.hostsNotReporting === 0 ? '-' : item.hostsNotReporting), + }, + { + id: 'redHosts', + header: 'Red hosts', + cell: item => (item.redHosts === 0 ? '-' : item.redHosts), + groupId: 'hostsReporting', + }, + { + id: 'yellowHosts', + header: 'Yellow hosts', + cell: item => (item.yellowHosts === 0 ? '-' : item.yellowHosts), + groupId: 'hostsReporting', + }, + { + id: 'greenHosts', + header: 'Green hosts', + cell: item => (item.greenHosts === 0 ? '-' : item.greenHosts), + groupId: 'hostsReporting', + }, +]; + +const patchingGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'hostsReporting', header: 'Hosts reporting' }, +]; + +// ============================================================================ +// 2. Supply Chain Forecast Explainability +// 2-3 levels, 3-5 sub-columns per time period, resizing, horizontal scroll, +// sticky header, sticky first column +// ============================================================================ + +interface ForecastItem { + ipn: string; + metric: string; + apr23snap1: number; + apr23snap2: number; + apr23change: string; + apr30snap1: number; + apr30snap2: number; + apr30change: string; + may7snap1: number; + may7snap2: number; + may7change: string; +} + +const forecastData: ForecastItem[] = [ + { + ipn: '100-001853-003', + metric: 'Net Demand', + apr23snap1: 40000, + apr23snap2: 60000, + apr23change: '+50%', + apr30snap1: 40000, + apr30snap2: 60000, + apr30change: '+50%', + may7snap1: 40000, + may7snap2: 60000, + may7change: '+50%', + }, + { + ipn: '100-001853-003', + metric: 'Currently with ODM', + apr23snap1: 20000, + apr23snap2: 20000, + apr23change: '0%', + apr30snap1: 20000, + apr30snap2: 20000, + apr30change: '0%', + may7snap1: 20000, + may7snap2: 20000, + may7change: '0%', + }, + { + ipn: '100-001853-003', + metric: 'In Transit', + apr23snap1: 20000, + apr23snap2: 10000, + apr23change: '-100%', + apr30snap1: 20000, + apr30snap2: 10000, + apr30change: '-100%', + may7snap1: 20000, + may7snap2: 10000, + may7change: '-100%', + }, + { + ipn: '100-001853-003', + metric: 'Gross Demand', + apr23snap1: 60000, + apr23snap2: 90000, + apr23change: '+50%', + apr30snap1: 60000, + apr30snap2: 90000, + apr30change: '+50%', + may7snap1: 60000, + may7snap2: 90000, + may7change: '+50%', + }, + { + ipn: '100-001853-003', + metric: 'Current RTF', + apr23snap1: 29405, + apr23snap2: 29405, + apr23change: '0%', + apr30snap1: 29405, + apr30snap2: 29405, + apr30change: '0%', + may7snap1: 29405, + may7snap2: 29405, + may7change: '0%', + }, + { + ipn: '100-001853-003', + metric: 'Variance VS RTF', + apr23snap1: -852, + apr23snap2: -852, + apr23change: '0%', + apr30snap1: -852, + apr30snap2: -852, + apr30change: '0%', + may7snap1: -852, + may7snap2: -852, + may7change: '0%', + }, +]; + +const fmt = (n: number) => n.toLocaleString(); + +const forecastColumns: TableProps.ColumnDefinition[] = [ + { id: 'ipn', header: 'IPN', cell: item => item.ipn, isRowHeader: true }, + { id: 'metric', header: 'Data', cell: item => item.metric }, + { id: 'apr23snap1', header: 'Snapshot day 1', cell: item => fmt(item.apr23snap1), groupId: 'apr23' }, + { id: 'apr23snap2', header: 'Snapshot day 2', cell: item => fmt(item.apr23snap2), groupId: 'apr23' }, + { id: 'apr23change', header: '% Change', cell: item => item.apr23change, groupId: 'apr23' }, + { id: 'apr30snap1', header: 'Snapshot day 1', cell: item => fmt(item.apr30snap1), groupId: 'apr30' }, + { id: 'apr30snap2', header: 'Snapshot day 2', cell: item => fmt(item.apr30snap2), groupId: 'apr30' }, + { id: 'apr30change', header: '% Change', cell: item => item.apr30change, groupId: 'apr30' }, + { id: 'may7snap1', header: 'Snapshot day 1', cell: item => fmt(item.may7snap1), groupId: 'may7' }, + { id: 'may7snap2', header: 'Snapshot day 2', cell: item => fmt(item.may7snap2), groupId: 'may7' }, + { id: 'may7change', header: '% Change', cell: item => item.may7change, groupId: 'may7' }, +]; + +const forecastGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'apr23', header: 'April 23' }, + { id: 'apr30', header: 'April 30' }, + { id: 'may7', header: 'May 7' }, +]; + +// ============================================================================ +// 3. Grocery Management Tech (GMT) — Performance +// 2 levels, 3 sub-columns per metric (current, comparison, diff), sorting, resizing, sticky +// ============================================================================ + +interface GroceryItem { + store: string; + salesCoreRanking: string; + salesReference: string; + salesDelta: string; + salesDeltaPct: string; + unitsCoreRanking: string; + unitsReference: string; + unitsDelta: string; + unitsDeltaPct: string; +} + +const groceryData: GroceryItem[] = [ + { + store: 'West Orange (WOP)', + salesCoreRanking: '$1.0M', + salesReference: '$1.0M', + salesDelta: '-$9.5K', + salesDeltaPct: '-0.94%', + unitsCoreRanking: '145.1K', + unitsReference: '150.4K', + unitsDelta: '-5.4K', + unitsDeltaPct: '-3.58%', + }, + { + store: 'Trolley Square (TSQ)', + salesCoreRanking: '$583.7K', + salesReference: '$512.6K', + salesDelta: '+$71.1K', + salesDeltaPct: '13.87%', + unitsCoreRanking: '82.1K', + unitsReference: '75.4K', + unitsDelta: '+6.7K', + unitsDeltaPct: '8.96%', + }, + { + store: 'Downtown Market (DTM)', + salesCoreRanking: '$2.1M', + salesReference: '$1.9M', + salesDelta: '+$200K', + salesDeltaPct: '10.5%', + unitsCoreRanking: '310.2K', + unitsReference: '295.8K', + unitsDelta: '+14.4K', + unitsDeltaPct: '4.87%', + }, + { + store: 'Harbor View (HBV)', + salesCoreRanking: '$750K', + salesReference: '$780K', + salesDelta: '-$30K', + salesDeltaPct: '-3.85%', + unitsCoreRanking: '98.5K', + unitsReference: '102.1K', + unitsDelta: '-3.6K', + unitsDeltaPct: '-3.53%', + }, +]; + +const groceryColumns: TableProps.ColumnDefinition[] = [ + { id: 'store', header: 'Store', cell: item => item.store, sortingField: 'store', isRowHeader: true }, + { + id: 'salesCoreRanking', + header: 'Core Ranking - Recommended Assortment', + cell: item => item.salesCoreRanking, + groupId: 'sales', + }, + { id: 'salesReference', header: 'Reference Assortment Process', cell: item => item.salesReference, groupId: 'sales' }, + { id: 'salesDelta', header: 'Delta', cell: item => item.salesDelta, groupId: 'sales' }, + { id: 'salesDeltaPct', header: 'Delta %', cell: item => item.salesDeltaPct, groupId: 'sales' }, + { + id: 'unitsCoreRanking', + header: 'Core Ranking - Recommended Assortment', + cell: item => item.unitsCoreRanking, + groupId: 'units', + }, + { id: 'unitsReference', header: 'Reference Assortment Process', cell: item => item.unitsReference, groupId: 'units' }, + { id: 'unitsDelta', header: 'Delta', cell: item => item.unitsDelta, groupId: 'units' }, + { id: 'unitsDeltaPct', header: 'Delta %', cell: item => item.unitsDeltaPct, groupId: 'units' }, +]; + +const groceryGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'sales', header: 'Sales' }, + { id: 'units', header: 'Units' }, +]; + +// ============================================================================ +// 4. Infra Supply Chain — Items at Risk +// 2 levels, 3-8 sub-columns, sorting, resizing, sticky columns +// ============================================================================ + +interface RiskItem { + priority: number; + impactedBom: string; + riskType: string; + bomItemAtRisk: string; + materialClass: string; + internalRackType: string; + externalRackType: string; + findNo: number; + alternateItem: string; + parentItem: string; +} + +const riskData: RiskItem[] = [ + { + priority: 1, + impactedBom: '100-019716-001', + riskType: 'Multi-source', + bomItemAtRisk: '504-002133-001', + materialClass: 'CABLE', + internalRackType: 'PATAGONIA-WEBBER18', + externalRackType: 'PATAGONIA-WEBBER18', + findNo: 10, + alternateItem: '111-019718-001', + parentItem: '100-019718-001', + }, + { + priority: 1, + impactedBom: '504-002133-001', + riskType: 'Multi-source', + bomItemAtRisk: '100-019716-001', + materialClass: 'CABLE', + internalRackType: '12.8T-BR', + externalRackType: '12.8T-BR', + findNo: 205, + alternateItem: '111-019718-001', + parentItem: '111-019718-001', + }, + { + priority: 3, + impactedBom: '504-002133-001', + riskType: 'Multi-source', + bomItemAtRisk: '100-019716-001', + materialClass: 'MOTHERBOARD', + internalRackType: '3PPS480.1X75.0', + externalRackType: '3PPS480.1X75.0', + findNo: 30, + alternateItem: '111-019718-001', + parentItem: '111-019718-001', + }, + { + priority: 4, + impactedBom: '504-002133-001', + riskType: 'Multi-source', + bomItemAtRisk: '100-019716-001', + materialClass: 'SHELL', + internalRackType: 'AWS.NW.VEGETRON', + externalRackType: 'AWS.NW.VEGETRON', + findNo: 20, + alternateItem: '111-019718-001', + parentItem: '111-019718-001', + }, + { + priority: 4, + impactedBom: '504-002133-001', + riskType: 'Multi-source', + bomItemAtRisk: '100-019716-001', + materialClass: 'CONNECTOR', + internalRackType: 'AWS.NW.VEGETRON', + externalRackType: 'AWS.NW.VEGETRON', + findNo: 50, + alternateItem: '111-019718-001', + parentItem: '111-019718-001', + }, + { + priority: 5, + impactedBom: '504-002133-001', + riskType: 'Multi-source', + bomItemAtRisk: '100-019716-001', + materialClass: 'SWITCH PANEL', + internalRackType: 'BF.BLACKFOOT.15', + externalRackType: 'BF.BLACKFOOT.15', + findNo: 20, + alternateItem: '111-019718-001', + parentItem: '111-019718-001', + }, +]; + +const riskColumns: TableProps.ColumnDefinition[] = [ + { id: 'priority', header: 'Priority', cell: item => item.priority, sortingField: 'priority', groupId: 'itemAtRisk' }, + { + id: 'impactedBom', + header: 'Impacted BOM', + cell: item => item.impactedBom, + sortingField: 'impactedBom', + groupId: 'itemAtRisk', + }, + { id: 'riskType', header: 'Risk type', cell: item => item.riskType, sortingField: 'riskType', groupId: 'itemAtRisk' }, + { + id: 'bomItemAtRisk', + header: 'BOM item at risk', + cell: item => item.bomItemAtRisk, + sortingField: 'bomItemAtRisk', + groupId: 'itemAtRisk', + }, + { + id: 'materialClass', + header: 'Material class', + cell: item => item.materialClass, + sortingField: 'materialClass', + groupId: 'itemAtRisk', + }, + { + id: 'internalRackType', + header: 'Internal Rack Type', + cell: item => item.internalRackType, + sortingField: 'internalRackType', + groupId: 'itemAtRisk', + }, + { + id: 'externalRackType', + header: 'External Rack Type', + cell: item => item.externalRackType, + sortingField: 'externalRackType', + groupId: 'itemAtRisk', + }, + { id: 'findNo', header: 'Find No', cell: item => item.findNo, sortingField: 'findNo', groupId: 'itemAtRisk' }, + { id: 'alternateItem', header: 'Alternate item', cell: item => item.alternateItem, groupId: 'suggestedAlternate' }, + { id: 'parentItem', header: 'Parent item', cell: item => item.parentItem, groupId: 'suggestedAlternate' }, +]; + +const riskGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'itemAtRisk', header: 'Item at risk' }, + { id: 'suggestedAlternate', header: 'Suggested alternate' }, +]; + +// ============================================================================ +// 5. AWS WWCO — ML Product Cost +// 2 levels, 3 sub-columns, sorting +// ============================================================================ + +interface MLCostItem { + product: string; + onDemandCost: string; + reservedCost: string; + spotCost: string; +} + +const mlCostData: MLCostItem[] = [ + { product: 'SageMaker Training', onDemandCost: '$12,450.00', reservedCost: '$8,715.00', spotCost: '$3,735.00' }, + { product: 'SageMaker Inference', onDemandCost: '$8,200.00', reservedCost: '$5,740.00', spotCost: '$2,460.00' }, + { product: 'Bedrock Foundation Models', onDemandCost: '$15,800.00', reservedCost: '$11,060.00', spotCost: 'N/A' }, + { product: 'Comprehend', onDemandCost: '$3,100.00', reservedCost: '$2,170.00', spotCost: 'N/A' }, + { product: 'Rekognition', onDemandCost: '$5,600.00', reservedCost: '$3,920.00', spotCost: 'N/A' }, +]; + +const mlCostColumns: TableProps.ColumnDefinition[] = [ + { id: 'product', header: 'ML Product', cell: item => item.product, sortingField: 'product', isRowHeader: true }, + { + id: 'onDemandCost', + header: 'On-Demand', + cell: item => item.onDemandCost, + sortingField: 'onDemandCost', + groupId: 'cost', + }, + { + id: 'reservedCost', + header: 'Reserved', + cell: item => item.reservedCost, + sortingField: 'reservedCost', + groupId: 'cost', + }, + { id: 'spotCost', header: 'Spot', cell: item => item.spotCost, sortingField: 'spotCost', groupId: 'cost' }, +]; + +const mlCostGroups: TableProps.ColumnGroupsDefinition[] = [{ id: 'cost', header: 'Cost by pricing type' }]; + +// ============================================================================ +// 6. AWS Region Services — Availability Zones with Partitions +// 2 levels, column visibility +// ============================================================================ + +interface RegionService { + service: string; + usEast1aPartition1: string; + usEast1aPartition2: string; + usEast1bPartition1: string; + usEast1bPartition2: string; + usEast1cPartition1: string; + usEast1cPartition2: string; +} + +const regionData: RegionService[] = [ + { + service: 'EC2', + usEast1aPartition1: 'Available', + usEast1aPartition2: 'Available', + usEast1bPartition1: 'Available', + usEast1bPartition2: 'Available', + usEast1cPartition1: 'Available', + usEast1cPartition2: 'Available', + }, + { + service: 'S3', + usEast1aPartition1: 'Available', + usEast1aPartition2: 'Available', + usEast1bPartition1: 'Available', + usEast1bPartition2: 'Degraded', + usEast1cPartition1: 'Available', + usEast1cPartition2: 'Available', + }, + { + service: 'RDS', + usEast1aPartition1: 'Available', + usEast1aPartition2: 'Unavailable', + usEast1bPartition1: 'Available', + usEast1bPartition2: 'Available', + usEast1cPartition1: 'Degraded', + usEast1cPartition2: 'Available', + }, + { + service: 'Lambda', + usEast1aPartition1: 'Available', + usEast1aPartition2: 'Available', + usEast1bPartition1: 'Available', + usEast1bPartition2: 'Available', + usEast1cPartition1: 'Available', + usEast1cPartition2: 'Available', + }, + { + service: 'DynamoDB', + usEast1aPartition1: 'Available', + usEast1aPartition2: 'Available', + usEast1bPartition1: 'Degraded', + usEast1bPartition2: 'Available', + usEast1cPartition1: 'Available', + usEast1cPartition2: 'Available', + }, +]; + +function StatusCell({ status }: { status: string }) { + const type = status === 'Available' ? 'success' : status === 'Degraded' ? 'warning' : 'error'; + return {status}; +} + +const regionColumns: TableProps.ColumnDefinition[] = [ + { id: 'service', header: 'Service', cell: item => item.service, isRowHeader: true }, + { + id: 'usEast1aP1', + header: 'Partition 1', + cell: item => , + groupId: 'usEast1a', + }, + { + id: 'usEast1aP2', + header: 'Partition 2', + cell: item => , + groupId: 'usEast1a', + }, + { + id: 'usEast1bP1', + header: 'Partition 1', + cell: item => , + groupId: 'usEast1b', + }, + { + id: 'usEast1bP2', + header: 'Partition 2', + cell: item => , + groupId: 'usEast1b', + }, + { + id: 'usEast1cP1', + header: 'Partition 1', + cell: item => , + groupId: 'usEast1c', + }, + { + id: 'usEast1cP2', + header: 'Partition 2', + cell: item => , + groupId: 'usEast1c', + }, +]; + +const regionGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'usEast1a', header: 'us-east-1a' }, + { id: 'usEast1b', header: 'us-east-1b' }, + { id: 'usEast1c', header: 'us-east-1c' }, +]; + +// ============================================================================ +// Page Component +// ============================================================================ + +export default function CustomerPagesDemo() { + const { + urlParams: { resizable = true, firstSticky = 1, lastSticky = 0, customGap = false, gap = 0 }, + setUrlParams, + } = useContext(AppContext as DemoContext); + + const tableWrapperStyle: React.CSSProperties = customGap + ? ({ '--awsui-table-resizer-block-gap': `${gap}px` } as React.CSSProperties) + : {}; + + const stickyColumns = { first: +firstSticky, last: +lastSticky }; + + return ( + + + + setUrlParams({ resizable: detail.checked })} checked={resizable}> + Resizable + + + setUrlParams({ firstSticky: +detail.value })} + value={String(firstSticky)} + name="first" + inputMode="numeric" + type="number" + /> + + + setUrlParams({ lastSticky: +detail.value })} + value={String(lastSticky)} + name="last" + inputMode="numeric" + type="number" + /> + + setUrlParams({ customGap: detail.checked })} checked={customGap}> + Experiment with resizer gap + + {customGap && ( + + setUrlParams({ gap: Math.min(Math.max(0, +detail.value), 25) })} + value={String(gap)} + name="gap" + inputMode="numeric" + type="number" + /> + + )} + + + {/* 1. Patching Findings Summary */} +
+ + Patching findings summary + + } + /> + + + {/* 2. Supply Chain Forecast Explainability */} +
+
+ Forecast Explainability + + } + /> + + + {/* 3. Grocery Management — Performance */} +
+
+ Performance + + } + /> + + + {/* 4. Infra Supply Chain — Items at Risk */} +
+
+ Items at Risk + + } + /> + + + {/* 5. AWS WWCO — ML Product Cost */} +
+
+ ML Product Costs + + } + /> + + + {/* 6. AWS Region Services */} +
+
+ AWS Region Services + + } + /> + + + + ); +} 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..abe75db508 --- /dev/null +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -0,0 +1,431 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext, useState } from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import { + Box, + Button, + CollectionPreferences, + CollectionPreferencesProps, + FormField, + Header, + Input, + Pagination, + SpaceBetween, + Table, + TableProps, + TextFilter, + Toggle, +} from '~components'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +interface EC2Instance { + id: string; + name: string; + cpuUtilization: number; + memoryUtilization: number; + networkIn: number; + networkOut: number; + instanceType: string; + az: string; + state: string; + monthlyCost: number; + spotPrice: number; +} + +const allInstances: EC2Instance[] = [ + { + id: 'i-1234567890abcdef0', + name: 'web-server-1', + cpuUtilization: 45.2, + memoryUtilization: 62.8, + networkIn: 1250, + networkOut: 890, + instanceType: 't3.medium', + az: 'us-east-1a', + state: 'running', + monthlyCost: 30.4, + spotPrice: 0.0416, + }, + { + id: 'i-0987654321fedcba0', + name: 'api-server-1', + cpuUtilization: 78.5, + memoryUtilization: 81.2, + networkIn: 3420, + networkOut: 2890, + instanceType: 't3.large', + az: 'us-east-1b', + state: 'running', + monthlyCost: 60.8, + spotPrice: 0.0832, + }, + { + id: 'i-abcdef1234567890a', + name: 'db-server-1', + cpuUtilization: 23.1, + memoryUtilization: 45.6, + networkIn: 890, + networkOut: 450, + instanceType: 'r5.xlarge', + az: 'us-east-1c', + state: 'running', + monthlyCost: 201.6, + spotPrice: 0.252, + }, + { + id: 'i-fedcba0987654321b', + name: 'cache-server-1', + cpuUtilization: 12.4, + memoryUtilization: 34.2, + networkIn: 560, + networkOut: 320, + instanceType: 'r5.large', + az: 'us-east-1a', + state: 'stopped', + monthlyCost: 100.8, + spotPrice: 0.126, + }, + { + id: 'i-1122334455667788c', + name: 'worker-1', + cpuUtilization: 91.3, + memoryUtilization: 88.7, + networkIn: 4560, + networkOut: 3210, + instanceType: 'c5.2xlarge', + az: 'us-east-1d', + state: 'running', + monthlyCost: 248.0, + spotPrice: 0.34, + }, +]; + +const columnDefinitions: TableProps['columnDefinitions'] = [ + { + id: 'id', + header: 'Instance ID', + cell: (item: EC2Instance) => item.id, + sortingField: 'id', + isRowHeader: true, + groupId: 'metrics', + }, + { + id: 'name', + header: 'Name', + cell: (item: EC2Instance) => item.name, + sortingField: 'name', + }, + { + id: 'cpuUtilization', + header: 'CPU (%)', + cell: (item: EC2Instance) => `${item.cpuUtilization.toFixed(1)}%`, + sortingField: 'cpuUtilization', + groupId: 'performance', + }, + { + id: 'memoryUtilization', + header: 'Memory (%)', + cell: (item: EC2Instance) => `${item.memoryUtilization.toFixed(1)}%`, + sortingField: 'memoryUtilization', + groupId: 'performance', + }, + { + id: 'networkIn', + header: 'Network In (MB/s)', + cell: (item: EC2Instance) => item.networkIn.toString(), + sortingField: 'networkIn', + groupId: 'performance', + }, + { + id: 'networkOut', + header: 'Network Out (MB/s)', + cell: (item: EC2Instance) => item.networkOut.toString(), + sortingField: 'networkOut', + groupId: 'performance', + }, + { + id: 'instanceType', + header: 'Instance Type', + cell: (item: EC2Instance) => item.instanceType, + sortingField: 'instanceType', + groupId: 'configuration', + }, + { + id: 'az', + header: 'Availability Zone', + cell: (item: EC2Instance) => item.az, + sortingField: 'az', + groupId: 'configuration', + }, + { + id: 'state', + header: 'State', + cell: (item: EC2Instance) => item.state, + sortingField: 'state', + groupId: 'configuration', + }, + { + id: 'monthlyCost', + header: 'Monthly Cost ($)', + cell: (item: EC2Instance) => `$${item.monthlyCost.toFixed(2)}`, + sortingField: 'monthlyCost', + groupId: 'cost', + }, + { + id: 'spotPrice', + header: 'Spot Price ($/hr)', + cell: (item: EC2Instance) => `$${item.spotPrice.toFixed(4)}`, + sortingField: 'spotPrice', + groupId: 'cost', + }, +]; + +const columnGroupingDefinitions: TableProps['columnGroupingDefinitions'] = [ + { + id: 'cost', + header: 'Cost', + }, + { + id: 'configuration', + header: 'Configuration', + }, + { + id: 'performance', + header: 'Performance', + groupId: 'metrics', + }, + { + id: 'metrics', + header: 'Metrics', + }, +]; + +const collectionPreferencesProps: CollectionPreferencesProps = { + title: 'Preferences', + confirmLabel: 'Confirm', + cancelLabel: 'Cancel', + pageSizePreference: { + title: 'Page size', + options: [ + { value: 2, label: '2 instances' }, + { value: 10, label: '10 instances' }, + { value: 30, label: '30 instances' }, + ], + }, + contentDisplayPreference: { + title: 'Column preferences', + description: 'Customize the columns visibility and order.', + options: [ + { id: 'name', label: 'Name' }, + { id: 'cpuUtilization', label: 'CPU (%)', groupId: 'performance' }, + { id: 'memoryUtilization', label: 'Memory (%)', groupId: 'performance' }, + { id: 'networkIn', label: 'Network In (MB/s)', groupId: 'performance' }, + { id: 'networkOut', label: 'Network Out (MB/s)', groupId: 'performance' }, + { id: 'instanceType', label: 'Instance Type', groupId: 'configuration' }, + { id: 'id', label: 'Instance ID', alwaysVisible: true, groupId: 'metrics' }, + { id: 'az', label: 'Availability Zone', groupId: 'configuration' }, + { id: 'state', label: 'State', groupId: 'configuration' }, + { id: 'monthlyCost', label: 'Monthly Cost ($)', groupId: 'cost' }, + { id: 'spotPrice', label: 'Spot Price ($/hr)', groupId: 'cost' }, + ], + groups: [ + { id: 'cost', label: 'Cost' }, + { id: 'configuration', label: 'Configuration' }, + { id: 'performance', label: 'Performance', groupId: 'metrics' }, + { id: 'metrics', label: 'Metrics' }, + ], + }, +}; + +function EmptyState({ title, subtitle, action }: { title: string; subtitle?: string; action?: React.ReactNode }) { + return ( + + + {title} + + {subtitle && ( + + {subtitle} + + )} + {action} + + ); +} + +type DemoContext = React.Context< + AppContextType<{ + resizable: boolean; + firstSticky: number; + lastSticky: number; + customGap: boolean; + gap: number; + }> +>; + +export default function EC2TableDemo() { + const [preferences, setPreferences] = useState({ + pageSize: 10, + contentDisplay: [ + { id: 'cpuUtilization', visible: true }, + { id: 'memoryUtilization', visible: true }, + { id: 'networkIn', visible: true }, + { id: 'networkOut', visible: true }, + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { id: 'instanceType', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + { id: 'monthlyCost', visible: false }, + { id: 'spotPrice', visible: false }, + ], + }); + + const ariaLabels: TableProps['ariaLabels'] = { + selectionGroupLabel: 'EC2 instances selection', + allItemsSelectionLabel: ({ selectedItems }) => + `${selectedItems.length} ${selectedItems.length === 1 ? 'instance' : 'instances'} selected`, + itemSelectionLabel: ({ selectedItems }, item) => { + const isItemSelected = selectedItems.includes(item); + return `${item.name} is ${isItemSelected ? '' : 'not '}selected`; + }, + tableLabel: 'EC2 Instances', + }; + + const { items, actions, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection( + allInstances, + { + filtering: { + empty: ( + Launch instance} + /> + ), + noMatch: ( + actions.setFiltering('')}>Clear filter} + /> + ), + }, + pagination: { pageSize: preferences?.pageSize }, + sorting: {}, + selection: {}, + } + ); + + const { selectedItems } = collectionProps; + + const { + urlParams: { resizable = true, firstSticky = 0, lastSticky = 0, customGap = false, gap = 0 }, + setUrlParams, + } = useContext(AppContext as DemoContext); + + // Build the CSS custom property style for the resizer gap + const tableWrapperStyle: React.CSSProperties = customGap + ? ({ '--awsui-table-resizer-block-gap': `${gap}px` } as React.CSSProperties) + : {}; + + return ( + +

EC2 Instances Table

+ + + setUrlParams({ resizable: detail.checked })} checked={resizable}> + Resizable + + + + setUrlParams({ firstSticky: +detail.value })} + value={String(firstSticky)} + name="first" + inputMode="numeric" + type="number" + /> + + + setUrlParams({ lastSticky: +detail.value })} + value={String(lastSticky)} + name="last" + inputMode="numeric" + type="number" + /> + + + setUrlParams({ customGap: detail.checked })} checked={customGap}> + Custom resizer gap + + + + setUrlParams({ gap: +detail.value })} + value={String(gap)} + name="gap" + inputMode="numeric" + type="number" + /> + + + +
+
+ EC2 Instances + + } + columnDefinitions={columnDefinitions} + columnGroupingDefinitions={columnGroupingDefinitions} + columnDisplay={preferences?.contentDisplay} + items={items} + pagination={} + filter={ + + } + preferences={ + setPreferences(detail)} + /> + } + /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 01fbd65f7e..93d0775298 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -8777,6 +8777,11 @@ You must provide an ordered list of the items to display in the \`preferences.co "optional": true, "type": "boolean", }, + { + "name": "groups", + "optional": true, + "type": "ReadonlyArray", + }, { "inlineType": { "name": "CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings", @@ -25702,6 +25707,13 @@ Use it in conjunction with the content display preference of the [collection pre "optional": true, "type": "ReadonlyArray", }, + { + "description": "Add grouping for the columns define groups for columns to be under and also nested groups for which +other groups will be nested under.", + "name": "columnGroupingDefinitions", + "optional": true, + "type": "ReadonlyArray>", + }, { "defaultValue": "'comfortable'", "description": "Toggles the content density of the table. Defaults to \`'comfortable'\`.", diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index 173522ee6d..02f8f90d3f 100644 --- a/src/collection-preferences/content-display/index.tsx +++ b/src/collection-preferences/content-display/index.tsx @@ -18,7 +18,14 @@ import InternalTextFilter from '../../text-filter/internal'; import { getAnalyticsInnerContextAttribute } from '../analytics-metadata/utils'; import { CollectionPreferencesProps } from '../interfaces'; import ContentDisplayOption from './content-display-option'; -import { getFilteredOptions, getSortedOptions, OptionWithVisibility } from './utils'; +import { + buildOptionTree, + flattenOptionTree, + getFilteredOptions, + getSortedOptions, + OptionTreeNode, + OptionWithVisibility, +} from './utils'; import styles from '../styles.css.js'; @@ -31,10 +38,73 @@ interface ContentDisplayPreferenceProps extends CollectionPreferencesProps.Conte value?: ReadonlyArray; } +interface HierarchicalContentDisplayProps { + tree: OptionTreeNode[]; + onToggle: (option: OptionWithVisibility) => void; + onTreeChange: (newTree: OptionTreeNode[]) => void; + ariaLabelledby?: string; + ariaDescribedby?: string; + i18nStrings: React.ComponentProps['i18nStrings']; + depth?: number; +} + +function HierarchicalContentDisplay({ + tree, + onToggle, + onTreeChange, + ariaLabelledby, + ariaDescribedby, + i18nStrings, + depth = 0, +}: HierarchicalContentDisplayProps) { + return ( + ({ + id: node.id, + announcementLabel: node.label, + content: node.isGroup ? ( + + {/* Group header — no toggle */} + + {node.label} + + {/* Recursively render children (sub-groups or leaf columns) */} + {node.children.length > 0 && ( + + + onTreeChange(tree.map(n => (n.id === node.id && n.isGroup ? { ...n, children: newChildren } : n))) + } + i18nStrings={i18nStrings} + depth={depth + 1} + /> + + )} + + ) : ( + // node is OptionLeafNode — has all OptionWithVisibility fields + + ), + })} + disableItemPaddings={true} + sortable={true} + onSortingChange={({ detail: { items } }) => { + onTreeChange([...items]); + }} + {...(depth === 0 ? { ariaLabelledby, ariaDescribedby } : {})} + i18nStrings={i18nStrings} + /> + ); +} + export default function ContentDisplayPreference({ title, description, options, + groups, value = options.map(({ id }) => ({ id, visible: true, @@ -56,11 +126,12 @@ export default function ContentDisplayPreference({ const titleId = `${idPrefix}-title`; const descriptionId = `${idPrefix}-description`; - const [sortedOptions, sortedAndFilteredOptions] = useMemo(() => { + const [sortedOptions, sortedAndFilteredOptions, optionTree] = useMemo(() => { const sorted = getSortedOptions({ options, contentDisplay: value }); const filtered = getFilteredOptions(sorted, columnFilteringText); - return [sorted, filtered]; - }, [columnFilteringText, options, value]); + const tree = groups && groups.length > 0 ? buildOptionTree(sorted, groups) : null; + return [sorted, filtered, tree]; + }, [columnFilteringText, groups, options, value]); const onToggle = (option: OptionWithVisibility) => { // We use sortedOptions as base and not value because there might be options that @@ -126,48 +197,85 @@ export default function ContentDisplayPreference({ )} - ({ - id: item.id, - content: , - announcementLabel: item.label, - })} - disableItemPaddings={true} - sortable={true} - sortDisabled={columnFilteringText.trim().length > 0} - onSortingChange={({ detail: { items } }) => { - onChange(items); - }} - ariaDescribedby={descriptionId} - ariaLabelledby={titleId} - i18nStrings={{ - liveAnnouncementDndStarted: i18n( - 'contentDisplayPreference.liveAnnouncementDndStarted', - liveAnnouncementDndStarted, - formatDndStarted - ), - liveAnnouncementDndItemReordered: i18n( - 'contentDisplayPreference.liveAnnouncementDndItemReordered', - liveAnnouncementDndItemReordered, - formatDndItemReordered - ), - liveAnnouncementDndItemCommitted: i18n( - 'contentDisplayPreference.liveAnnouncementDndItemCommitted', - liveAnnouncementDndItemCommitted, - formatDndItemCommitted - ), - liveAnnouncementDndDiscarded: i18n( - 'contentDisplayPreference.liveAnnouncementDndDiscarded', - liveAnnouncementDndDiscarded - ), - dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel), - dragHandleAriaDescription: i18n( - 'contentDisplayPreference.dragHandleAriaDescription', - dragHandleAriaDescription - ), - }} - /> + {/* Grouped hierarchical view */} + {optionTree && columnFilteringText.trim().length === 0 ? ( + onChange(flattenOptionTree(newTree))} + ariaDescribedby={descriptionId} + ariaLabelledby={titleId} + i18nStrings={{ + liveAnnouncementDndStarted: i18n( + 'contentDisplayPreference.liveAnnouncementDndStarted', + liveAnnouncementDndStarted, + formatDndStarted + ), + liveAnnouncementDndItemReordered: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemReordered', + liveAnnouncementDndItemReordered, + formatDndItemReordered + ), + liveAnnouncementDndItemCommitted: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemCommitted', + liveAnnouncementDndItemCommitted, + formatDndItemCommitted + ), + liveAnnouncementDndDiscarded: i18n( + 'contentDisplayPreference.liveAnnouncementDndDiscarded', + liveAnnouncementDndDiscarded + ), + dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel), + dragHandleAriaDescription: i18n( + 'contentDisplayPreference.dragHandleAriaDescription', + dragHandleAriaDescription + ), + }} + /> + ) : ( + ({ + id: item.id, + content: , + announcementLabel: item.label, + })} + disableItemPaddings={true} + sortable={true} + sortDisabled={columnFilteringText.trim().length > 0} + onSortingChange={({ detail: { items } }) => { + onChange(items); + }} + ariaDescribedby={descriptionId} + ariaLabelledby={titleId} + i18nStrings={{ + liveAnnouncementDndStarted: i18n( + 'contentDisplayPreference.liveAnnouncementDndStarted', + liveAnnouncementDndStarted, + formatDndStarted + ), + liveAnnouncementDndItemReordered: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemReordered', + liveAnnouncementDndItemReordered, + formatDndItemReordered + ), + liveAnnouncementDndItemCommitted: i18n( + 'contentDisplayPreference.liveAnnouncementDndItemCommitted', + liveAnnouncementDndItemCommitted, + formatDndItemCommitted + ), + liveAnnouncementDndDiscarded: i18n( + 'contentDisplayPreference.liveAnnouncementDndDiscarded', + liveAnnouncementDndDiscarded + ), + dragHandleAriaLabel: i18n('contentDisplayPreference.dragHandleAriaLabel', dragHandleAriaLabel), + dragHandleAriaDescription: i18n( + 'contentDisplayPreference.dragHandleAriaDescription', + dragHandleAriaDescription + ), + }} + /> + )} ); } diff --git a/src/collection-preferences/content-display/utils.ts b/src/collection-preferences/content-display/utils.ts index 9877ce3ed6..433e1243f5 100644 --- a/src/collection-preferences/content-display/utils.ts +++ b/src/collection-preferences/content-display/utils.ts @@ -6,6 +6,25 @@ export interface OptionWithVisibility extends CollectionPreferencesProps.Content visible: boolean; } +/** + * A group header node in the hierarchical tree. + * children can be either nested sub-group nodes OR leaf column nodes — supporting N-level nesting. + */ +export interface OptionGroupNode { + id: string; + label: string; + groupId?: string; + isGroup: true; + children: OptionTreeNode[]; +} + +/** A flat leaf column node (not a group header). */ +export interface OptionLeafNode extends OptionWithVisibility { + isGroup: false; +} + +export type OptionTreeNode = OptionGroupNode | OptionLeafNode; + export function getSortedOptions({ options, contentDisplay, @@ -28,6 +47,204 @@ export function getSortedOptions({ return Array.from(optionsById.values()); } +export function buildOptionTree( + sortedOptions: ReadonlyArray, + groups: ReadonlyArray +): OptionTreeNode[] { + if (!groups || groups.length === 0) { + return sortedOptions.map(opt => ({ ...opt, isGroup: false as const })); + } + + // Map group id → group definition + const groupById = new Map(); + for (const group of groups) { + groupById.set(group.id, group); + } + + // insertion-order tracking so we preserve the sorted options order. + const childrenMap = new Map(); + childrenMap.set(null, []); // null = root level + for (const group of groups) { + childrenMap.set(group.id, []); + } + + // Track which node IDs have already been placed to avoid duplicates + const placed = new Set(); + + /** + * Resolve the effective parent for a node: + * - If node.groupId exists and that group exists → parent is that group + * - Otherwise → parent is root (null) + */ + const resolveParent = (groupId: string | undefined): string | null => { + if (groupId && groupById.has(groupId)) { + return groupId; + } + return null; + }; + + /** + * Find the earliest leaf option index that is a descendant of a given group. + * Used to determine insertion order of groups relative to their sibling leaf columns. + */ + const firstLeafIndex = new Map(); + // Pre-compute for each leaf option its index in sortedOptions + const optionIndex = new Map(); + sortedOptions.forEach((opt, i) => optionIndex.set(opt.id, i)); + + // For each group, find the minimum index among its direct and indirect leaf descendants + const computeFirstLeafIndex = (groupId: string): number => { + if (firstLeafIndex.has(groupId)) { + return firstLeafIndex.get(groupId)!; + } + let min = Infinity; + // Direct leaf children + for (const opt of sortedOptions) { + if (opt.groupId === groupId) { + const idx = optionIndex.get(opt.id) ?? Infinity; + if (idx < min) { + min = idx; + } + } + } + // Indirect children via sub-groups + for (const group of groups) { + if (group.groupId === groupId) { + const sub = computeFirstLeafIndex(group.id); + if (sub < min) { + min = sub; + } + } + } + firstLeafIndex.set(groupId, min); + return min; + }; + for (const group of groups) { + computeFirstLeafIndex(group.id); + } + + // We build children lists by processing sortedOptions (leaf columns) in order, + // and inserting group nodes at the position of their first leaf descendant. + // We use an insertion-order approach: for each parent, track which children + // have been added and in what order. + + const parentOrder = new Map(); // parent → ordered child IDs + const parentOrderSet = new Map>(); // for O(1) membership + parentOrder.set(null, []); + parentOrderSet.set(null, new Set()); + for (const group of groups) { + parentOrder.set(group.id, []); + parentOrderSet.set(group.id, new Set()); + } + + const ensureAncestorsPlaced = (groupId: string) => { + // Walk up the ancestor chain and ensure each ancestor is registered with its parent + const chain: string[] = []; + let current: string | undefined = groupId; + while (current) { + chain.unshift(current); + const parentGroupId: string | undefined = groupById.get(current)?.groupId; + if (!parentGroupId || !groupById.has(parentGroupId)) { + break; + } + current = parentGroupId; + } + // Now place from top down + for (const gid of chain) { + const parentId = resolveParent(groupById.get(gid)?.groupId); + const order = parentOrder.get(parentId)!; + const orderSet = parentOrderSet.get(parentId)!; + if (!orderSet.has(gid)) { + orderSet.add(gid); + order.push(gid); + } + } + }; + + // Process sorted leaf options to establish ordering + for (const option of sortedOptions) { + const directParentId = resolveParent(option.groupId); + + if (directParentId !== null) { + // Ensure the full ancestor chain is placed first + ensureAncestorsPlaced(directParentId); + } + + // Place the leaf option itself + const order = parentOrder.get(directParentId)!; + const orderSet = parentOrderSet.get(directParentId)!; + const leafKey = `leaf:${option.id}`; + if (!orderSet.has(leafKey)) { + orderSet.add(leafKey); + order.push(leafKey); + } + } + + // Build node map for groups + const groupNodes = new Map(); + for (const group of groups) { + groupNodes.set(group.id, { + id: group.id, + label: group.label, + groupId: group.groupId, + isGroup: true, + children: [], + }); + } + + // Leaf option map for fast lookup + const optionByLeafKey = new Map(); + for (const opt of sortedOptions) { + optionByLeafKey.set(`leaf:${opt.id}`, opt); + } + + // Recursive builder: given a parent, build its ordered children array + const buildChildren = (parentId: string | null): OptionTreeNode[] => { + const order = parentOrder.get(parentId) ?? []; + const result: OptionTreeNode[] = []; + for (const key of order) { + if (key.startsWith('leaf:')) { + const opt = optionByLeafKey.get(key); + if (opt) { + result.push({ ...opt, isGroup: false as const }); + } + } else { + // It's a group ID + const groupNode = groupNodes.get(key); + if (groupNode && !placed.has(key)) { + placed.add(key); + groupNode.children = buildChildren(key); + result.push(groupNode); + } + } + } + return result; + }; + + return buildChildren(null); +} + +/** + * Recursively flattens an N-level tree back to a flat ContentDisplayItem array. + * Only leaf columns are emitted (depth-first order, group children follow the group). + */ +export function flattenOptionTree( + tree: OptionTreeNode[] +): ReadonlyArray { + const result: CollectionPreferencesProps.ContentDisplayItem[] = []; + const walk = (nodes: OptionTreeNode[]) => { + for (const node of nodes) { + if (node.isGroup) { + walk(node.children); // FIXED: recurse into children instead of treating them as leaves + } else { + result.push({ id: node.id, visible: node.visible }); + } + } + }; + walk(tree); + return result; +} + export function getFilteredOptions(options: ReadonlyArray, filterText: string) { filterText = filterText.trim().toLowerCase(); diff --git a/src/collection-preferences/interfaces.ts b/src/collection-preferences/interfaces.ts index 5768238b0a..fa8d52ee60 100644 --- a/src/collection-preferences/interfaces.ts +++ b/src/collection-preferences/interfaces.ts @@ -229,16 +229,20 @@ export namespace CollectionPreferencesProps { title?: string; description?: string; options: ReadonlyArray; + groups?: ReadonlyArray; enableColumnFiltering?: boolean; i18nStrings?: ContentDisplayPreferenceI18nStrings; } export interface ContentDisplayOption { id: string; + groupId?: string; label: string; alwaysVisible?: boolean; } + export type ContentDisplayOptionGroup = Omit; + export interface ContentDisplayItem { id: string; visible: boolean; diff --git a/src/table/__tests__/column-grouping-rendering.test.tsx b/src/table/__tests__/column-grouping-rendering.test.tsx new file mode 100644 index 0000000000..ea23fd7d5b --- /dev/null +++ b/src/table/__tests__/column-grouping-rendering.test.tsx @@ -0,0 +1,233 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Item { + id: number; + name: string; + cpu: number; + memory: number; + networkIn: number; + type: string; + az: string; + cost: number; +} + +const items: Item[] = [ + { id: 1, name: 'web-1', cpu: 45, memory: 62, networkIn: 1250, type: 't3.medium', az: 'us-east-1a', cost: 30 }, + { id: 2, name: 'api-1', cpu: 78, memory: 81, networkIn: 3420, type: 't3.large', az: 'us-east-1b', cost: 60 }, +]; + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: item => item.id, isRowHeader: true }, + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'cpu', header: 'CPU', cell: item => item.cpu, groupId: 'performance' }, + { id: 'memory', header: 'Memory', cell: item => item.memory, groupId: 'performance' }, + { id: 'networkIn', header: 'Network In', cell: item => item.networkIn, groupId: 'performance' }, + { id: 'type', header: 'Type', cell: item => item.type, groupId: 'config' }, + { id: 'az', header: 'AZ', cell: item => item.az, groupId: 'config' }, + { id: 'cost', header: 'Cost', cell: item => `$${item.cost}`, groupId: 'pricing' }, +]; + +const columnGroupingDefinitions: TableProps.ColumnGroupsDefinition[] = [ + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Configuration' }, + { id: 'pricing', header: 'Pricing' }, +]; + +function renderTable(props: Partial> = {}) { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + return { wrapper, container }; +} + +describe('Table with column grouping', () => { + test('renders multiple header rows when columnGroupingDefinitions are provided', () => { + const { wrapper } = renderTable(); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows.length).toBeGreaterThan(1); + }); + + test('renders group header cells with correct text', () => { + const { wrapper } = renderTable(); + const thead = wrapper.find('thead')!; + const thElements = thead.findAll('th'); + const headerTexts = thElements.map(th => th.getElement().textContent?.trim()); + expect(headerTexts).toContain('Performance'); + expect(headerTexts).toContain('Configuration'); + expect(headerTexts).toContain('Pricing'); + }); + + test('renders leaf column headers', () => { + const { wrapper } = renderTable(); + const thead = wrapper.find('thead')!; + const thElements = thead.findAll('th'); + const headerTexts = thElements.map(th => th.getElement().textContent?.trim()); + expect(headerTexts).toContain('ID'); + expect(headerTexts).toContain('Name'); + expect(headerTexts).toContain('CPU'); + expect(headerTexts).toContain('Memory'); + expect(headerTexts).toContain('Cost'); + }); + + test('group header cells have correct colspan', () => { + const { wrapper } = renderTable(); + const thead = wrapper.find('thead')!; + const thElements = thead.findAll('th'); + + // Find the Performance group header + const perfHeader = thElements.find(th => th.getElement().textContent?.trim() === 'Performance'); + expect(perfHeader).toBeDefined(); + const colspan = perfHeader!.getElement().getAttribute('colspan'); + // Performance group has cpu, memory, networkIn = 3 columns + expect(colspan).toBe('3'); + + // Find the Configuration group header + const configHeader = thElements.find(th => th.getElement().textContent?.trim() === 'Configuration'); + expect(configHeader).toBeDefined(); + const configColspan = configHeader!.getElement().getAttribute('colspan'); + // Configuration has type, az = 2 columns + expect(configColspan).toBe('2'); + }); + + test('renders correct number of body rows', () => { + const { wrapper } = renderTable(); + const rows = wrapper.findAll('tr').filter(row => !row.getElement().closest('thead')); + // 2 items + expect(rows.length).toBe(2); + }); + + test('renders correct number of body cells per row', () => { + const { wrapper } = renderTable(); + const bodyRows = wrapper.findAll('tr').filter(row => !row.getElement().closest('thead')); + // Each row should have cells for all visible columns + bodyRows.forEach(row => { + const cells = row.findAll('td'); + expect(cells.length).toBeGreaterThan(0); + }); + }); + + test('aria-rowindex accounts for multiple header rows', () => { + const { wrapper } = renderTable(); + const thead = wrapper.find('thead')!; + const headerRows = thead.findAll('tr'); + + headerRows.forEach((row, idx) => { + expect(row.getElement().getAttribute('aria-rowindex')).toBe(`${idx + 1}`); + }); + }); + + test('works with selectionType', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: () => {}, + ariaLabels: { + selectionGroupLabel: 'Items selection', + allItemsSelectionLabel: () => 'Select all', + itemSelectionLabel: (_, item) => `Select ${item.name}`, + }, + }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows.length).toBeGreaterThan(1); + // Selection checkbox should be in the first header row + const firstRowThs = rows[0].findAll('th'); + expect(firstRowThs.length).toBeGreaterThan(0); + }); + + test('renders single header row when no columnGroupingDefinitions', () => { + const { wrapper } = renderTable({ columnGroupingDefinitions: undefined }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows.length).toBe(1); + }); + + test('renders with resizableColumns', () => { + const { wrapper } = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows.length).toBeGreaterThan(1); + }); + + test('renders with columnDisplay to control visibility', () => { + const { wrapper } = renderTable({ + columnDisplay: [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { id: 'cpu', visible: true }, + { id: 'memory', visible: false }, + { id: 'networkIn', visible: false }, + { id: 'type', visible: false }, + { id: 'az', visible: false }, + { id: 'cost', visible: false }, + ], + }); + const thead = wrapper.find('thead')!; + const thElements = thead.findAll('th'); + const headerTexts = thElements.map(th => th.getElement().textContent?.trim()); + expect(headerTexts).toContain('ID'); + expect(headerTexts).toContain('Name'); + expect(headerTexts).toContain('CPU'); + // Hidden columns should not be present + expect(headerTexts).not.toContain('Memory'); + expect(headerTexts).not.toContain('Cost'); + }); + + test('renders with nested column groups', () => { + const nestedGroupDefs: TableProps.ColumnGroupsDefinition[] = [ + { id: 'performance', header: 'Performance', groupId: 'metrics' }, + { id: 'metrics', header: 'Metrics' }, + { id: 'config', header: 'Configuration' }, + { id: 'pricing', header: 'Pricing' }, + ]; + const { wrapper } = renderTable({ columnGroupingDefinitions: nestedGroupDefs }); + const thead = wrapper.find('thead')!; + const thElements = thead.findAll('th'); + const headerTexts = thElements.map(th => th.getElement().textContent?.trim()); + expect(headerTexts).toContain('Metrics'); + expect(headerTexts).toContain('Performance'); + }); + + test('renders colgroup when resizableColumns and grouped', () => { + const { container } = renderTable({ resizableColumns: true }); + const colgroup = container.querySelector('colgroup'); + expect(colgroup).toBeTruthy(); + }); +}); + +describe('Table with column grouping and resizable columns', () => { + test('renders with resizable columns enabled', () => { + const { wrapper } = renderTable({ resizableColumns: true }); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + // Should still have multiple header rows + expect(rows.length).toBeGreaterThan(1); + // All group + leaf header cells should be rendered + const thElements = thead.findAll('th'); + expect(thElements.length).toBeGreaterThan(0); + }); + + test('group headers have scope="colgroup"', () => { + const { wrapper } = renderTable(); + const thead = wrapper.find('thead')!; + const thElements = thead.findAll('th'); + + const perfHeader = thElements.find(th => th.getElement().textContent?.trim() === 'Performance'); + if (perfHeader) { + expect(perfHeader.getElement().getAttribute('scope')).toBe('colgroup'); + } + }); +}); diff --git a/src/table/__tests__/column-grouping-utils.test.tsx b/src/table/__tests__/column-grouping-utils.test.tsx new file mode 100644 index 0000000000..9a0b4b9848 --- /dev/null +++ b/src/table/__tests__/column-grouping-utils.test.tsx @@ -0,0 +1,671 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { CalculateHierarchyTree, TableHeaderNode } from '../column-grouping-utils'; +import { TableProps } from '../interfaces'; + +describe('column-grouping-utils', () => { + describe('TableHeaderNode', () => { + it('creates node with basic properties', () => { + const node = new TableHeaderNode('test-id'); + + expect(node.id).toBe('test-id'); + expect(node.colspan).toBe(1); + expect(node.rowspan).toBe(1); + expect(node.subtreeHeight).toBe(1); + expect(node.children).toEqual([]); + expect(node.rowIndex).toBe(-1); + expect(node.colIndex).toBe(-1); + expect(node.isRoot).toBe(false); + }); + + it('creates node with options', () => { + const columnDef: TableProps.ColumnDefinition = { + id: 'test', + header: 'Test', + cell: () => 'test', + }; + + const node = new TableHeaderNode('test', { + colspan: 2, + rowspan: 3, + columnDefinition: columnDef, + rowIndex: 1, + colIndex: 2, + }); + + expect(node.colspan).toBe(2); + expect(node.rowspan).toBe(3); + expect(node.columnDefinition).toBe(columnDef); + expect(node.rowIndex).toBe(1); + expect(node.colIndex).toBe(2); + }); + + it('creates root node', () => { + const node = new TableHeaderNode('root', { isRoot: true }); + + expect(node.isRoot).toBe(true); + expect(node.isRootNode).toBe(true); + }); + + it('identifies group nodes correctly', () => { + const groupNode = new TableHeaderNode('group', { + groupDefinition: { id: 'group', header: 'Group' }, + }); + const colNode = new TableHeaderNode('col', { + columnDefinition: { id: 'col', header: 'Col', cell: () => 'col' }, + }); + + expect(groupNode.isGroup).toBe(true); + expect(colNode.isGroup).toBe(false); + }); + + it('identifies leaf nodes correctly', () => { + const parent = new TableHeaderNode('parent'); + const child = new TableHeaderNode('child'); + const root = new TableHeaderNode('root', { isRoot: true }); + + parent.addChild(child); + + expect(parent.isLeaf).toBe(false); + expect(child.isLeaf).toBe(true); + expect(root.isLeaf).toBe(false); // root is never a leaf + }); + + it('adds child and sets parent relationship', () => { + const parent = new TableHeaderNode('parent'); + const child = new TableHeaderNode('child'); + + parent.addChild(child); + + expect(parent.children).toHaveLength(1); + expect(parent.children[0]).toBe(child); + expect(child.parentNode).toBe(parent); + }); + + it('maintains children order', () => { + const parent = new TableHeaderNode('parent'); + const child1 = new TableHeaderNode('child1'); + const child2 = new TableHeaderNode('child2'); + const child3 = new TableHeaderNode('child3'); + + parent.addChild(child1); + parent.addChild(child2); + parent.addChild(child3); + + expect(parent.children[0]).toBe(child1); + expect(parent.children[1]).toBe(child2); + expect(parent.children[2]).toBe(child3); + }); + }); + + describe('CalculateHierarchyTree', () => { + describe('no grouping', () => { + it('returns single row with all visible columns', () => { + const columns: TableProps.ColumnDefinition[] = [ + { id: 'col1', header: 'Col 1', cell: () => 'col1' }, + { id: 'col2', header: 'Col 2', cell: () => 'col2' }, + { id: 'col3', header: 'Col 3', cell: () => 'col3' }, + ]; + + const result = CalculateHierarchyTree(columns, ['col1', 'col2', 'col3'], []); + + expect(result?.maxDepth).toBe(1); + expect(result?.rows).toHaveLength(1); + expect(result?.rows[0].columns).toHaveLength(3); + expect(result?.rows[0].columns.map(c => c.id)).toEqual(['col1', 'col2', 'col3']); + }); + + it('all columns have rowspan=1, colspan=1', () => { + const columns: TableProps.ColumnDefinition[] = [ + { id: 'col1', header: 'Col 1', cell: () => 'col1' }, + { id: 'col2', header: 'Col 2', cell: () => 'col2' }, + ]; + + const result = CalculateHierarchyTree(columns, ['col1', 'col2'], []); + + result?.rows[0].columns.forEach(col => { + expect(col.rowspan).toBe(1); + expect(col.colspan).toBe(1); + expect(col.isGroup).toBe(false); + }); + }); + + it('assigns sequential colIndex values', () => { + const columns: TableProps.ColumnDefinition[] = [ + { id: 'a', header: 'A', cell: () => 'a' }, + { id: 'b', header: 'B', cell: () => 'b' }, + { id: 'c', header: 'C', cell: () => 'c' }, + ]; + + const result = CalculateHierarchyTree(columns, ['a', 'b', 'c'], []); + + expect(result?.rows[0].columns[0].colIndex).toBe(0); + expect(result?.rows[0].columns[1].colIndex).toBe(1); + expect(result?.rows[0].columns[2].colIndex).toBe(2); + }); + + it('columnToParentIds is empty for ungrouped columns', () => { + const columns: TableProps.ColumnDefinition[] = [{ id: 'col1', header: 'Col 1', cell: () => 'col1' }]; + + const result = CalculateHierarchyTree(columns, ['col1'], []); + + expect(result?.columnToParentIds.size).toBe(0); + }); + }); + + describe('flat grouping', () => { + const columns: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'cpu', header: 'CPU', groupId: 'perf', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', groupId: 'perf', cell: () => 'memory' }, + { id: 'type', header: 'Type', groupId: 'config', cell: () => 'type' }, + ]; + + const groups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'perf', header: 'Performance' }, + { id: 'config', header: 'Configuration' }, + ]; + + it('creates two rows', () => { + const result = CalculateHierarchyTree( + columns, + ['id', 'name', 'cpu', 'memory', 'type'], + groups + // [] + ); + + expect(result?.maxDepth).toBe(2); + expect(result?.rows).toHaveLength(2); + }); + + it('row 0 has ungrouped columns expanded into hidden placeholder cells (rowspan=1, isHidden=true)', () => { + const result = CalculateHierarchyTree( + columns, + ['id', 'name', 'cpu', 'memory', 'type'], + groups + // [] + ); + + const row0 = result?.rows[0].columns; + const idCol = row0?.find(c => c.id === 'id'); + const nameCol = row0?.find(c => c.id === 'name'); + + // Ungrouped leaf columns are expanded: a hidden placeholder appears in row 0 + // and the real cell appears in row 1 (aligned with leaf columns of groups) + expect(idCol?.rowspan).toBe(1); + expect(idCol?.isHidden).toBe(true); + expect(idCol?.colspan).toBe(1); + expect(nameCol?.rowspan).toBe(1); + expect(nameCol?.isHidden).toBe(true); + expect(nameCol?.colspan).toBe(1); + }); + + it('row 0 has group headers with rowspan=1 and correct colspan', () => { + const result = CalculateHierarchyTree( + columns, + ['id', 'name', 'cpu', 'memory', 'type'], + groups + // [] + ); + + const row0 = result?.rows[0].columns; + const perfGroup = row0?.find(c => c.id === 'perf'); + const configGroup = row0?.find(c => c.id === 'config'); + + expect(perfGroup?.isGroup).toBe(true); + expect(perfGroup?.rowspan).toBe(1); + expect(perfGroup?.colspan).toBe(2); // cpu + memory + + expect(configGroup?.isGroup).toBe(true); + expect(configGroup?.rowspan).toBe(1); + expect(configGroup?.colspan).toBe(1); // type only + }); + + it('row 1 has grouped columns plus real ungrouped columns (expanded from row 0)', () => { + const result = CalculateHierarchyTree( + columns, + ['id', 'name', 'cpu', 'memory', 'type'], + groups + // [] + ); + + const row1 = result?.rows[1].columns; + + // Row 1 contains the real ungrouped columns (id, name) plus the grouped leaf columns + expect(row1?.length).toBe(5); // id, name, cpu, memory, type + expect(row1?.map(c => c.id)).toEqual(['id', 'name', 'cpu', 'memory', 'type']); + + // id and name are the real (non-hidden) cells + const idCol = row1?.find(c => c.id === 'id'); + const nameCol = row1?.find(c => c.id === 'name'); + expect(idCol?.isHidden).toBe(false); + expect(nameCol?.isHidden).toBe(false); + + // grouped leaf columns are normal + ['cpu', 'memory', 'type'].forEach(id => { + const col = row1?.find(c => c.id === id); + expect(col?.isGroup).toBe(false); + expect(col?.rowspan).toBe(1); + expect(col?.colspan).toBe(1); + expect(col?.isHidden).toBe(false); + }); + }); + + it('maintains correct column order', () => { + const result = CalculateHierarchyTree( + columns, + ['id', 'name', 'cpu', 'memory', 'type'], + groups + // [] + ); + + const row0 = result?.rows[0].columns; + expect(row0?.map(c => c.id)).toEqual(['id', 'name', 'perf', 'config']); + }); + + it('assigns correct colIndex across rows', () => { + const result = CalculateHierarchyTree( + columns, + ['id', 'name', 'cpu', 'memory', 'type'], + groups + // [] + ); + + const row0 = result?.rows[0].columns; + expect(row0?.find(c => c.id === 'id')?.colIndex).toBe(0); + expect(row0?.find(c => c.id === 'name')?.colIndex).toBe(1); + expect(row0?.find(c => c.id === 'perf')?.colIndex).toBe(2); + expect(row0?.find(c => c.id === 'config')?.colIndex).toBe(4); + + const row1 = result?.rows[1].columns; + expect(row1?.find(c => c.id === 'cpu')?.colIndex).toBe(2); + expect(row1?.find(c => c.id === 'memory')?.colIndex).toBe(3); + expect(row1?.find(c => c.id === 'type')?.colIndex).toBe(4); + }); + + it('tracks parent IDs for grouped columns', () => { + const result = CalculateHierarchyTree( + columns, + ['id', 'name', 'cpu', 'memory', 'type'], + groups + // [] + ); + + expect(result?.columnToParentIds.get('cpu')).toEqual(['perf']); + expect(result?.columnToParentIds.get('memory')).toEqual(['perf']); + expect(result?.columnToParentIds.get('type')).toEqual(['config']); + expect(result?.columnToParentIds.has('id')).toBe(false); + expect(result?.columnToParentIds.has('name')).toBe(false); + }); + + it('includes parentGroupIds in column metadata', () => { + const result = CalculateHierarchyTree( + columns, + ['id', 'name', 'cpu', 'memory', 'type'], + groups + // [] + ); + + const row1 = result?.rows[1].columns; + const cpuCol = row1?.find(c => c.id === 'cpu'); + + expect(cpuCol?.parentGroupIds).toEqual(['perf']); + }); + }); + + describe('nested grouping', () => { + const columns: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', groupId: 'perf', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', groupId: 'perf', cell: () => 'memory' }, + ]; + + const groups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'perf', header: 'Performance', groupId: 'metrics' }, + ]; + + it('creates correct number of rows for 2-level nesting', () => { + const result = CalculateHierarchyTree( + columns, + ['cpu', 'memory'], + groups + // [] + ); + + expect(result?.maxDepth).toBe(3); + expect(result?.rows).toHaveLength(3); + }); + + it('places groups in correct rows', () => { + const result = CalculateHierarchyTree( + columns, + ['cpu', 'memory'], + groups + // [] + ); + + // Row 0: metrics (top-level group) + expect(result?.rows[0].columns).toHaveLength(1); + expect(result?.rows[0].columns[0].id).toBe('metrics'); + expect(result?.rows[0].columns[0].rowIndex).toBe(0); + + // Row 1: perf (nested group) + expect(result?.rows[1].columns).toHaveLength(1); + expect(result?.rows[1].columns[0].id).toBe('perf'); + expect(result?.rows[1].columns[0].rowIndex).toBe(1); + + // Row 2: leaf columns + expect(result?.rows[2].columns).toHaveLength(2); + expect(result?.rows[2].columns.map(c => c.id)).toEqual(['cpu', 'memory']); + }); + + it('calculates correct colspan for nested groups', () => { + const result = CalculateHierarchyTree( + columns, + ['cpu', 'memory'], + groups + // [] + ); + + expect(result?.rows[0].columns[0].colspan).toBe(2); // metrics spans both columns + expect(result?.rows[1].columns[0].colspan).toBe(2); // perf spans both columns + expect(result?.rows[2].columns[0].colspan).toBe(1); // cpu + expect(result?.rows[2].columns[1].colspan).toBe(1); // memory + }); + + it('tracks full parent chain for nested groups', () => { + const result = CalculateHierarchyTree( + columns, + ['cpu', 'memory'], + groups + // [] + ); + + expect(result?.columnToParentIds.get('cpu')).toEqual(['metrics', 'perf']); + expect(result?.columnToParentIds.get('memory')).toEqual(['metrics', 'perf']); + }); + + it('handles 3-level nesting', () => { + const deepGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'level1', header: 'Level 1' }, + { id: 'level2', header: 'Level 2', groupId: 'level1' }, + { id: 'level3', header: 'Level 3', groupId: 'level2' }, + ]; + const deepCols: TableProps.ColumnDefinition[] = [ + { id: 'col', header: 'Col', groupId: 'level3', cell: () => 'col' }, + ]; + + const result = CalculateHierarchyTree(deepCols, ['col'], deepGroups); + + expect(result?.maxDepth).toBe(4); + expect(result?.rows).toHaveLength(4); + expect(result?.columnToParentIds.get('col')).toEqual(['level1', 'level2', 'level3']); + }); + + it('handles mixed nested and flat groups', () => { + const mixedGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'perf', header: 'Performance', groupId: 'metrics' }, + { id: 'config', header: 'Config' }, // flat + ]; + const mixedCols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', groupId: 'perf', cell: () => 'cpu' }, + { id: 'type', header: 'Type', groupId: 'config', cell: () => 'type' }, + ]; + + const result = CalculateHierarchyTree(mixedCols, ['cpu', 'type'], mixedGroups); + + expect(result?.maxDepth).toBe(3); + + // Row 0: metrics and a hidden placeholder for config (config had rowspan=2, now expanded) + expect(result?.rows[0].columns.map(c => c.id)).toEqual(['metrics', 'config']); + const configRow0 = result?.rows[0].columns.find(c => c.id === 'config'); + expect(configRow0?.isHidden).toBe(true); + expect(configRow0?.rowspan).toBe(1); + + // Row 1: perf and the real config (dropped down from row 0) + expect(result?.rows[1].columns.map(c => c.id)).toEqual(['perf', 'config']); + const configRow1 = result?.rows[1].columns.find(c => c.id === 'config'); + expect(configRow1?.isHidden).toBe(false); + expect(configRow1?.isGroup).toBe(true); + expect(configRow1?.rowspan).toBe(1); + + // Row 2: both leaf columns + expect(result?.rows[2].columns.map(c => c.id)).toEqual(['cpu', 'type']); + }); + + it('handles multiple trees at same level', () => { + const parallelGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'tree1', header: 'Tree 1' }, + { id: 'tree1child', header: 'Tree 1 Child', groupId: 'tree1' }, + { id: 'tree2', header: 'Tree 2' }, + { id: 'tree2child', header: 'Tree 2 Child', groupId: 'tree2' }, + ]; + const parallelCols: TableProps.ColumnDefinition[] = [ + { id: 'col1', header: 'Col 1', groupId: 'tree1child', cell: () => 'col1' }, + { id: 'col2', header: 'Col 2', groupId: 'tree2child', cell: () => 'col2' }, + ]; + + const result = CalculateHierarchyTree(parallelCols, ['col1', 'col2'], parallelGroups); + + expect(result?.maxDepth).toBe(3); + expect(result?.rows[0].columns.map(c => c.id)).toEqual(['tree1', 'tree2']); + expect(result?.rows[1].columns.map(c => c.id)).toEqual(['tree1child', 'tree2child']); + expect(result?.rows[2].columns.map(c => c.id)).toEqual(['col1', 'col2']); + }); + }); + + describe('mixed ungrouped and grouped columns', () => { + it('ungrouped columns are expanded into hidden placeholder in row 0, real cell in row 1', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }]; + const columns: TableProps.ColumnDefinition[] = [ + { id: 'ungrouped', header: 'Ungrouped', cell: () => 'ungrouped' }, + { id: 'grouped', header: 'Grouped', groupId: 'group1', cell: () => 'grouped' }, + ]; + + const result = CalculateHierarchyTree(columns, ['ungrouped', 'grouped'], groups); + + // Row 0 has a hidden placeholder for 'ungrouped' + const ungroupedRow0 = result?.rows[0].columns.find(c => c.id === 'ungrouped'); + expect(ungroupedRow0?.rowspan).toBe(1); + expect(ungroupedRow0?.isHidden).toBe(true); + expect(ungroupedRow0?.rowIndex).toBe(0); + + // Row 1 has the real 'ungrouped' cell + const ungroupedRow1 = result?.rows[1].columns.find(c => c.id === 'ungrouped'); + expect(ungroupedRow1?.rowspan).toBe(1); + expect(ungroupedRow1?.isHidden).toBe(false); + expect(ungroupedRow1?.rowIndex).toBe(1); + }); + + it('maintains correct column order with mixed types', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }]; + const columns: TableProps.ColumnDefinition[] = [ + { id: 'a', header: 'A', cell: () => 'a' }, + { id: 'b', header: 'B', groupId: 'group1', cell: () => 'b' }, + { id: 'c', header: 'C', cell: () => 'c' }, + { id: 'd', header: 'D', groupId: 'group1', cell: () => 'd' }, + ]; + + const result = CalculateHierarchyTree(columns, ['a', 'b', 'c', 'd'], groups); + + // Row 0: hidden placeholders for a and c, plus the group header + expect(result?.rows[0].columns.map(c => c.id)).toEqual(['a', 'group1', 'c']); + const row0Hidden = result?.rows[0].columns.filter(c => c.isHidden).map(c => c.id); + expect(row0Hidden).toEqual(['a', 'c']); + + // Row 1: real a and c cells plus grouped columns b and d + // b and d appear in the order determined by the tree traversal (b before d), + // with c inserted after d based on its colIndex + expect(result?.rows[1].columns.map(c => c.id)).toEqual(['a', 'b', 'd', 'c']); + const row1Hidden = result?.rows[1].columns.filter(c => c.isHidden).map(c => c.id); + expect(row1Hidden).toEqual([]); // no hidden cells in row 1 + }); + }); + + describe('visibility filtering', () => { + const columns: TableProps.ColumnDefinition[] = [ + { id: 'col1', header: 'Col 1', cell: () => 'col1' }, + { id: 'col2', header: 'Col 2', cell: () => 'col2' }, + { id: 'col3', header: 'Col 3', groupId: 'group1', cell: () => 'col3' }, + { id: 'col4', header: 'Col 4', groupId: 'group1', cell: () => 'col4' }, + ]; + + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }]; + + it('only includes visible columns', () => { + const result = CalculateHierarchyTree(columns, ['col1', 'col3'], groups); + + const allColumnIds = result?.rows.flatMap(row => row.columns.map(c => c.id)); + expect(allColumnIds).toContain('col1'); + expect(allColumnIds).toContain('col3'); + expect(allColumnIds).toContain('group1'); + expect(allColumnIds).not.toContain('col2'); + expect(allColumnIds).not.toContain('col4'); + }); + + it('adjusts group colspan when some children hidden', () => { + const result = CalculateHierarchyTree(columns, ['col1', 'col3'], groups); + + const group = result?.rows[0].columns.find(c => c.id === 'group1'); + expect(group?.colspan).toBe(1); // only col3 visible + }); + + it('hides group when all children hidden', () => { + const result = CalculateHierarchyTree(columns, ['col1', 'col2'], groups); + + const allIds = result?.rows[0].columns.map(c => c.id); + expect(allIds).not.toContain('group1'); + }); + + it('respects columnDisplay visibility settings', () => { + const columnDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'col1', visible: true }, + { id: 'col2', visible: false }, + { id: 'col3', visible: true }, + { id: 'col4', visible: true }, + ]; + + const result = CalculateHierarchyTree(columns, ['col1', 'col2', 'col3', 'col4'], groups, columnDisplay); + + const leafColumns = result?.rows[result.rows.length - 1].columns; + expect(leafColumns?.map(c => c.id)).not.toContain('col2'); + }); + }); + + describe('edge cases', () => { + it('handles empty column list', () => { + const result = CalculateHierarchyTree([], [], []); + + expect(result?.rows).toHaveLength(0); + expect(result?.maxDepth).toBe(0); + // expect(result?.rows[0].columns).toHaveLength(0); + }); + + it('handles column without id', () => { + const columns: TableProps.ColumnDefinition[] = [ + { header: 'No ID', cell: () => 'noid' } as any, + { id: 'withid', header: 'With ID', cell: () => 'withid' }, + ]; + + const result = CalculateHierarchyTree(columns, ['withid'], []); + + // Column without id should be skipped + expect(result?.rows[0].columns).toHaveLength(1); + expect(result?.rows[0].columns[0].id).toBe('withid'); + }); + + it('handles group without id', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [ + { header: 'No ID' } as any, + { id: 'valid', header: 'Valid' }, + ]; + const columns: TableProps.ColumnDefinition[] = [ + { id: 'col', header: 'Col', groupId: 'valid', cell: () => 'col' }, + ]; + + const result = CalculateHierarchyTree(columns, ['col'], groups); + + // Group without id should be skipped + const groupIds = result?.rows[0].columns.filter(c => c.isGroup).map(c => c.id); + expect(groupIds).toEqual(['valid']); + }); + + it('handles column referencing non-existent group', () => { + const columns: TableProps.ColumnDefinition[] = [ + { id: 'orphan', header: 'Orphan', groupId: 'nonexistent', cell: () => 'orphan' }, + ]; + + const result = CalculateHierarchyTree(columns, ['orphan'], []); + + // Should treat as ungrouped + expect(result?.rows).toHaveLength(1); + expect(result?.rows[0].columns[0]).toMatchObject({ + id: 'orphan', + rowspan: 1, + isGroup: false, + }); + expect(result?.columnToParentIds.has('orphan')).toBe(false); + }); + + it('handles group referencing non-existent parent', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'orphan', header: 'Orphan', groupId: 'nonexistent' }, + ]; + const columns: TableProps.ColumnDefinition[] = [ + { id: 'col', header: 'Col', groupId: 'orphan', cell: () => 'col' }, + ]; + + const result = CalculateHierarchyTree(columns, ['col'], groups); + + // Orphan group should be top-level + expect(result?.rows[0].columns.find(c => c.id === 'orphan')).toBeDefined(); + expect(result?.columnToParentIds.get('col')).toEqual(['orphan']); + }); + + it('handles all columns in one group', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'all', header: 'All' }]; + const columns: TableProps.ColumnDefinition[] = [ + { id: 'a', header: 'A', groupId: 'all', cell: () => 'a' }, + { id: 'b', header: 'B', groupId: 'all', cell: () => 'b' }, + ]; + + const result = CalculateHierarchyTree(columns, ['a', 'b'], groups); + + expect(result?.rows).toHaveLength(2); + expect(result?.rows[0].columns).toHaveLength(1); // only group header + expect(result?.rows[1].columns).toHaveLength(2); // both columns + }); + + it('handles single column', () => { + const columns: TableProps.ColumnDefinition[] = [{ id: 'only', header: 'Only', cell: () => 'only' }]; + + const result = CalculateHierarchyTree(columns, ['only'], []); + + expect(result?.maxDepth).toBe(1); + expect(result?.rows).toHaveLength(1); + expect(result?.rows[0].columns).toHaveLength(1); + expect(result?.rows[0].columns[0]).toMatchObject({ + id: 'only', + colspan: 1, + rowspan: 1, + colIndex: 0, + }); + }); + + it('handles group with no visible children', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'empty', header: 'Empty' }]; + const columns: TableProps.ColumnDefinition[] = [ + { id: 'hidden', header: 'Hidden', groupId: 'empty', cell: () => 'hidden' }, + ]; + + const result = CalculateHierarchyTree(columns, [], groups); + + // Group with no visible children should not appear + expect(result?.rows.length).toEqual(0); + // expect(result?.rows[0].columns.find(c => c.id === 'empty')).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/table/__tests__/use-column-grouping.test.tsx b/src/table/__tests__/use-column-grouping.test.tsx new file mode 100644 index 0000000000..fd0ace060c --- /dev/null +++ b/src/table/__tests__/use-column-grouping.test.tsx @@ -0,0 +1,571 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../__tests__/render-hook'; +import { CalculateHierarchyTree } from '../column-grouping-utils'; +import { TableProps } from '../interfaces'; +import { useColumnGrouping } from '../use-column-grouping'; + +describe('useColumnGrouping', () => { + const mockColumns: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', groupId: 'performance', cell: () => 'memory' }, + { id: 'type', header: 'Type', groupId: 'config', cell: () => 'type' }, + { id: 'az', header: 'AZ', groupId: 'config', cell: () => 'az' }, + ]; + + const mockGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Configuration' }, + ]; + + describe('no grouping', () => { + it('returns single row when no groups defined', () => { + const { result } = renderHook(() => useColumnGrouping(undefined, mockColumns)); + + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + expect(result.current.rows[0].columns).toHaveLength(6); + expect(result.current.rows[0].columns[0]).toMatchObject({ + id: 'id', + colspan: 1, + rowspan: 1, + isGroup: false, + }); + }); + + it('returns single row when groups array is empty', () => { + const { result } = renderHook(() => useColumnGrouping([], mockColumns)); + + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + }); + }); + + describe('flat grouping', () => { + it('creates two rows with grouped and ungrouped columns', () => { + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns)); + + expect(result.current.maxDepth).toBe(2); + expect(result.current.rows).toHaveLength(2); + }); + + it('row 0 contains hidden placeholders for ungrouped columns and group headers', () => { + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns)); + + const row0 = result.current.rows[0].columns; + + // Hidden placeholders at top for ungrouped columns + expect(row0[0]).toMatchObject({ id: 'id', isHidden: true, rowspan: 1 }); + expect(row0[1]).toMatchObject({ id: 'name', isHidden: true, rowspan: 1 }); + + // Group headers + expect(row0[2]).toMatchObject({ + id: 'performance', + header: 'Performance', + colspan: 2, + rowspan: 1, + isGroup: true, + }); + expect(row0[3]).toMatchObject({ + id: 'config', + header: 'Configuration', + colspan: 2, + rowspan: 1, + isGroup: true, + }); + }); + + it('row 0 has hidden placeholders at top for ungrouped columns, row 1 has visible cells at bottom', () => { + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns)); + + // Row 0: hidden placeholders for id, name (at top) + group headers + const row0 = result.current.rows[0].columns; + expect(row0[0]).toMatchObject({ id: 'id', isHidden: true, rowspan: 1 }); + expect(row0[1]).toMatchObject({ id: 'name', isHidden: true, rowspan: 1 }); + + const row1 = result.current.rows[1].columns; + + // Row 1: visible id + visible name (at bottom, aligned with leaf row) + 4 leaf columns + const visibleId = row1.find(c => c.id === 'id' && !c.isHidden); + const visibleName = row1.find(c => c.id === 'name' && !c.isHidden); + expect(visibleId).toBeDefined(); + expect(visibleName).toBeDefined(); + + // Leaf columns + expect(row1.find(c => c.id === 'cpu' && !c.isHidden)).toMatchObject({ + id: 'cpu', + header: 'CPU', + colspan: 1, + rowspan: 1, + isGroup: false, + }); + expect(row1.find(c => c.id === 'memory' && !c.isHidden)).toMatchObject({ + id: 'memory', + header: 'Memory', + colspan: 1, + rowspan: 1, + isGroup: false, + }); + }); + + it('maintains column order from columnDefinitions', () => { + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns)); + + const row0 = result.current.rows[0].columns; + expect(row0.map((c: any) => c.id)).toEqual(['id', 'name', 'performance', 'config']); + + const row1 = result.current.rows[1].columns; + // Visible id and name now in row 1 (bottom) alongside leaf columns + expect(row1.map((c: any) => c.id)).toEqual(['id', 'name', 'cpu', 'memory', 'type', 'az']); + }); + }); + + describe('visibility filtering', () => { + it('filters columns by visibleColumnIds', () => { + const visibleIds = new Set(['id', 'cpu', 'memory']); + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns, visibleIds)); + + const row0 = result.current.rows[0].columns; + // Row 0: hidden placeholder for id (top) + performance group + expect(row0).toHaveLength(2); + expect(row0[0]).toMatchObject({ id: 'id', isHidden: true }); + expect(row0[1].id).toBe('performance'); + + const row1 = result.current.rows[1].columns; + // Row 1: visible id (bottom) + cpu + memory + expect(row1).toHaveLength(3); + const visibleId = row1.find(c => c.id === 'id' && !c.isHidden); + expect(visibleId).toBeDefined(); + }); + + it('hides group when all children are hidden', () => { + const visibleIds = new Set(['id', 'name', 'type', 'az']); + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns, visibleIds)); + + const row0 = result.current.rows[0].columns; + const groupIds = row0.filter((c: any) => c.isGroup).map((c: any) => c.id); + expect(groupIds).toEqual(['config']); // performance group hidden + }); + + it('adjusts colspan when some children are hidden', () => { + const visibleIds = new Set(['id', 'cpu', 'type', 'az']); + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns, visibleIds)); + + const row0 = result.current.rows[0].columns; + const perfGroup = row0.find((c: any) => c.id === 'performance'); + expect(perfGroup?.colspan).toBe(1); // only cpu visible + }); + }); + + describe('parent tracking', () => { + it('tracks parent group IDs for grouped columns', () => { + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns)); + + expect(result.current.columnToParentIds.get('cpu')).toEqual(['performance']); + expect(result.current.columnToParentIds.get('memory')).toEqual(['performance']); + expect(result.current.columnToParentIds.get('type')).toEqual(['config']); + }); + + it('ungrouped columns do not have parent group entries', () => { + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns)); + + // Ungrouped columns may have entries due to hidden node chain, but should not have group IDs + const idParents = result.current.columnToParentIds.get('id'); + const nameParents = result.current.columnToParentIds.get('name'); + // Either undefined or empty — no group parents + expect(!idParents || idParents.length === 0).toBe(true); + expect(!nameParents || nameParents.length === 0).toBe(true); + }); + }); + + describe('column indices', () => { + it('assigns sequential colIndex values', () => { + const { result } = renderHook(() => useColumnGrouping(mockGroups, mockColumns)); + + const row0 = result.current.rows[0].columns; + expect(row0[0].colIndex).toBe(0); // hidden id placeholder + expect(row0[1].colIndex).toBe(1); // hidden name placeholder + expect(row0[2].colIndex).toBe(2); // performance group starts at 2 + expect(row0[3].colIndex).toBe(4); // config group starts at 4 + + const row1 = result.current.rows[1].columns; + // Row 1: visible id + name (at bottom) + leaf columns + expect(row1[0].colIndex).toBe(0); // visible id + expect(row1[1].colIndex).toBe(1); // visible name + expect(row1[2].colIndex).toBe(2); // cpu + expect(row1[3].colIndex).toBe(3); // memory + expect(row1[4].colIndex).toBe(4); // type + expect(row1[5].colIndex).toBe(5); // az + }); + }); + + describe('edge cases', () => { + it('handles columns without IDs', () => { + const columnsNoIds: TableProps.ColumnDefinition[] = [ + { header: 'Col1', cell: () => 'col1' }, + { header: 'Col2', groupId: 'group1', cell: () => 'col2' }, + ]; + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }]; + + const { result } = renderHook(() => useColumnGrouping(groups, columnsNoIds)); + + // Columns without IDs are skipped by CalculateHierarchyTree, resulting in empty rows + expect(result.current.rows).toBeDefined(); + expect(result.current.rows.length).toBe(0); + }); + + it('handles group without header', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'performance', header: undefined }]; + + const { result } = renderHook(() => useColumnGrouping(groups, mockColumns)); + + const perfGroup = result.current.rows[0].columns.find((c: any) => c.id === 'performance'); + // When header is undefined, it stays undefined (no fallback to id) + expect(perfGroup?.header).toBeUndefined(); + }); + + it('handles all columns ungrouped', () => { + const ungroupedCols: TableProps.ColumnDefinition[] = [ + { id: 'a', header: 'A', cell: () => 'a' }, + { id: 'b', header: 'B', cell: () => 'b' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(mockGroups, ungroupedCols)); + + // When groups are defined but no columns use them, we should have only 1 row + expect(result.current.rows).toHaveLength(1); + expect(result.current.rows[0].columns).toHaveLength(2); + expect(result.current.rows[0].columns[0].rowspan).toBe(1); + }); + + it('handles all columns grouped', () => { + const allGrouped: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', groupId: 'performance', cell: () => 'memory' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(mockGroups, allGrouped)); + + expect(result.current.rows).toHaveLength(2); + expect(result.current.rows[0].columns).toHaveLength(1); // only group header + expect(result.current.rows[1].columns).toHaveLength(2); // both columns + }); + }); + + describe('nested groups', () => { + it('handles nested group definitions', () => { + const nestedGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance', groupId: 'metrics' }, + ]; + const nestedCols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(nestedGroups, nestedCols)); + + expect(result.current.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + + it('creates correct number of rows for nested groups', () => { + const nestedGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance', groupId: 'metrics' }, + ]; + const nestedCols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', groupId: 'performance', cell: () => 'memory' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(nestedGroups, nestedCols)); + + // Should have 3 rows: row 0 (metrics), row 1 (performance), row 2 (leaf columns) + expect(result.current.maxDepth).toBe(3); + expect(result.current.rows).toHaveLength(3); + }); + + it('places nested groups in correct rows', () => { + const nestedGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance', groupId: 'metrics' }, + ]; + const nestedCols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', groupId: 'performance', cell: () => 'memory' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(nestedGroups, nestedCols)); + + // Row 0 should have metrics group + expect(result.current.rows[0].columns).toHaveLength(1); + expect(result.current.rows[0].columns[0]).toMatchObject({ + id: 'metrics', + isGroup: true, + colspan: 2, + }); + + // Row 1 should have performance group + expect(result.current.rows[1].columns).toHaveLength(1); + expect(result.current.rows[1].columns[0]).toMatchObject({ + id: 'performance', + isGroup: true, + colspan: 2, + }); + + // Row 2 should have leaf columns + expect(result.current.rows[2].columns).toHaveLength(2); + expect(result.current.rows[2].columns[0].id).toBe('cpu'); + expect(result.current.rows[2].columns[1].id).toBe('memory'); + }); + + it('handles 3-level nesting', () => { + const deepGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'level1', header: 'Level 1' }, + { id: 'level2', header: 'Level 2', groupId: 'level1' }, + { id: 'level3', header: 'Level 3', groupId: 'level2' }, + ]; + const deepCols: TableProps.ColumnDefinition[] = [ + { id: 'col1', header: 'Col 1', groupId: 'level3', cell: () => 'col1' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(deepGroups, deepCols)); + + expect(result.current.maxDepth).toBe(4); + expect(result.current.rows).toHaveLength(4); + expect(result.current.columnToParentIds.get('col1')).toEqual(['level1', 'level2', 'level3']); + }); + + it('handles mixed nested and flat groups', () => { + const mixedGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance', groupId: 'metrics' }, + { id: 'config', header: 'Configuration' }, // flat group + ]; + const mixedCols: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, // ungrouped + { id: 'cpu', header: 'CPU', groupId: 'performance', cell: () => 'cpu' }, + { id: 'type', header: 'Type', groupId: 'config', cell: () => 'type' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(mixedGroups, mixedCols)); + + expect(result.current.maxDepth).toBe(3); + expect(result.current.rows).toHaveLength(3); + + // Row 0: hidden placeholder for id (top), metrics group, config group + expect(result.current.rows[0].columns).toHaveLength(3); + expect(result.current.rows[0].columns[0]).toMatchObject({ + id: 'id', + rowspan: 1, + isHidden: true, + }); + expect(result.current.rows[0].columns[1]).toMatchObject({ + id: 'metrics', + isGroup: true, + }); + expect(result.current.rows[0].columns[2]).toMatchObject({ + id: 'config', + isGroup: true, + }); + + // Row 1: hidden placeholder for id, performance group, hidden placeholder for config's type + const row1Ids = result.current.rows[1].columns.map(c => c.id); + expect(row1Ids).toContain('id'); // hidden placeholder + expect(row1Ids).toContain('performance'); + + // Row 2: leaf columns (hidden placeholder for id, cpu, type) + const row2 = result.current.rows[2].columns; + const row2NonHidden = row2.filter(c => !c.isHidden); + expect(row2NonHidden.length).toBeGreaterThanOrEqual(2); // at least cpu and type + }); + + it('prevents circular references in nested groups', () => { + const circularGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'a', header: 'A', groupId: 'b' }, + { id: 'b', header: 'B', groupId: 'a' }, + ]; + const cols: TableProps.ColumnDefinition[] = [{ id: 'col', header: 'Col', groupId: 'a', cell: () => 'col' }]; + + const { result } = renderHook(() => useColumnGrouping(circularGroups, cols)); + + // Should not crash and should handle gracefully + expect(result.current.rows).toBeDefined(); + // Circular groups should be detected and one will be marked as circular + // The column referencing the circular group will be treated as ungrouped + const allGroupIds = result.current.rows.flatMap(row => row.columns.filter(c => c.isGroup).map(c => c.id)); + // At least one group should be excluded or treated specially + expect(allGroupIds.length).toBeLessThanOrEqual(1); + }); + + it('handles non-existent parent group reference', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'child', header: 'Child', groupId: 'nonexistent' }, + ]; + const cols: TableProps.ColumnDefinition[] = [ + { id: 'col', header: 'Col', groupId: 'child', cell: () => 'col' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(groups, cols)); + + // Should treat child as top-level group + expect(result.current.rows[0].columns.find(c => c.id === 'child')).toBeDefined(); + // Parent chain should only include 'child', not the non-existent parent + expect(result.current.columnToParentIds.get('col')).toEqual(['child']); + }); + + it('handles column referencing non-existent group', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: 'group1', header: 'Group 1' }]; + const cols: TableProps.ColumnDefinition[] = [ + { id: 'col1', header: 'Col 1', groupId: 'nonexistent', cell: () => 'col1' }, + { id: 'col2', header: 'Col 2', groupId: 'group1', cell: () => 'col2' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(groups, cols)); + + // col1 should appear with rowspan=1 in the bottom row (visible, not hidden) + const allRows = result.current.rows; + const lastRow = allRows[allRows.length - 1]; + const col1Visible = lastRow.columns.find(c => c.id === 'col1' && !c.isHidden); + expect(col1Visible).toBeDefined(); + expect(col1Visible?.rowspan).toBe(1); + }); + + it('handles group without id (should be skipped)', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [ + { id: '', header: 'Invalid Group' } as any, + { id: 'valid', header: 'Valid Group' }, + ]; + const cols: TableProps.ColumnDefinition[] = [ + { id: 'col', header: 'Col', groupId: 'valid', cell: () => 'col' }, + ]; + + const { result } = renderHook(() => useColumnGrouping(groups, cols)); + + // Should only have valid group + const groupIds = result.current.rows[0].columns.filter(c => c.isGroup).map(c => c.id); + expect(groupIds).toEqual(['valid']); + }); + }); + + describe('CalculateHierarchyTree direct tests for edge cases', () => { + it('skips columns with undefined id in visibleLeafColumns', () => { + // Column has no id → line 62 early return in createNodeConnections + const cols: TableProps.ColumnDefinition[] = [{ header: 'No ID', cell: () => 'x' }]; + const result = CalculateHierarchyTree(cols, ['col-0'], [], undefined); + // No columns with ids → empty rows + expect(result.rows).toHaveLength(0); + }); + + it('skips columns whose id is not in the node map (not visible)', () => { + // Column has id but is not in visibleColumnIds → getVisibleColumnDefinitions filters it out + // Then createNodeConnections iterates visibleColumns but the node is not in idToNodeMap → line 67 + const cols: TableProps.ColumnDefinition[] = [ + { id: 'a', header: 'A', cell: () => 'a' }, + { id: 'b', header: 'B', cell: () => 'b' }, + ]; + // Only 'a' is visible, so 'b' is filtered out before createNodeConnections + const result = CalculateHierarchyTree(cols, ['a'], [], undefined); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].columns).toHaveLength(1); + expect(result.rows[0].columns[0].id).toBe('a'); + }); + + it('skips group definitions with undefined id', () => { + // Group definition has undefined id → line 209 early return + const cols: TableProps.ColumnDefinition[] = [{ id: 'a', header: 'A', cell: () => 'a' }]; + const groups = [{ id: undefined, header: 'Bad Group' } as any]; + const result = CalculateHierarchyTree(cols, ['a'], groups, undefined); + // Should just have column 'a', no group + expect(result.rows).toHaveLength(1); + expect(result.rows[0].columns[0].id).toBe('a'); + }); + + it('handles circular reference with already-visited node connecting to root', () => { + // Two groups referencing each other: a→b, b→a + // When traversing from leaf "col" → group "a" → group "b" → detects cycle at "a" + // The circular node gets connected to root (lines 79-80) + const cols: TableProps.ColumnDefinition[] = [ + { id: 'col-circ', header: 'Col', groupId: 'grp-a-circ', cell: () => 'x' }, + ]; + const groups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'grp-a-circ', header: 'A', groupId: 'grp-b-circ' }, + { id: 'grp-b-circ', header: 'B', groupId: 'grp-a-circ' }, + ]; + const result = CalculateHierarchyTree(cols, ['col-circ'], groups, undefined); + // Should not crash; produces some result + expect(result.rows).toBeDefined(); + expect(result.maxDepth).toBeGreaterThanOrEqual(0); + }); + }); + + describe('error handling and warnings', () => { + let consoleWarnSpy: jest.SpyInstance; + let originalNodeEnv: string | undefined; + + beforeEach(() => { + originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + process.env.NODE_ENV = originalNodeEnv; + }); + + // Note: warnOnce from the toolkit has global deduplication. + // Warnings may already be consumed by earlier tests in the same run. + // These tests verify that warnings are called at least once across the suite + // by checking console.warn was called with a matching pattern. + + it('warns about circular references in development mode', () => { + const circularGroups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'circ-x', header: 'X', groupId: 'circ-y' }, + { id: 'circ-y', header: 'Y', groupId: 'circ-x' }, + ]; + const cols: TableProps.ColumnDefinition[] = [ + { id: 'circ-col', header: 'Col', groupId: 'circ-x', cell: () => 'col' }, + ]; + + renderHook(() => useColumnGrouping(circularGroups, cols)); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Circular reference detected')); + }); + + it('warns about non-existent parent group', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [ + { id: 'warn-child', header: 'Child', groupId: 'warn-nonexistent-parent' }, + ]; + const cols: TableProps.ColumnDefinition[] = [ + { id: 'warn-col', header: 'Col', groupId: 'warn-child', cell: () => 'col' }, + ]; + + renderHook(() => useColumnGrouping(groups, cols)); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('references non-existent parent group')); + }); + + it('warns about column referencing non-existent group', () => { + const groups: TableProps.ColumnGroupsDefinition[] = []; + const cols: TableProps.ColumnDefinition[] = [ + { id: 'warn-col2', header: 'Col', groupId: 'warn-nonexistent-group', cell: () => 'col' }, + ]; + + renderHook(() => useColumnGrouping(groups, cols)); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('references non-existent parent group')); + }); + + it('handles group without id gracefully', () => { + const groups: TableProps.ColumnGroupsDefinition[] = [{ id: '', header: 'Invalid' } as any]; + const cols: TableProps.ColumnDefinition[] = []; + + const { result } = renderHook(() => useColumnGrouping(groups, cols)); + + // Group with empty id is skipped; no crash + expect(result.current.rows).toBeDefined(); + }); + }); +}); diff --git a/src/table/column-grouping-utils.ts b/src/table/column-grouping-utils.ts new file mode 100644 index 0000000000..a3161f772c --- /dev/null +++ b/src/table/column-grouping-utils.ts @@ -0,0 +1,437 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; + +import { isDevelopment } from '../internal/is-development'; +import { TableProps } from './interfaces'; +import { getVisibleColumnDefinitions } from './utils'; + +export namespace TableGroupedTypes { + export interface ColumnInRow { + id: string; + header?: React.ReactNode; + colspan: number; + rowspan: number; + isGroup: boolean; + isHidden: boolean; // True for placeholder cells that fill gaps where rowspan > 1 would have been + // TODO: I could find a better way to make this modular instead of 2 props + // for column and column-group + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.ColumnGroupsDefinition; + parentGroupIds: string[]; // Chain of parent group IDs for ARIA headers attribute + rowIndex: number; + colIndex: number; + } + + export interface HeaderRow { + columns: ColumnInRow[]; + } + + export interface HierarchicalStructure { + rows: HeaderRow[]; + maxDepth: number; + columnToParentIds: Map; // Maps leaf column IDs to their parent group IDs + } +} + +// namespace TableTreeTypes { +// export interface TableHeaderNode { +// id: string; +// header?: React.ReactNode; +// colspan?: number; // -1 for phantom +// rowspan?: number; /// same -1 for phantom +// columnDefinition?: TableProps.ColumnDefinition; +// subtreeHeight?: number; +// groupDefinition?: TableProps.ColumnGroupsDefinition; +// parentNode?: TableHeaderNode; +// children?: TableHeaderNode[]; // order here implies actual render order +// colIndex?: number; // Absolute column index for aria-colindex +// } +// } + +// creates the connection between nodes going from child to root +function createNodeConnections( + visibleLeafColumns: Readonly[]>, + idToNodeMap: Map>, + rootNode: TableHeaderNode +): void { + const visitedNodesIdSet = new Set(); + + visibleLeafColumns.forEach(column => { + if (!column.id) { + return; + } + + let currentNode = idToNodeMap.get(column.id); + if (!currentNode) { + return; + } + + // Track nodes visited in this single leaf-to-root path to detect cycles + const pathVisited = new Set(); + + while (currentNode) { + // Cycle detection: if we've seen this node in the current path, we have a circular reference + if (pathVisited.has(currentNode.id)) { + warnOnceInDev(`Circular reference detected in column group definitions involving "${currentNode.id}".`); + // Connect the node to root to prevent orphaning + if (!visitedNodesIdSet.has(currentNode.id)) { + rootNode.addChild(currentNode); + visitedNodesIdSet.add(currentNode.id); + } + break; + } + pathVisited.add(currentNode.id); + + const groupDef = currentNode.groupDefinition; + const colDef = currentNode.columnDefinition; + + // Find parent ID from either groupDefinition or columnDefinition + const parentId = groupDef?.groupId || colDef?.groupId; + + if (!parentId) { + // No parent means this connects to root + if (!visitedNodesIdSet.has(currentNode.id)) { + rootNode.addChild(currentNode); + visitedNodesIdSet.add(currentNode.id); + } + break; + } + + const parentNode = idToNodeMap.get(parentId); + if (!parentNode) { + // Parent not found, connect to root + warnOnceInDev( + `Group "${currentNode.id}" references non-existent parent group "${parentId}". Treating as top-level.` + ); + if (!visitedNodesIdSet.has(currentNode.id)) { + rootNode.addChild(currentNode); + visitedNodesIdSet.add(currentNode.id); + } + break; + } + + // Connect child to parent (only if not already connected) + if (!visitedNodesIdSet.has(currentNode.id)) { + parentNode.addChild(currentNode); + visitedNodesIdSet.add(currentNode.id); + } + + // Move up the tree + currentNode = parentNode; + } + }); +} + +export class TableHeaderNode { + id: string; + colspan: number = 1; + rowspan: number = 1; + subtreeHeight: number = 1; + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.ColumnGroupsDefinition; + parentNode?: TableHeaderNode; + children: TableHeaderNode[] = []; + rowIndex: number = -1; + colIndex: number = -1; + isRoot: boolean = false; + isHidden: boolean = false; + + constructor( + id: string, + options?: { + colspan?: number; + rowspan?: number; + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.ColumnGroupsDefinition; + parentNode?: TableHeaderNode; + rowIndex?: number; + colIndex?: number; + isRoot?: boolean; + isHidden?: boolean; + } + ) { + this.id = id; + Object.assign(this, options); + this.children = []; + } + + get isGroup(): boolean { + return !!this.groupDefinition; + } + + get isRootNode(): boolean { + return this.isRoot; + } + + public addChild(child: TableHeaderNode): void { + this.children.push(child); + child.parentNode = this; + } + + get isLeaf(): boolean { + return !this.isRoot && this.children.length === 0; + } +} + +export function warnOnceInDev(message: string): void { + if (isDevelopment) { + warnOnce(`[Table]`, message); + } +} + +// function to evaluate validity of the grouping and if the ordering via column display splits it +// should give dev warning ... + +// from +// column defininitions +// column grouping definitions +// columnDisplay : array of colprops (id: string; visible: boolean;) - only child columns + +// output the hierarchical tree: + +export function CalculateHierarchyTree( + columnDefinitions: TableProps.ColumnDefinition[], + visibleColumnIds: string[], + columnGroupingDefinitions: TableProps.ColumnGroupsDefinition[], + columnDisplayProperties?: TableProps.ColumnDisplayProperties[] +): TableGroupedTypes.HierarchicalStructure { + // filtering by visible columns + const visibleColumns: Readonly[]> = getVisibleColumnDefinitions({ + columnDisplay: columnDisplayProperties, + visibleColumns: visibleColumnIds, + columnDefinitions: columnDefinitions, + }); + + // creating hashmap from id to node + const idToNodeMap: Map> = new Map(); + + visibleColumns.forEach((columnDefinition: TableProps.ColumnDefinition) => { + if (columnDefinition.id === undefined) { + return; + } + + idToNodeMap.set( + columnDefinition.id, + new TableHeaderNode(columnDefinition.id, { columnDefinition: columnDefinition }) + ); + }); + + columnGroupingDefinitions.forEach((groupDefinition: TableProps.ColumnGroupsDefinition) => { + if (groupDefinition.id === undefined) { + return; + } + + idToNodeMap.set( + groupDefinition.id, + new TableHeaderNode(groupDefinition.id, { groupDefinition: groupDefinition }) + ); + }); + + // traverse from root to parent to create a + + const rootNode = new TableHeaderNode('*', { isRoot: true }); + + // create connection 0(N)ish pass leaf to root + createNodeConnections(visibleColumns, idToNodeMap, rootNode); + + // traversal for SubTreeHeight + traverseForSubtreeHeight(rootNode); + + rootNode.colspan = visibleColumnIds.length; + rootNode.rowIndex = -1; // Root starts at -1 so children start at 0 + rootNode.rowspan = 1; // Root takes one row (not rendered) + + // bfs for row span and row index + rootNode.children.forEach(node => { + traverseForRowSpanAndRowIndex(node, rootNode.subtreeHeight - 1); + }); + + // Expand nodes with rowspan > 1 into chains of hidden nodes in the tree + expandRowspansToHiddenNodes(rootNode); + + // Re-compute subtree heights after hidden node insertion + traverseForSubtreeHeight(rootNode); + + // dfs for colspan and col index + traverseForColSpanAndColIndex(rootNode); + + return buildHierarchicalStructure(rootNode); +} + +function traverseForSubtreeHeight(node: TableHeaderNode) { + node.subtreeHeight = 1; + + for (const child of node.children) { + node.subtreeHeight = Math.max(node.subtreeHeight, traverseForSubtreeHeight(child) + 1); + } + + return node.subtreeHeight; +} + +function traverseForRowSpanAndRowIndex( + node: TableHeaderNode, + allTreesMaxHeight: number, + rowsTakenByAncestors: number = 0 +) { + // formula: rowSpan = totalTreeHeight - rowsTakenByAncestors - maxSubtreeHeight + const maxSubtreeHeight = Math.max(...node.children.map(child => child.subtreeHeight as number), 0); + node.rowspan = allTreesMaxHeight - rowsTakenByAncestors - maxSubtreeHeight; + + // rowIndex = parentRowIndex + parentRowSpan + if (node.parentNode) { + node.rowIndex = node.parentNode.rowIndex + node.parentNode.rowspan; + } + + node.children.forEach(childNode => { + traverseForRowSpanAndRowIndex(childNode, allTreesMaxHeight, rowsTakenByAncestors + node.rowspan); + }); +} + +/** + * Expands nodes with rowspan > 1 into a chain of hidden placeholder nodes ABOVE + * the real node, so the visible cell appears at the bottom (aligned with leaf columns). + * + * For a node at rowIndex=R with rowspan=N, we: + * 1. Create (N-1) hidden placeholder nodes at rows R, R+1, ..., R+N-2 + * 2. Move the real node to row R+N-1 (the bottom of its span) with rowspan=1 + * 3. The hidden nodes form a parent chain: parent→hidden(R)→hidden(R+1)→...→realNode(R+N-1) + * 4. The real node keeps its original children. + */ +function expandRowspansToHiddenNodes(node: TableHeaderNode): void { + // Process children first (bottom-up so we don't interfere with our own expansion) + for (const child of [...node.children]) { + expandRowspansToHiddenNodes(child); + } + + if (node.isRoot) { + return; + } + + if (node.rowspan <= 1) { + return; + } + + const originalRowspan = node.rowspan; + const originalRowIndex = node.rowIndex; + const parentNode = node.parentNode!; + + // Remove this node from its parent's children + const indexInParent = parentNode.children.indexOf(node); + parentNode.children.splice(indexInParent, 1); + + // Build a chain of hidden nodes at the top rows + let currentParent = parentNode; + for (let r = 0; r < originalRowspan - 1; r++) { + const hiddenNode = new TableHeaderNode(node.id, { + rowIndex: originalRowIndex + r, + rowspan: 1, + colspan: node.colspan, + columnDefinition: node.columnDefinition, + groupDefinition: node.groupDefinition, + isHidden: true, + }); + // Insert at the same position in parent to maintain column order + if (currentParent === parentNode) { + currentParent.children.splice(indexInParent, 0, hiddenNode); + } else { + currentParent.addChild(hiddenNode); + } + hiddenNode.parentNode = currentParent; + currentParent = hiddenNode; + } + + // Move the real node to the bottom row + node.rowIndex = originalRowIndex + originalRowspan - 1; + node.rowspan = 1; + currentParent.addChild(node); + node.parentNode = currentParent; +} + +// takes currColIndex from where to start from +// and returns the starting colindex for next node +function traverseForColSpanAndColIndex(node: TableHeaderNode, currColIndex = 0): number { + node.colIndex = currColIndex; + + if (node.isLeaf) { + return currColIndex + 1; + } + + let runningColIndex = currColIndex; + for (const childNode of node.children) { + runningColIndex = traverseForColSpanAndColIndex(childNode, runningColIndex); + } + + node.colspan = runningColIndex - currColIndex; + return runningColIndex; +} + +function buildParentChain(node: TableHeaderNode): string[] { + const chain: string[] = []; + let current = node.parentNode; + + while (current && !current.isRoot) { + // Skip hidden placeholder nodes — they are not real group parents + if (!current.isHidden) { + chain.push(current.id); + } + current = current.parentNode; + } + + // Already in order: immediate parent to root + return chain.reverse(); +} + +function buildHierarchicalStructure(rootNode: TableHeaderNode): TableGroupedTypes.HierarchicalStructure { + const maxDepth = rootNode.subtreeHeight - 1; + const rowsMap = new Map[]>(); + const columnToParentIds = new Map(); + + // BFS traversal - naturally gives column order per row + const queue: TableHeaderNode[] = [...rootNode.children]; // Skip root + + while (queue.length > 0) { + const node = queue.shift()!; + const parentChain = buildParentChain(node); + + const columnInRow: TableGroupedTypes.ColumnInRow = { + id: node.id, + header: node.groupDefinition?.header || node.columnDefinition?.header, + colspan: node.colspan, + rowspan: node.rowspan, + isGroup: node.isGroup, + isHidden: node.isHidden, + columnDefinition: node.columnDefinition, + groupDefinition: node.groupDefinition, + parentGroupIds: parentChain, + rowIndex: node.rowIndex, + colIndex: node.colIndex, + }; + + // Add to appropriate row + if (!rowsMap.has(node.rowIndex)) { + rowsMap.set(node.rowIndex, []); + } + rowsMap.get(node.rowIndex)!.push(columnInRow); + + // Track parent chain for leaf columns (skip hidden placeholder nodes) + if (node.isLeaf && node.columnDefinition && !node.isHidden) { + const parentChainForTracking = buildParentChain(node); + if (parentChainForTracking.length > 0) { + columnToParentIds.set(node.id, parentChainForTracking); + } + } + + // Add children to queue (already in correct order) + queue.push(...node.children); + } + + // Convert map to sorted array + const rows: TableGroupedTypes.HeaderRow[] = Array.from(rowsMap.keys()) + .sort((a, b) => a - b) + .map(key => ({ + columns: rowsMap.get(key)!.sort((a, b) => a.colIndex - b.colIndex), + })); + + return { rows, maxDepth, columnToParentIds }; +} diff --git a/src/table/header-cell/group-header-cell.tsx b/src/table/header-cell/group-header-cell.tsx new file mode 100644 index 0000000000..a2404878ec --- /dev/null +++ b/src/table/header-cell/group-header-cell.tsx @@ -0,0 +1,308 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef } from 'react'; +import clsx from 'clsx'; + +import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; +import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; +import { useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { ColumnWidthStyle } from '../column-widths-utils'; +import { TableProps } from '../interfaces'; +import { Divider, Resizer } from '../resizer'; +import { StickyColumnsCellState, StickyColumnsModel } from '../sticky-columns'; +import { TableRole } from '../table-role'; +import { TableThElement } from './th-element'; + +import styles from './styles.css.js'; + +export interface TableGroupHeaderCellProps { + group: TableProps.ColumnGroupsDefinition; + colspan: number; + rowspan: number; + colIndex: number; + groupId: string; + /** First visible leaf column ID among this group's children — used for sticky offset. */ + firstChildColumnId?: PropertyKey; + /** Last visible leaf column ID among this group's children — used for clip-path computation. */ + lastChildColumnId?: PropertyKey; + resizableColumns?: boolean; + resizableStyle?: ColumnWidthStyle; + onResizeFinish: () => void; + updateGroupWidth: (groupId: PropertyKey, newWidth: number) => void; + childColumnIds: PropertyKey[]; + childColumnMinWidths: Map; + focusedComponent?: null | string; + tabIndex: number; + stuck?: boolean; + sticky?: boolean; + hidden?: boolean; + stripedRows?: boolean; + stickyState: StickyColumnsModel; + cellRef: React.RefCallback; + tableRole: TableRole; + resizerRoleDescription?: string; + resizerTooltipText?: string; + variant: TableProps.Variant; + tableVariant?: TableProps.Variant; +} + +export function TableGroupHeaderCell({ + group, + colspan, + rowspan, + colIndex, + groupId, + firstChildColumnId, + lastChildColumnId, + resizableColumns, + resizableStyle, + onResizeFinish, + updateGroupWidth, + // childColumnIds, + // childColumnMinWidths, + focusedComponent, + tabIndex, + stuck, + sticky, + hidden, + stripedRows, + stickyState, + cellRef, + tableRole, + resizerRoleDescription, + resizerTooltipText, + variant, + tableVariant, +}: TableGroupHeaderCellProps) { + const headerId = useUniqueId('table-group-header-'); + + const clickableHeaderRef = useRef(null); + const { tabIndex: clickableHeaderTabIndex } = useSingleTabStopNavigation(clickableHeaderRef, { tabIndex }); + + const cellRefObject = useRef(null); + const cellRefCombined = useMergeRefs(cellRef, cellRefObject); + + // Cached wrapper element and cleanup for scroll listener + const wrapperRef = useRef(null); + const removeScrollListenerRef = useRef<(() => void) | null>(null); + + /** + * Dynamically constrains the group diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 1628cf5492..b57a3d09c9 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -255,6 +255,12 @@ export interface TableProps extends BaseComponentProps { */ columnDisplay?: ReadonlyArray; + /** + * Add grouping for the columns define groups for columns to be under and also nested groups for which + * other groups will be nested under. + */ + columnGroupingDefinitions?: ReadonlyArray>; + /** * Specifies an array containing the `id`s of visible columns. If not set, all columns are displayed. * @@ -494,6 +500,7 @@ export namespace TableProps { export type ColumnDefinition = { id?: string; + groupId?: string; header: React.ReactNode; ariaLabel?(data: LabelData): string; width?: number | string; @@ -513,6 +520,11 @@ export namespace TableProps { selectedItemsCount?: number; } + export type ColumnGroupsDefinition = Pick< + ColumnDefinition, + 'id' | 'header' | 'groupId' | 'ariaLabel' + >; + export interface StickyColumns { first?: number; last?: number; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index cf2e4db610..7500cd5c52 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -61,7 +61,13 @@ import { import Thead, { TheadProps } from './thead'; import ToolsHeader from './tools-header'; import { useCellEditing } from './use-cell-editing'; -import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH } from './use-column-widths'; +import { useColumnGrouping } from './use-column-grouping'; +import { + ColumnWidthDefinition, + ColumnWidthsProvider, + DEFAULT_COLUMN_WIDTH, + useColumnWidths, +} from './use-column-widths'; import { usePreventStickyClickScroll } from './use-prevent-sticky-click-scroll'; import { useRowEvents } from './use-row-events'; import useTableFocusNavigation from './use-table-focus-navigation'; @@ -75,6 +81,33 @@ const GRID_NAVIGATION_PAGE_SIZE = 10; const SELECTION_COLUMN_WIDTH = 54; const selectionColumnId = Symbol('selection-column-id'); +/** + * Renders a with elements for each leaf column. + * With table-layout:fixed, widths control actual column widths, + * which makes colspan headers automatically span the correct width. + * Must be rendered inside ColumnWidthsProvider. + */ +function TableColGroup({ + visibleColumnDefinitions, + hasSelection, + selectionColumnWidth, +}: { + visibleColumnDefinitions: ReadonlyArray>; + hasSelection: boolean; + selectionColumnWidth: number; +}) { + const { setCol } = useColumnWidths(); + return ( + + {hasSelection && } + {visibleColumnDefinitions.map((column, colIndex) => { + const columnId = getColumnKey(column, colIndex); + return setCol(columnId, node)} />; + })} + + ); +} + type InternalTableProps = SomeRequired< TableProps, 'items' | 'selectedItems' | 'variant' | 'firstIndex' | 'cellVerticalAlign' @@ -107,6 +140,7 @@ const InternalTable = React.forwardRef( preferences, items, columnDefinitions, + columnGroupingDefinitions, trackBy, loading, loadingText, @@ -300,6 +334,16 @@ const InternalTable = React.forwardRef( visibleColumns, }); + // Build visible column IDs set for grouping + const visibleColumnIds = new Set(visibleColumnDefinitions.map((col, idx) => col.id || `column-${idx}`)); + + const hierarchicalStructure = useColumnGrouping( + columnGroupingDefinitions, + columnDefinitions, + visibleColumnIds, + columnDisplay + ); + const selectionProps = { items: allItems, rootItems: items, @@ -394,6 +438,8 @@ const InternalTable = React.forwardRef( selectionType, getSelectAllProps: selection.getSelectAllProps, columnDefinitions: visibleColumnDefinitions, + columnGroupingDefinitions, + hierarchicalStructure, variant: computedVariant, tableVariant: computedVariant, wrapLines, @@ -452,6 +498,7 @@ const InternalTable = React.forwardRef( const colIndexOffset = selectionType ? 1 : 0; const totalColumnsCount = visibleColumnDefinitions.length + colIndexOffset; + const headerRowCount = hierarchicalStructure?.rows.length || 1; return ( @@ -460,6 +507,7 @@ const InternalTable = React.forwardRef( visibleColumns={visibleColumnWidthsWithSelection} resizableColumns={resizableColumns} containerRef={wrapperMeasureRefObject} + hierarchicalStructure={hierarchicalStructure} > + {resizableColumns && hierarchicalStructure && hierarchicalStructure.rows.length > 1 && ( + + )} 1) { + cell.rowSpan = cellDef.rowspan; + } + if (cellDef.colspan && cellDef.colspan > 1) { + cell.colSpan = cellDef.colspan; + } + tr.appendChild(cell); + } + table.appendChild(tr); + } + return table; +} + +describe('getAllCellsInRow', () => { + test('returns empty array for null table', () => { + expect(getAllCellsInRow(null, 1)).toEqual([]); + }); + + test('returns cells from a simple row', () => { + const table = buildTable([ + { + rowindex: 1, + cells: [ + { tag: 'th', colindex: 1, text: 'Col 1' }, + { tag: 'th', colindex: 2, text: 'Col 2' }, + ], + }, + { + rowindex: 2, + cells: [ + { tag: 'td', colindex: 1, text: 'A' }, + { tag: 'td', colindex: 2, text: 'B' }, + ], + }, + ]); + const cells = getAllCellsInRow(table, 2); + expect(cells.length).toBe(2); + expect(cells[0].textContent).toBe('A'); + expect(cells[1].textContent).toBe('B'); + }); + + test('includes cells with rowspan that span into the target row', () => { + const table = buildTable([ + { + rowindex: 1, + cells: [ + { tag: 'th', colindex: 1, text: 'Selection', rowspan: 3 }, + { tag: 'th', colindex: 2, text: 'Group' }, + ], + }, + { rowindex: 2, cells: [{ tag: 'th', colindex: 2, text: 'Leaf Col' }] }, + { + rowindex: 3, + cells: [ + { tag: 'td', colindex: 1, text: 'Data A' }, + { tag: 'td', colindex: 2, text: 'Data B' }, + ], + }, + ]); + + // Row 2: should include Selection (rowspan=3 from row 1) + Leaf Col + const row2Cells = getAllCellsInRow(table, 2); + expect(row2Cells.length).toBe(2); + expect(row2Cells[0].textContent).toBe('Selection'); + expect(row2Cells[1].textContent).toBe('Leaf Col'); + + // Row 3: should include Selection (still spanning) + both data cells + const row3Cells = getAllCellsInRow(table, 3); + expect(row3Cells.length).toBe(3); + }); + + test('excludes cells whose rowspan does not reach the target row', () => { + const table = buildTable([ + { + rowindex: 1, + cells: [ + { tag: 'th', colindex: 1, text: 'Group', rowspan: 2 }, + { tag: 'th', colindex: 2, text: 'Other' }, + ], + }, + { rowindex: 2, cells: [{ tag: 'th', colindex: 2, text: 'Under Other' }] }, + { + rowindex: 3, + cells: [ + { tag: 'td', colindex: 1, text: 'Data' }, + { tag: 'td', colindex: 2, text: 'Data' }, + ], + }, + ]); + + // Row 3: Group (rowspan=2, from row1) does NOT reach row 3 + const row3Cells = getAllCellsInRow(table, 3); + expect(row3Cells.length).toBe(2); + expect(row3Cells[0].textContent).toBe('Data'); + }); + + test('skips rows with aria-rowindex greater than target', () => { + const table = buildTable([ + { rowindex: 1, cells: [{ tag: 'td', colindex: 1, text: 'R1' }] }, + { rowindex: 5, cells: [{ tag: 'td', colindex: 1, text: 'R5' }] }, + ]); + const cells = getAllCellsInRow(table, 1); + expect(cells.length).toBe(1); + expect(cells[0].textContent).toBe('R1'); + }); +}); + +describe('findClosestCellByAriaColIndex', () => { + function createCells( + colConfigs: Array<{ colindex: number; colspan?: number; text: string }> + ): HTMLTableCellElement[] { + return colConfigs.map(({ colindex, colspan, text }) => { + const td = document.createElement('td'); + td.setAttribute('aria-colindex', String(colindex)); + if (colspan && colspan > 1) { + td.colSpan = colspan; + } + td.textContent = text; + return td; + }); + } + + test('returns exact match by colspan range', () => { + const cells = createCells([ + { colindex: 1, text: 'A' }, + { colindex: 2, colspan: 3, text: 'B-span' }, + { colindex: 5, text: 'C' }, + ]); + + // Target colindex 3 falls within B-span (colindex=2, colspan=3 -> covers 2,3,4) + const result = findClosestCellByAriaColIndex(cells, 3, 1); + expect(result?.textContent).toBe('B-span'); + }); + + test('returns exact match for single column', () => { + const cells = createCells([ + { colindex: 1, text: 'A' }, + { colindex: 2, text: 'B' }, + { colindex: 3, text: 'C' }, + ]); + + const result = findClosestCellByAriaColIndex(cells, 2, 1); + expect(result?.textContent).toBe('B'); + }); + + test('returns closest cell in positive direction when no exact match', () => { + const cells = createCells([ + { colindex: 1, text: 'A' }, + { colindex: 5, text: 'E' }, + ]); + + // Looking for colindex 3, delta > 0 -> should return colindex 5 + const result = findClosestCellByAriaColIndex(cells, 3, 1); + expect(result?.textContent).toBe('E'); + }); + + test('returns closest cell in negative direction when no exact match', () => { + const cells = createCells([ + { colindex: 1, text: 'A' }, + { colindex: 5, text: 'E' }, + ]); + + // Looking for colindex 3, delta < 0 -> should return colindex 1 + const result = findClosestCellByAriaColIndex(cells, 3, -1); + expect(result?.textContent).toBe('A'); + }); + + test('returns null for empty cells array', () => { + const result = findClosestCellByAriaColIndex([], 1, 1); + expect(result).toBe(null); + }); +}); diff --git a/src/table/table-role/grid-navigation.tsx b/src/table/table-role/grid-navigation.tsx index f655d4aec6..c3880e755f 100644 --- a/src/table/table-role/grid-navigation.tsx +++ b/src/table/table-role/grid-navigation.tsx @@ -17,9 +17,11 @@ import { nodeBelongs } from '../../internal/utils/node-belongs'; import { FocusedCell, GridNavigationProps } from './interfaces'; import { defaultIsSuppressed, + findClosestCellByAriaColIndex, findTableRowByAriaRowIndex, findTableRowCellByAriaColIndex, focusNextElement, + getAllCellsInRow, getClosestCell, isElementDisabled, isTableCell, @@ -330,16 +332,48 @@ export class GridNavigationProcessor { return cellFocusables[nextElementIndex]; } - // Find next cell to focus or move focus into (can be null if the left/right edge is reached). + // Find next cell to focus or move focus into. + // Use getAllCellsInRow to include cells from earlier rows that span into the target row via rowspan. const targetAriaColIndex = from.colIndex + delta.x; - const targetCell = findTableRowCellByAriaColIndex(targetRow, targetAriaColIndex, delta.x); + const targetRowAriaIndex = parseInt(targetRow.getAttribute('aria-rowindex') ?? ''); + let allVisibleCells = getAllCellsInRow(this.table, targetRowAriaIndex); + let targetCell = + allVisibleCells.length > 0 + ? findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x) + : findTableRowCellByAriaColIndex(targetRow, targetAriaColIndex, delta.x); + + // When vertical movement lands on the same cell (due to rowspan), skip past it. + if (targetCell === cellElement && delta.y !== 0 && cellElement) { + const cellRow = cellElement.closest('tr'); + const cellRowIndex = parseInt(cellRow?.getAttribute('aria-rowindex') ?? '0'); + const cellRowSpan = (cellElement as HTMLTableCellElement).rowSpan || 1; + // Jump to the first row after this cell's span (↓) or one row before the cell's start (↑). + const skipToRowIndex = delta.y > 0 ? cellRowIndex + cellRowSpan : cellRowIndex - 1; + const skipRow = findTableRowByAriaRowIndex(this.table, skipToRowIndex, delta.y); + if (!skipRow) { + return null; + } + const skipRowAriaIndex = parseInt(skipRow.getAttribute('aria-rowindex') ?? ''); + allVisibleCells = getAllCellsInRow(this.table, skipRowAriaIndex); + targetCell = + allVisibleCells.length > 0 + ? findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x) + : findTableRowCellByAriaColIndex(skipRow, targetAriaColIndex, delta.x); + } + if (!targetCell) { return null; } - // When target cell matches the current cell it means we reached the left or right boundary. - if (targetCell === cellElement && delta.x !== 0) { - return null; + // When horizontal movement lands on the same cell (due to colspan), skip past it. + if (targetCell === cellElement && delta.x !== 0 && cellElement) { + const cellColIndex = parseInt(cellElement.getAttribute('aria-colindex') ?? '0'); + const cellColSpan = (cellElement as HTMLTableCellElement).colSpan || 1; + const skipToColIndex = delta.x > 0 ? cellColIndex + cellColSpan : cellColIndex - 1; + targetCell = findClosestCellByAriaColIndex(allVisibleCells, skipToColIndex, delta.x); + if (!targetCell || targetCell === cellElement) { + return null; + } } const targetCellFocusables = this.getFocusablesFrom(targetCell); diff --git a/src/table/table-role/table-role-helper.ts b/src/table/table-role/table-role-helper.ts index f28752e3fd..8b9b894ebd 100644 --- a/src/table/table-role/table-role-helper.ts +++ b/src/table/table-role/table-role-helper.ts @@ -22,6 +22,7 @@ export function getTableRoleProps(options: { ariaLabelledby?: string; totalItemsCount?: number; totalColumnsCount?: number; + headerRowCount?: number; }): React.TableHTMLAttributes { const nativeProps: React.TableHTMLAttributes = {}; @@ -32,9 +33,10 @@ export function getTableRoleProps(options: { nativeProps['aria-label'] = options.ariaLabel; nativeProps['aria-labelledby'] = options.ariaLabelledby; - // Incrementing the total count by one to account for the header row. + // Incrementing the total count to account for the header row(s). + const headerRows = options.headerRowCount ?? 1; if (typeof options.totalItemsCount === 'number' && options.totalItemsCount > 0) { - nativeProps['aria-rowcount'] = options.totalItemsCount + 1; + nativeProps['aria-rowcount'] = options.totalItemsCount + headerRows; } if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { @@ -68,12 +70,13 @@ export function getTableWrapperRoleProps(options: { return nativeProps; } -export function getTableHeaderRowRoleProps(options: { tableRole: TableRole }) { +export function getTableHeaderRowRoleProps(options: { tableRole: TableRole; rowIndex?: number }) { const nativeProps: React.HTMLAttributes = {}; // For grids headers are treated similar to data rows and are indexed accordingly. + // With grouped columns there can be multiple header rows (rowIndex 0, 1, 2, ...). if (options.tableRole === 'grid' || options.tableRole === 'grid-default' || options.tableRole === 'treegrid') { - nativeProps['aria-rowindex'] = 1; + nativeProps['aria-rowindex'] = (options.rowIndex ?? 0) + 1; } return nativeProps; @@ -83,19 +86,21 @@ export function getTableRowRoleProps(options: { tableRole: TableRole; rowIndex: number; firstIndex?: number; + headerRowCount?: number; level?: number; setSize?: number; posInSet?: number; }) { const nativeProps: React.HTMLAttributes = {}; - // The data cell indices are incremented by 1 to account for the header cells. + // The data cell indices are incremented by headerRowCount to account for the header row(s). + const headerRows = options.headerRowCount ?? 1; if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { - nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + 1; + nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + headerRows; } // For tables indices are only added when the first index is not 0 (not the first page/frame). else if (options.firstIndex !== undefined) { - nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + 1; + nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + headerRows; } if (options.tableRole === 'treegrid' && options.level && options.level !== 0) { nativeProps['aria-level'] = options.level; diff --git a/src/table/table-role/utils.ts b/src/table/table-role/utils.ts index c39809a50d..bc533bfb42 100644 --- a/src/table/table-role/utils.ts +++ b/src/table/table-role/utils.ts @@ -68,14 +68,75 @@ export function findTableRowCellByAriaColIndex( targetAriaColIndex: number, delta: number ) { + const cellElements = Array.from( + tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]') + ); + return findClosestCellByAriaColIndex(cellElements, targetAriaColIndex, delta); +} + +/** + * Collects all cells visually present in a row, including cells from earlier rows + * that span into this row via rowspan. This is needed because cells with rowspan > 1 + * are only in one in the DOM but visually occupy multiple rows. + */ +export function getAllCellsInRow(table: null | HTMLTableElement, targetAriaRowIndex: number): HTMLTableCellElement[] { + if (!table) { + return []; + } + + const cells: HTMLTableCellElement[] = []; + const rows = table.querySelectorAll('tr[aria-rowindex]'); + + for (const row of Array.from(rows)) { + const rowIndex = parseInt(row.getAttribute('aria-rowindex') ?? ''); + if (isNaN(rowIndex) || rowIndex > targetAriaRowIndex) { + continue; + } + + const rowCells = row.querySelectorAll('td[aria-colindex],th[aria-colindex]'); + for (const cell of Array.from(rowCells)) { + const rowspan = cell.rowSpan || 1; + // Cell is visible in target row if: rowIndex <= targetAriaRowIndex < rowIndex + rowspan + if (rowIndex + rowspan > targetAriaRowIndex) { + cells.push(cell); + } + } + } + + return cells; +} + +/** + * From a list of cell elements, find the closest one to targetAriaColIndex in the direction of delta. + * Accounts for colspan: a cell with colindex=2 and colspan=4 covers columns 2,3,4,5. + */ +export function findClosestCellByAriaColIndex( + cellElements: HTMLTableCellElement[], + targetAriaColIndex: number, + delta: number +): HTMLTableCellElement | null { + // First check if any cell's colspan range covers the target exactly. + for (const element of cellElements) { + const colIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); + const colspan = element.colSpan || 1; + if (colIndex <= targetAriaColIndex && targetAriaColIndex < colIndex + colspan) { + return element; + } + } + + // Otherwise find the closest cell in the direction of delta. let targetCell: null | HTMLTableCellElement = null; - const cellElements = Array.from(tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]')); + const sorted = [...cellElements].sort((a, b) => { + const aIdx = parseInt(a.getAttribute('aria-colindex') ?? '0'); + const bIdx = parseInt(b.getAttribute('aria-colindex') ?? '0'); + return aIdx - bIdx; + }); if (delta < 0) { - cellElements.reverse(); + sorted.reverse(); } - for (const element of cellElements) { + for (const element of sorted) { const columnIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); - targetCell = element as HTMLTableCellElement; + targetCell = element; if (columnIndex === targetAriaColIndex) { break; diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 10024c014e..9206a1808c 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -6,7 +6,10 @@ 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 { TableHiddenHeaderCell } from './header-cell/hidden-header-cell'; import { InternalSelectionType, TableProps } from './interfaces'; import { focusMarkers, ItemSelectionProps } from './selection'; import { TableHeaderSelectionCell } from './selection/selection-cell'; @@ -20,6 +23,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 +58,7 @@ const Thead = React.forwardRef( selectionType, getSelectAllProps, columnDefinitions, + hierarchicalStructure: h, sortingColumn, sortingDisabled, sortingDescending, @@ -80,7 +86,42 @@ const Thead = React.forwardRef( }: TheadProps, outerRef: React.Ref ) => { - const { getColumnStyles, columnWidths, updateColumn, setCell } = useColumnWidths(); + const { getColumnStyles, columnWidths, updateColumn, updateGroup, setCell } = useColumnWidths(); + + const hierarchicalStructure: TableGroupedTypes.HierarchicalStructure | undefined = h; + + // Helper to get child column IDs for a group (for getting minWidths) + const getChildColumnIds = (groupId: string): string[] => { + if (!hierarchicalStructure) { + return []; + } + + const childIds: string[] = []; + const leafRow = hierarchicalStructure.rows[hierarchicalStructure.rows.length - 1]; + + leafRow.columns.forEach(col => { + if (!col.isGroup && col.parentGroupIds.includes(groupId)) { + childIds.push(col.id); + } + }); + + return childIds; + }; + + // Helper to get minWidth for columns + const getColumnMinWidths = (columnIds: string[]): Map => { + const minWidths = new Map(); + + columnIds.forEach(colId => { + const col = columnDefinitions.find((c, idx) => (c.id || `column-${idx}`) === colId); + if (col && col.minWidth) { + const minWidth = typeof col.minWidth === 'string' ? parseInt(col.minWidth) : col.minWidth; + minWidths.set(colId, minWidth); + } + }); + + return minWidths; + }; const commonCellProps = { stuck, @@ -93,67 +134,215 @@ const Thead = React.forwardRef( stickyState, }; + // No grouping - render single row + if (!hierarchicalStructure || hierarchicalStructure.rows.length <= 1) { + return ( + + { + const focusControlElement = findUpUntil(event.target, element => !!element.getAttribute('data-focus-id')); + const focusId = focusControlElement?.getAttribute('data-focus-id') ?? null; + onFocusedComponentChange?.(focusId); + }} + onBlur={() => onFocusedComponentChange?.(null)} + > + {selectionType ? ( + + ) : null} + + {columnDefinitions.map((column, colIndex) => { + const columnId = getColumnKey(column, colIndex); + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={getColumnStyles(sticky, columnId)} + onClick={detail => { + setLastUserAction('sorting'); + fireNonCancelableEvent(onSortingChange, detail); + }} + isEditable={!!column.editConfig} + cellRef={node => setCell(sticky, columnId, node)} + tableRole={tableRole} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isExpandable={colIndex === 0 && isExpandable} + hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + /> + ); + })} + + + ); + } + + // Grouped columns + // console.log(hierarchicalStructure.rows); 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 => { + // Hidden placeholder cell — fills gaps where rowspan > 1 would have been + if (col.isHidden) { + const columnDef = col.columnDefinition; + const minWidth = columnDef?.minWidth + ? typeof columnDef.minWidth === 'string' + ? parseInt(columnDef.minWidth) + : columnDef.minWidth + : undefined; + + return ( + onResizeFinish(columnWidths)} + updateColumn={updateColumn} + cellRef={node => setCell(sticky, col.id, node)} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + minWidth={minWidth} + /> + ); + } + + 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} + firstChildColumnId={childIds[0]} + lastChildColumnId={childIds[childIds.length - 1]} + childColumnMinWidths={getColumnMinWidths(childIds)} + cellRef={node => setCell(sticky, col.id, node)} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + /> + ); + } else { + // Regular column cell + const column = col.columnDefinition!; + const columnId = col.id; + const colIndex = col.colIndex; + + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={resizableColumns ? {} : getColumnStyles(sticky, columnId)} + onClick={detail => { + setLastUserAction('sorting'); + fireNonCancelableEvent(onSortingChange, detail); + }} + isEditable={!!column.editConfig} + cellRef={node => { + setCell(sticky, columnId, node); + }} + tableRole={tableRole} + resizerRoleDescription={resizerRoleDescription} + resizerTooltipText={resizerTooltipText} + isExpandable={colIndex === 0 && isExpandable} + hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + colSpan={col.colspan} + rowSpan={col.rowspan} + /> + ); + } + })} + + ))} ); } diff --git a/src/table/use-column-grouping.ts b/src/table/use-column-grouping.ts new file mode 100644 index 0000000000..9d67cedecd --- /dev/null +++ b/src/table/use-column-grouping.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useMemo } from 'react'; + +import { CalculateHierarchyTree, TableGroupedTypes } from './column-grouping-utils'; +import { TableProps } from './interfaces'; + +/** + * Processes flat group definitions and column definitions to create a hierarchical + * structure that represents multiple header rows for grouped columns. + * + * @param columnGroupingDefinitions - Optional flat array of group definitions + * @param columnDefinitions - Array of column definitions (with optional groupId) + * @param visibleColumnIds - Optional set of visible column IDs for filtering + */ +export function useColumnGrouping( + columnGroupingDefinitions: ReadonlyArray> | undefined, + columnDefinitions: ReadonlyArray>, + visibleColumnIds?: Set, + columnDisplay?: ReadonlyArray +): TableGroupedTypes.HierarchicalStructure { + return useMemo(() => { + // Convert Set to Array for CalculateHierarchyTree + const visibleIds = visibleColumnIds + ? Array.from(visibleColumnIds) + : columnDefinitions.map((col, idx) => col.id || `column-${idx}`); + + // Convert readonly arrays to mutable for CalculateHierarchyTree + const groups = columnGroupingDefinitions ? [...columnGroupingDefinitions] : []; + const columns = [...columnDefinitions]; + const columnDisplayMutable = columnDisplay ? [...columnDisplay] : undefined; + + // Call the CalculateHierarchyTree function + return CalculateHierarchyTree(columns, visibleIds, groups, columnDisplayMutable); + }, [columnGroupingDefinitions, columnDefinitions, visibleColumnIds, columnDisplay]); +} diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index db41a79c8c..16a6639143 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -5,6 +5,7 @@ import React, { createContext, useContext, useEffect, useRef, useState } from 'r import { useResizeObserver, useStableCallback } from '@cloudscape-design/component-toolkit/internal'; import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolkit/internal'; +import { TableGroupedTypes } from './column-grouping-utils'; import { ColumnWidthStyle, setElementWidths } from './column-widths-utils'; export const DEFAULT_COLUMN_WIDTH = 120; @@ -39,7 +40,7 @@ function updateWidths( oldWidths: Map, newWidth: number, columnId: PropertyKey -) { +): Map { const column = visibleColumns.find(column => column.id === columnId); let minWidth = DEFAULT_COLUMN_WIDTH; if (typeof column?.width === 'number' && column.width < DEFAULT_COLUMN_WIDTH) { @@ -61,14 +62,18 @@ interface WidthsContext { getColumnStyles(sticky: boolean, columnId: PropertyKey): ColumnWidthStyle; columnWidths: Map; updateColumn: (columnId: PropertyKey, newWidth: number) => void; + updateGroup: (groupId: PropertyKey, newWidth: number) => void; setCell: (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => void; + setCol: (columnId: PropertyKey, node: null | HTMLElement) => void; } const WidthsContext = createContext({ getColumnStyles: () => ({}), columnWidths: new Map(), updateColumn: () => {}, + updateGroup: () => {}, setCell: () => {}, + setCol: () => {}, }); interface WidthProviderProps { @@ -76,15 +81,24 @@ interface WidthProviderProps { resizableColumns: boolean | undefined; containerRef: React.RefObject; children: React.ReactNode; + hierarchicalStructure: TableGroupedTypes.HierarchicalStructure; } -export function ColumnWidthsProvider({ visibleColumns, resizableColumns, containerRef, children }: WidthProviderProps) { +export function ColumnWidthsProvider({ + visibleColumns, + resizableColumns, + containerRef, + hierarchicalStructure, + children, +}: WidthProviderProps) { const visibleColumnsRef = useRef(null); const containerWidthRef = useRef(0); const [columnWidths, setColumnWidths] = useState>(null); const cellsRef = useRef(new Map()); const stickyCellsRef = useRef(new Map()); + const colsRef = useRef(new Map()); + const hasColElements = useRef(false); const getCell = (columnId: PropertyKey): null | HTMLElement => cellsRef.current.get(columnId) ?? null; const setCell = (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => { const ref = sticky ? stickyCellsRef : cellsRef; @@ -94,6 +108,102 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain ref.current.delete(columnId); } }; + const setCol = (columnId: PropertyKey, node: null | HTMLElement) => { + if (node) { + colsRef.current.set(columnId, node); + hasColElements.current = true; + } else { + colsRef.current.delete(columnId); + hasColElements.current = colsRef.current.size > 0; + } + }; + + // Helper: Get all child column IDs for a group (only direct children) + const getDirectChildColumnIds = (groupId: string): string[] => { + if (!hierarchicalStructure) { + return []; + } + + const childIds: string[] = []; + + // Find the group in the hierarchy + for (const row of hierarchicalStructure.rows) { + for (const col of row.columns) { + if (col.id === groupId && col.isGroup) { + // Look in the next row for direct children + const rowIndex = hierarchicalStructure.rows.indexOf(row); + if (rowIndex < hierarchicalStructure.rows.length - 1) { + const nextRow = hierarchicalStructure.rows[rowIndex + 1]; + nextRow.columns.forEach(childCol => { + // Check if this column has the group as immediate parent + if (childCol.parentGroupIds && childCol.parentGroupIds[childCol.parentGroupIds.length - 1] === groupId) { + childIds.push(childCol.id); + } + }); + } + break; + } + } + } + + return childIds; + }; + + // Helper: Find the rightmost leaf descendant of a group + const findRightmostLeaf = (groupId: string, widths: Map): string | null => { + if (!hierarchicalStructure) { + return null; + } + + // Get direct children + const childIds = getDirectChildColumnIds(groupId); + if (childIds.length === 0) { + return null; + } + + // Start from the rightmost child + for (let i = childIds.length - 1; i >= 0; i--) { + const childId = childIds[i]; + + // Check if this child is a leaf (not a group) + const isLeaf = !hierarchicalStructure.rows.some(row => + row.columns.some(col => col.id === childId && col.isGroup) + ); + + if (isLeaf) { + return childId; + } else { + // It's a group, recurse into it + const leaf = findRightmostLeaf(childId, widths); + if (leaf) { + return leaf; + } + } + } + + return null; + }; + + // Helper: Calculate group width as sum of direct children + const calculateGroupWidth = (groupId: string, widths: Map): number => { + const childIds = getDirectChildColumnIds(groupId); + let totalWidth = 0; + + childIds.forEach(childId => { + // If child is a group, calculate its width recursively + const isGroup = hierarchicalStructure?.rows.some(row => + row.columns.some(col => col.id === childId && col.isGroup) + ); + + if (isGroup) { + totalWidth += calculateGroupWidth(childId, widths); + } else { + totalWidth += widths.get(childId) || DEFAULT_COLUMN_WIDTH; + } + }); + + return totalWidth; + }; const getColumnStyles = (sticky: boolean, columnId: PropertyKey): ColumnWidthStyle => { const column = visibleColumns.find(column => column.id === columnId); @@ -131,12 +241,35 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain // Imperatively sets width style for a cell avoiding React state. // This allows setting the style as soon container's size change is observed. const updateColumnWidths = useStableCallback(() => { - for (const { id } of visibleColumns) { - const element = cellsRef.current.get(id); - if (element) { - setElementWidths(element, getColumnStyles(false, id)); + if (!columnWidths) { + return; + } + + // When col elements exist (grouped columns), apply widths to elements. + // With table-layout:fixed, widths control the actual column widths. + if (hasColElements.current) { + for (const { id } of visibleColumns) { + const colElement = colsRef.current.get(id); + if (colElement) { + const styles = getColumnStyles(false, id); + setElementWidths(colElement, styles); + } + // Still update th cells for non-width styles (but width comes from col) + const element = cellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(false, id)); + } + } + } else { + // No col elements - apply widths directly to th cells (single-row headers) + for (const { id } of visibleColumns) { + const element = cellsRef.current.get(id); + if (element) { + setElementWidths(element, getColumnStyles(false, id)); + } } } + // Sticky column widths must be synchronized once all real column widths are assigned. for (const { id } of visibleColumns) { const element = stickyCellsRef.current.get(id); @@ -193,8 +326,33 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, contain setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths ?? new Map(), newWidth, columnId)); } + function updateGroup(groupId: PropertyKey, newGroupWidth: number) { + if (!columnWidths) { + return; + } + + // Calculate current group width + const currentGroupWidth = calculateGroupWidth(String(groupId), columnWidths); + const delta = newGroupWidth - currentGroupWidth; + + // Find the rightmost leaf descendant + const rightmostLeaf = findRightmostLeaf(String(groupId), columnWidths); + if (!rightmostLeaf) { + return; + } + + // Apply the delta to the rightmost leaf column + const currentLeafWidth = columnWidths.get(rightmostLeaf) || DEFAULT_COLUMN_WIDTH; + const newLeafWidth = currentLeafWidth + delta; + + // Use updateColumn to handle the leaf resize (which will propagate to parents automatically) + updateColumn(rightmostLeaf, newLeafWidth); + } + return ( - + {children} );
's width as child columns scroll out of view. + * + * Approach (same as ag-grid): directly set `style.width` on the sticky to + * max(0, lastChild.getBoundingClientRect().right - groupTh.getBoundingClientRect().left) + * + * As the last child column scrolls left, its `.right` decreases, shrinking the group width. + * The right border moves with the last child's right edge — no clip-path needed. + * When all children are off-screen, width collapses to 0. + * When all children are fully visible, width is cleared so colspan drives layout. + */ + const computeWidth = () => { + const groupEl = cellRefObject.current; + if (!groupEl) { + return; + } + + // Find the last child's via data-focus-id attribute, scoped to thead + let lastChildEl: Element | null = null; + if (lastChildColumnId) { + const thead = groupEl.closest('thead'); + if (thead) { + lastChildEl = thead.querySelector(`[data-focus-id="header-${String(lastChildColumnId)}"]`); + } + } + + if (!lastChildEl) { + groupEl.style.maxWidth = ''; + return; + } + + const groupRect = groupEl.getBoundingClientRect(); + const lastChildRect = lastChildEl.getBoundingClientRect(); + // Width from our stuck left edge to the last child's current right edge + const constrainedWidth = lastChildRect.right - groupRect.left; + + if (constrainedWidth >= groupEl.offsetWidth) { + // All children fully visible — release constraint + groupEl.style.maxWidth = ''; + } else { + // Constrain right edge: overflow:hidden on the th clips the border/content + groupEl.style.maxWidth = `${Math.max(0, constrainedWidth)}px`; + } + }; + + /** + * Finds the scrollable wrapper ancestor and attaches the scroll listener. + * Called after the ref is populated (from within the sticky store subscription + * or a deferred effect). + */ + const attachScrollListener = () => { + const el = cellRefObject.current; + if (!el) { + return; + } + + // Remove any existing listener before re-attaching + removeScrollListenerRef.current?.(); + removeScrollListenerRef.current = null; + + // Find scrollable wrapper ancestor + let wrapper: HTMLElement | null = el.parentElement; + while ( + wrapper && + getComputedStyle(wrapper).overflowX !== 'auto' && + getComputedStyle(wrapper).overflowX !== 'scroll' + ) { + wrapper = wrapper.parentElement; + } + + if (!wrapper) { + return; + } + wrapperRef.current = wrapper; + + const listener = () => computeWidth(); + wrapper.addEventListener('scroll', listener, { passive: true }); + removeScrollListenerRef.current = () => wrapper!.removeEventListener('scroll', listener); + + // Run immediately to set initial width state + computeWidth(); + }; + + // Attach scroll listener after React has committed the DOM and populated cellRefObject. + // We use setTimeout(0) to defer past the synchronous ref-assignment phase. + useEffect(() => { + const id = setTimeout(attachScrollListener, 0); + return () => { + clearTimeout(id); + removeScrollListenerRef.current?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastChildColumnId]); + + // Subscribe to the sticky store for the first child column's state. + // When sticky columns are active, we apply the first child's insetInlineStart offset + // to the group and trigger a clip recompute. + // We also apply the shadow class when the last child is at the sticky boundary. + useEffect(() => { + if (!firstChildColumnId) { + return; + } + + const selector = ( + state: Parameters[0] extends (s: infer S) => any ? S : never + ) => state.cellState.get(firstChildColumnId) ?? null; + + const lastSelector = lastChildColumnId + ? (state: Parameters[0] extends (s: infer S) => any ? S : never) => + state.cellState.get(lastChildColumnId) ?? null + : null; + + const applyStyles = (firstState: StickyColumnsCellState | null, lastState: StickyColumnsCellState | null) => { + const thEl = cellRefObject.current; + if (!thEl) { + return; + } + + // Apply sticky offset to the itself + if (firstState?.offset.insetInlineStart !== undefined) { + thEl.style.insetInlineStart = `${firstState.offset.insetInlineStart}px`; + } else { + thEl.style.insetInlineStart = ''; + } + + // Shadow class on the when last child is at the sticky boundary + const stickyLastClass = styles['sticky-cell-last-inline-start']; + if (lastState?.lastInsetInlineStart) { + thEl.classList.add(stickyLastClass); + } else { + thEl.classList.remove(stickyLastClass); + } + + // Re-attach scroll listener if wrapper not yet found (handles initial mount timing) + if (!wrapperRef.current) { + attachScrollListener(); + } else { + computeWidth(); + } + }; + + // Apply immediately from current state + const firstState = stickyState.store.get().cellState.get(firstChildColumnId) ?? null; + const lastState = lastChildColumnId ? (stickyState.store.get().cellState.get(lastChildColumnId) ?? null) : null; + applyStyles(firstState, lastState); + + const unsubFirst = stickyState.store.subscribe(selector, () => { + const s1 = stickyState.store.get().cellState.get(firstChildColumnId) ?? null; + const s2 = lastChildColumnId ? (stickyState.store.get().cellState.get(lastChildColumnId) ?? null) : null; + applyStyles(s1, s2); + }); + + const unsubLast = lastChildColumnId + ? stickyState.store.subscribe(lastSelector!, () => { + const s1 = stickyState.store.get().cellState.get(firstChildColumnId) ?? null; + const s2 = stickyState.store.get().cellState.get(lastChildColumnId) ?? null; + applyStyles(s1, s2); + }) + : null; + + return () => { + unsubFirst(); + unsubLast?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [firstChildColumnId, lastChildColumnId, stickyState.store]); + + return ( + + ); +} diff --git a/src/table/header-cell/hidden-header-cell.tsx b/src/table/header-cell/hidden-header-cell.tsx new file mode 100644 index 0000000000..7b57520825 --- /dev/null +++ b/src/table/header-cell/hidden-header-cell.tsx @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef } from 'react'; + +import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; +import { useSingleTabStopNavigation } 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 TableHiddenHeaderCellProps { + columnId: string; + colIndex: number; + colspan: number; + resizableColumns?: boolean; + resizableStyle?: ColumnWidthStyle; + onResizeFinish: () => void; + updateColumn: (columnId: PropertyKey, newWidth: number) => void; + 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; + minWidth?: number; +} + +export function TableHiddenHeaderCell({ + columnId, + colIndex, + colspan, + resizableColumns, + resizableStyle, + onResizeFinish, + updateColumn, + focusedComponent, + tabIndex, + stuck, + sticky, + hidden, + stripedRows, + stickyState, + cellRef, + tableRole, + resizerRoleDescription, + resizerTooltipText, + variant, + tableVariant, + minWidth, +}: TableHiddenHeaderCellProps) { + const cellRefObject = useRef(null); + const cellRefCombined = useMergeRefs(cellRef, cellRefObject); + + const focusableRef = useRef(null); + const { tabIndex: focusableTabIndex } = useSingleTabStopNavigation(focusableRef, { tabIndex }); + + return ( +