From b5bdb2d84805613a695ba4f0ea23da2901ce400b Mon Sep 17 00:00:00 2001 From: Ernst Kaese Date: Thu, 23 Jul 2026 12:32:06 +0000 Subject: [PATCH] feat: add table row group headers --- pages/table/row-grouping.page.tsx | 87 +++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 84 +++++++++++++++ .../test-utils-selectors.test.tsx.snap | 1 + src/table/__tests__/row-grouping.test.tsx | 100 ++++++++++++++++++ src/table/index.tsx | 1 + src/table/interfaces.tsx | 27 +++++ src/table/internal.tsx | 64 ++++++++++- src/table/row-grouping/group-header-row.tsx | 49 +++++++++ src/table/styles.scss | 21 ++++ src/test-utils/dom/table/index.ts | 9 +- 10 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 pages/table/row-grouping.page.tsx create mode 100644 src/table/__tests__/row-grouping.test.tsx create mode 100644 src/table/row-grouping/group-header-row.tsx diff --git a/pages/table/row-grouping.page.tsx b/pages/table/row-grouping.page.tsx new file mode 100644 index 0000000000..0b2f5b84a5 --- /dev/null +++ b/pages/table/row-grouping.page.tsx @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import StatusIndicator from '~components/status-indicator'; +import Table, { TableProps } from '~components/table'; + +interface Employee { + id: string; + name: string; + role: string; + department: string; + status: 'active' | 'on-leave'; +} + +const items: Employee[] = [ + { id: '1', name: 'Alice Johnson', role: 'Frontend Engineer', department: 'Engineering', status: 'active' }, + { id: '2', name: 'Bob Smith', role: 'Backend Engineer', department: 'Engineering', status: 'on-leave' }, + { id: '3', name: 'Carol White', role: 'Engineering Manager', department: 'Engineering', status: 'active' }, + { id: '4', name: 'Dan Brown', role: 'Product Manager', department: 'Product', status: 'active' }, + { id: '5', name: 'Eve Davis', role: 'Product Designer', department: 'Product', status: 'active' }, + { id: '6', name: 'Frank Miller', role: 'Account Executive', department: 'Sales', status: 'on-leave' }, + { id: '7', name: 'Grace Lee', role: 'Sales Development Rep', department: 'Sales', status: 'active' }, +]; + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'name', header: 'Name', cell: item => item.name, isRowHeader: true }, + { id: 'role', header: 'Role', cell: item => item.role }, + { + id: 'status', + header: 'Status', + cell: item => ( + + {item.status === 'active' ? 'Active' : 'On leave'} + + ), + }, +]; + +const rowGrouping: TableProps.RowGrouping = { + getGroupId: item => item.department, + renderGroupHeader: ({ groupId, items }) => ( + + {groupId} + ({items.length}) + + ), +}; + +export default function RowGroupingPage() { + const [selectedItems, setSelectedItems] = useState([]); + return ( + +

Table row group headers

+ + Employees grouped by department} + columnDefinitions={columnDefinitions} + items={items} + trackBy="id" + selectionType="multi" + selectedItems={selectedItems} + onSelectionChange={({ detail }) => setSelectedItems(detail.selectedItems)} + rowGrouping={rowGrouping} + ariaLabels={{ + selectionGroupLabel: 'Employee selection', + allItemsSelectionLabel: () => 'Select all employees', + itemSelectionLabel: (_data, item) => `Select ${item.name}`, + tableLabel: 'Employees', + }} + /> + +
Grouped without selection} + columnDefinitions={columnDefinitions} + items={items} + trackBy="id" + rowGrouping={rowGrouping} + ariaLabels={{ tableLabel: 'Employees without selection' }} + /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..faf79f866e 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28939,6 +28939,62 @@ the table items array is empty.", "optional": true, "type": "boolean", }, + { + "description": "Visually groups table rows under non-selectable group header rows. Use it to organize the items +into sections without changing the flat data structure of \`items\`. + +The configuration contains: +* \`getGroupId\` ((Item) => string) - Returns the group identifier for the given item. Consecutive +items that share the same identifier form a group. A group header row is rendered above the first +item of every group. +* \`renderGroupHeader\` ((TableProps.RowGroupHeaderDetail) => ReactNode) - Renders the content +of a group header row. It is called once per group with the group identifier and the list of items +belonging to the group. + +The group header row spans all columns and is not selectable, editable, or expandable. +The feature is opt-in and does not change the behavior of tables that do not define it.", + "inlineType": { + "name": "TableProps.RowGrouping", + "properties": [ + { + "inlineType": { + "name": "(item: T) => string", + "parameters": [ + { + "name": "item", + "type": "T", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "getGroupId", + "optional": false, + "type": "(item: T) => string", + }, + { + "inlineType": { + "name": "(detail: TableProps.RowGroupHeaderDetail) => React.ReactNode", + "parameters": [ + { + "name": "detail", + "type": "TableProps.RowGroupHeaderDetail", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "renderGroupHeader", + "optional": false, + "type": "(detail: TableProps.RowGroupHeaderDetail) => React.ReactNode", + }, + ], + "type": "object", + }, + "name": "rowGrouping", + "optional": true, + "type": "TableProps.RowGrouping", + }, { "defaultValue": "[]", "description": "List of selected items.", @@ -44364,6 +44420,20 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from ], }, }, + { + "description": "Returns all group header rows rendered when the \`rowGrouping\` property is used.", + "name": "findRowGroupHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, { "name": "findRows", "parameters": [], @@ -53779,6 +53849,20 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from "name": "ElementWrapper", }, }, + { + "description": "Returns all group header rows rendered when the \`rowGrouping\` property is used.", + "name": "findRowGroupHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, { "name": "findRows", "parameters": [], diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 4b1af42bc5..ce75018626 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -651,6 +651,7 @@ exports[`test-utils selectors 1`] = ` "awsui_body-cell-editor_c6tup", "awsui_body-cell_c6tup", "awsui_empty_wih1l", + "awsui_group-header-row_wih1l", "awsui_header-cell-ascending_1spae", "awsui_header-cell-descending_1spae", "awsui_header-controls_wih1l", diff --git a/src/table/__tests__/row-grouping.test.tsx b/src/table/__tests__/row-grouping.test.tsx new file mode 100644 index 0000000000..f647b28931 --- /dev/null +++ b/src/table/__tests__/row-grouping.test.tsx @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Item { + id: number; + name: string; + group: string; +} + +const columns: TableProps.ColumnDefinition[] = [ + { header: 'id', cell: item => item.id, isRowHeader: true }, + { header: 'name', cell: item => item.name }, +]; + +const items: Item[] = [ + { id: 1, name: 'Apples', group: 'Fruit' }, + { id: 2, name: 'Oranges', group: 'Fruit' }, + { id: 3, name: 'Carrots', group: 'Vegetable' }, + { id: 4, name: 'Potatoes', group: 'Vegetable' }, + { id: 5, name: 'Bread', group: 'Bakery' }, +]; + +const rowGrouping: TableProps.RowGrouping = { + getGroupId: item => item.group, + renderGroupHeader: ({ groupId, items }) => `${groupId} (${items.length})`, +}; + +function renderTable(props?: Partial>) { + const { container } = render(
); + return createWrapper(container).findTable()!; +} + +describe('Table row grouping', () => { + test('does not render group header rows when rowGrouping is not set', () => { + const wrapper = renderTable(); + expect(wrapper.findRowGroupHeaders()).toHaveLength(0); + expect(wrapper.findRows()).toHaveLength(5); + }); + + test('renders one group header per distinct group in order', () => { + const wrapper = renderTable({ rowGrouping }); + const headers = wrapper.findRowGroupHeaders(); + expect(headers).toHaveLength(3); + expect(headers.map(h => h.getElement().textContent)).toEqual(['Fruit (2)', 'Vegetable (2)', 'Bakery (1)']); + }); + + test('findRows excludes group header rows', () => { + const wrapper = renderTable({ rowGrouping }); + expect(wrapper.findRows()).toHaveLength(5); + }); + + test('group header cell spans all columns (including selection column)', () => { + const wrapper = renderTable({ rowGrouping, selectionType: 'multi' }); + const cell = wrapper.findRowGroupHeaders()[0].find('td')!.getElement(); + // 2 data columns + 1 selection column = 3 + expect(cell).toHaveAttribute('colspan', '3'); + }); + + test('group header cell colspan matches column count without selection', () => { + const wrapper = renderTable({ rowGrouping }); + const cell = wrapper.findRowGroupHeaders()[0].find('td')!.getElement(); + expect(cell).toHaveAttribute('colspan', '2'); + }); + + test('group header rows are not selectable (single spanning cell, no controls)', () => { + const wrapper = renderTable({ rowGrouping, selectionType: 'multi' }); + const header = wrapper.findRowGroupHeaders()[0]; + expect(header.findAll('td')).toHaveLength(1); + expect(header.findAll('input')).toHaveLength(0); + }); + + test('exposes the group id via data attribute', () => { + const wrapper = renderTable({ rowGrouping }); + expect(wrapper.findRowGroupHeaders()[0].getElement()).toHaveAttribute('data-group-id', 'Fruit'); + expect(wrapper.findRowGroupHeaders()[1].getElement()).toHaveAttribute('data-group-id', 'Vegetable'); + }); + + test('creates separate groups for non-consecutive items with the same id', () => { + const wrapper = renderTable({ + items: [ + { id: 1, name: 'Apples', group: 'Fruit' }, + { id: 2, name: 'Carrots', group: 'Vegetable' }, + { id: 3, name: 'Oranges', group: 'Fruit' }, + ], + rowGrouping, + }); + const headers = wrapper.findRowGroupHeaders(); + expect(headers.map(h => h.getElement().getAttribute('data-group-id'))).toEqual(['Fruit', 'Vegetable', 'Fruit']); + }); + + test('findRowGroupHeaders returns an empty array when no grouping is configured', () => { + const wrapper = renderTable(); + expect(wrapper.findRowGroupHeaders()).toEqual([]); + }); +}); diff --git a/src/table/index.tsx b/src/table/index.tsx index 28a1fd60ed..ef70d09269 100644 --- a/src/table/index.tsx +++ b/src/table/index.tsx @@ -53,6 +53,7 @@ const Table = React.forwardRef( }, metadata: { expandableRows: !!props.expandableRows, + rowGrouping: !!props.rowGrouping, progressiveLoading: !!props.getLoadingStatus, hasSkeleton: !!props.skeleton, skeletonTotalRows: props.skeleton?.totalRows ?? null, diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index e00ef8fc17..4ac96a92fa 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -429,6 +429,23 @@ export interface TableProps extends BaseComponentProps { */ expandableRows?: TableProps.ExpandableRows; + /** + * Visually groups table rows under non-selectable group header rows. Use it to organize the items + * into sections without changing the flat data structure of `items`. + * + * The configuration contains: + * * `getGroupId` ((Item) => string) - Returns the group identifier for the given item. Consecutive + * items that share the same identifier form a group. A group header row is rendered above the first + * item of every group. + * * `renderGroupHeader` ((TableProps.RowGroupHeaderDetail) => ReactNode) - Renders the content + * of a group header row. It is called once per group with the group identifier and the list of items + * belonging to the group. + * + * The group header row spans all columns and is not selectable, editable, or expandable. + * The feature is opt-in and does not change the behavior of tables that do not define it. + */ + rowGrouping?: TableProps.RowGrouping; + /** * A function that specifies the current status of loading more items. It is called once for the entire * table with `item=null` and then for each expanded item. The function result is one of the four possible states: @@ -670,6 +687,16 @@ export namespace TableProps { totalSelectedItemsCount?: number; } + export interface RowGrouping { + getGroupId: (item: T) => string; + renderGroupHeader: (detail: RowGroupHeaderDetail) => React.ReactNode; + } + + export interface RowGroupHeaderDetail { + groupId: string; + items: ReadonlyArray; + } + export type OnExpandableItemToggle = NonCancelableEventHandler>; export interface ExpandableItemToggleDetail { diff --git a/src/table/internal.tsx b/src/table/internal.tsx index d58a59f1f9..23afa3d18b 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -47,6 +47,7 @@ import { getLoaderContent } from './progressive-loading/items-loader'; import { TableLoaderCell } from './progressive-loading/loader-cell'; import { useProgressiveLoadingProps } from './progressive-loading/progressive-loading-utils'; import { ResizeTracker } from './resizer'; +import { GroupHeaderRow } from './row-grouping/group-header-row'; import { focusMarkers, useSelection, useSelectionFocusMove } from './selection'; import { TableBodySelectionCell } from './selection/selection-cell'; import { useGroupSelection } from './selection/use-group-selection'; @@ -145,6 +146,7 @@ const InternalTable = React.forwardRef( columnDisplay, enableKeyboardNavigation, expandableRows: externalExpandableRows, + rowGrouping, getLoadingStatus, renderLoaderPending, renderLoaderLoading, @@ -184,6 +186,41 @@ const InternalTable = React.forwardRef( const { allRows } = useProgressiveLoadingProps({ getLoadingStatus, expandableRows }); const selectionType = expandableRows.hasGroupSelection ? ('group' as const) : externalSelectionType; + // Row grouping: map each group id to its items (in items order) and mark which rows start a new group. + const groupedItemsById = useMemo(() => { + const map = new Map(); + if (rowGrouping) { + for (const item of allItems) { + const groupId = rowGrouping.getGroupId(item); + const existing = map.get(groupId); + if (existing) { + existing.push(item); + } else { + map.set(groupId, [item]); + } + } + } + return map; + }, [rowGrouping, allItems]); + + const rowGroupStart = useMemo(() => { + const starts = new Array(allRows.length).fill(false); + if (rowGrouping) { + let previousGroupId: string | undefined = undefined; + for (let i = 0; i < allRows.length; i++) { + const row = allRows[i]; + if (row.type === 'data') { + const groupId = rowGrouping.getGroupId(row.item); + if (groupId !== previousGroupId) { + starts[i] = true; + previousGroupId = groupId; + } + } + } + } + return starts; + }, [rowGrouping, allRows]); + const [containerWidth, wrapperMeasureRef] = useContainerQuery(rect => rect.borderBoxWidth); const wrapperMeasureRefObject = useRef(null); const wrapperMeasureMergedRef = useMergeRefs(wrapperMeasureRef, wrapperMeasureRefObject); @@ -670,7 +707,24 @@ const InternalTable = React.forwardRef( }; if (row.type === 'data') { const rowId = `${getTableItemKey(row.item)}`; - return ( + const isGroupStart = rowGroupStart[rowIndex]; + const groupId = rowGrouping && isGroupStart ? rowGrouping.getGroupId(row.item) : undefined; + const groupHeaderRow = + rowGrouping && groupId !== undefined ? ( + + ) : null; + const dataRowElement = ( ); + return groupHeaderRow ? ( + + {groupHeaderRow} + {dataRowElement} + + ) : ( + dataRowElement + ); } const loaderSelectionProps = selection.getLoaderSelectionProps && selection.getLoaderSelectionProps(row.item); diff --git a/src/table/row-grouping/group-header-row.tsx b/src/table/row-grouping/group-header-row.tsx new file mode 100644 index 0000000000..7ca206c2e7 --- /dev/null +++ b/src/table/row-grouping/group-header-row.tsx @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getTableCellRoleProps, getTableRowRoleProps, TableRole } from '../table-role'; + +import styles from '../styles.css.js'; + +export interface GroupHeaderRowProps { + groupId: string; + content: React.ReactNode; + totalColumnsCount: number; + tableRole: TableRole; + rowIndex: number; + firstIndex?: number; + headerRowCount: number; +} + +/** + * A non-selectable, non-editable table row that visually separates a group of data rows. + * It spans all visible columns (including the selection column when present) and is rendered + * above the first item of every group defined via `rowGrouping`. + */ +export function GroupHeaderRow({ + groupId, + content, + totalColumnsCount, + tableRole, + rowIndex, + firstIndex, + headerRowCount, +}: GroupHeaderRowProps) { + return ( + + + + ); +} diff --git a/src/table/styles.scss b/src/table/styles.scss index 2585b019ab..5e1b70a8e9 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -233,6 +233,27 @@ filter search icon. /* used in test-utils */ } +.group-header-row { + /* used in test-utils */ +} + +.group-header-cell { + box-sizing: border-box; + padding-block: awsui.$space-scaled-xs; + padding-inline: awsui.$space-l; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + background-color: awsui.$color-background-cell-shaded; + color: awsui.$color-text-body-secondary; + font-weight: styles.$font-weight-bold; + text-align: start; +} + +.group-header-content { + display: flex; + align-items: center; + gap: awsui.$space-xxs; +} + .skeleton-loading-cell { padding-block: 0; padding-inline: 0; diff --git a/src/test-utils/dom/table/index.ts b/src/test-utils/dom/table/index.ts index b074bb30d4..f610a18036 100644 --- a/src/test-utils/dom/table/index.ts +++ b/src/test-utils/dom/table/index.ts @@ -102,7 +102,14 @@ export default class TableWrapper extends ComponentWrapper { } findRows(): Array { - return this.findNativeTable().findAll(`tr.${styles.row}:not([aria-hidden])`); + return this.findNativeTable().findAll(`tr.${styles.row}:not([aria-hidden]):not(.${styles['group-header-row']})`); + } + + /** + * Returns all group header rows rendered when the `rowGrouping` property is used. + */ + findRowGroupHeaders(): Array { + return this.findNativeTable().findAll(`tr.${styles['group-header-row']}`); } findSelectedRows(): Array {
+
{content}
+