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..57284f4821 --- /dev/null +++ b/pages/collection-preferences/multi-level-reorder.page.tsx @@ -0,0 +1,110 @@ +// 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[] = [ + // ungrouped + { id: 'name', label: 'Name', alwaysVisible: true }, + { id: 'status', label: 'Status' }, + + // performance + { id: 'cpuUtilization', label: 'CPU (%)' }, + { id: 'memoryUtilization', label: 'Memory (%)' }, + { id: 'networkIn', label: 'Network In (MB/s)' }, + { id: 'networkOut', label: 'Network Out (MB/s)' }, + + // config + { id: 'instanceType', label: 'Instance Type' }, + { id: 'availabilityZone', label: 'Availability Zone' }, + { id: 'region', label: 'Region' }, + + // cost + { id: 'monthlyCost', label: 'Monthly Cost ($)' }, + { id: 'spotPrice', label: 'Spot Price ($/hr)' }, + { + id: 'reservedCost', + label: + 'Reserved Instance Cost - Long text to verify wrapping behavior and ensure the reordering feature works correctly with extended content', + }, +]; + +const columnGroups: CollectionPreferencesProps.ContentDisplayOptionGroup[] = [ + { id: 'performance', label: 'Performance' }, + { id: 'configuration', label: 'Configuration' }, + { id: 'cost', label: 'Cost' }, +]; + +const defaultContentDisplay: CollectionPreferencesProps.ContentDisplayItem[] = [ + { id: 'name', visible: true }, + { id: 'status', visible: true }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpuUtilization', visible: true }, + { id: 'memoryUtilization', visible: true }, + { id: 'networkIn', visible: true }, + { id: 'networkOut', visible: true }, + ], + }, + { + type: 'group', + id: 'configuration', + visible: true, + children: [ + { id: 'instanceType', visible: true }, + { id: 'availabilityZone', visible: true }, + { id: 'region', visible: true }, + ], + }, + { + type: 'group', + id: 'cost', + visible: true, + children: [ + { id: 'monthlyCost', visible: true }, + { id: 'spotPrice', visible: true }, + { id: 'reservedCost', visible: true }, + ], + }, +]; + +export default function App() { + const [preferences, setPreferences] = React.useState({ + contentDisplay: defaultContentDisplay, + }); + + return ( + <> +

Multi-level Reorder Preferences

+ setPreferences(detail)} + contentDisplayPreference={{ + title: 'Column preferences', + description: 'Customize the columns visibility and order.', + options: columnOptions, + groups: columnGroups, + ...contentDisplayPreferenceI18nStrings, + }} + /> + + ); +} 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/prompt-input/simple.page.tsx b/pages/prompt-input/simple.page.tsx index 0b42d43ce6..d7708a730c 100644 --- a/pages/prompt-input/simple.page.tsx +++ b/pages/prompt-input/simple.page.tsx @@ -28,7 +28,6 @@ type DemoContext = React.Context< isReadOnly: boolean; isInvalid: boolean; hasWarning: boolean; - hasText: boolean; hasSecondaryContent: boolean; hasSecondaryActions: boolean; hasPrimaryActions: boolean; @@ -50,7 +49,6 @@ export default function PromptInputPage() { isReadOnly, isInvalid, hasWarning, - hasText, hasSecondaryActions, hasSecondaryContent, hasPrimaryActions, @@ -63,19 +61,6 @@ export default function PromptInputPage() { { label: 'Item 3', dismissLabel: 'Remove item 3', disabled: isDisabled }, ]); - useEffect(() => { - if (hasText) { - setTextareaValue(placeholderText); - } - }, [hasText]); - - useEffect(() => { - if (textareaValue !== placeholderText) { - setUrlParams({ hasText: false }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [textareaValue]); - useEffect(() => { if (items.length === 0) { ref.current?.focus(); @@ -164,7 +149,14 @@ export default function PromptInputPage() { Infinite max rows - diff --git a/pages/table-fragments/grid-navigation-custom.page.tsx b/pages/table-fragments/grid-navigation-custom.page.tsx index 495cc4a7d0..ccc19bb8f5 100644 --- a/pages/table-fragments/grid-navigation-custom.page.tsx +++ b/pages/table-fragments/grid-navigation-custom.page.tsx @@ -216,7 +216,7 @@ export default function Page() { onConfirm={({ detail }) => setUrlParams({ visibleColumns: (detail.contentDisplay ?? []) - .filter(column => column.visible) + .filter(column => column.type !== 'group' && column.visible) .map(column => column.id) .join(','), }) 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..2c365bb064 --- /dev/null +++ b/pages/table/grouped-column-customer-pages.page.tsx @@ -0,0 +1,928 @@ +// 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), + }, + { + id: 'yellowHosts', + header: 'Yellow hosts', + cell: item => (item.yellowHosts === 0 ? '-' : item.yellowHosts), + }, + { + id: 'greenHosts', + header: 'Green hosts', + cell: item => (item.greenHosts === 0 ? '-' : item.greenHosts), + }, +]; + +const patchingGroups: TableProps.GroupDefinition[] = [ + { id: 'hostsReporting', header: 'Hosts reporting' }, +]; + +const patchingColumnDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'owner', visible: true }, + { id: 'totalHosts', visible: true }, + { id: 'hostsNotReporting', visible: true }, + { + type: 'group', + id: 'hostsReporting', + visible: true, + children: [ + { id: 'redHosts', visible: true }, + { id: 'yellowHosts', visible: true }, + { id: 'greenHosts', visible: true }, + ], + }, +]; + +// ============================================================================ +// 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) }, + { id: 'apr23snap2', header: 'Snapshot day 2', cell: item => fmt(item.apr23snap2) }, + { id: 'apr23change', header: '% Change', cell: item => item.apr23change }, + { id: 'apr30snap1', header: 'Snapshot day 1', cell: item => fmt(item.apr30snap1) }, + { id: 'apr30snap2', header: 'Snapshot day 2', cell: item => fmt(item.apr30snap2) }, + { id: 'apr30change', header: '% Change', cell: item => item.apr30change }, + { id: 'may7snap1', header: 'Snapshot day 1', cell: item => fmt(item.may7snap1) }, + { id: 'may7snap2', header: 'Snapshot day 2', cell: item => fmt(item.may7snap2) }, + { id: 'may7change', header: '% Change', cell: item => item.may7change }, +]; + +const forecastGroups: TableProps.GroupDefinition[] = [ + { id: 'apr23', header: 'April 23' }, + { id: 'apr30', header: 'April 30' }, + { id: 'may7', header: 'May 7' }, +]; + +const forecastColumnDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'ipn', visible: true }, + { id: 'metric', visible: true }, + { + type: 'group', + id: 'apr23', + visible: true, + children: [ + { id: 'apr23snap1', visible: true }, + { id: 'apr23snap2', visible: true }, + { id: 'apr23change', visible: true }, + ], + }, + { + type: 'group', + id: 'apr30', + visible: true, + children: [ + { id: 'apr30snap1', visible: true }, + { id: 'apr30snap2', visible: true }, + { id: 'apr30change', visible: true }, + ], + }, + { + type: 'group', + id: 'may7', + visible: true, + children: [ + { id: 'may7snap1', visible: true }, + { id: 'may7snap2', visible: true }, + { id: 'may7change', visible: true }, + ], + }, +]; + +// ============================================================================ +// 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 }, + { id: 'salesReference', header: 'Reference Assortment Process', cell: item => item.salesReference }, + { id: 'salesDelta', header: 'Delta', cell: item => item.salesDelta }, + { id: 'salesDeltaPct', header: 'Delta %', cell: item => item.salesDeltaPct }, + { id: 'unitsCoreRanking', header: 'Core Ranking - Recommended Assortment', cell: item => item.unitsCoreRanking }, + { id: 'unitsReference', header: 'Reference Assortment Process', cell: item => item.unitsReference }, + { id: 'unitsDelta', header: 'Delta', cell: item => item.unitsDelta }, + { id: 'unitsDeltaPct', header: 'Delta %', cell: item => item.unitsDeltaPct }, +]; + +const groceryGroups: TableProps.GroupDefinition[] = [ + { id: 'sales', header: 'Sales' }, + { id: 'units', header: 'Units' }, +]; + +const groceryColumnDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'store', visible: true }, + { + type: 'group', + id: 'sales', + visible: true, + children: [ + { id: 'salesCoreRanking', visible: true }, + { id: 'salesReference', visible: true }, + { id: 'salesDelta', visible: true }, + { id: 'salesDeltaPct', visible: true }, + ], + }, + { + type: 'group', + id: 'units', + visible: true, + children: [ + { id: 'unitsCoreRanking', visible: true }, + { id: 'unitsReference', visible: true }, + { id: 'unitsDelta', visible: true }, + { id: 'unitsDeltaPct', visible: true }, + ], + }, +]; + +// ============================================================================ +// 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' }, + { id: 'impactedBom', header: 'Impacted BOM', cell: item => item.impactedBom, sortingField: 'impactedBom' }, + { id: 'riskType', header: 'Risk type', cell: item => item.riskType, sortingField: 'riskType' }, + { id: 'bomItemAtRisk', header: 'BOM item at risk', cell: item => item.bomItemAtRisk, sortingField: 'bomItemAtRisk' }, + { id: 'materialClass', header: 'Material class', cell: item => item.materialClass, sortingField: 'materialClass' }, + { + id: 'internalRackType', + header: 'Internal Rack Type', + cell: item => item.internalRackType, + sortingField: 'internalRackType', + }, + { + id: 'externalRackType', + header: 'External Rack Type', + cell: item => item.externalRackType, + sortingField: 'externalRackType', + }, + { id: 'findNo', header: 'Find No', cell: item => item.findNo, sortingField: 'findNo' }, + { id: 'alternateItem', header: 'Alternate item', cell: item => item.alternateItem }, + { id: 'parentItem', header: 'Parent item', cell: item => item.parentItem }, +]; + +const riskGroups: TableProps.GroupDefinition[] = [ + { id: 'itemAtRisk', header: 'Item at risk' }, + { id: 'suggestedAlternate', header: 'Suggested alternate' }, +]; + +const riskColumnDisplay: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'itemAtRisk', + visible: true, + children: [ + { id: 'priority', visible: true }, + { id: 'impactedBom', visible: true }, + { id: 'riskType', visible: true }, + { id: 'bomItemAtRisk', visible: true }, + { id: 'materialClass', visible: true }, + { id: 'internalRackType', visible: true }, + { id: 'externalRackType', visible: true }, + { id: 'findNo', visible: true }, + ], + }, + { + type: 'group', + id: 'suggestedAlternate', + visible: true, + children: [ + { id: 'alternateItem', visible: true }, + { id: 'parentItem', visible: true }, + ], + }, +]; + +// ============================================================================ +// 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' }, + { id: 'reservedCost', header: 'Reserved', cell: item => item.reservedCost, sortingField: 'reservedCost' }, + { id: 'spotCost', header: 'Spot', cell: item => item.spotCost, sortingField: 'spotCost' }, +]; + +const mlCostGroups: TableProps.GroupDefinition[] = [{ id: 'cost', header: 'Cost by pricing type' }]; + +const mlCostColumnDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'product', visible: true }, + { + type: 'group', + id: 'cost', + visible: true, + children: [ + { id: 'onDemandCost', visible: true }, + { id: 'reservedCost', visible: true }, + { id: 'spotCost', visible: true }, + ], + }, +]; + +// ============================================================================ +// 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 => }, + { id: 'usEast1aP2', header: 'Partition 2', cell: item => }, + { id: 'usEast1bP1', header: 'Partition 1', cell: item => }, + { id: 'usEast1bP2', header: 'Partition 2', cell: item => }, + { id: 'usEast1cP1', header: 'Partition 1', cell: item => }, + { id: 'usEast1cP2', header: 'Partition 2', cell: item => }, +]; + +const regionGroups: TableProps.GroupDefinition[] = [ + { id: 'usEast1a', header: 'us-east-1a' }, + { id: 'usEast1b', header: 'us-east-1b' }, + { id: 'usEast1c', header: 'us-east-1c' }, +]; + +const regionColumnDisplay: TableProps.ColumnDisplayProperties[] = [ + { id: 'service', visible: true }, + { + type: 'group', + id: 'usEast1a', + visible: true, + children: [ + { id: 'usEast1aP1', visible: true }, + { id: 'usEast1aP2', visible: true }, + ], + }, + { + type: 'group', + id: 'usEast1b', + visible: true, + children: [ + { id: 'usEast1bP1', visible: true }, + { id: 'usEast1bP2', visible: true }, + ], + }, + { + type: 'group', + id: 'usEast1c', + visible: true, + children: [ + { id: 'usEast1cP1', visible: true }, + { id: 'usEast1cP2', visible: true }, + ], + }, +]; + +// ============================================================================ +// 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..006ca43da6 --- /dev/null +++ b/pages/table/grouped-column-with-preference.page.tsx @@ -0,0 +1,424 @@ +// 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 Instance { + id: string; + name: string; + cpuUtilization: number; + memoryUtilization: number; + networkIn: number; + networkOut: number; + instanceType: string; + az: string; + state: string; + monthlyCost: number; + spotPrice: number; +} + +const allInstances: Instance[] = [ + { + 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: Instance) => item.id, + sortingField: 'id', + isRowHeader: true, + minWidth: 350, + }, + { + id: 'name', + header: 'Name', + cell: (item: Instance) => item.name, + sortingField: 'name', + minWidth: 250, + }, + { + id: 'cpuUtilization', + header: 'CPU (%)', + cell: (item: Instance) => `${item.cpuUtilization.toFixed(1)}%`, + sortingField: 'cpuUtilization', + minWidth: 150, + }, + { + id: 'memoryUtilization', + header: 'Memory (%)', + cell: (item: Instance) => `${item.memoryUtilization.toFixed(1)}%`, + sortingField: 'memoryUtilization', + }, + { + id: 'networkIn', + header: 'Network In (MB/s)', + cell: (item: Instance) => item.networkIn.toString(), + sortingField: 'networkIn', + }, + { + id: 'networkOut', + header: 'Network Out (MB/s)', + cell: (item: Instance) => item.networkOut.toString(), + sortingField: 'networkOut', + }, + { + id: 'instanceType', + header: 'Instance Type', + cell: (item: Instance) => item.instanceType, + sortingField: 'instanceType', + }, + { + id: 'az', + header: 'Availability Zone', + cell: (item: Instance) => item.az, + sortingField: 'az', + }, + { + id: 'state', + header: 'State', + cell: (item: Instance) => item.state, + sortingField: 'state', + }, + { + id: 'monthlyCost', + header: 'Monthly Cost ($)', + cell: (item: Instance) => `$${item.monthlyCost.toFixed(2)}`, + sortingField: 'monthlyCost', + }, + { + id: 'spotPrice', + header: 'Spot Price ($/hr)', + cell: (item: Instance) => `$${item.spotPrice.toFixed(4)}`, + sortingField: 'spotPrice', + }, +]; + +const groupDefinitions: TableProps['groupDefinitions'] = [ + { id: 'cost', header: 'Cost' }, + { id: 'configuration', header: 'Configuration' }, + { id: 'performance', header: 'Performance' }, + { id: 'metrics', header: 'Metrics' }, + { id: 'network', header: 'Network' }, +]; + +const collectionPreferencesProps: CollectionPreferencesProps = { + title: 'Preferences', + confirmLabel: 'Confirm', + cancelLabel: 'Cancel', + pageSizePreference: { + title: 'Page size', + options: [ + { value: 2, label: '2 instances' }, + { value: 10, label: '10 instances' }, + { value: 30, label: '30 instances' }, + ], + }, + contentDisplayPreference: { + title: 'Column preferences', + description: 'Customize the columns visibility and order.', + options: [ + { id: 'id', label: 'Instance ID', alwaysVisible: true }, + { id: 'name', label: 'Name' }, + { id: 'cpuUtilization', label: 'CPU (%)' }, + { id: 'memoryUtilization', label: 'Memory (%)' }, + { id: 'networkIn', label: 'Network In (MB/s)' }, + { id: 'networkOut', label: 'Network Out (MB/s)' }, + { id: 'instanceType', label: 'Instance Type' }, + { id: 'az', label: 'Availability Zone' }, + { id: 'state', label: 'State' }, + { id: 'monthlyCost', label: 'Monthly Cost ($)' }, + { id: 'spotPrice', label: 'Spot Price ($/hr)' }, + ], + groups: [ + { id: 'cost', label: 'Cost' }, + { id: 'configuration', label: 'Configuration' }, + { id: 'performance', label: 'Performance' }, + { id: 'metrics', label: 'Metrics' }, + { id: 'network', label: 'Network' }, + ], + }, +}; + +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; + gap: number; + }> +>; + +export default function TableDemo() { + const [preferences, setPreferences] = useState({ + pageSize: 10, + contentDisplay: [ + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpuUtilization', visible: true }, + { id: 'memoryUtilization', visible: true }, + ], + }, + { + type: 'group', + id: 'network', + visible: true, + children: [ + { id: 'networkIn', visible: true }, + { id: 'networkOut', visible: true }, + ], + }, + ], + }, + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'configuration', + visible: true, + children: [ + { id: 'instanceType', visible: true }, + { id: 'az', visible: true }, + { id: 'state', visible: true }, + ], + }, + { + type: 'group', + id: 'cost', + visible: true, + children: [ + { id: 'monthlyCost', visible: false }, + { id: 'spotPrice', visible: false }, + ], + }, + ], + }); + + const ariaLabels: TableProps['ariaLabels'] = { + selectionGroupLabel: '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: '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 }, + setUrlParams, + } = useContext(AppContext as DemoContext); + + return ( + +

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" + /> + + + +
+ Instances + + } + columnDefinitions={columnDefinitions} + groupDefinitions={groupDefinitions} + 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..540cde08b7 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", @@ -25919,6 +25924,13 @@ table with \`item=null\` and then for each expanded item. The function result is "optional": true, "type": "TableProps.GetLoadingStatus", }, + { + "description": "Defines the column groups. Each group has an \`id\` and \`header\` used to label the group header cell. +The hierarchy is encoded in the \`columnDisplay\` property via nested \`ColumnDisplayGroup\` entries.", + "name": "groupDefinitions", + "optional": true, + "type": "ReadonlyArray>", + }, { "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must @@ -35504,9 +35516,23 @@ Returns the current value of the input.", }, }, { - "description": "Returns options that the user can reorder.", + "description": "Returns the top-level items in the preference list. + +For tables **without** column grouping this returns all column options. +For tables **with** column grouping this returns the top-level entries only +(which are group items). Use \`.findChildrenOptions()\` on a group item to +access the leaf columns nested within it.", "name": "findOptions", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "Array", @@ -35545,6 +35571,24 @@ Returns the current value of the input.", }, { "methods": [ + { + "description": "Returns all child option items nested under this item when it is a group. +Returns \`null\` when this item is a leaf column (has no nested children). + +The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's +nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.", + "name": "findChildrenOptions", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "Array", + "typeArguments": [ + { + "name": "ContentDisplayOptionWrapper", + }, + ], + }, + }, { "description": "Returns the drag handle for the option item.", "name": "findDragHandle", @@ -35574,7 +35618,8 @@ Returns the current value of the input.", }, }, { - "description": "Returns the visibility toggle for the option item.", + "description": "Returns the visibility toggle for the option item. +Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.", "name": "findVisibilityToggle", "parameters": [], "returnType": { @@ -40310,8 +40355,28 @@ Returns the current value of the input.", }, }, { + "description": "Returns all column header cells in the last header row (leaf columns). +For tables without column grouping this is equivalent to querying \`tr > *\`. +For tables with column grouping this returns only the leaf-level column headers, +not the group header cells above them. + +Pass \`{ level }\` to target a specific header row (1-based). Level 1 is the +topmost row (group headers); the last level is always the leaf-column row. + +Pass \`{ groupId }\` to return only the leaf column headers that are direct +children of the specified group. This uses the \`data-column-group-id\` attribute +set on each leaf \`
\` by the renderer.", "name": "findColumnHeaders", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; level?: number | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "Array", @@ -40323,17 +40388,31 @@ Returns the current value of the input.", }, }, { - "description": "Returns the element the user clicks when resizing a column.", + "description": "Returns the element the user clicks when resizing a column or group header. +Targets the leaf-column header row by default. + +Pass \`{ level }\` to target a specific header row (1-based). + +Pass \`{ groupId }\` to return the resizer of the group header cell with that ID. +When \`groupId\` is provided, \`columnIndex\` is ignored.", "name": "findColumnResizer", "parameters": [ { - "description": "1-based index of the column containing the resizer.", + "description": "1-based index of the column containing the resizer (ignored when groupId is set).", "flags": { "isOptional": false, }, "name": "columnIndex", "typeName": "number", }, + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; level?: number | undefined; }", + }, ], "returnType": { "isNullable": true, @@ -40346,9 +40425,12 @@ Returns the current value of the input.", }, }, { + "description": "Returns the clickable sorting area of a column header. +Targets the leaf-column header row by default.", "name": "findColumnSortingArea", "parameters": [ { + "description": "1-based index of the column.", "flags": { "isOptional": false, }, @@ -45966,9 +46048,23 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, }, { - "description": "Returns options that the user can reorder.", + "description": "Returns the top-level items in the preference list. + +For tables **without** column grouping this returns all column options. +For tables **with** column grouping this returns the top-level entries only +(which are group items). Use \`.findChildrenOptions()\` on a group item to +access the leaf columns nested within it.", "name": "findOptions", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ group?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "MultiElementWrapper", @@ -46002,6 +46098,24 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, { "methods": [ + { + "description": "Returns all child option items nested under this item when it is a group. +Returns \`null\` when this item is a leaf column (has no nested children). + +The children are the leaf-level \`ContentDisplayOptionWrapper\`s inside the group's +nested \`InternalList\` — i.e. they already carry a drag handle and visibility toggle.", + "name": "findChildrenOptions", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ContentDisplayOptionWrapper", + }, + ], + }, + }, { "description": "Returns the drag handle for the option item.", "name": "findDragHandle", @@ -46021,7 +46135,8 @@ To find a specific item use the \`findBreadcrumbLink(n)\` function as chaining \ }, }, { - "description": "Returns the visibility toggle for the option item.", + "description": "Returns the visibility toggle for the option item. +Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle.", "name": "findVisibilityToggle", "parameters": [], "returnType": { @@ -49292,8 +49407,28 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "description": "Returns all column header cells in the last header row (leaf columns). +For tables without column grouping this is equivalent to querying \`tr > *\`. +For tables with column grouping this returns only the leaf-level column headers, +not the group header cells above them. + +Pass \`{ level }\` to target a specific header row (1-based). Level 1 is the +topmost row (group headers); the last level is always the leaf-column row. + +Pass \`{ groupId }\` to return only the leaf column headers that are direct +children of the specified group. This uses the \`data-column-group-id\` attribute +set on each leaf \`\` by the renderer.", "name": "findColumnHeaders", - "parameters": [], + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; level?: number | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "MultiElementWrapper", @@ -49305,17 +49440,31 @@ If not specified, the method returns the result text that is currently displayed }, }, { - "description": "Returns the element the user clicks when resizing a column.", + "description": "Returns the element the user clicks when resizing a column or group header. +Targets the leaf-column header row by default. + +Pass \`{ level }\` to target a specific header row (1-based). + +Pass \`{ groupId }\` to return the resizer of the group header cell with that ID. +When \`groupId\` is provided, \`columnIndex\` is ignored.", "name": "findColumnResizer", "parameters": [ { - "description": "1-based index of the column containing the resizer.", + "description": "1-based index of the column containing the resizer (ignored when groupId is set).", "flags": { "isOptional": false, }, "name": "columnIndex", "typeName": "number", }, + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "option", + "typeName": "{ groupId?: string | undefined; level?: number | undefined; }", + }, ], "returnType": { "isNullable": false, @@ -49323,9 +49472,12 @@ If not specified, the method returns the result text that is currently displayed }, }, { + "description": "Returns the clickable sorting area of a column header. +Targets the leaf-column header row by default.", "name": "findColumnSortingArea", "parameters": [ { + "description": "1-based index of the column.", "flags": { "isOptional": false, }, diff --git a/src/collection-preferences/analytics-metadata/utils.ts b/src/collection-preferences/analytics-metadata/utils.ts index 22f98f9113..7cf4904983 100644 --- a/src/collection-preferences/analytics-metadata/utils.ts +++ b/src/collection-preferences/analytics-metadata/utils.ts @@ -43,7 +43,7 @@ export const getComponentAnalyticsMetadata = ( } } if (preferences.contentDisplay) { - metadata.properties.contentDisplayVisibleCount = `${preferences.contentDisplay.filter(({ visible }) => !!visible).length}`; + metadata.properties.contentDisplayVisibleCount = `${preferences.contentDisplay.filter(item => item.type !== 'group' && !!item.visible).length}`; } return metadata; }; diff --git a/src/collection-preferences/content-display/index.tsx b/src/collection-preferences/content-display/index.tsx index 173522ee6d..0b4e67a11b 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,77 @@ 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,16 +130,27 @@ 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, value) : 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 - // are not in the value yet, so they're added as non-visible after the known ones. - onChange(sortedOptions.map(({ id, visible }) => ({ id, visible: id === option.id ? !option.visible : visible }))); + // Re-build the hierarchical contentDisplay with the toggled visibility. + // We use sortedOptions (which carries groupId) to reconstruct the tree and then + // flatten it back to ContentDisplayItem[] preserving the hierarchy. + const updatedOptions = sortedOptions.map(opt => ({ + ...opt, + visible: opt.id === option.id ? !option.visible : opt.visible, + })); + const updatedTree = groups && groups.length > 0 ? buildOptionTree(updatedOptions, groups, value) : null; + if (updatedTree) { + onChange(flattenOptionTree(updatedTree)); + } else { + onChange(updatedOptions.map(({ id, visible }) => ({ id, visible }))); + } }; return ( @@ -126,48 +211,87 @@ 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 } }) => { + // items is a flat OptionWithVisibility[] — no group hierarchy in flat/filtered view, + // so we emit flat ContentDisplayItem entries. + onChange(items.map(({ id, visible }) => ({ id, visible }))); + }} + 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..a8d9690693 100644 --- a/src/collection-preferences/content-display/utils.ts +++ b/src/collection-preferences/content-display/utils.ts @@ -6,6 +6,47 @@ 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; + isGroup: true; + children: OptionTreeNode[]; + visible: boolean; +} + +/** A flat leaf column node (not a group header). */ +export interface OptionLeafNode extends OptionWithVisibility { + isGroup: false; +} + +export type OptionTreeNode = OptionGroupNode | OptionLeafNode; + +/** + * Extracts a flat ordered list of leaf items from the contentDisplay tree (depth-first). + * Used for `getSortedOptions` to rebuild the sorted option list with correct order, + * and to flatten the internal tree back to a public-facing ContentDisplayItem[] before emitting onChange. + */ +export function walkLeaves( + items: ReadonlyArray +): { id: string; visible: boolean }[] { + const result: { id: string; visible: boolean }[] = []; + const walk = (nodes: ReadonlyArray) => { + for (const node of nodes) { + if (node.type === 'group') { + walk(node.children); + } else { + result.push({ id: node.id, visible: node.visible }); + } + } + }; + walk(items); + return result; +} + export function getSortedOptions({ options, contentDisplay, @@ -13,21 +54,108 @@ export function getSortedOptions({ options: ReadonlyArray; contentDisplay: ReadonlyArray; }): ReadonlyArray { + // Walk the tree depth-first to get ordered leaf items + const leaves = walkLeaves(contentDisplay); + // By using a Map, we are guaranteed to preserve insertion order on future iteration. const optionsById = new Map(); - // We insert contentDisplay first so we respect the currently selected order - for (const { id, visible } of contentDisplay) { - // If an option is provided in contentDisplay and not options, we default the label to the id + + // Insert leaves first so we respect the currently selected order + for (const { id, visible } of leaves) { optionsById.set(id, { id, label: id, visible }); } - // We merge options data, and insert any that were not in contentDisplay as non-visible + + // Merge options metadata, and insert any that were not in contentDisplay as non-visible for (const option of options) { const existing = optionsById.get(option.id); optionsById.set(option.id, { ...option, visible: !!existing?.visible }); } + return Array.from(optionsById.values()); } +/** + * Directly converts a ContentDisplayItem[] tree into an OptionTreeNode[] tree, + * looking up labels from the options and groups lookup maps. + * This preserves arbitrary nesting depth without any reconstruction. + */ +function convertTree( + items: ReadonlyArray, + optionByKey: Map, + groupByKey: Map, + sortedOptionsById: Map +): OptionTreeNode[] { + const result: OptionTreeNode[] = []; + for (const item of items) { + if (item.type === 'group') { + const groupDef = groupByKey.get(item.id); + result.push({ + id: item.id, + label: groupDef?.label ?? item.id, + isGroup: true, + children: convertTree(item.children, optionByKey, groupByKey, sortedOptionsById), + visible: item.visible, + }); + } else { + const opt = sortedOptionsById.get(item.id); + if (opt) { + result.push({ ...opt, isGroup: false as const }); + } + } + } + return result; +} + +export function buildOptionTree( + sortedOptions: ReadonlyArray, + groups: ReadonlyArray, + contentDisplay: ReadonlyArray +): OptionTreeNode[] { + if (!groups || groups.length === 0) { + return sortedOptions.map(opt => ({ ...opt, isGroup: false as const })); + } + + const groupByKey = new Map(); + for (const group of groups) { + groupByKey.set(group.id, group); + } + + const optionByKey = new Map(); + // (not used directly in convertTree but kept for future extension) + + const sortedOptionsById = new Map(); + for (const opt of sortedOptions) { + sortedOptionsById.set(opt.id, opt); + } + + return convertTree(contentDisplay, optionByKey, groupByKey, sortedOptionsById); +} + +/** + * Converts an OptionTreeNode[] back to ContentDisplayItem[], preserving the group hierarchy. + * Group nodes become ContentDisplayGroup entries with their children. + * Leaf nodes become ContentDisplayItem entries. + */ +export function flattenOptionTree( + tree: OptionTreeNode[] +): ReadonlyArray { + const convert = (nodes: OptionTreeNode[]): CollectionPreferencesProps.ContentDisplayItem[] => { + return nodes.map(node => { + if (node.isGroup) { + return { + type: 'group' as const, + id: node.id, + children: convert(node.children) as ReadonlyArray, + visible: node.visible, + }; + } else { + return { id: node.id, visible: node.visible }; + } + }); + }; + return convert(tree); +} + export function getFilteredOptions(options: ReadonlyArray, filterText: string) { filterText = filterText.trim().toLowerCase(); diff --git a/src/collection-preferences/index.tsx b/src/collection-preferences/index.tsx index 6e9bdecea5..cae32b17a8 100644 --- a/src/collection-preferences/index.tsx +++ b/src/collection-preferences/index.tsx @@ -138,9 +138,28 @@ export default function CollectionPreferences({ // When both are used contentDisplayPreference takes preference and so we always prefer to use this as our visible columns if available if (preferences?.contentDisplay) { - tableComponentContext.preferencesRef.current.visibleColumns = preferences?.contentDisplay - .filter(column => column.visible) - .map(column => column.id); + // Walk the tree depth-first, collecting leaf column ids that are visible. + // A leaf is only included if all ancestor groups (and the leaf itself) have visible: true. + const collectVisibleIds = ( + items: ReadonlyArray, + ancestorVisible: boolean + ): string[] => { + const result: string[] = []; + for (const item of items) { + if (item.type === 'group') { + if (ancestorVisible && item.visible) { + result.push(...collectVisibleIds(item.children, true)); + } + } else if (ancestorVisible && item.visible) { + result.push(item.id); + } + } + return result; + }; + tableComponentContext.preferencesRef.current.visibleColumns = collectVisibleIds( + preferences.contentDisplay, + true + ); } else if (preferences?.visibleContent) { tableComponentContext.preferencesRef.current.visibleColumns = [...preferences.visibleContent]; } diff --git a/src/collection-preferences/interfaces.ts b/src/collection-preferences/interfaces.ts index 5768238b0a..8b57f71c7e 100644 --- a/src/collection-preferences/interfaces.ts +++ b/src/collection-preferences/interfaces.ts @@ -229,19 +229,35 @@ export namespace CollectionPreferencesProps { title?: string; description?: string; options: ReadonlyArray; + groups?: ReadonlyArray; enableColumnFiltering?: boolean; i18nStrings?: ContentDisplayPreferenceI18nStrings; } + export interface ContentDisplayColumn { + type?: 'column' | undefined; + id: string; + visible: boolean; + } + + export interface ContentDisplayGroup { + type: 'group'; + id: string; + visible: boolean; + children: ReadonlyArray; + } + + export type ContentDisplayItem = ContentDisplayColumn | ContentDisplayGroup; + export interface ContentDisplayOption { id: string; label: string; alwaysVisible?: boolean; } - export interface ContentDisplayItem { + export interface ContentDisplayOptionGroup { id: string; - visible: boolean; + label: string; } export interface VisibleContentPreference { diff --git a/src/table/__tests__/column-grouping-fixtures.ts b/src/table/__tests__/column-grouping-fixtures.ts new file mode 100644 index 0000000000..65159a91b4 --- /dev/null +++ b/src/table/__tests__/column-grouping-fixtures.ts @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Shared fixtures for column-grouping tests. +import { TableProps } from '../interfaces'; + +export interface Item { + id: number; + name: string; + cpu: number; + memory: number; + networkIn: number; + type: string; + az: string; + cost: number; +} + +export 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 }, +]; + +export const COLUMN_DEFS: 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 }, + { id: 'memory', header: 'Memory', cell: item => item.memory }, + { id: 'networkIn', header: 'Network In', cell: item => item.networkIn }, + { id: 'type', header: 'Type', cell: item => item.type }, + { id: 'az', header: 'AZ', cell: item => item.az }, + { id: 'cost', header: 'Cost', cell: item => `$${item.cost}` }, +]; + +export const GROUP_DEFS: TableProps.GroupDefinition[] = [ + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Configuration' }, + { id: 'pricing', header: 'Pricing' }, +]; + +/** Flat columnDisplay: id+name ungrouped, cpu/memory/networkIn under performance, type/az under config, cost under pricing */ +export const FLAT_DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + visible: true, + id: 'performance', + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + { id: 'networkIn', visible: true }, + ], + }, + { + type: 'group', + visible: true, + id: 'config', + children: [ + { id: 'type', visible: true }, + { id: 'az', visible: true }, + ], + }, + { type: 'group', visible: true, id: 'pricing', children: [{ id: 'cost', visible: true }] }, +]; + +/** Nested: metrics → performance → cpu/memory */ +export const NESTED_GROUPS: TableProps.GroupDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance' }, +]; + +export const NESTED_DISPLAY: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + visible: true, + id: 'metrics', + children: [ + { + type: 'group', + visible: true, + id: 'performance', + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: true }, + ], + }, + ], + }, +]; 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..15ff95bf2a --- /dev/null +++ b/src/table/__tests__/column-grouping-rendering.test.tsx @@ -0,0 +1,134 @@ +// 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'; +import { COLUMN_DEFS, FLAT_DISPLAY, GROUP_DEFS, ITEMS } from './column-grouping-fixtures'; + +function renderTable(props: Partial> = {}) { + const { container } = render( + + ); + return createWrapper(container).findTable()!; +} + +describe('Table with column grouping', () => { + test('renders multiple header rows with correct group text, colspan, and scope', () => { + const wrapper = renderTable(); + const thead = wrapper.find('thead')!; + const rows = thead.findAll('tr'); + expect(rows.length).toBeGreaterThan(1); + + const ths = thead.findAll('th'); + const texts = ths.map(th => th.getElement().textContent?.trim()); + expect(texts).toContain('Performance'); + expect(texts).toContain('Configuration'); + + const perfTh = ths.find(th => th.getElement().textContent?.trim() === 'Performance')!; + expect(perfTh.getElement().getAttribute('colspan')).toBe('3'); // cpu+memory+networkIn + expect(perfTh.getElement().getAttribute('scope')).toBe('colgroup'); + + const configTh = ths.find(th => th.getElement().textContent?.trim() === 'Configuration')!; + expect(configTh.getElement().getAttribute('colspan')).toBe('2'); // type+az + }); + + test('leaf headers carry data-column-group-id; ungrouped headers do not', () => { + const wrapper = renderTable(); + // Search across all header rows — ungrouped columns now span rows from row 0 + const allThs = wrapper.find('thead')!.findAll('th'); + + const cpuTh = allThs.find(th => th.getElement().textContent?.trim() === 'CPU'); + expect(cpuTh?.getElement().getAttribute('data-column-group-id')).toBe('performance'); + + const idTh = allThs.find(th => th.getElement().textContent?.trim() === 'ID'); + expect(idTh?.getElement().getAttribute('data-column-group-id')).toBeNull(); + }); + + test('aria-rowindex on each header row equals its 1-based position', () => { + const wrapper = renderTable(); + const rows = wrapper.find('thead')!.findAll('tr'); + rows.forEach((row, i) => { + expect(row.getElement().getAttribute('aria-rowindex')).toBe(`${i + 1}`); + }); + }); + + test('hidden columns are absent from the DOM', () => { + const wrapper = renderTable({ + columnDisplay: [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { + type: 'group', + id: 'performance', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: false }, + { id: 'networkIn', visible: false }, + ], + }, + { + type: 'group', + id: 'config', + visible: true, + children: [ + { id: 'type', visible: false }, + { id: 'az', visible: false }, + ], + }, + { type: 'group', visible: true, id: 'pricing', children: [{ id: 'cost', visible: false }] }, + ], + }); + const texts = wrapper + .find('thead')! + .findAll('th') + .map(th => th.getElement().textContent?.trim()); + expect(texts).toContain('CPU'); + expect(texts).not.toContain('Memory'); + expect(texts).not.toContain('Cost'); + }); + + test('no groupDefinitions → single header row', () => { + const wrapper = renderTable({ groupDefinitions: undefined, columnDisplay: undefined }); + expect(wrapper.find('thead')!.findAll('tr')).toHaveLength(1); + }); + + test('selectionType: checkbox present in last header row', () => { + const wrapper = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: () => {}, + ariaLabels: { + selectionGroupLabel: 'Items selection', + allItemsSelectionLabel: () => 'Select all', + itemSelectionLabel: (_, item) => `Select ${item.name}`, + }, + }); + const rows = wrapper.find('thead')!.findAll('tr'); + expect(rows.length).toBeGreaterThan(1); + expect(wrapper.findSelectAllTrigger()).not.toBeNull(); + }); + + test('resizableColumns: multiple header rows and colgroup still present', () => { + const { container } = render( +
+ ); + const wrapper = createWrapper(container).findTable()!; + expect(wrapper.find('thead')!.findAll('tr').length).toBeGreaterThan(1); + expect(container.querySelector('colgroup')).toBeTruthy(); + }); +}); 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..91795a5dd2 --- /dev/null +++ b/src/table/__tests__/column-grouping-utils.test.tsx @@ -0,0 +1,297 @@ +// 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'; +import { FLAT_DISPLAY, GROUP_DEFS, NESTED_DISPLAY, NESTED_GROUPS } from './column-grouping-fixtures'; + +// Minimal column set used across most tests +const COLS: TableProps.ColumnDefinition[] = [ + { id: 'id', header: 'ID', cell: () => 'id' }, + { id: 'name', header: 'Name', cell: () => 'name' }, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + { id: 'networkIn', header: 'Network In', cell: () => 'networkIn' }, + { id: 'type', header: 'Type', cell: () => 'type' }, + { id: 'az', header: 'AZ', cell: () => 'az' }, + { id: 'cost', header: 'Cost', cell: () => 'cost' }, +]; + +const ALL_IDS = COLS.map(c => c.id!); + +describe('column-grouping-utils', () => { + describe('TableHeaderNode', () => { + it('creates node with default properties', () => { + const node = new TableHeaderNode('test-id'); + expect(node.id).toBe('test-id'); + expect(node.colspan).toBe(1); + expect(node.rowspan).toBe(1); + expect(node.subtreeHeight).toBe(1); + expect(node.children).toEqual([]); + expect(node.rowIndex).toBe(-1); + expect(node.colIndex).toBe(-1); + expect(node.isRoot).toBe(false); + }); + + it('accepts constructor options and identifies node types', () => { + const colDef: TableProps.ColumnDefinition = { id: 'col', header: 'Col', cell: () => 'col' }; + const groupDef = { id: 'grp', header: 'Grp' }; + + const colNode = new TableHeaderNode('col', { + colspan: 2, + rowspan: 3, + columnDefinition: colDef, + rowIndex: 1, + colIndex: 2, + }); + const groupNode = new TableHeaderNode('grp', { groupDefinition: groupDef }); + const rootNode = new TableHeaderNode('root', { isRoot: true }); + + expect(colNode.colspan).toBe(2); + expect(colNode.rowspan).toBe(3); + expect(colNode.columnDefinition).toBe(colDef); + expect(colNode.isGroup).toBe(false); + expect(groupNode.isGroup).toBe(true); + expect(rootNode.isRoot).toBe(true); + expect(rootNode.isRootNode).toBe(true); + }); + + it('manages parent/child relationships and leaf detection', () => { + const parent = new TableHeaderNode('parent'); + const child1 = new TableHeaderNode('child1'); + const child2 = new TableHeaderNode('child2'); + const root = new TableHeaderNode('root', { isRoot: true }); + + parent.addChild(child1); + parent.addChild(child2); + + expect(parent.children).toHaveLength(2); + expect(parent.children[0]).toBe(child1); + expect(parent.children[1]).toBe(child2); + expect(child1.parentNode).toBe(parent); + expect(parent.isLeaf).toBe(false); + expect(child1.isLeaf).toBe(true); + expect(root.isLeaf).toBe(false); // root is never a leaf + }); + }); + + describe('CalculateHierarchyTree', () => { + describe('no grouping', () => { + it('returns a single row with all visible columns, rowspan=1, sequential colIndex', () => { + const result = CalculateHierarchyTree(COLS, ALL_IDS, []); + + expect(result.maxDepth).toBe(1); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].columns).toHaveLength(COLS.length); + result.rows[0].columns.forEach((col, i) => { + expect(col.rowspan).toBe(1); + expect(col.colspan).toBe(1); + expect(col.isGroup).toBe(false); + expect(col.colIndex).toBe(i); + }); + expect(result.columnToParentIds.size).toBe(0); + }); + }); + + describe('flat grouping', () => { + it('creates two rows with correct structure', () => { + const result = CalculateHierarchyTree(COLS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY); + + expect(result.maxDepth).toBe(2); + expect(result.rows).toHaveLength(2); + + // Row 0: ungrouped columns span all rows (rowspan=2), plus group headers + const row0 = result.rows[0].columns; + expect(row0.map(c => c.id)).toEqual(['id', 'name', 'performance', 'config', 'pricing']); + expect(row0.find(c => c.id === 'id')).toMatchObject({ rowspan: 2 }); + expect(row0.find(c => c.id === 'name')).toMatchObject({ rowspan: 2 }); + expect(row0.find(c => c.id === 'performance')).toMatchObject({ isGroup: true, colspan: 3, rowspan: 1 }); + expect(row0.find(c => c.id === 'config')).toMatchObject({ isGroup: true, colspan: 2 }); + expect(row0.find(c => c.id === 'pricing')).toMatchObject({ isGroup: true, colspan: 1 }); + + // Row 1: only leaf columns under groups (ungrouped columns are not repeated) + const row1 = result.rows[1].columns; + expect(row1.map(c => c.id)).toEqual(['cpu', 'memory', 'networkIn', 'type', 'az', 'cost']); + expect(row1.find(c => c.id === 'cpu')).toMatchObject({ isGroup: false, rowspan: 1, colspan: 1 }); + }); + + it('tracks parent IDs and colIndex correctly', () => { + const result = CalculateHierarchyTree(COLS, ALL_IDS, GROUP_DEFS, FLAT_DISPLAY); + + expect(result.columnToParentIds.get('cpu')).toEqual(['performance']); + expect(result.columnToParentIds.get('type')).toEqual(['config']); + expect(result.columnToParentIds.has('id')).toBe(false); + + const row0 = result.rows[0].columns; + expect(row0.find(c => c.id === 'performance')?.colIndex).toBe(2); + expect(row0.find(c => c.id === 'config')?.colIndex).toBe(5); + }); + }); + + describe('nested grouping', () => { + const nestedCols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + ]; + + it('creates three rows and calculates colspan/parentIds correctly', () => { + const result = CalculateHierarchyTree(nestedCols, ['cpu', 'memory'], NESTED_GROUPS, NESTED_DISPLAY); + + expect(result.maxDepth).toBe(3); + expect(result.rows).toHaveLength(3); + expect(result.rows[0].columns[0]).toMatchObject({ id: 'metrics', colspan: 2, rowIndex: 0 }); + expect(result.rows[1].columns[0]).toMatchObject({ id: 'performance', colspan: 2, rowIndex: 1 }); + expect(result.rows[2].columns.map(c => c.id)).toEqual(['cpu', 'memory']); + expect(result.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + + it('handles 3-level nesting', () => { + const groups: TableProps.GroupDefinition[] = [ + { id: 'l1', header: 'L1' }, + { id: 'l2', header: 'L2' }, + { id: 'l3', header: 'L3' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'l1', + visible: true, + children: [ + { + type: 'group', + id: 'l2', + visible: true, + children: [ + { + type: 'group', + visible: true, + id: 'l3', + children: [{ id: 'cpu', visible: true }], + }, + ], + }, + ], + }, + ]; + const result = CalculateHierarchyTree(nestedCols, ['cpu'], groups, display); + expect(result.maxDepth).toBe(4); + expect(result.columnToParentIds.get('cpu')).toEqual(['l1', 'l2', 'l3']); + }); + + it('handles mixed nested and flat groups', () => { + const groups: TableProps.GroupDefinition[] = [ + { id: 'metrics', header: 'Metrics' }, + { id: 'performance', header: 'Performance' }, + { id: 'config', header: 'Config' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'metrics', + visible: true, + children: [ + { + type: 'group', + id: 'performance', + visible: true, + children: [{ id: 'cpu', visible: true }], + }, + ], + }, + { type: 'group', visible: true, id: 'config', children: [{ id: 'memory', visible: true }] }, + ]; + const result = CalculateHierarchyTree(nestedCols, ['cpu', 'memory'], groups, display); + + expect(result.maxDepth).toBe(3); + // Row 0: metrics + config spanning all rows (rowspan=2, not hidden) + const row0 = result.rows[0].columns; + expect(row0.map(c => c.id)).toEqual(['metrics', 'config']); + expect(row0.find(c => c.id === 'config')).toMatchObject({ rowspan: 2 }); + // Row 1: only performance (config is already in row 0 with rowspan=2, not repeated) + expect(result.rows[1].columns.map(c => c.id)).toEqual(['performance']); + }); + }); + + describe('visibility filtering', () => { + it('includes only visible columns and adjusts group colspan', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { + type: 'group', + id: 'g', + visible: true, + children: [ + { id: 'cpu', visible: true }, + { id: 'memory', visible: false }, + ], + }, + ]; + const result = CalculateHierarchyTree(COLS, ['id', 'cpu'], groups, display); + + const allIds = result.rows.flatMap(r => r.columns.map(c => c.id)); + expect(allIds).toContain('cpu'); + expect(allIds).not.toContain('memory'); + expect(result.rows[0].columns.find(c => c.id === 'g')?.colspan).toBe(1); + }); + + it('omits a group entirely when all its children are hidden', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { type: 'group', visible: true, id: 'g', children: [{ id: 'cpu', visible: false }] }, + ]; + const result = CalculateHierarchyTree(COLS, ['id'], groups, display); + expect(result.rows[0].columns.map(c => c.id)).not.toContain('g'); + }); + }); + + describe('edge cases', () => { + it('returns empty structure for empty column list', () => { + const result = CalculateHierarchyTree([], [], []); + expect(result.rows).toHaveLength(0); + expect(result.maxDepth).toBe(0); + }); + + it('skips columns without id and groups without id', () => { + const colsNoId: TableProps.ColumnDefinition[] = [ + { header: 'No ID', cell: () => 'x' } as any, + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + ]; + const groupsNoId: TableProps.GroupDefinition[] = [ + { header: 'No ID' } as any, + { id: 'valid', header: 'Valid' }, + ]; + const display: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + visible: true, + id: 'valid', + children: [{ id: 'cpu', visible: true }], + }, + ]; + const result = CalculateHierarchyTree(colsNoId, ['cpu'], groupsNoId, display); + const groupIds = result.rows[0].columns.filter(c => c.isGroup).map(c => c.id); + expect(groupIds).toEqual(['valid']); + expect(result.rows[1].columns[0].id).toBe('cpu'); + }); + + it('skips entire subtree when group id is not in groupDefinitions', () => { + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', visible: true, id: 'nonexistent', children: [{ id: 'cpu', visible: true }] }, + ]; + const result = CalculateHierarchyTree(COLS, ['cpu'], [], display); + expect(result.rows).toHaveLength(0); + expect(result.columnToParentIds.has('cpu')).toBe(false); + }); + + it('treats a group with no visible children as absent', () => { + const groups: TableProps.GroupDefinition[] = [{ id: 'g', header: 'G' }]; + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', visible: true, id: 'g', children: [{ id: 'cpu', visible: false }] }, + ]; + const result = CalculateHierarchyTree(COLS, [], groups, display); + expect(result.rows).toHaveLength(0); + }); + }); + }); +}); 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..2497c6133b --- /dev/null +++ b/src/table/__tests__/use-column-grouping.test.tsx @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../__tests__/render-hook'; +import { TableProps } from '../interfaces'; +import { useColumnGrouping } from '../use-column-grouping'; +import { COLUMN_DEFS, FLAT_DISPLAY, GROUP_DEFS, NESTED_DISPLAY, NESTED_GROUPS } from './column-grouping-fixtures'; + +describe('useColumnGrouping', () => { + describe('no grouping', () => { + it('returns a single flat row when no groups are defined', () => { + const { result } = renderHook(() => useColumnGrouping(undefined, COLUMN_DEFS)); + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + expect(result.current.rows[0].columns).toHaveLength(COLUMN_DEFS.length); + }); + + it('treats empty groups array the same as no groups', () => { + const { result } = renderHook(() => useColumnGrouping([], COLUMN_DEFS)); + expect(result.current.maxDepth).toBe(1); + expect(result.current.rows).toHaveLength(1); + }); + }); + + describe('grouped columns', () => { + it('creates two rows for flat grouping', () => { + const { result } = renderHook(() => useColumnGrouping(GROUP_DEFS, COLUMN_DEFS, undefined, FLAT_DISPLAY)); + expect(result.current.maxDepth).toBe(2); + expect(result.current.rows).toHaveLength(2); + }); + + it('creates three rows for nested grouping', () => { + const cols: TableProps.ColumnDefinition[] = [ + { id: 'cpu', header: 'CPU', cell: () => 'cpu' }, + { id: 'memory', header: 'Memory', cell: () => 'memory' }, + ]; + const { result } = renderHook(() => useColumnGrouping(NESTED_GROUPS, cols, undefined, NESTED_DISPLAY)); + expect(result.current.maxDepth).toBe(3); + expect(result.current.rows).toHaveLength(3); + expect(result.current.columnToParentIds.get('cpu')).toEqual(['metrics', 'performance']); + }); + }); + + describe('visibleColumnIds filtering (hook-specific)', () => { + it('uses the visibleColumnIds Set to exclude hidden columns', () => { + const visibleIds = new Set(['id', 'cpu']); + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { type: 'group', id: 'performance', visible: true, children: [{ id: 'cpu', visible: true }] }, + ]; + const { result } = renderHook(() => useColumnGrouping(GROUP_DEFS, COLUMN_DEFS, visibleIds, display)); + const allIds = result.current.rows.flatMap(r => r.columns.map(c => c.id)); + expect(allIds).toContain('cpu'); + expect(allIds).not.toContain('memory'); + expect(allIds).not.toContain('type'); + }); + + it('hides a group entirely when all its children are outside visibleColumnIds', () => { + const visibleIds = new Set(['id', 'name']); + const display: TableProps.ColumnDisplayProperties[] = [ + { id: 'id', visible: true }, + { id: 'name', visible: true }, + { type: 'group', visible: true, id: 'performance', children: [{ id: 'cpu', visible: false }] }, + ]; + const { result } = renderHook(() => useColumnGrouping(GROUP_DEFS, COLUMN_DEFS, visibleIds, display)); + const groupIds = result.current.rows.flatMap(r => r.columns.filter(c => c.isGroup).map(c => c.id)); + expect(groupIds).not.toContain('performance'); + }); + }); + + describe('edge cases', () => { + it('handles columns without IDs gracefully', () => { + const cols: TableProps.ColumnDefinition[] = [{ header: 'No ID', cell: () => 'x' }]; + const { result } = renderHook(() => useColumnGrouping([], cols)); + expect(result.current.rows).toBeDefined(); + }); + + it('warns in dev when a group referenced in columnDisplay is not in groupDefinitions', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const display: TableProps.ColumnDisplayProperties[] = [ + { type: 'group', visible: true, id: 'ghost-group', children: [{ id: 'cpu', visible: true }] }, + ]; + renderHook(() => useColumnGrouping([], COLUMN_DEFS, undefined, display)); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ghost-group')); + warnSpy.mockRestore(); + process.env.NODE_ENV = originalEnv; + }); + }); +}); diff --git a/src/table/column-grouping-utils.ts b/src/table/column-grouping-utils.ts new file mode 100644 index 0000000000..0a95572581 --- /dev/null +++ b/src/table/column-grouping-utils.ts @@ -0,0 +1,426 @@ +// 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 + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.GroupDefinition; + 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.GroupDefinition; +// parentNode?: TableHeaderNode; +// children?: TableHeaderNode[]; // order here implies actual render order +// colIndex?: number; // Absolute column index for aria-colindex +// } +// } + +/** + * Recursively builds the tree from the nested columnDisplay structure. + * Each ColumnDisplayGroup becomes a group node whose children are built recursively. + * Each ColumnDisplayItem (visible leaf) becomes a leaf column node. + */ +function buildTreeFromColumnDisplay( + displayItems: ReadonlyArray, + idToNodeMap: Map>, + parentNode: TableHeaderNode +): void { + for (const item of displayItems) { + if (item.type === 'group') { + // ColumnDisplayGroup — only add it if it has at least one visible descendant + const groupNode = idToNodeMap.get(item.id); + if (!groupNode) { + warnOnceInDev(`Group "${item.id}" referenced in columnDisplay not found in groupDefinitions. Skipping.`); + continue; + } + // Recursively build children first, then only attach the group if it got any children + buildTreeFromColumnDisplay(item.children, idToNodeMap, groupNode); + if (groupNode.children.length > 0) { + parentNode.addChild(groupNode); + } + } else { + // ColumnDisplayItem — leaf column + if (!item.visible) { + continue; + } + const colNode = idToNodeMap.get(item.id); + if (!colNode) { + continue; + } + parentNode.addChild(colNode); + } + } +} + +/** + * Fallback: when no columnDisplay is provided, connect all visible columns directly + * to root (flat, no grouping). + */ +function connectFlatColumns( + visibleLeafColumns: Readonly[]>, + idToNodeMap: Map>, + rootNode: TableHeaderNode +): void { + visibleLeafColumns.forEach(column => { + if (!column.id) { + return; + } + const node = idToNodeMap.get(column.id); + if (node) { + rootNode.addChild(node); + } + }); +} + +export class TableHeaderNode { + id: string; + colspan: number = 1; + rowspan: number = 1; + subtreeHeight: number = 1; + columnDefinition?: TableProps.ColumnDefinition; + groupDefinition?: TableProps.GroupDefinition; + 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.GroupDefinition; + 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.GroupDefinition[], + 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.GroupDefinition) => { + 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 }); + + // Build tree: use columnDisplay hierarchy if provided, otherwise flat + if (columnDisplayProperties && columnDisplayProperties.length > 0) { + buildTreeFromColumnDisplay(columnDisplayProperties, idToNodeMap, rootNode); + } else { + connectFlatColumns(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); + }); +} + +/** + * All nodes with rowspan > 1 keep their natural rowspan — no hidden placeholder expansion. + * + * - Leaf column nodes span from their row downward to the last header row, with the + * resizer covering the full height and content bottom-aligned via CSS. + * - Group nodes span from their natural rowIndex downward, filling available vertical + * space (e.g. "Configuration" which only needs 1 row of depth still spans rows 0–1 + * because its rowspan is calculated to fill the tree height imbalance). + * + * This function is kept as a no-op expansion pass — nothing is expanded into hidden nodes. + */ +function expandRowspansToHiddenNodes(node: TableHeaderNode): void { + // Process children (kept for structural consistency; no expansion occurs). + for (const child of [...node.children]) { + expandRowspansToHiddenNodes(child); + } + + if (node.isRoot) { + return; + } + + if (node.rowspan <= 1) { + return; + } + + // All nodes (leaf and group) keep their rowspan — skip hidden placeholder expansion. + 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) { + 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..a7f73600e5 --- /dev/null +++ b/src/table/header-cell/group-header-cell.tsx @@ -0,0 +1,368 @@ +// 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.GroupDefinition; + colspan: number; + rowspan: number; + colIndex: number; + groupId: string; + firstChildColumnId?: PropertyKey; + 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; + spansRows?: boolean; + isLastChildOfGroup?: boolean; + columnGroupId?: string; + isLastInRow?: boolean; +} + +export function TableGroupHeaderCell({ + group, + colspan, + rowspan, + colIndex, + groupId, + firstChildColumnId, + lastChildColumnId, + resizableColumns, + resizableStyle, + onResizeFinish, + updateGroupWidth, + childColumnIds, + focusedComponent, + tabIndex, + stuck, + sticky, + hidden, + stripedRows, + stickyState, + cellRef, + tableRole, + resizerRoleDescription, + resizerTooltipText, + variant, + tableVariant, + spansRows, + isLastChildOfGroup, + columnGroupId, +}: 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); + + const wrapperRef = useRef(null); + const removeScrollListenerRef = useRef<(() => void) | null>(null); + const resizeObserverRef = useRef(null); + const innerWrapperRef = useRef(null); + const rafRef = useRef(null); + + const computeVisibleWidth = () => { + const thEl = cellRefObject.current; + const innerEl = innerWrapperRef.current; + if (!thEl || !innerEl) { + return; + } + const wrapper = wrapperRef.current; + if (!wrapper) { + return; + } + + const thRect = thEl.getBoundingClientRect(); + const wrapperRect = wrapper.getBoundingClientRect(); + const thead = thEl.closest('thead'); + + const cellState = stickyState.store.get().cellState; + const stickyLeft = firstChildColumnId ? (cellState.get(firstChildColumnId)?.offset.insetInlineStart ?? 0) : 0; + const stickyRight = lastChildColumnId ? (cellState.get(lastChildColumnId)?.offset.insetInlineEnd ?? 0) : 0; + + // screenLeft: where the sticky inner wrapper's left edge actually sits on screen. + const screenLeft = Math.max(thRect.left, wrapperRect.left + stickyLeft); + + // Ceiling: last child's right edge, capped to the group's own right and the wrapper's sticky-right boundary. + const lastChildEl = lastChildColumnId + ? thead?.querySelector(`[data-focus-id="header-${String(lastChildColumnId)}"]`) + : null; + const ceilingRight = Math.min( + thRect.right, + lastChildEl ? lastChildEl.getBoundingClientRect().right : thRect.right, + wrapperRect.right - stickyRight + ); + + // Floor: inner wrapper must cover rightmost stuck-first child's right edge (or + // leftmost stuck-last child's left edge) so the group label stays over its own + // stuck children. Measured from screenLeft, not thRect.left. + let floor = 0; + if (thead) { + for (const id of childColumnIds) { + const cs = cellState.get(id); + if (cs?.offset.insetInlineStart !== undefined) { + const el = thead.querySelector(`[data-focus-id="header-${String(id)}"]`); + if (el) { + floor = Math.max(floor, el.getBoundingClientRect().right - screenLeft); + } + } else if (cs?.offset.insetInlineEnd !== undefined) { + const el = thead.querySelector(`[data-focus-id="header-${String(id)}"]`); + if (el) { + floor = Math.max(floor, screenLeft - el.getBoundingClientRect().left); + } + } + } + } + + const maxWidth = Math.max(floor, Math.max(0, ceilingRight - screenLeft)); + innerEl.style.maxWidth = maxWidth >= thEl.offsetWidth ? '' : `${maxWidth}px`; + innerEl.style.insetInlineStart = stickyLeft > 0 ? `${stickyLeft}px` : ''; + innerEl.style.insetInlineEnd = stickyRight > 0 ? `${stickyRight}px` : ''; + + const isStuck = floor > 0; + + const visibleRight = screenLeft + maxWidth; + const trimRight = Math.max(0, thRect.right - visibleRight); + thEl.style.clipPath = isStuck && trimRight > 0 ? `inset(0 ${trimRight}px 0 -24px)` : ''; + + // Position:sticky on the diff --git a/src/table/index.tsx b/src/table/index.tsx index 5bb3cd03a6..c254bb6fa6 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -32,7 +32,7 @@ const Table = React.forwardRef( const analyticsMetadata = getAnalyticsMetadataProps(props as BasePropsWithAnalyticsMetadata); const hasHiddenColumns = (props.visibleColumns && props.visibleColumns.length < props.columnDefinitions.length) || - props.columnDisplay?.some(col => !col.visible); + props.columnDisplay?.some(col => col.type !== 'group' && !col.visible); const hasStickyColumns = !!props.stickyColumns?.first || !!props.stickyColumns?.last; const baseComponentProps = useBaseComponent( 'Table', diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 1628cf5492..c68e308054 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -255,6 +255,12 @@ export interface TableProps extends BaseComponentProps { */ columnDisplay?: ReadonlyArray; + /** + * Defines the column groups. Each group has an `id` and `header` used to label the group header cell. + * The hierarchy is encoded in the `columnDisplay` property via nested `ColumnDisplayGroup` entries. + */ + groupDefinitions?: ReadonlyArray>; + /** * Specifies an array containing the `id`s of visible columns. If not set, all columns are displayed. * @@ -513,6 +519,13 @@ export namespace TableProps { selectedItemsCount?: number; } + // eslint-disable-next-line + export interface GroupDefinition { + id: string; + header: React.ReactNode; + ariaLabel?: (data: LabelData) => string; + } + export interface StickyColumns { first?: number; last?: number; @@ -602,11 +615,21 @@ export namespace TableProps { newValue: ValueType ) => Promise | void; - export interface ColumnDisplayProperties { + export interface ColumnDisplay { + type?: 'column' | undefined; + id: string; + visible: boolean; + } + + export interface GroupDisplay { + type: 'group'; id: string; visible: boolean; + children: ReadonlyArray; } + export type ColumnDisplayProperties = ColumnDisplay | GroupDisplay; + export interface ExpandableRows { getItemChildren: (item: T) => readonly T[]; isItemExpandable: (item: T) => boolean; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index cf2e4db610..6e647dec04 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, + groupDefinitions, 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( + groupDefinitions, + columnDefinitions, + visibleColumnIds, + columnDisplay + ); + const selectionProps = { items: allItems, rootItems: items, @@ -394,6 +438,8 @@ const InternalTable = React.forwardRef( selectionType, getSelectAllProps: selection.getSelectAllProps, columnDefinitions: visibleColumnDefinitions, + groupDefinitions, + 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 && ( + + )} .divider, inset-block-end: 0; inset-block-start: 0; min-block-size: awsui.$line-height-heading-xs; - max-block-size: calc(100% - #{$block-gap}); - margin-block: auto; + // stylelint-disable-next-line custom-property-pattern + max-block-size: var(--awsui-resizer-max-block-size, calc(100% - #{$block-gap})); + // stylelint-disable-next-line custom-property-pattern + margin-block-start: var(--awsui-resizer-margin-block-start, auto); + // stylelint-disable-next-line custom-property-pattern + margin-block-end: var(--awsui-resizer-margin-block-end, auto); margin-inline: auto; border-inline-start: awsui.$border-item-width solid awsui.$color-border-divider-interactive-default; box-sizing: border-box; diff --git a/src/table/selection/selection-cell.tsx b/src/table/selection/selection-cell.tsx index f2b894af81..1fe00d5bcd 100644 --- a/src/table/selection/selection-cell.tsx +++ b/src/table/selection/selection-cell.tsx @@ -40,6 +40,7 @@ export function TableHeaderSelectionCell({ colIndex={0} focusedComponent={focusedComponent} ariaLabel={selectAllProps?.selectionGroupLabel} + spansRows={!!props.rowSpan && props.rowSpan > 1} {...getAnalyticsMetadataAttribute({ action: selectAllProps?.checked ? 'deselectAll' : 'selectAll', })} @@ -52,6 +53,7 @@ export function TableHeaderSelectionCell({ focusedComponent={focusedComponent} {...selectAllProps} {...(props.sticky ? { tabIndex: -1 } : {})} + spansRows={!!props.rowSpan && props.rowSpan > 1} /> ) : ( {singleSelectionHeaderAriaLabel} diff --git a/src/table/selection/selection-control.tsx b/src/table/selection/selection-control.tsx index eec48e843b..8d27bcb364 100644 --- a/src/table/selection/selection-control.tsx +++ b/src/table/selection/selection-control.tsx @@ -23,6 +23,8 @@ export interface SelectionControlProps extends ItemSelectionProps { rowIndex?: number; itemKey?: string; verticalAlign?: 'middle' | 'top'; + /** Internal: of the cell (multi-row grouped header). */ + spansRows?: boolean; } export function SelectionControl({ @@ -37,6 +39,7 @@ export function SelectionControl({ rowIndex, itemKey, verticalAlign = 'middle', + spansRows, onChange, ...sharedProps }: SelectionControlProps) { @@ -104,7 +107,12 @@ export function SelectionControl({ onMouseUp={setShiftState} onClick={handleClick} htmlFor={controlId} - className={clsx(styles.label, styles.root, verticalAlign === 'top' && styles['label-top'])} + className={clsx( + styles.label, + styles.root, + verticalAlign === 'top' && !spansRows && styles['label-top'], + spansRows && styles['label-bottom'] + )} aria-label={ariaLabel} title={ariaLabel} {...(rowIndex !== undefined && !sharedProps.disabled diff --git a/src/table/selection/styles.scss b/src/table/selection/styles.scss index 0e5218042f..1486092c38 100644 --- a/src/table/selection/styles.scss +++ b/src/table/selection/styles.scss @@ -13,6 +13,7 @@ .label { display: flex; align-items: center; + // align-items: end; justify-content: center; position: absolute; padding-block-end: awsui.$space-xxs; @@ -29,6 +30,13 @@ padding-block-start: awsui.$space-xs; } +.label-bottom { + align-items: end; + // Match the bottom padding of leaf column header text: + // .header-cell padding-block-end ($space-scaled-xxs) + .header-cell-content padding-block-end ($space-scaled-xxs) + padding-block-end: calc(#{awsui.$space-scaled-xxs} + #{awsui.$space-scaled-xxs}); +} + .stud { visibility: hidden; } diff --git a/src/table/styles.scss b/src/table/styles.scss index 9b6744b964..8f3c9fd2af 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -55,7 +55,7 @@ inline-size: 100%; border-spacing: 0; position: relative; - box-sizing: border-box; + // box-sizing: border-box; &-layout-fixed { table-layout: fixed; } @@ -144,6 +144,15 @@ filter search icon. padding-inline: awsui.$space-scaled-l; border-inline-start: awsui.$border-item-width solid transparent; } + + // When the selection cell spans multiple header rows, use flex to push the + // checkbox to the bottom of the cell, matching bottom-aligned leaf column headers. + &-content-spans-rows { + display: flex; + flex-direction: column; + justify-content: flex-end; + block-size: 100%; + } } .header-secondary { diff --git a/src/table/table-role/__tests__/utils.test.ts b/src/table/table-role/__tests__/utils.test.ts new file mode 100644 index 0000000000..42b81a93de --- /dev/null +++ b/src/table/table-role/__tests__/utils.test.ts @@ -0,0 +1,199 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { findClosestCellByAriaColIndex, getAllCellsInRow } from '../../../../lib/components/table/table-role/utils'; + +interface CellDef { + tag: 'th' | 'td'; + colindex: number; + text: string; + rowspan?: number; + colspan?: number; +} + +interface RowDef { + rowindex: number; + cells: CellDef[]; +} + +function buildTable(rows: RowDef[]): HTMLTableElement { + const table = document.createElement('table'); + for (const rowDef of rows) { + const tr = document.createElement('tr'); + tr.setAttribute('aria-rowindex', String(rowDef.rowindex)); + for (const cellDef of rowDef.cells) { + const cell = document.createElement(cellDef.tag); + cell.setAttribute('aria-colindex', String(cellDef.colindex)); + cell.textContent = cellDef.text; + if (cellDef.rowspan && cellDef.rowspan > 1) { + cell.rowSpan = cellDef.rowspan; + } + if (cellDef.colspan && cellDef.colspan > 1) { + cell.colSpan = cellDef.colspan; + } + tr.appendChild(cell); + } + table.appendChild(tr); + } + return table; +} + +describe('getAllCellsInRow', () => { + test('returns empty array for null table', () => { + expect(getAllCellsInRow(null, 1)).toEqual([]); + }); + + test('returns cells from a simple row', () => { + const table = buildTable([ + { + rowindex: 1, + cells: [ + { tag: 'th', colindex: 1, text: 'Col 1' }, + { tag: 'th', colindex: 2, text: 'Col 2' }, + ], + }, + { + rowindex: 2, + cells: [ + { tag: 'td', colindex: 1, text: 'A' }, + { tag: 'td', colindex: 2, text: 'B' }, + ], + }, + ]); + const cells = getAllCellsInRow(table, 2); + expect(cells.length).toBe(2); + expect(cells[0].textContent).toBe('A'); + expect(cells[1].textContent).toBe('B'); + }); + + test('includes cells with rowspan that span into the target row', () => { + const table = buildTable([ + { + rowindex: 1, + cells: [ + { tag: 'th', colindex: 1, text: 'Selection', rowspan: 3 }, + { tag: 'th', colindex: 2, text: 'Group' }, + ], + }, + { rowindex: 2, cells: [{ tag: 'th', colindex: 2, text: 'Leaf Col' }] }, + { + rowindex: 3, + cells: [ + { tag: 'td', colindex: 1, text: 'Data A' }, + { tag: 'td', colindex: 2, text: 'Data B' }, + ], + }, + ]); + + // Row 2: should include Selection (rowspan=3 from row 1) + Leaf Col + const row2Cells = getAllCellsInRow(table, 2); + expect(row2Cells.length).toBe(2); + expect(row2Cells[0].textContent).toBe('Selection'); + expect(row2Cells[1].textContent).toBe('Leaf Col'); + + // Row 3: should include Selection (still spanning) + both data cells + const row3Cells = getAllCellsInRow(table, 3); + expect(row3Cells.length).toBe(3); + }); + + test('excludes cells whose rowspan does not reach the target row', () => { + const table = buildTable([ + { + rowindex: 1, + cells: [ + { tag: 'th', colindex: 1, text: 'Group', rowspan: 2 }, + { tag: 'th', colindex: 2, text: 'Other' }, + ], + }, + { rowindex: 2, cells: [{ tag: 'th', colindex: 2, text: 'Under Other' }] }, + { + rowindex: 3, + cells: [ + { tag: 'td', colindex: 1, text: 'Data' }, + { tag: 'td', colindex: 2, text: 'Data' }, + ], + }, + ]); + + // Row 3: Group (rowspan=2, from row1) does NOT reach row 3 + const row3Cells = getAllCellsInRow(table, 3); + expect(row3Cells.length).toBe(2); + expect(row3Cells[0].textContent).toBe('Data'); + }); + + test('skips rows with aria-rowindex greater than target', () => { + const table = buildTable([ + { rowindex: 1, cells: [{ tag: 'td', colindex: 1, text: 'R1' }] }, + { rowindex: 5, cells: [{ tag: 'td', colindex: 1, text: 'R5' }] }, + ]); + const cells = getAllCellsInRow(table, 1); + expect(cells.length).toBe(1); + expect(cells[0].textContent).toBe('R1'); + }); +}); + +describe('findClosestCellByAriaColIndex', () => { + function createCells( + colConfigs: Array<{ colindex: number; colspan?: number; text: string }> + ): HTMLTableCellElement[] { + return colConfigs.map(({ colindex, colspan, text }) => { + const td = document.createElement('td'); + td.setAttribute('aria-colindex', String(colindex)); + if (colspan && colspan > 1) { + td.colSpan = colspan; + } + td.textContent = text; + return td; + }); + } + + test('returns exact match by colspan range', () => { + const cells = createCells([ + { colindex: 1, text: 'A' }, + { colindex: 2, colspan: 3, text: 'B-span' }, + { colindex: 5, text: 'C' }, + ]); + + // Target colindex 3 falls within B-span (colindex=2, colspan=3 -> covers 2,3,4) + const result = findClosestCellByAriaColIndex(cells, 3, 1); + expect(result?.textContent).toBe('B-span'); + }); + + test('returns exact match for single column', () => { + const cells = createCells([ + { colindex: 1, text: 'A' }, + { colindex: 2, text: 'B' }, + { colindex: 3, text: 'C' }, + ]); + + const result = findClosestCellByAriaColIndex(cells, 2, 1); + expect(result?.textContent).toBe('B'); + }); + + test('returns closest cell in positive direction when no exact match', () => { + const cells = createCells([ + { colindex: 1, text: 'A' }, + { colindex: 5, text: 'E' }, + ]); + + // Looking for colindex 3, delta > 0 -> should return colindex 5 + const result = findClosestCellByAriaColIndex(cells, 3, 1); + expect(result?.textContent).toBe('E'); + }); + + test('returns closest cell in negative direction when no exact match', () => { + const cells = createCells([ + { colindex: 1, text: 'A' }, + { colindex: 5, text: 'E' }, + ]); + + // Looking for colindex 3, delta < 0 -> should return colindex 1 + const result = findClosestCellByAriaColIndex(cells, 3, -1); + expect(result?.textContent).toBe('A'); + }); + + test('returns null for empty cells array', () => { + const result = findClosestCellByAriaColIndex([], 1, 1); + expect(result).toBe(null); + }); +}); diff --git a/src/table/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..91c3a377f7 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>; + groupDefinitions?: 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,203 @@ 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 — render once in the first row with rowSpan covering all header rows */} + {selectionType && rowIndex === 0 ? ( + onResizeFinish(columnWidths)} - resizableColumns={resizableColumns} - resizableStyle={getColumnStyles(sticky, columnId)} - onClick={detail => { - setLastUserAction('sorting'); - fireNonCancelableEvent(onSortingChange, detail); - }} - isEditable={!!column.editConfig} - cellRef={node => setCell(sticky, columnId, node)} - tableRole={tableRole} - resizerRoleDescription={resizerRoleDescription} - resizerTooltipText={resizerTooltipText} - // Expandable option is only applicable to the first data column of the table. - // When present, the header content receives extra padding to match the first offset in the data cells. - isExpandable={colIndex === 0 && isExpandable} - hasDynamicContent={hidden && !resizableColumns && column.hasDynamicContent} + columnId={selectionColumnId} + getSelectAllProps={getSelectAllProps} + onFocusMove={onFocusMove} + singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel} + rowSpan={hierarchicalStructure.rows.length} /> - ); - })} - + ) : null} + + {row.columns.map((col, colIndexInRow) => { + // A cell is the last child of its parent group when the next rendered cell + // in the same row belongs to a different top-level parent, i.e. they don't + // share the same immediate parent group. + const nextCol = row.columns[colIndexInRow + 1]; + const thisParent = col.parentGroupIds[col.parentGroupIds.length - 1] ?? null; + const nextParent = nextCol ? (nextCol.parentGroupIds[nextCol.parentGroupIds.length - 1] ?? null) : null; + const isLastChildOfGroup = thisParent !== null && thisParent !== nextParent; + + if (col.isGroup) { + // Group header cell + const groupDefinition = col.groupDefinition!; + const childIds = getChildColumnIds(col.id); + + return ( + 1} + colIndex={selectionType ? col.colIndex + 1 : col.colIndex} + groupId={col.id} + resizableColumns={resizableColumns} + resizableStyle={resizableColumns ? {} : getColumnStyles(sticky, col.id)} + onResizeFinish={() => 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} + isLastChildOfGroup={isLastChildOfGroup} + columnGroupId={ + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined + } + /> + ); + } else { + // Regular column cell + const column = col.columnDefinition!; + const columnId = col.id; + const colIndex = col.colIndex; + + return ( + onResizeFinish(columnWidths)} + resizableColumns={resizableColumns} + resizableStyle={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} + spansRows={col.rowspan > 1} + isLastChildOfGroup={isLastChildOfGroup} + columnGroupId={ + col.parentGroupIds.length > 0 ? col.parentGroupIds[col.parentGroupIds.length - 1] : undefined + } + /> + ); + } + })} + + ))} ); } diff --git a/src/table/use-column-grouping.ts b/src/table/use-column-grouping.ts new file mode 100644 index 0000000000..4922e3f6e5 --- /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} ); diff --git a/src/table/utils.ts b/src/table/utils.ts index 6822e581e4..3907a44744 100644 --- a/src/table/utils.ts +++ b/src/table/utils.ts @@ -68,6 +68,24 @@ export function getVisibleColumnDefinitions({ } } +/** + * Recursively flattens a nested ColumnDisplayProperties array into an ordered list + * of visible leaf column IDs. + */ +function flattenVisibleColumnIds(items: ReadonlyArray): string[] { + const ids: string[] = []; + for (const item of items) { + if (item.type === 'group') { + // ColumnDisplayGroup — recurse into children + ids.push(...flattenVisibleColumnIds(item.children)); + } else if (item.visible) { + // ColumnDisplayItem — include if visible + ids.push(item.id); + } + } + return ids; +} + function getVisibleColumnDefinitionsFromColumnDisplay({ columnDisplay, columnDefinitions, @@ -79,10 +97,8 @@ function getVisibleColumnDefinitionsFromColumnDisplay({ (accumulator, item) => (item.id === undefined ? accumulator : { ...accumulator, [item.id]: item }), {} ); - return columnDisplay - .filter(item => item.visible) - .map(item => columnDefinitionsById[item.id]) - .filter(Boolean); + const visibleIds = flattenVisibleColumnIds(columnDisplay); + return visibleIds.map(id => columnDefinitionsById[id]).filter(Boolean); } function getVisibleColumnDefinitionsFromVisibleColumns({ diff --git a/src/test-utils/dom/collection-preferences/content-display-preference.ts b/src/test-utils/dom/collection-preferences/content-display-preference.ts index f3a9952be4..26bdb286ba 100644 --- a/src/test-utils/dom/collection-preferences/content-display-preference.ts +++ b/src/test-utils/dom/collection-preferences/content-display-preference.ts @@ -14,6 +14,7 @@ export class ContentDisplayOptionWrapper extends ComponentWrapper { private getListItem(): ListItemWrapper { return new ListItemWrapper(this.getElement()); } + /** * Returns the drag handle for the option item. */ @@ -30,12 +31,37 @@ export class ContentDisplayOptionWrapper extends ComponentWrapper { /** * Returns the visibility toggle for the option item. + * Note that, despite its typings, this may return null for group items since groups do not have a visibility toggle. */ findVisibilityToggle(): ToggleWrapper { return this.getListItem() .findContent() .findComponent(`.${styles['content-display-option-toggle']}`, ToggleWrapper)!; } + + /** + * Returns all child option items nested under this item when it is a group. + * Returns `null` when this item is a leaf column (has no nested children). + * + * The children are the leaf-level `ContentDisplayOptionWrapper`s inside the group's + * nested `InternalList` — i.e. they already carry a drag handle and visibility toggle. + */ + findChildrenOptions(): Array | null { + // Group items wrap their content in
. + // If that wrapper is absent this is a leaf column. + const groupWrapper = this.getListItem().findContent().find('[data-item-type="group"]'); + if (!groupWrapper) { + return null; + } + // The nested list is scoped inside the group wrapper. + const nestedList = groupWrapper.find(`.${ListWrapper.rootSelector}`); + if (!nestedList) { + return null; + } + return new ListWrapper(nestedList.getElement()) + .findItems() + .map(item => new ContentDisplayOptionWrapper(item.getElement())); + } } export default class ContentDisplayPreferenceWrapper extends ComponentWrapper { @@ -70,9 +96,37 @@ export default class ContentDisplayPreferenceWrapper extends ComponentWrapper { } /** - * Returns options that the user can reorder. + * Returns the top-level items in the preference list. + * + * For tables **without** column grouping this returns all column options. + * For tables **with** column grouping this returns the top-level entries only + * (which are group items). Use `.findChildrenOptions()` on a group item to + * access the leaf columns nested within it. + * + * @param option.group When `true`, returns only group items. When `false`, returns only leaf column items. + * When omitted, returns all top-level items regardless of type. + * @param option.visible When `true`, returns only visible items. When `false`, returns only hidden items. + * Note that group items have no visibility toggle and are excluded when this filter is active. */ - findOptions(): Array { + findOptions( + option: { + group?: boolean; + } = {} + ): Array { + if (option.group === true) { + // Only group items — identified by the data-item-type="group" wrapper inside the list item + return this.getList() + .findAll(`li:has([data-item-type="group"])`) + .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); + } + if (option.group === false) { + // Only leaf column items — identified by the data-item-type="column" wrapper + return this.getList() + .findAll(`li:has([data-item-type="column"])`) + .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); + } + + // No group filter — return all top-level items return this.getList() .findItems() .map(wrapper => new ContentDisplayOptionWrapper(wrapper.getElement())); diff --git a/src/test-utils/dom/table/index.ts b/src/test-utils/dom/table/index.ts index 4858341902..325b2d057d 100644 --- a/src/test-utils/dom/table/index.ts +++ b/src/test-utils/dom/table/index.ts @@ -46,17 +46,66 @@ export default class TableWrapper extends ComponentWrapper { return this.containerWrapper.findFooter(); } - findColumnHeaders(): Array { - return this.findActiveTHead().findAll('tr > *'); + /** + * Returns all column header cells in the last header row (leaf columns). + * For tables without column grouping this is equivalent to querying `tr > *`. + * For tables with column grouping this returns only the leaf-level column headers, + * not the group header cells above them. + * + * Pass `{ level }` to target a specific header row (1-based). Level 1 is the + * topmost row (group headers); the last level is always the leaf-column row. + * + * Pass `{ groupId }` to return only the leaf column headers that are direct + * children of the specified group. This uses the `data-column-group-id` attribute + * set on each leaf `
only when actually stuck (not just when sticky columns exist). + if (isStuck && stickyLeft > 0) { + thEl.style.position = 'sticky'; + thEl.style.zIndex = '800'; + thEl.style.insetInlineStart = `${stickyLeft}px`; + thEl.style.insetInlineEnd = ''; + } else if (isStuck && stickyRight > 0) { + thEl.style.position = 'sticky'; + thEl.style.zIndex = '800'; + thEl.style.insetInlineEnd = `${stickyRight}px`; + thEl.style.insetInlineStart = ''; + } else { + thEl.style.position = ''; + thEl.style.zIndex = ''; + thEl.style.insetInlineStart = ''; + thEl.style.insetInlineEnd = ''; + } + + // thEl.classList.toggle(styles['header-cell-stuck'], isStuck); + // thEl.classList.toggle(styles['sticky-cell-last-inline-start'], isStuck && stickyLeft > 0); + thEl.classList.toggle(styles['sticky-cell-last-inline-end'], isStuck && stickyRight > 0); + }; + + const scheduleCompute = () => { + if (rafRef.current !== null) { + return; + } + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + computeVisibleWidth(); + }); + }; + + const attachScrollListener = () => { + const el = cellRefObject.current; + if (!el) { + return; + } + + removeScrollListenerRef.current?.(); + removeScrollListenerRef.current = null; + + 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; + + wrapper.addEventListener('scroll', scheduleCompute, { passive: true }); + removeScrollListenerRef.current = () => wrapper!.removeEventListener('scroll', scheduleCompute); + + // Observe the size so computeVisibleWidth re-runs after column resizes. + resizeObserverRef.current?.disconnect(); + const ro = new ResizeObserver(scheduleCompute); + ro.observe(el); + resizeObserverRef.current = ro; + + computeVisibleWidth(); + }; + + useEffect(() => { + const id = setTimeout(attachScrollListener, 0); + return () => { + clearTimeout(id); + removeScrollListenerRef.current?.(); + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastChildColumnId]); + + useEffect(() => { + if (!firstChildColumnId && !lastChildColumnId) { + return; + } + + const firstSelector = firstChildColumnId + ? (state: Parameters[0] extends (s: infer S) => any ? S : never) => + state.cellState.get(firstChildColumnId) ?? null + : 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; + } + + const stickyLastClass = styles['sticky-cell-last-inline-start']; + if (lastState?.lastInsetInlineStart) { + thEl.classList.add(stickyLastClass); + } else { + thEl.classList.remove(stickyLastClass); + } + + const stickyLastEndClass = styles['sticky-cell-last-inline-end']; + if (firstState?.lastInsetInlineEnd) { + thEl.classList.add(stickyLastEndClass); + } else { + thEl.classList.remove(stickyLastEndClass); + } + + if (!wrapperRef.current) { + attachScrollListener(); + } else { + computeVisibleWidth(); + } + }; + + const firstState = firstChildColumnId ? (stickyState.store.get().cellState.get(firstChildColumnId) ?? null) : null; + const lastState = lastChildColumnId ? (stickyState.store.get().cellState.get(lastChildColumnId) ?? null) : null; + applyStyles(firstState, lastState); + + const unsubFirst = firstSelector + ? stickyState.store.subscribe(firstSelector, () => { + const s1 = firstChildColumnId ? (stickyState.store.get().cellState.get(firstChildColumnId) ?? null) : null; + const s2 = lastChildColumnId ? (stickyState.store.get().cellState.get(lastChildColumnId) ?? null) : null; + applyStyles(s1, s2); + }) + : null; + + const unsubLast = lastSelector + ? stickyState.store.subscribe(lastSelector, () => { + const s1 = firstChildColumnId ? (stickyState.store.get().cellState.get(firstChildColumnId) ?? null) : null; + const s2 = lastChildColumnId ? (stickyState.store.get().cellState.get(lastChildColumnId) ?? null) : 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..0fcffb3460 --- /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 { 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 ( +// as data-column-group-id for test-utils. */ + columnGroupId?: string; + /** When true, applies bottom-alignment (cell spans multiple header rows in a grouped table). */ + spansRows?: boolean; + /** When true, this cell is the rightmost child within its parent group. */ + isLastChildOfGroup?: boolean; } export function TableHeaderCell({ @@ -82,6 +90,11 @@ export function TableHeaderCell({ hasDynamicContent, variant, tableVariant, + colSpan, + rowSpan, + columnGroupId, + spansRows, + isLastChildOfGroup, }: TableHeaderCellProps) { const i18n = useInternalI18n('table'); const sortable = !!column.sortingComparator || !!column.sortingField; @@ -139,6 +152,11 @@ export function TableHeaderCell({ tableRole={tableRole} variant={variant} tableVariant={tableVariant} + colSpan={colSpan} + rowSpan={rowSpan} + columnGroupId={columnGroupId} + spansRows={spansRows} + isLastChildOfGroup={isLastChildOfGroup} {...(sortingDisabled ? {} : getAnalyticsMetadataAttribute({ @@ -210,8 +228,6 @@ export function TableHeaderCell({ ariaLabelledby={headerId} minWidth={typeof column.minWidth === 'string' ? parseInt(column.minWidth) : column.minWidth} roleDescription={i18n('ariaLabels.resizerRoleDescription', resizerRoleDescription)} - // TODO: Replace with this when strings are available - // tooltipText={i18n('ariaLabels.resizerTooltipText', resizerTooltipText)} tooltipText={resizerTooltipText} isBorderless={variant === 'full-page' || variant === 'embedded' || variant === 'borderless'} /> diff --git a/src/table/header-cell/styles.scss b/src/table/header-cell/styles.scss index d004215c5e..1b40f446e0 100644 --- a/src/table/header-cell/styles.scss +++ b/src/table/header-cell/styles.scss @@ -13,6 +13,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; @include focus-visible.when-visible { @content; } + &.header-cell-fake-focus { @include focus-visible.when-visible-unfocused { @content; @@ -52,7 +53,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; .header-cell { position: relative; text-align: start; - box-sizing: border-box; + // box-sizing: border-box; border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; background: awsui.$color-background-table-header; color: awsui.$color-text-column-header; @@ -66,19 +67,53 @@ $cell-horizontal-padding: awsui.$space-scaled-l; &-sticky { border-block-end: awsui.$border-table-sticky-width solid awsui.$color-border-divider-default; } + &-stuck:not(.header-cell-variant-full-page) { border-block-end-color: transparent; } + &-variant-full-page { background: awsui.$color-background-layout-main; } + + // &-group { + // position: sticky; + // inset-inline-start: 0; + // z-index: 1; + // } + + &:has(.header-cell-hidden-content) { + border-block-end-color: transparent; + } + + &:has(.header-cell-hidden-content:focus) { + // @include styles.focus-highlight(calc(-1 * #{awsui.$space-scaled-xxs})); + } + + &:not(:last-child):has(.header-cell-hidden-content) { + > .resize-divider { + position: absolute; + outline: none; + pointer-events: none; + inset-inline-end: 0; + inset-block-end: 0; + inset-block-start: 0; + margin-block: auto; + margin-inline: auto; + border-inline-start: awsui.$border-item-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + } + } + &-variant-full-page.header-cell-hidden { border-block-end-color: transparent; } + &-variant-embedded.is-visual-refresh:not(:is(.header-cell-sticky, .sticky-cell)), &-variant-borderless.is-visual-refresh:not(:is(.header-cell-sticky, .sticky-cell)) { background: none; } + &:last-child, &.header-cell-sortable { padding-inline-end: awsui.$space-xs; @@ -88,20 +123,26 @@ $cell-horizontal-padding: awsui.$space-scaled-l; position: sticky; background: awsui.$color-background-table-header; z-index: 798; // Lower than the AppLayout's notification slot z-index(799) + @include styles.with-motion { transition-property: padding; transition-duration: awsui.$motion-duration-transition-show-quick; transition-timing-function: awsui.$motion-easing-sticky; } + &.table-variant-full-page { background: awsui.$color-background-layout-main; } + &-pad-left:not(.has-selection) { padding-inline-start: awsui.$space-table-horizontal; } + &-last-inline-start { box-shadow: awsui.$shadow-sticky-column-first; clip-path: inset(0px -24px 0px 0px); + + // stylelint-disable-next-line no-descending-specificity & > .resize-divider { display: none; } @@ -111,6 +152,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; clip-path: inset(0 0 0 -24px); } } + &-last-inline-end { box-shadow: awsui.$shadow-sticky-column-last; clip-path: inset(0 0 0 -24px); @@ -146,6 +188,7 @@ $cell-horizontal-padding: awsui.$space-scaled-l; .header-cell-sortable > & { padding-inline-end: calc(#{awsui.$space-xl} + #{awsui.$space-xxs}); } + &:focus { outline: none; text-decoration: none; @@ -160,13 +203,55 @@ $cell-horizontal-padding: awsui.$space-scaled-l; } } +.header-cell-spans-rows { + block-size: 1px; + vertical-align: bottom; + + > .header-cell-content { + block-size: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-end; + + // stylelint-disable-next-line no-descending-specificity + > .sorting-icon { + inset-block-start: auto; + inset-block-end: awsui.$space-scaled-xxs; + transform: none; + } + } + + > .header-cell-content-group-inner { + block-size: 100%; + + > .header-cell-content { + block-size: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + justify-content: flex-end; + } + } +} + +// stylelint-disable-next-line no-descending-specificity +.header-cell-content-group-inner { + position: sticky; + inset-inline-start: 0; + overflow: hidden; + box-sizing: border-box; +} + .header-cell-sortable:not(.header-cell-disabled) { & > .header-cell-content { cursor: pointer; } + & > .header-cell-content:hover, &.header-cell-sorted > .header-cell-content { color: awsui.$color-text-interactive-active; + & > .sorting-icon { color: awsui.$color-text-interactive-active; } @@ -206,12 +291,22 @@ settings icon in the pagination slot. &:first-child { @include header-cell-focus-outline-first(awsui.$space-scaled-xxs); } + + &:nth-child(2):not(:first-child) { + @include header-cell-focus-outline-first(awsui.$space-scaled-xxs); + } + &:first-child > .header-cell-content { - @include cell-offset(0px); + // @include cell-offset(0px); + @include header-cell-focus-outline-first(awsui.$space-table-header-focus-outline-gutter); + } + + &:nth-child(2):not(:first-child) > .header-cell-content { @include header-cell-focus-outline-first(awsui.$space-table-header-focus-outline-gutter); } - &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start) { + &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start), + &:nth-child(2):not(:first-child):not(.has-striped-rows):not(.sticky-cell-pad-inline-start) { @include cell-offset(awsui.$space-xxxs); } @@ -220,7 +315,8 @@ settings icon in the pagination slot. shaded background makes the child content appear too close to the table edge. */ - &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start) { + &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start), + &:nth-child(2):not(:first-child).has-striped-rows:not(.sticky-cell-pad-inline-start) { @include cell-offset(awsui.$space-xxs); } @@ -232,3 +328,68 @@ settings icon in the pagination slot. @include cell-offset($cell-horizontal-padding); } } + +// stylelint-disable-next-line custom-property-pattern +$resizer-block-gap: var(--awsui-table-resizer-block-gap, 18px); + +.header-cell-group, +.header-cell-group:last-child { + padding-block: 0; + padding-inline: 0; + // stylelint-disable custom-property-pattern + --awsui-resizer-max-block-size: calc(100% - #{$resizer-block-gap} / 2); + --awsui-resizer-margin-block-start: auto; + --awsui-resizer-margin-block-end: 0px; + // stylelint-enable custom-property-pattern +} + +.header-cell-group.header-cell-grouped { + // stylelint-disable custom-property-pattern + --awsui-resizer-max-block-size: 100%; + --awsui-resizer-margin-block-start: 0px; + --awsui-resizer-margin-block-end: 0px; + // stylelint-enable custom-property-pattern +} + +.header-cell-last-child-of-group:not(.header-cell-group) { + // stylelint-disable custom-property-pattern + --awsui-resizer-max-block-size: calc(100% - #{$resizer-block-gap} / 2); + --awsui-resizer-margin-block-start: 0px; + --awsui-resizer-margin-block-end: auto; + // stylelint-enable custom-property-pattern +} + +// stylelint-disable-next-line no-descending-specificity, selector-combinator-disallowed-list +.header-cell-group:not(.header-cell-grouped):last-child .resize-divider { + display: none; +} + +// stylelint-disable-next-line no-descending-specificity, selector-combinator-disallowed-list +.header-cell-group .resize-divider { + position: absolute; + outline: none; + pointer-events: none; + inset-inline-end: 0; + inset-block-end: 0; + inset-block-start: 0; + min-block-size: awsui.$line-height-heading-xs; + // stylelint-disable-next-line custom-property-pattern + max-block-size: var(--awsui-resizer-max-block-size, calc(100% - #{$resizer-block-gap})); + // stylelint-disable-next-line custom-property-pattern + margin-block-start: var(--awsui-resizer-margin-block-start, auto); + // stylelint-disable-next-line custom-property-pattern + margin-block-end: var(--awsui-resizer-margin-block-end, 0px); + margin-inline: auto; + border-inline-start: awsui.$border-item-width solid awsui.$color-border-divider-default; + box-sizing: border-box; +} + +// stylelint-disable-next-line no-descending-specificity +.header-cell-last-child-of-group:not(.header-cell-group) > .resize-divider { + // stylelint-disable-next-line custom-property-pattern + max-block-size: var(--awsui-resizer-max-block-size, calc(100% - #{$resizer-block-gap})); + // stylelint-disable-next-line custom-property-pattern + margin-block-start: var(--awsui-resizer-margin-block-start, auto); + // stylelint-disable-next-line custom-property-pattern + margin-block-end: var(--awsui-resizer-margin-block-end, auto); +} diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 55e5739e02..bf091df348 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -38,6 +38,32 @@ export interface TableThElementProps { variant: TableProps.Variant; tableVariant?: TableProps.Variant; ariaLabel?: string; + colSpan?: number; + rowSpan?: number; + scope?: 'col' | 'colgroup'; + /** + * When true, the cell is a hidden placeholder (not a real header). + * A distinct data-focus-id prefix ("header-placeholder-") is used so that + * focusedComponent matching never accidentally triggers header-cell-fake-focus + * on the real leaf cell that shares the same columnId. + */ + isPlaceholder?: boolean; + /** + * ID of the direct parent group for this leaf column cell. + * Used as a `data-column-group-id` test-utils hook to allow querying columns by group. + * Omit for top-level columns that have no group parent. + */ + columnGroupId?: string; + /** + * When true, the cell spans multiple header rows (leaf column in a grouped table). + * Applies bottom-alignment so content sits flush with grouped column labels in the last row. + */ + spansRows?: boolean; + /** + * When true, this cell is the rightmost child within its parent group. + * Its divider/resizer extends fully to connect to the parent group's horizontal border. + */ + isLastChildOfGroup?: boolean; } export function TableThElement({ @@ -60,6 +86,13 @@ export function TableThElement({ variant, ariaLabel, tableVariant, + colSpan, + rowSpan, + scope, + isPlaceholder, + columnGroupId, + spansRows, + isLastChildOfGroup, ...props }: TableThElementProps) { const isVisualRefresh = useVisualRefresh(); @@ -76,7 +109,7 @@ export function TableThElement({ return ( 1 ? { colSpan } : {})} + {...(rowSpan && rowSpan > 1 ? { rowSpan } : {})} + {...(columnGroupId ? { 'data-column-group-id': columnGroupId } : {})} > {children}
` by the renderer. + * + * @param option.level 1-based index of the header row to query. Defaults to the last row. + * @param option.groupId ID of the parent group whose direct child columns to return. + */ + findColumnHeaders( + option: { + groupId?: string; + level?: number; + } = {} + ): Array { + if (option.groupId !== undefined) { + return this.findActiveTHead().findAll(`tr:last-child > th[data-column-group-id="${option.groupId}"]`); + } + if (option.level !== undefined) { + return this.findActiveTHead().findAll(`tr:nth-child(${option.level}) > *`); + } + return this.findActiveTHead().findAll('tr:last-child > *'); } /** - * Returns the element the user clicks when resizing a column. + * Returns the element the user clicks when resizing a column or group header. + * Targets the leaf-column header row by default. * - * @param columnIndex 1-based index of the column containing the resizer. + * Pass `{ level }` to target a specific header row (1-based). + * + * Pass `{ groupId }` to return the resizer of the group header cell with that ID. + * When `groupId` is provided, `columnIndex` is ignored. + * + * @param columnIndex 1-based index of the column containing the resizer (ignored when groupId is set). + * @param option.level 1-based index of the header row to query. Defaults to the last row. + * @param option.groupId ID of the group header whose resizer to return. */ - findColumnResizer(columnIndex: number): ElementWrapper | null { - return this.findActiveTHead().find(`th:nth-child(${columnIndex}) .${resizerStyles.resizer}`); + findColumnResizer( + columnIndex: number, + option: { + groupId?: string; + level?: number; + } = {} + ): ElementWrapper | null { + if (option.groupId !== undefined) { + // Use a CSS :has() selector to locate the colgroup containing the group focus marker, + // then find the resizer inside it. + return this.findActiveTHead().find( + `th[scope="colgroup"]:has([data-focus-id="group-header-${option.groupId}"]) .${resizerStyles.resizer}` + ); + } + const rowSelector = option.level !== undefined ? `tr:nth-child(${option.level})` : 'tr:last-child'; + return this.findActiveTHead().find(`${rowSelector} th:nth-child(${columnIndex}) .${resizerStyles.resizer}`); } /** @@ -105,8 +154,14 @@ export default class TableWrapper extends ComponentWrapper { return this.findByClassName(styles.loading); } + /** + * Returns the clickable sorting area of a column header. + * Targets the leaf-column header row by default. + * + * @param colIndex 1-based index of the column. + */ findColumnSortingArea(colIndex: number): ElementWrapper | null { - return this.findActiveTHead().find(`tr > *:nth-child(${colIndex}) [role=button]`); + return this.findActiveTHead().find(`tr:last-child > *:nth-child(${colIndex}) [role=button]`); } /**