From 0aa179380bcbf569e430c356b857747a0d3cd9a1 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 4 Sep 2026 14:39:58 +0000 Subject: [PATCH 01/35] refactor(table): extract internal TableCell substrate from TableTdElement Introduce src/table/table-cell (internal, not yet exported) holding the pure body-cell substrate: the td/th element, base .body-cell padding, the .body-cell-content wrapper, disablePaddings support, and raw-node ref forwarding. TableTdElement now composes InternalTableCell, keeping all feature layering (sticky/resize/editable/selection/expandable + compound .body-cell.* CSS) on its own wrapper. DOM/CSS-identical by construction: same classes, same child order, feature SCSS unchanged; only an inert .body-cell-content.disable-paddings rule is added (never applied by classic). --- src/table/body-cell/styles.scss | 11 +++++ src/table/body-cell/td-element.tsx | 64 +++++++++++++++++------------- src/table/table-cell/interfaces.ts | 26 ++++++++++++ src/table/table-cell/internal.tsx | 57 ++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 src/table/table-cell/interfaces.ts create mode 100644 src/table/table-cell/internal.tsx diff --git a/src/table/body-cell/styles.scss b/src/table/body-cell/styles.scss index a25f7f3f55..510ab251e1 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -557,3 +557,14 @@ $cell-negative-space-vertical: 2px; @include cell-focus-outline; } } + +// Atomic-only substrate hook consumed by the extracted InternalTableCell. Lets a +// consumer opt out of the built-in cell padding (e.g. a selection-control column). +// Keyed on the content wrapper itself (where the padding lives); the classic table +// never applies `disable-paddings`, so this block is inert for it. +.body-cell-content.disable-paddings { + padding-block: 0; + padding-inline: 0; + margin-block: 0; + margin-inline: 0; +} diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index 49c45e45ef..13f0119bf9 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -12,6 +12,7 @@ import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces.js'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; +import { InternalTableCell } from '../table-cell/internal'; import { getTableCellRoleProps, TableRole } from '../table-role'; import { getStickyClassNames } from '../utils'; @@ -104,12 +105,16 @@ export const TableTdElement = React.forwardRef { - const Element = isRowHeader ? 'th' : 'td'; + const tag = isRowHeader ? 'th' : 'td'; const isVisualRefresh = useVisualRefresh(); resizableStyle = resizableColumns ? {} : resizableStyle; - nativeAttributes = { ...nativeAttributes, ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }) }; + const cellNativeAttributes = { + ...nativeAttributes, + ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }), + ...copyAnalyticsMetadataAttribute(rest), + }; const stickyStyles = useStickyCellStyles({ stickyColumns: stickyState, @@ -122,11 +127,16 @@ export const TableTdElement = React.forwardRef` CSS continues to match unchanged. return ( - + + + ) : null + } > - {level !== undefined && isExpandable && !isEditingActive && ( -
- + {children} + {counter ? ( +
+ + {counter}
- )} - -
- {children} - {counter ? ( -
- - {counter} -
- ) : null} -
- + ) : null} + ); } ); diff --git a/src/table/table-cell/interfaces.ts b/src/table/table-cell/interfaces.ts new file mode 100644 index 0000000000..dcc3d7fccc --- /dev/null +++ b/src/table/table-cell/interfaces.ts @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +export interface InternalTableCellProps { + // The rendered element: 'td' for a data cell, 'th' for a row-header cell. + tag: 'td' | 'th'; + // Feature classes layered on top of the base cell substrate by the composing component. + className?: string; + style?: React.CSSProperties; + // When true the cell content wraps; otherwise it is truncated with an ellipsis. + wrapLines?: boolean; + // Removes the cell's built-in block/inline padding and defeats the first-column padding reduction. + disablePaddings?: boolean; + nativeAttributes?: Omit< + React.TdHTMLAttributes | React.ThHTMLAttributes, + 'style' | 'className' | 'onClick' + >; + tabIndex?: number; + onClick?: () => void; + onFocus?: () => void; + onBlur?: () => void; + // Rendered inside the cell before the content wrapper (e.g. an expand toggle). + beforeContent?: React.ReactNode; + children?: React.ReactNode; +} diff --git a/src/table/table-cell/internal.tsx b/src/table/table-cell/internal.tsx new file mode 100644 index 0000000000..7a859d283e --- /dev/null +++ b/src/table/table-cell/internal.tsx @@ -0,0 +1,57 @@ +// 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 { InternalTableCellProps } from './interfaces'; + +// The atomic cell reuses classic's proven box model: the base `.body-cell` +// padding and the `.body-cell-content` truncation wrapper live in the shared +// body-cell stylesheet, so the extracted substrate is pixel-identical to the +// element TableTdElement used to render inline. +import styles from '../body-cell/styles.css.js'; + +export const InternalTableCell = React.forwardRef( + ( + { + tag, + className, + style, + wrapLines, + disablePaddings, + nativeAttributes, + tabIndex, + onClick, + onFocus, + onBlur, + beforeContent, + children, + }, + ref + ) => { + const Element = tag; + return ( + + {beforeContent} +
+ {children} +
+
+ ); + } +); From 5b211c43bcf121e67888015462e092c7778f751c Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 4 Sep 2026 16:36:03 +0000 Subject: [PATCH 02/35] refactor(table): extract internal TableHeaderCell substrate from TableThElement --- src/table/header-cell/th-element.tsx | 42 ++++++++++++++--------- src/table/table-header-cell/interfaces.ts | 17 +++++++++ src/table/table-header-cell/internal.tsx | 27 +++++++++++++++ 3 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 src/table/table-header-cell/interfaces.ts create mode 100644 src/table/table-header-cell/internal.tsx diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 68dd326b54..a03ad85690 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -11,6 +11,7 @@ import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; +import { InternalTableHeaderCell } from '../table-header-cell/internal'; import { getTableColHeaderRoleProps, TableRole } from '../table-role'; import { getStickyClassNames } from '../utils'; import { SortingStatus } from './utils'; @@ -99,11 +100,30 @@ export function TableThElement({ const mergedRef = useMergeRefs(stickyStyles.ref, cellRef, cellRefObject); const { tabIndex: cellTabIndex } = useSingleTabStopNavigation(cellRefObject); + // The bare `.header-cell` substrate (the element, base padding, ref) is provided by + // the extracted InternalTableHeaderCell. All feature layering stays here, keyed on the same + // `.header-cell` class so the compound `.header-cell.` CSS continues to match + // unchanged, and every computed native attribute is threaded through verbatim. + const nativeAttributes = { + 'data-focus-id': `header-${String(columnId)}`, + colSpan, + rowSpan, + ...getTableColHeaderRoleProps({ + tableRole, + sortingStatus: suppressAriaSort ? undefined : sortingStatus, + colIndex, + }), + scope: scope ?? 'col', + ...copyAnalyticsMetadataAttribute(props), + ...(ariaLabel ? { 'aria-label': ariaLabel } : {}), + ...(isLast ? { 'data-rightmost': true } : {}), + ...(scope !== 'colgroup' ? { 'data-column-index': colIndex + 1 } : {}), + ...(columnGroupId ? { 'data-column-group-id': columnGroupId } : {}), + }; + return ( - {children} - + ); } diff --git a/src/table/table-header-cell/interfaces.ts b/src/table/table-header-cell/interfaces.ts new file mode 100644 index 0000000000..0cf7b0afb3 --- /dev/null +++ b/src/table/table-header-cell/interfaces.ts @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +export interface InternalTableHeaderCellProps { + // Feature classes layered on top of the base header-cell substrate by the composing component. + className?: string; + style?: React.CSSProperties; + // Native attributes (role/scope/aria-sort/analytics/data-*) computed by the composing + // component and spread verbatim onto the element. Kept opaque here so the substrate holds no + // header-specific logic. + nativeAttributes?: React.ThHTMLAttributes & { + [key: `data-${string}`]: string | number | boolean | undefined; + }; + tabIndex?: number; + children?: React.ReactNode; +} diff --git a/src/table/table-header-cell/internal.tsx b/src/table/table-header-cell/internal.tsx new file mode 100644 index 0000000000..ecc2028c41 --- /dev/null +++ b/src/table/table-header-cell/internal.tsx @@ -0,0 +1,27 @@ +// 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 { InternalTableHeaderCellProps } from './interfaces'; + +// The atomic header cell reuses classic's proven box model: the base `.header-cell` +// padding lives in the shared header-cell stylesheet, so the extracted substrate is +// pixel-identical to the that TableThElement used to render inline. +import styles from '../header-cell/styles.css.js'; + +export const InternalTableHeaderCell = React.forwardRef( + ({ className, style, nativeAttributes, tabIndex, children }, ref) => { + return ( + + {children} + + ); + } +); From a9dcf4e0ffc6ef85b57a0d1bc8cfe85f067cb5f3 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 4 Sep 2026 18:36:11 +0000 Subject: [PATCH 03/35] feat(table): extract internal TableRow element from inline render (Inc3a) --- src/table/internal.tsx | 17 ++++++++--------- src/table/table-row/interfaces.ts | 16 ++++++++++++++++ src/table/table-row/internal.tsx | 30 ++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 src/table/table-row/interfaces.ts create mode 100644 src/table/table-row/internal.tsx diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 8877eb304b..205271821d 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -63,6 +63,7 @@ import { GridNavigationProvider, TableRole, } from './table-role'; +import { InternalTableRow } from './table-row/internal'; import Thead, { TheadProps } from './thead'; import ToolsHeader from './tools-header'; import { useAutoSkeletonRows } from './use-auto-skeleton-rows'; @@ -728,9 +729,9 @@ const InternalTable = React.forwardRef( if (row.type === 'data') { const rowId = `${getTableItemKey(row.item)}`; return ( - { // When an element inside table row receives focus we want to adjust the scroll. // However, that behavior is unwanted when the focus is received as result of a click @@ -739,12 +740,11 @@ const InternalTable = React.forwardRef( stickyHeaderRef.current?.scrollToRow(currentTarget); } }} - {...focusMarkers.item} onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)} onContextMenu={ onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item) } - {...rowRoleProps} + nativeAttributes={{ ...focusMarkers.item, ...rowRoleProps }} > {selection.getItemSelectionProps && ( ); })} - + ); } const loaderSelectionProps = @@ -842,10 +842,9 @@ const InternalTable = React.forwardRef( }); return ( loaderContent && ( - {selectionType ? ( ))} - + ) ); }) diff --git a/src/table/table-row/interfaces.ts b/src/table/table-row/interfaces.ts new file mode 100644 index 0000000000..3964081737 --- /dev/null +++ b/src/table/table-row/interfaces.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +export interface InternalTableRowProps { + // Feature classes layered on top of the base row substrate by the composing component. + className?: string; + // Native attributes (aria-row*/focus markers/data-*) computed by the composing component + // and spread verbatim onto the element. Kept opaque here so the substrate holds no row-feature + // logic. + nativeAttributes?: React.HTMLAttributes; + onClick?: React.MouseEventHandler; + onFocus?: React.FocusEventHandler; + onContextMenu?: React.MouseEventHandler; + children?: React.ReactNode; +} diff --git a/src/table/table-row/internal.tsx b/src/table/table-row/internal.tsx new file mode 100644 index 0000000000..d22613605b --- /dev/null +++ b/src/table/table-row/internal.tsx @@ -0,0 +1,30 @@ +// 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 { InternalTableRowProps } from './interfaces'; + +// The atomic row is the neutral `` substrate: it owns only the base `.row` +// marker class (used in test-utils) and forwards the raw node ref. All feature +// layering (selection, striping, sticky, expandable, focus/role wiring) stays on +// the composing component, so the extracted substrate is DOM-identical to the +// inline `` the table body used to render. +import styles from '../styles.css.js'; + +export const InternalTableRow = React.forwardRef( + ({ className, nativeAttributes, onClick, onFocus, onContextMenu, children }, ref) => { + return ( + + {children} + + ); + } +); From 16e3491ddb22bbfb0f924ac3f9f7b44a6f8d24da Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 07:26:37 +0000 Subject: [PATCH 04/35] refactor(table): extract internal TableBody/TableHead substrates (Inc3b) Carve the and element substrates out of classic's render sites into thin internal forwardRef components (src/table/table-body, src/table/table-head), mirroring the Inc1/Inc2/Inc3a pattern. Render-neutral: each substrate renders only the bare element and forwards ref/className/nativeAttributes/children; all feature wiring stays on the composing sites (internal.tsx, thead.tsx). TableRoot's is deferred (performanceMarkAttributes data-* threading is incompatible with the data-*-free substrate contract) -- see evidence FINDINGS/DECISIONS. --- src/table/internal.tsx | 5 +++-- src/table/table-body/interfaces.ts | 12 ++++++++++++ src/table/table-body/internal.tsx | 20 ++++++++++++++++++++ src/table/table-head/interfaces.ts | 13 +++++++++++++ src/table/table-head/internal.tsx | 21 +++++++++++++++++++++ src/table/thead.tsx | 9 +++++---- 6 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 src/table/table-body/interfaces.ts create mode 100644 src/table/table-body/internal.tsx create mode 100644 src/table/table-head/interfaces.ts create mode 100644 src/table/table-head/internal.tsx diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 205271821d..2b7712ca01 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -56,6 +56,7 @@ import { SkeletonRows } from './skeleton-rows'; import { useStickyColumns } from './sticky-columns'; import StickyHeader, { StickyHeaderRef } from './sticky-header'; import { StickyScrollbar } from './sticky-scrollbar'; +import { InternalTableBody } from './table-body/internal'; import { getTableRoleProps, getTableRowRoleProps, @@ -663,7 +664,7 @@ const InternalTable = React.forwardRef( onFocusedComponentChange={focusId => stickyHeaderRef.current?.setFocus(focusId)} {...theadProps} /> - + {skeleton && allItems.length === 0 && loading ? ( )} - +
diff --git a/src/table/table-body/interfaces.ts b/src/table/table-body/interfaces.ts new file mode 100644 index 0000000000..0ad82cc8a4 --- /dev/null +++ b/src/table/table-body/interfaces.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +export interface InternalTableBodyProps { + // Feature classes layered on top of the base body substrate by the composing component. + className?: string; + // Native attributes computed by the composing component and spread verbatim onto the + // element. Kept opaque here so the substrate holds no body-feature logic. + nativeAttributes?: React.HTMLAttributes; + children?: React.ReactNode; +} diff --git a/src/table/table-body/internal.tsx b/src/table/table-body/internal.tsx new file mode 100644 index 0000000000..3d872c17a1 --- /dev/null +++ b/src/table/table-body/internal.tsx @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { InternalTableBodyProps } from './interfaces'; + +// The atomic body is the neutral `` substrate: it forwards the raw node +// ref and spreads any classes/attributes the composing component computes. The +// classic table body carries no base class or wiring on `` itself (rows +// and cells own all feature layering), so the extracted substrate is +// DOM-identical to the inline `` the table used to render. +export const InternalTableBody = React.forwardRef( + ({ className, nativeAttributes, children }, ref) => { + return ( + + {children} + + ); + } +); diff --git a/src/table/table-head/interfaces.ts b/src/table/table-head/interfaces.ts new file mode 100644 index 0000000000..9660a4c278 --- /dev/null +++ b/src/table/table-head/interfaces.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +export interface InternalTableHeadProps { + // Feature classes layered on top of the base head substrate by the composing component + // (e.g. the `thead-active` marker gated on the sticky-header `hidden` flag). + className?: string; + // Native attributes computed by the composing component and spread verbatim onto the + // element. Kept opaque here so the substrate holds no head-feature logic. + nativeAttributes?: React.HTMLAttributes; + children?: React.ReactNode; +} diff --git a/src/table/table-head/internal.tsx b/src/table/table-head/internal.tsx new file mode 100644 index 0000000000..f808216e12 --- /dev/null +++ b/src/table/table-head/internal.tsx @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { InternalTableHeadProps } from './interfaces'; + +// The atomic head is the neutral `` substrate: it forwards the raw node +// ref and spreads any classes/attributes the composing component computes. The +// classic thead's only class is the `thead-active` marker (gated on the +// sticky-header `hidden` flag), which the composing component keeps computing and +// passes through here, so the extracted substrate is DOM-identical to the inline +// `` the header used to render. +export const InternalTableHead = React.forwardRef( + ({ className, nativeAttributes, children }, ref) => { + return ( + + {children} + + ); + } +); diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 9286e4fb91..fa7e641ac8 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -16,6 +16,7 @@ import { InternalSelectionType } from './internal-interfaces'; import { focusMarkers, ItemSelectionProps } from './selection'; import { TableHeaderSelectionCell } from './selection/selection-cell'; import { StickyColumnsModel } from './sticky-columns'; +import { InternalTableHead } from './table-head/internal'; import { getTableHeaderRowRoleProps, TableRole } from './table-role'; import { DEFAULT_COLUMN_WIDTH, useColumnWidths } from './use-column-widths'; import { getColumnKey } from './utils'; @@ -145,7 +146,7 @@ const Thead = React.forwardRef( // No grouping - render single row if (!columnGroupsLayout || columnGroupsLayout.rows.length <= 1) { return ( - + - + ); } // Grouped columns const totalColumns = columnDefinitions.length; return ( - + {columnGroupsLayout.rows.map((row, rowIndex) => ( ))} - + ); } ); From aeefcb0c8eb22034a27248f5cb1855c0987804b4 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 08:43:16 +0000 Subject: [PATCH 05/35] feat(table): add public table atomic components unified onto carved substrates (Inc4a) --- build-tools/utils/pluralize.js | 7 + src/table-body/index.tsx | 19 ++ src/table-body/interfaces.ts | 21 ++ src/table-body/internal.tsx | 32 +++ src/table-body/styles.scss | 12 + src/table-cell/index.tsx | 19 ++ src/table-cell/interfaces.ts | 16 ++ src/table-cell/internal.tsx | 48 ++++ src/table-cell/styles.scss | 20 ++ src/table-head/index.tsx | 19 ++ src/table-head/interfaces.ts | 11 + src/table-head/internal.tsx | 31 ++ src/table-head/styles.scss | 12 + src/table-header-cell/index.tsx | 19 ++ src/table-header-cell/interfaces.ts | 27 ++ src/table-header-cell/internal.tsx | 45 +++ src/table-header-cell/styles.scss | 26 ++ src/table-header-row/index.tsx | 19 ++ src/table-header-row/interfaces.ts | 11 + src/table-header-row/internal.tsx | 30 ++ src/table-header-row/styles.scss | 16 ++ .../__tests__/basic-table-aria-label.test.tsx | 62 ++++ .../__tests__/basic-table-roles.test.tsx | 104 +++++++ src/table-root/__tests__/basic-table.test.tsx | 265 ++++++++++++++++++ .../__tests__/use-table-root.test.tsx | 55 ++++ src/table-root/context.ts | 16 ++ src/table-root/index.tsx | 26 ++ src/table-root/interfaces.ts | 63 +++++ src/table-root/internal.tsx | 55 ++++ src/table-root/styles.scss | 48 ++++ src/table-root/use-table-root.ts | 35 +++ src/table-row/context.ts | 16 ++ src/table-row/index.tsx | 19 ++ src/table-row/interfaces.ts | 55 ++++ src/table-row/internal.tsx | 59 ++++ src/table-row/styles.scss | 24 ++ src/test-utils/dom/table-body/index.ts | 9 + src/test-utils/dom/table-cell/index.ts | 9 + src/test-utils/dom/table-head/index.ts | 9 + src/test-utils/dom/table-header-cell/index.ts | 9 + src/test-utils/dom/table-header-row/index.ts | 9 + src/test-utils/dom/table-root/index.ts | 9 + src/test-utils/dom/table-row/index.ts | 9 + 43 files changed, 1425 insertions(+) create mode 100644 src/table-body/index.tsx create mode 100644 src/table-body/interfaces.ts create mode 100644 src/table-body/internal.tsx create mode 100644 src/table-body/styles.scss create mode 100644 src/table-cell/index.tsx create mode 100644 src/table-cell/interfaces.ts create mode 100644 src/table-cell/internal.tsx create mode 100644 src/table-cell/styles.scss create mode 100644 src/table-head/index.tsx create mode 100644 src/table-head/interfaces.ts create mode 100644 src/table-head/internal.tsx create mode 100644 src/table-head/styles.scss create mode 100644 src/table-header-cell/index.tsx create mode 100644 src/table-header-cell/interfaces.ts create mode 100644 src/table-header-cell/internal.tsx create mode 100644 src/table-header-cell/styles.scss create mode 100644 src/table-header-row/index.tsx create mode 100644 src/table-header-row/interfaces.ts create mode 100644 src/table-header-row/internal.tsx create mode 100644 src/table-header-row/styles.scss create mode 100644 src/table-root/__tests__/basic-table-aria-label.test.tsx create mode 100644 src/table-root/__tests__/basic-table-roles.test.tsx create mode 100644 src/table-root/__tests__/basic-table.test.tsx create mode 100644 src/table-root/__tests__/use-table-root.test.tsx create mode 100644 src/table-root/context.ts create mode 100644 src/table-root/index.tsx create mode 100644 src/table-root/interfaces.ts create mode 100644 src/table-root/internal.tsx create mode 100644 src/table-root/styles.scss create mode 100644 src/table-root/use-table-root.ts create mode 100644 src/table-row/context.ts create mode 100644 src/table-row/index.tsx create mode 100644 src/table-row/interfaces.ts create mode 100644 src/table-row/internal.tsx create mode 100644 src/table-row/styles.scss create mode 100644 src/test-utils/dom/table-body/index.ts create mode 100644 src/test-utils/dom/table-cell/index.ts create mode 100644 src/test-utils/dom/table-head/index.ts create mode 100644 src/test-utils/dom/table-header-cell/index.ts create mode 100644 src/test-utils/dom/table-header-row/index.ts create mode 100644 src/test-utils/dom/table-root/index.ts create mode 100644 src/test-utils/dom/table-row/index.ts diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 0a6439d9c0..c48b07cbbb 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -79,6 +79,13 @@ const pluralizationMap = { StatusIndicator: 'StatusIndicators', Steps: 'Steps', Table: 'Tables', + TableBody: 'TableBodies', + TableCell: 'TableCells', + TableHead: 'TableHeads', + TableHeaderCell: 'TableHeaderCells', + TableHeaderRow: 'TableHeaderRows', + TableRoot: 'TableRoots', + TableRow: 'TableRows', Tabs: 'Tabs', TagEditor: 'TagEditors', TextContent: 'TextContents', diff --git a/src/table-body/index.tsx b/src/table-body/index.tsx new file mode 100644 index 0000000000..b5d956d043 --- /dev/null +++ b/src/table-body/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableBodyProps } from './interfaces'; +import { Body } from './internal'; + +export type { TableBodyProps }; + +function TableBody(props: TableBodyProps) { + const baseComponentProps = useBaseComponent('TableBody'); + return ; +} + +applyDisplayName(TableBody, 'TableBody'); +export default TableBody; diff --git a/src/table-body/interfaces.ts b/src/table-body/interfaces.ts new file mode 100644 index 0000000000..b5a2c84c44 --- /dev/null +++ b/src/table-body/interfaces.ts @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders the table body that contains the rows. Its children are `TableRow` components. */ +export interface TableBodyProps extends BaseComponentProps { + /** + * Applies inline styles to the body element. Use this to enable row positioning, for example for + * virtualization or draggable rows. It is not supported to use this for general styling purposes. + */ + style?: TableBodyProps.Style; + /** The body rows. */ + children?: React.ReactNode; +} + +export namespace TableBodyProps { + /** Inline styles supported on the body element, for row positioning (for example, virtualization). */ + export type Style = Pick; +} diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx new file mode 100644 index 0000000000..ec289715a2 --- /dev/null +++ b/src/table-body/internal.tsx @@ -0,0 +1,32 @@ +// 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 { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { InternalTableBody } from '../table/table-body/internal'; +import { useTableContext } from '../table-root/context'; +import { TableBodyProps } from './interfaces'; + +import styles from './styles.css.js'; + +// The bare element + ref is provided by the extracted InternalTableBody substrate (shared +// with classic's body). This public component layers the standalone body class, the grid-mode role, +// and the positioning `style` (for virtualization) on top. The substrate takes no `style`, so it is +// passed through the native-attribute channel it already spreads onto the element. +export function Body(props: TableBodyProps & InternalBaseComponentProps) { + const { children, style, __internalRootRef } = props; + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(props); + return ( + + {children} + + ); +} diff --git a/src/table-body/styles.scss b/src/table-body/styles.scss new file mode 100644 index 0000000000..cf15c7d64b --- /dev/null +++ b/src/table-body/styles.scss @@ -0,0 +1,12 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.body { + position: relative; +} + +.body-grid { + display: block; +} diff --git a/src/table-cell/index.tsx b/src/table-cell/index.tsx new file mode 100644 index 0000000000..e0d006fd39 --- /dev/null +++ b/src/table-cell/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableCellProps } from './interfaces'; +import { Cell } from './internal'; + +export type { TableCellProps }; + +function TableCell(props: TableCellProps) { + const baseComponentProps = useBaseComponent('TableCell'); + return ; +} + +applyDisplayName(TableCell, 'TableCell'); +export default TableCell; diff --git a/src/table-cell/interfaces.ts b/src/table-cell/interfaces.ts new file mode 100644 index 0000000000..57c87f85a2 --- /dev/null +++ b/src/table-cell/interfaces.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders a single data cell. */ +export interface TableCellProps extends BaseComponentProps { + /** + * Removes the cell's built-in padding so you can compose your own spacing, for example to match a + * selection-control column. Defaults to `false`. + */ + disablePaddings?: boolean; + /** The cell content. */ + children?: React.ReactNode; +} diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx new file mode 100644 index 0000000000..22115188b8 --- /dev/null +++ b/src/table-cell/internal.tsx @@ -0,0 +1,48 @@ +// 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 { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { InternalTableCell } from '../table/table-cell/internal'; +import { useTableContext } from '../table-root/context'; +import { useRowVariant } from '../table-row/context'; +import { TableCellProps } from './interfaces'; + +import bodyCellStyles from '../table/body-cell/styles.css.js'; +import styles from './styles.css.js'; + +// The element, classic's proven `.body-cell` box model (base padding + `.body-cell-content` +// wrapper), `disablePaddings`, and ref are all provided by the extracted InternalTableCell substrate +// (the single shared piece with classic's td-element). This public component layers its own test-utils +// marker, the grid-mode role/layout, and selection/shading — painted by reusing classic's own +// `.body-cell-selected` / `.body-cell-shaded` classes, keyed off the row's variant via context, so no +// selection stylesheet is duplicated and no `data-*` styling hook is needed. +// +// Deferred to the visual-regression increment (Inc4c): consecutive-row border reconciliation +// (prev/next), first/last-row placeholders, sticky columns, and full visual-refresh gating. +export function Cell(props: TableCellProps & InternalBaseComponentProps) { + const { children, disablePaddings, __internalRootRef } = props; + const { columnLayout } = useTableContext(); + const variant = useRowVariant(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(props); + return ( + + {children} + + ); +} diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss new file mode 100644 index 0000000000..64c9e1c320 --- /dev/null +++ b/src/table-cell/styles.scss @@ -0,0 +1,20 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +// The base cell box model (padding, borders, divider, selection/shading) is supplied by the shared +// `.body-cell` class and classic's `.body-cell-selected` / `.body-cell-shaded` classes from the +// extracted substrate (src/table/table-cell + src/table/body-cell, i.e. classic's proven stylesheet). +// This standalone module only adds the test-utils marker and the grid-mode min-inline-size — it must +// NOT re-declare the base geometry, which would double the substrate's padding. Pixel reconciliation +// of these layers is verified in the visual-regression increment (Inc4c). +.cell { + box-sizing: border-box; +} + +// Lets grid columns shrink below their content size. No overflow/clip here — that would crop the +// focus ring of an interactive control (checkbox, radio, link) inside the cell. +.cell-grid { + min-inline-size: 0; +} diff --git a/src/table-head/index.tsx b/src/table-head/index.tsx new file mode 100644 index 0000000000..2a13944925 --- /dev/null +++ b/src/table-head/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeadProps } from './interfaces'; +import { Head } from './internal'; + +export type { TableHeadProps }; + +function TableHead(props: TableHeadProps) { + const baseComponentProps = useBaseComponent('TableHead'); + return ; +} + +applyDisplayName(TableHead, 'TableHead'); +export default TableHead; diff --git a/src/table-head/interfaces.ts b/src/table-head/interfaces.ts new file mode 100644 index 0000000000..6ff1f61998 --- /dev/null +++ b/src/table-head/interfaces.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders the table head. Its child is a single `TableHeaderRow` of `TableHeaderCell`s. */ +export interface TableHeadProps extends BaseComponentProps { + /** The header row: a `TableHeaderRow` whose cells are `TableHeaderCell` components. */ + children?: React.ReactNode; +} diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx new file mode 100644 index 0000000000..90e58f1b3a --- /dev/null +++ b/src/table-head/internal.tsx @@ -0,0 +1,31 @@ +// 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 { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { InternalTableHead } from '../table/table-head/internal'; +import { useTableContext } from '../table-root/context'; +import { TableHeadProps } from './interfaces'; + +import styles from './styles.css.js'; + +// The bare element + ref is provided by the extracted InternalTableHead substrate (shared +// with classic's thead). This public component layers the standalone head class and the grid-mode +// role/layout on top via className/nativeAttributes. +export function Head(props: TableHeadProps & InternalBaseComponentProps) { + const { children, __internalRootRef } = props; + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(props); + return ( + + {children} + + ); +} diff --git a/src/table-head/styles.scss b/src/table-head/styles.scss new file mode 100644 index 0000000000..2d1806dcf5 --- /dev/null +++ b/src/table-head/styles.scss @@ -0,0 +1,12 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.head { + position: relative; +} + +.head-grid { + display: block; +} diff --git a/src/table-header-cell/index.tsx b/src/table-header-cell/index.tsx new file mode 100644 index 0000000000..577ea9097b --- /dev/null +++ b/src/table-header-cell/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeaderCellProps } from './interfaces'; +import { HeaderCell } from './internal'; + +export type { TableHeaderCellProps }; + +function TableHeaderCell(props: TableHeaderCellProps) { + const baseComponentProps = useBaseComponent('TableHeaderCell'); + return ; +} + +applyDisplayName(TableHeaderCell, 'TableHeaderCell'); +export default TableHeaderCell; diff --git a/src/table-header-cell/interfaces.ts b/src/table-header-cell/interfaces.ts new file mode 100644 index 0000000000..69324a4d30 --- /dev/null +++ b/src/table-header-cell/interfaces.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders a single column header cell. Put the column label, or a composed sort control, in `children`. */ +export interface TableHeaderCellProps extends BaseComponentProps { + /** Provides an accessible name for the header cell. Use this or `ariaLabelledby`. */ + ariaLabel?: string; + /** Sets `aria-labelledby`. Use the ID(s) of visible element(s) that label the header cell. */ + ariaLabelledby?: string; + /** Sets `aria-describedby`. Use the ID(s) of visible element(s) that describe the header cell. */ + ariaDescribedby?: string; + /** + * Sets the column's sort direction on the cell's `aria-sort` attribute. Use it on a sortable + * column and render your own sort control in `children`; the table does not manage sort state. + */ + ariaSort?: React.AriaAttributes['aria-sort']; + /** + * Removes the cell's built-in padding so you can compose your own spacing, for example to match a + * selection-control column. Defaults to `false`. + */ + disablePaddings?: boolean; + /** The header content, such as a column label or a sort control. */ + children?: React.ReactNode; +} diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx new file mode 100644 index 0000000000..0d66d83788 --- /dev/null +++ b/src/table-header-cell/internal.tsx @@ -0,0 +1,45 @@ +// 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 { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { InternalTableHeaderCell } from '../table/table-header-cell/internal'; +import { useTableContext } from '../table-root/context'; +import { TableHeaderCellProps } from './interfaces'; + +import styles from './styles.css.js'; + +// The element, classic's `.header-cell` box model, and ref are provided by the extracted +// InternalTableHeaderCell substrate (shared with classic's th-element). This public component layers +// its own test-utils marker class, the grid-mode role/layout, the optional padding opt-out, and the +// header aria surface (`aria-sort`, labelling) on top via className/nativeAttributes. +export function HeaderCell(props: TableHeaderCellProps & InternalBaseComponentProps) { + const { children, ariaLabel, ariaLabelledby, ariaDescribedby, ariaSort, disablePaddings, __internalRootRef } = props; + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(props); + return ( + + {children} + + ); +} diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss new file mode 100644 index 0000000000..5e13f61f61 --- /dev/null +++ b/src/table-header-cell/styles.scss @@ -0,0 +1,26 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +// The base header-cell box model (padding, divider, background, first-column placeholder) is supplied +// by the shared `.header-cell` class from the extracted substrate (src/table/table-header-cell, which +// reuses classic's proven header-cell stylesheet). This standalone module only adds the test-utils +// marker, the grid-mode min-inline-size, and the padding opt-out — it must NOT re-declare the base +// geometry, which would double the substrate's padding. Pixel reconciliation of these layers is +// verified in the visual-regression increment (Inc4c). +.header-cell { + box-sizing: border-box; +} + +// Lets grid columns shrink below their content size. +.header-cell-grid { + min-inline-size: 0; +} + +// Opt out of the built-in padding so the consumer can compose exact spacing (e.g. a selection +// control). Base padding lives on the substrate's `.header-cell`; this zeroes it on the same element. +.header-cell.disable-paddings { + padding-block: 0; + padding-inline: 0; +} diff --git a/src/table-header-row/index.tsx b/src/table-header-row/index.tsx new file mode 100644 index 0000000000..e3be64ae8f --- /dev/null +++ b/src/table-header-row/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeaderRowProps } from './interfaces'; +import { HeaderRow } from './internal'; + +export type { TableHeaderRowProps }; + +function TableHeaderRow(props: TableHeaderRowProps) { + const baseComponentProps = useBaseComponent('TableHeaderRow'); + return ; +} + +applyDisplayName(TableHeaderRow, 'TableHeaderRow'); +export default TableHeaderRow; diff --git a/src/table-header-row/interfaces.ts b/src/table-header-row/interfaces.ts new file mode 100644 index 0000000000..7c159f0d20 --- /dev/null +++ b/src/table-header-row/interfaces.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders the header row, inside `TableHead`. Its children are `TableHeaderCell` components. */ +export interface TableHeaderRowProps extends BaseComponentProps { + /** The header cells, one per column, in order. */ + children?: React.ReactNode; +} diff --git a/src/table-header-row/internal.tsx b/src/table-header-row/internal.tsx new file mode 100644 index 0000000000..2b2af77d34 --- /dev/null +++ b/src/table-header-row/internal.tsx @@ -0,0 +1,30 @@ +// 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 { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { TableHeaderRowProps } from './interfaces'; + +import styles from './styles.css.js'; + +export function HeaderRow(props: TableHeaderRowProps & InternalBaseComponentProps) { + const { children, __internalRootRef } = props; + const { columnLayout, gridTemplateColumns } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const baseProps = getBaseProps(props); + return ( + + {children} + + ); +} diff --git a/src/table-header-row/styles.scss b/src/table-header-row/styles.scss new file mode 100644 index 0000000000..66ce26719e --- /dev/null +++ b/src/table-header-row/styles.scss @@ -0,0 +1,16 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +.header-row { + background: awsui.$color-background-table-header; +} + +.header-row-grid { + display: grid; + inline-size: 100%; + align-items: center; +} diff --git a/src/table-root/__tests__/basic-table-aria-label.test.tsx b/src/table-root/__tests__/basic-table-aria-label.test.tsx new file mode 100644 index 0000000000..2c652933f8 --- /dev/null +++ b/src/table-root/__tests__/basic-table-aria-label.test.tsx @@ -0,0 +1,62 @@ +// 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 TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; + +// The accessible name is set through the top-level `ariaLabel` / `ariaLabelledby` props, which the +// component applies to the table's `aria-label` / `aria-labelledby`. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, name: `Resource ${i}`, status: i % 2 === 0 ? 'Up' : 'Down' })); + +const COLUMNS: ReadonlyArray = [{ minWidth: 120 }, {}]; + +function buildTree(labelProps: Pick) { + const items = makeItems(10); + return ( + + + + Name + Status + + + + {items.map(item => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +const getTable = (container: HTMLElement) => container.querySelector('table')!; + +describe('Table labelling', () => { + test('ariaLabel passes through to the table aria-label', () => { + const { container } = render(buildTree({ ariaLabel: 'Resources' })); + expect(getTable(container).getAttribute('aria-label')).toBe('Resources'); + }); + + test('ariaLabelledby passes through to the table aria-labelledby', () => { + const { container } = render(buildTree({ ariaLabelledby: 'heading-id' })); + expect(getTable(container).getAttribute('aria-labelledby')).toBe('heading-id'); + }); +}); diff --git a/src/table-root/__tests__/basic-table-roles.test.tsx b/src/table-root/__tests__/basic-table-roles.test.tsx new file mode 100644 index 0000000000..3090913404 --- /dev/null +++ b/src/table-root/__tests__/basic-table-roles.test.tsx @@ -0,0 +1,104 @@ +// 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 TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +// Role semantics for the atomic table. In `auto` layout the parts are native +// //// element, `.row` marker, ref, and event handlers are provided by the extracted +// InternalTableRow substrate (shared with the row classic's body renders). This public component +// layers the grid-mode role/layout and aria surface, and publishes the row's `variant` to its cells +// through RowVariantContext so each cell self-paints selection/shading via its own class — no +// `data-*` styling hook on the row. +export function Row(props: TableRowProps & InternalBaseComponentProps) { + const { + variant = 'default', + ariaLabel, + ariaLabelledby, + ariaDescribedby, + ariaSelected, + ariaRowindex, + children, + style, + onClick, + onFocus, + onContextMenu, + __internalRootRef, + } = props; + const { columnLayout, gridTemplateColumns } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(props); + return ( + + {children} + + ); +} diff --git a/src/table-row/styles.scss b/src/table-row/styles.scss new file mode 100644 index 0000000000..af9533340e --- /dev/null +++ b/src/table-row/styles.scss @@ -0,0 +1,24 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +.row { + position: relative; + box-sizing: border-box; + // The row divider and selection/shaded paint now live on the cell (see table-cell/styles.scss), + // shared with the classic body-cell via the same _selection-mixins. The row only carries the + // grid layout (grid mode) and the derived data-selected / data-shaded gate for its cells. +} + +.row-grid { + display: grid; + inline-size: 100%; + align-items: center; + grid-auto-rows: minmax( + calc(#{awsui.$line-height-body-m} + 2 * #{awsui.$space-scaled-xs} + 4px - #{awsui.$border-divider-list-width}), + auto + ); +} diff --git a/src/test-utils/dom/table-body/index.ts b/src/test-utils/dom/table-body/index.ts new file mode 100644 index 0000000000..527bc8fce3 --- /dev/null +++ b/src/test-utils/dom/table-body/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-body/styles.selectors.js'; + +export default class TableBodyWrapper extends ComponentWrapper { + static rootSelector: string = styles.body; +} diff --git a/src/test-utils/dom/table-cell/index.ts b/src/test-utils/dom/table-cell/index.ts new file mode 100644 index 0000000000..868c00b2ac --- /dev/null +++ b/src/test-utils/dom/table-cell/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-cell/styles.selectors.js'; + +export default class TableCellWrapper extends ComponentWrapper { + static rootSelector: string = styles.cell; +} diff --git a/src/test-utils/dom/table-head/index.ts b/src/test-utils/dom/table-head/index.ts new file mode 100644 index 0000000000..1873b20805 --- /dev/null +++ b/src/test-utils/dom/table-head/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-head/styles.selectors.js'; + +export default class TableHeadWrapper extends ComponentWrapper { + static rootSelector: string = styles.head; +} diff --git a/src/test-utils/dom/table-header-cell/index.ts b/src/test-utils/dom/table-header-cell/index.ts new file mode 100644 index 0000000000..10156afb4c --- /dev/null +++ b/src/test-utils/dom/table-header-cell/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-header-cell/styles.selectors.js'; + +export default class TableHeaderCellWrapper extends ComponentWrapper { + static rootSelector: string = styles['header-cell']; +} diff --git a/src/test-utils/dom/table-header-row/index.ts b/src/test-utils/dom/table-header-row/index.ts new file mode 100644 index 0000000000..2db852ed2e --- /dev/null +++ b/src/test-utils/dom/table-header-row/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-header-row/styles.selectors.js'; + +export default class TableHeaderRowWrapper extends ComponentWrapper { + static rootSelector: string = styles['header-row']; +} diff --git a/src/test-utils/dom/table-root/index.ts b/src/test-utils/dom/table-root/index.ts new file mode 100644 index 0000000000..2825d8f158 --- /dev/null +++ b/src/test-utils/dom/table-root/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-root/styles.selectors.js'; + +export default class TableRootWrapper extends ComponentWrapper { + static rootSelector: string = styles.root; +} diff --git a/src/test-utils/dom/table-row/index.ts b/src/test-utils/dom/table-row/index.ts new file mode 100644 index 0000000000..fd60b2e552 --- /dev/null +++ b/src/test-utils/dom/table-row/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-row/styles.selectors.js'; + +export default class TableRowWrapper extends ComponentWrapper { + static rootSelector: string = styles.row; +} From 6fdb7ea63f9fd258ee6acf8d523d6dad7104f697 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 08:49:44 +0000 Subject: [PATCH 06/35] feat(table): add table-root demo pages (Inc4b) --- pages/table-root/common.tsx | 67 ++++++++++ pages/table-root/loading-and-empty.page.tsx | 75 +++++++++++ pages/table-root/selection.page.tsx | 111 ++++++++++++++++ pages/table-root/simple.page.tsx | 31 +++++ pages/table-root/single-selection.page.tsx | 78 +++++++++++ pages/table-root/sorting.page.tsx | 136 ++++++++++++++++++++ pages/table-root/striped-rows.page.tsx | 43 +++++++ pages/table-root/styles.scss | 61 +++++++++ pages/table-root/virtualization.page.tsx | 96 ++++++++++++++ 9 files changed, 698 insertions(+) create mode 100644 pages/table-root/common.tsx create mode 100644 pages/table-root/loading-and-empty.page.tsx create mode 100644 pages/table-root/selection.page.tsx create mode 100644 pages/table-root/simple.page.tsx create mode 100644 pages/table-root/single-selection.page.tsx create mode 100644 pages/table-root/sorting.page.tsx create mode 100644 pages/table-root/striped-rows.page.tsx create mode 100644 pages/table-root/styles.scss create mode 100644 pages/table-root/virtualization.page.tsx diff --git a/pages/table-root/common.tsx b/pages/table-root/common.tsx new file mode 100644 index 0000000000..78e8dbf9ed --- /dev/null +++ b/pages/table-root/common.tsx @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableHeaderRow, + TableRootProps, + TableRow, +} from '~components'; + +export interface Item { + id: string; + name: string; + type: string; + size: string; + status: string; +} + +export const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, index) => ({ + id: `resource-${index}`, + name: `Resource ${index}`, + type: index % 3 === 0 ? 'Compute' : index % 3 === 1 ? 'Storage' : 'Network', + size: `${(index % 8) + 1} GiB`, + status: index % 2 === 0 ? 'Available' : 'Pending', + })); + +// A 4-column grid layout (no control column): Name fixed, Type/Size flexible-with-min, Status flexible. +export const DATA_COLUMNS: ReadonlyArray = [ + { size: 220 }, + { minWidth: 140 }, + { minWidth: 120 }, + {}, +]; + +// Renders the standard header row for the shared 4-column item shape: a TableRow of TableHeaderCells. +export function DataHeader() { + return ( + + + Name + Type + Size + Status + + + ); +} + +export function DataBody({ items }: { items: Item[] }) { + return ( + + {items.map(item => ( + + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + + ); +} diff --git a/pages/table-root/loading-and-empty.page.tsx b/pages/table-root/loading-and-empty.page.tsx new file mode 100644 index 0000000000..82499fe852 --- /dev/null +++ b/pages/table-root/loading-and-empty.page.tsx @@ -0,0 +1,75 @@ +// 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 SegmentedControl from '~components/segmented-control'; +import SpaceBetween from '~components/space-between'; +import StatusIndicator from '~components/status-indicator'; +import TableBody from '~components/table-body'; +import TableRoot from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { DataBody, DataHeader, makeItems } from './common'; + +type State = 'loaded' | 'loading' | 'empty'; + +const COLUMN_COUNT = 4; + +// Loading and empty states are composed by the consumer. In auto layout the table is a native +// `
/
, so the browser supplies the table semantics and no explicit +// ARIA roles are emitted. In `grid` layout the parts are laid out with display:grid, which strips the +// native table semantics, so the hook restores role=table -> rowgroup -> row -> columnheader/cell. +// Grid keyboard navigation is not part of the component (composed by the consumer), so there is no +// roving tabindex. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, name: `Resource ${i}`, status: i % 2 === 0 ? 'Up' : 'Down' })); + +const GRID_COLUMNS: ReadonlyArray = [{ size: 200 }, {}]; + +function LogTable({ items, grid }: { items: Item[]; grid?: boolean }) { + const columnLayout: TableRootProps.ColumnLayout = grid ? { type: 'grid', columns: GRID_COLUMNS } : { type: 'auto' }; + return ( + + + + Name + Status + + + + {items.map(item => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +function renderTable(items: Item[], grid?: boolean) { + const { container } = render(); + const wrapper = createWrapper(container); + return { container, wrapper, table: () => wrapper.find('table')!.getElement() }; +} + +describe('Table role semantics', () => { + describe('auto layout uses native table semantics (no explicit roles)', () => { + test('the table, rows, and cells carry no ARIA role attributes', () => { + const { table } = renderTable(makeItems(20)); + expect(table().hasAttribute('role')).toBe(false); + expect(table().querySelectorAll('[role]')).toHaveLength(0); + }); + }); + + describe('grid layout restores a coherent table accessibility tree', () => { + test('collapses to one role=table -> rowgroup -> row -> columnheader/cell tree', () => { + const { table } = renderTable(makeItems(20), true); + const grid = table(); + expect(grid.getAttribute('role')).toBe('table'); + + const rowGroups = Array.from(grid.children).filter(child => child.getAttribute('role') === 'rowgroup'); + expect(rowGroups.length).toBeGreaterThanOrEqual(2); + + grid.querySelectorAll('[role="row"]').forEach(row => { + expect(row.closest('[role="rowgroup"]')).not.toBeNull(); + }); + grid.querySelectorAll('[role="columnheader"], [role="cell"]').forEach(cell => { + expect(cell.closest('[role="row"]')).not.toBeNull(); + }); + }); + + test('column headers are ', () => { + const { wrapper } = renderTable(makeItems(20), true); + const th = wrapper.findAllTableHeaderCells()[0].getElement(); + expect(th.tagName).toBe('TH'); + expect(th.getAttribute('role')).toBe('columnheader'); + expect(th.getAttribute('scope')).toBe('col'); + }); + + test('the container is not a tab stop and declares no roving active descendant', () => { + const { table } = renderTable(makeItems(20), true); + const grid = table(); + // No grid keyboard-navigation subsystem: the table is not focusable and manages no tabindex. + expect(grid.hasAttribute('tabindex')).toBe(false); + expect(grid.hasAttribute('aria-activedescendant')).toBe(false); + expect(grid.querySelectorAll('[tabindex]')).toHaveLength(0); + }); + }); +}); diff --git a/src/table-root/__tests__/basic-table.test.tsx b/src/table-root/__tests__/basic-table.test.tsx new file mode 100644 index 0000000000..dded87c857 --- /dev/null +++ b/src/table-root/__tests__/basic-table.test.tsx @@ -0,0 +1,265 @@ +// 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 TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +// Tests for the atomic table parts (TableRoot/TableHead/TableHeaderCell/TableBody/TableRow/TableCell) +// over the headless useTableRoot hook, accessed through the generated per-part test-utils finders. +// The consumer declares the head as a TableRow of TableHeaderCells and maps the body Rows/Cells; +// TableRoot auto-renders neither. `{ type: 'auto' }` (default) renders a native with no +// explicit ARIA roles; `{ type: 'grid' }` renders a display:grid table that restores the table roles +// and applies the shared column template. Sorting/selection are composed by the consumer. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, index) => ({ + id: `row-${index}`, + name: `Resource ${index}`, + status: index % 2 === 0 ? 'Available' : 'Pending', + })); + +// Grid layout needs one column entry per column (Name fixed 200px, Status flexible). +const GRID_COLUMNS: ReadonlyArray = [{ size: 200 }, {}]; + +interface RenderOptions { + grid?: boolean; + count?: number; + items?: Item[]; + ariaRowcount?: number; +} + +function TableHarness({ options }: { options: RenderOptions }) { + const items = options.items ?? makeItems(options.count ?? 5); + const columnLayout: TableRootProps.ColumnLayout = options.grid + ? { type: 'grid', columns: GRID_COLUMNS } + : { type: 'auto' }; + return ( + + + + Name + Status + + + + {items.map(item => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +function renderTable(options: RenderOptions = {}) { + const utils = render(); + const wrapper = createWrapper(utils.container); + const table = () => wrapper.find('table')!.getElement(); + return { wrapper, table, ...utils }; +} + +describe('Table atomic parts', () => { + test('renders the declarative header cells, discoverable via the generated finder', () => { + const { wrapper } = renderTable(); + expect(wrapper.findTableRoot()).not.toBeNull(); + const headerCells = wrapper.findAllTableHeaderCells(); + expect(headerCells).toHaveLength(2); + expect(headerCells[0].getElement().textContent).toContain('Name'); + expect(headerCells[1].getElement().textContent).toContain('Status'); + }); + + test('renders the mapped rows and cells, discoverable via the generated finders', () => { + const { wrapper } = renderTable({ count: 5 }); + const rows = wrapper.findAllTableRows(); + expect(rows).toHaveLength(5); // body rows only; the header row uses a different root class + + const firstRowCells = createWrapper(rows[0].getElement()).findAllTableCells(); + expect(firstRowCells).toHaveLength(2); + expect(firstRowCells[0].getElement().textContent).toBe('Resource 0'); + expect(firstRowCells[1].getElement().textContent).toBe('Available'); + + expect(wrapper.findTableBody()).not.toBeNull(); + expect(wrapper.findTableHead()).not.toBeNull(); + expect(wrapper.findAllTableCells()).toHaveLength(10); + }); + + test('ariaLabel is applied to the table element', () => { + const { table } = renderTable(); + expect(table().getAttribute('aria-label')).toBe('Resources'); + }); + + test('ariaRowcount is applied to aria-rowcount as-is', () => { + const { table } = renderTable({ count: 5, ariaRowcount: 40 }); + expect(table().getAttribute('aria-rowcount')).toBe('40'); + }); + + test('omits aria-rowcount when ariaRowcount is not provided (count derives from the DOM)', () => { + const { table } = renderTable({ count: 5 }); + expect(table().hasAttribute('aria-rowcount')).toBe(false); + }); + + describe('auto column layout (default)', () => { + test('renders a native
with no explicit table/row/cell ARIA roles', () => { + const { table, wrapper } = renderTable(); + expect(table().tagName).toBe('TABLE'); + expect(table().hasAttribute('role')).toBe(false); + expect(table().querySelectorAll('[role="row"]')).toHaveLength(0); + expect(table().querySelectorAll('[role="columnheader"]')).toHaveLength(0); + expect(table().querySelectorAll('[role="cell"], [role="gridcell"]')).toHaveLength(0); + const th = wrapper.findAllTableHeaderCells()[0].getElement(); + expect(th.tagName).toBe('TH'); + expect(th.getAttribute('scope')).toBe('col'); + }); + + test('does not emit an inline grid-template-columns on rows', () => { + const { wrapper } = renderTable(); + const row = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(row.style.gridTemplateColumns).toBe(''); + }); + }); + + describe('grid column layout', () => { + test('restores the table ARIA roles that display:grid strips', () => { + const { table, wrapper } = renderTable({ grid: true }); + expect(table().getAttribute('role')).toBe('table'); + expect(table().querySelectorAll('[role="rowgroup"]').length).toBeGreaterThanOrEqual(2); + + const headerCell = wrapper.findAllTableHeaderCells()[0].getElement(); + expect(headerCell.getAttribute('role')).toBe('columnheader'); + expect(headerCell.getAttribute('scope')).toBe('col'); + + const dataRow = wrapper.findAllTableRows()[0].getElement(); + expect(dataRow.getAttribute('role')).toBe('row'); + expect(dataRow.querySelectorAll('[role="cell"]')).toHaveLength(2); + }); + + test('the header row is aria-rowindex 1 and shares the column template with the data rows', () => { + const { wrapper } = renderTable({ grid: true }); + const headerRow = wrapper.findTableHead()!.find('[role="row"]')!.getElement() as HTMLElement; + expect(headerRow.getAttribute('aria-rowindex')).toBe('1'); + const template = '200px minmax(0px, 1fr)'; + expect(headerRow.style.gridTemplateColumns).toBe(template); + + const dataRow = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(dataRow.style.gridTemplateColumns).toBe(template); + }); + }); + + describe('row aria-selected is driven by ariaSelected, not variant', () => { + test('variant is visual-only; ariaSelected sets aria-selected independently', () => { + const { container } = render( + + + + Name + + + + + Selected + announced + + + Selected visual only + + + Explicitly not selected + + + Default + + + + ); + const rows = createWrapper(container).findAllTableRows(); + // variant='selected' + ariaSelected -> aria-selected="true" + expect(rows[0].getElement().getAttribute('aria-selected')).toBe('true'); + // variant='selected' WITHOUT ariaSelected -> no aria-selected (variant is visual only) + expect(rows[1].getElement().hasAttribute('aria-selected')).toBe(false); + // ariaSelected={false} -> aria-selected="false" + expect(rows[2].getElement().getAttribute('aria-selected')).toBe('false'); + // default -> no aria-selected + expect(rows[3].getElement().hasAttribute('aria-selected')).toBe(false); + }); + }); + + describe('declared per-part ARIA props', () => { + test('HeaderCell ariaSort sets aria-sort on the column header', () => { + const { container } = render( + + + + Name + Status + + + + + Resource 0 + Available + + + + ); + const headerCells = createWrapper(container).findAllTableHeaderCells(); + expect(headerCells[0].getElement().getAttribute('aria-sort')).toBe('ascending'); + expect(headerCells[1].getElement().hasAttribute('aria-sort')).toBe(false); + }); + + test('Row ariaRowindex sets aria-rowindex for virtualization', () => { + const { container } = render( + + + + Name + + + + + Resource 200 + + + + ); + const row = createWrapper(container).findAllTableRows()[0].getElement(); + expect(row.getAttribute('aria-rowindex')).toBe('202'); + }); + }); + + describe('native data-* passthrough on parts (virtualization interop)', () => { + test('a row and cell forward data-* to their roots', () => { + const { container } = render( + + + + Name + + + + + Resource 7 + + + + ); + const wrapper = createWrapper(container); + expect(wrapper.findAllTableRows()[0].getElement().getAttribute('data-index')).toBe('7'); + expect(wrapper.findAllTableCells()[0].getElement().getAttribute('data-column')).toBe('name'); + }); + }); +}); diff --git a/src/table-root/__tests__/use-table-root.test.tsx b/src/table-root/__tests__/use-table-root.test.tsx new file mode 100644 index 0000000000..1dc475eb15 --- /dev/null +++ b/src/table-root/__tests__/use-table-root.test.tsx @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../__tests__/render-hook'; +import { TableRootProps } from '../interfaces'; +import { useTableRoot } from '../use-table-root'; + +const COLUMNS: ReadonlyArray = [{ size: 200 }, {}, { size: 100 }]; + +function gridTemplate(columns: ReadonlyArray) { + return renderHook(() => useTableRoot({ type: 'grid', columns })).result.current.gridTemplateColumns; +} + +describe('useTableRoot', () => { + test('auto layout exposes the layout and no grid template', () => { + const { result } = renderHook(() => useTableRoot({ type: 'auto' })); + expect(result.current.columnLayout.type).toBe('auto'); + expect(result.current.gridTemplateColumns).toBeUndefined(); + }); + + test('grid layout exposes the layout', () => { + const { result } = renderHook(() => useTableRoot({ type: 'grid', columns: COLUMNS })); + expect(result.current.columnLayout.type).toBe('grid'); + }); + + describe('gridTemplateColumns compiled from the size union', () => { + test('multiple columns join into one template', () => { + expect(gridTemplate(COLUMNS)).toBe('200px minmax(0px, 1fr) 100px'); + }); + + test('a fixed pixel size becomes a px track', () => { + expect(gridTemplate([{ size: 200 }])).toBe('200px'); + }); + + test('an absent size becomes a flexible minmax(0px, 1fr) track', () => { + expect(gridTemplate([{}])).toBe('minmax(0px, 1fr)'); + }); + + test('a flex weight becomes minmax(0px, fr)', () => { + expect(gridTemplate([{ size: { flex: 2 } }])).toBe('minmax(0px, 2fr)'); + }); + + test('minWidth floors a flexible track', () => { + expect(gridTemplate([{ minWidth: 150 }])).toBe('minmax(150px, 1fr)'); + }); + + test('a fixed size ignores minWidth (redundant on a fixed track)', () => { + expect(gridTemplate([{ size: 200, minWidth: 150 }])).toBe('200px'); + }); + + test('maxWidth caps a flexible track at a px ceiling', () => { + expect(gridTemplate([{ maxWidth: 300 }])).toBe('minmax(0px, 300px)'); + expect(gridTemplate([{ minWidth: 100, maxWidth: 300 }])).toBe('minmax(100px, 300px)'); + }); + }); +}); diff --git a/src/table-root/context.ts b/src/table-root/context.ts new file mode 100644 index 0000000000..dd31fa5bb3 --- /dev/null +++ b/src/table-root/context.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createContext, useContext } from 'react'; + +import { UseTableRootResult } from './use-table-root'; + +// A part rendered outside `TableRoot` reads this default (auto layout) instead of throwing. +const defaultTableContext: UseTableRootResult = { columnLayout: { type: 'auto' }, gridTemplateColumns: undefined }; + +const TableContext = createContext(defaultTableContext); + +export const TableContextProvider = TableContext.Provider; + +export function useTableContext(): UseTableRootResult { + return useContext(TableContext); +} diff --git a/src/table-root/index.tsx b/src/table-root/index.tsx new file mode 100644 index 0000000000..b2fa1c4fb4 --- /dev/null +++ b/src/table-root/index.tsx @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableRootProps } from './interfaces'; +import { InternalRoot } from './internal'; + +// Root of the atomic table. The parts (TableHead, TableRow, …) are sibling top-level components; +// keeping each dir single-component (one default export + its props type) is what lets the +// documenter treat every part as its own documented component. +export type { TableRootProps }; + +function TableRoot({ columnLayout = { type: 'auto' }, ...props }: TableRootProps) { + const baseComponentProps = useBaseComponent('TableRoot', { + props: {}, + metadata: { columnLayoutType: columnLayout.type }, + }); + return ; +} + +applyDisplayName(TableRoot, 'TableRoot'); + +export default TableRoot; diff --git a/src/table-root/interfaces.ts b/src/table-root/interfaces.ts new file mode 100644 index 0000000000..faab94bfc4 --- /dev/null +++ b/src/table-root/interfaces.ts @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** + * A composable table. You render the head and rows as children, and TableRoot provides the column + * layout and accessibility semantics. + */ +export interface TableRootProps extends BaseComponentProps { + /** + * The table's content. Provide a `TableHead` followed by a `TableBody` that contains the rows. + */ + children: React.ReactNode; + + /** + * Determines how column widths are calculated. + * * `{ type: 'auto' }` - Renders a standard HTML table whose columns size to their content. No + * column configuration is required. + * * `{ type: 'grid'; columns }` - Renders a CSS grid and applies each column's `size`, `minWidth`, + * and `maxWidth`. Provide one `columns` entry per column, in display order; cells bind to columns + * by position. Virtualization requires this layout. + * * `size` (number | { flex: number }) - A number sets a fixed pixel width; `{ flex }` gives the + * column a weight that shares the remaining space in proportion. Omit it for a flexible column + * with the default weight of 1. + * * `minWidth` (number) - The minimum width in pixels, for a flexible column. + * * `maxWidth` (number) - The maximum width in pixels. + * + * Defaults to `{ type: 'auto' }`. + */ + columnLayout?: TableRootProps.ColumnLayout; + + /** Provides an accessible name for the table. Use this or `ariaLabelledby` to label the table. */ + ariaLabel?: string; + /** Sets the `aria-labelledby` attribute. Use the ID of a visible element that labels the table. */ + ariaLabelledby?: string; + /** Sets the `aria-describedby` attribute. Use the ID of a visible element that describes the table. */ + ariaDescribedby?: string; + + /** + * The total number of rows in the full dataset, set on the table's `aria-rowcount`. Provide it + * only when you render a subset of rows, such as with virtualization, so assistive technologies + * report the whole table. Omit it when you render every row, and the count is derived from the DOM. + */ + ariaRowcount?: number; +} + +export namespace TableRootProps { + export type ColumnLayout = { type: 'auto' } | { type: 'grid'; columns: ReadonlyArray }; + + export interface ColumnDefinition { + /** + * A number sets a fixed pixel width; `{ flex }` gives the column a weight that shares the + * remaining space in proportion. Omit it for a flexible column with the default weight of 1. + */ + size?: number | { flex: number }; + /** The minimum width in pixels, for a flexible column. */ + minWidth?: number; + /** The maximum width in pixels. */ + maxWidth?: number; + } +} diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx new file mode 100644 index 0000000000..959b227ca8 --- /dev/null +++ b/src/table-root/internal.tsx @@ -0,0 +1,55 @@ +// 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 { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { TableContextProvider } from './context'; +import { TableRootProps } from './interfaces'; +import { useTableRoot } from './use-table-root'; + +import styles from './styles.css.js'; + +type InternalRootProps = TableRootProps & InternalBaseComponentProps; + +export function InternalRoot(props: InternalRootProps) { + const { + columnLayout = { type: 'auto' }, + ariaRowcount, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + children, + __internalRootRef, + } = props; + + const isGrid = columnLayout.type === 'grid'; + const table = useTableRoot(columnLayout); + const baseProps = getBaseProps(props); + + return ( +
+ + {/* The page owns vertical scroll; this wrapper reintroduces an inline scroll viewport so a wide table scrolls horizontally instead of spilling out. */} +
+
+
role is used (an explicit + // role="table" there is redundant and flagged by a11y validators). + role={isGrid ? 'table' : undefined} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledby} + aria-describedby={ariaDescribedby} + aria-rowcount={ariaRowcount} + className={clsx(styles.table, isGrid ? styles['table-grid'] : styles['table-auto'])} + > + {children} +
+ + + + + ); +} diff --git a/src/table-root/styles.scss b/src/table-root/styles.scss new file mode 100644 index 0000000000..d5723ee2c5 --- /dev/null +++ b/src/table-root/styles.scss @@ -0,0 +1,48 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/index' as styles; +@use '../internal/styles/tokens' as awsui; + +.root { + @include styles.styles-reset; + position: relative; + display: flex; + flex-direction: column; + inline-size: 100%; + background: awsui.$color-background-container-content; +} + +.scroll-container { + position: relative; + flex: 1 1 auto; + min-block-size: 0; + overflow: auto; + inline-size: 100%; +} + +.body-scroller { + overflow-x: auto; +} + +.table { + inline-size: 100%; + // Separate borders (matching classic Table, which is also separate) so every cell paints its own + // top/bottom border instead of merging with its neighbour's. Required for sticky columns — the + // collapsed model breaks the sticky separator — and it gives the per-cell selected-row outline + // well-defined corner radii. Only takes effect in auto layout; grid mode sets `display: block` + // via `.table-grid`, so the border model does not apply there. + border-collapse: separate; + border-spacing: 0; +} + +.table-auto { + table-layout: auto; +} + +// Grid mode lays the table out as blocks so the inline grid-template-columns govern widths. +.table-grid { + display: block; +} diff --git a/src/table-root/use-table-root.ts b/src/table-root/use-table-root.ts new file mode 100644 index 0000000000..10dd763b28 --- /dev/null +++ b/src/table-root/use-table-root.ts @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useMemo } from 'react'; + +import { TableRootProps } from './interfaces'; + +export interface UseTableRootResult { + columnLayout: TableRootProps.ColumnLayout; + /** The `grid-template-columns` value for `grid` layout, compiled from each column's `size` union; `undefined` in `auto` layout. */ + gridTemplateColumns?: string; +} + +export function useTableRoot(columnLayout: TableRootProps.ColumnLayout): UseTableRootResult { + const gridTemplateColumns = useMemo(() => { + if (columnLayout.type !== 'grid') { + return undefined; + } + return columnLayout.columns + .map(column => { + if (typeof column.size === 'number') { + return `${column.size}px`; + } + const min = `${column.minWidth ?? 0}px`; + if (column.maxWidth !== undefined) { + return `minmax(${min}, ${column.maxWidth}px)`; + } + // `size` is narrowed to `{ flex: number } | undefined` here (the numeric case returned above). + const flex = column.size?.flex ?? 1; + return `minmax(${min}, ${flex}fr)`; + }) + .join(' '); + }, [columnLayout]); + + return { columnLayout, gridTemplateColumns }; +} diff --git a/src/table-row/context.ts b/src/table-row/context.ts new file mode 100644 index 0000000000..2cdb591973 --- /dev/null +++ b/src/table-row/context.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createContext, useContext } from 'react'; + +import { TableRowProps } from './interfaces'; + +// A row→cell channel that lets a `TableCell` learn its row's visual state and paint its own selection +// via its own module class, instead of the row exposing a `data-*` attribute as a CSS styling hook +// (an anti-pattern). A cell rendered outside a `TableRow` reads the default (`'default'`). +const RowVariantContext = createContext('default'); + +export const RowVariantContextProvider = RowVariantContext.Provider; + +export function useRowVariant(): TableRowProps.Variant { + return useContext(RowVariantContext); +} diff --git a/src/table-row/index.tsx b/src/table-row/index.tsx new file mode 100644 index 0000000000..914efa08f2 --- /dev/null +++ b/src/table-row/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableRowProps } from './interfaces'; +import { Row } from './internal'; + +export type { TableRowProps }; + +function TableRow(props: TableRowProps) { + const baseComponentProps = useBaseComponent('TableRow', { props: { variant: props.variant } }); + return ; +} + +applyDisplayName(TableRow, 'TableRow'); +export default TableRow; diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts new file mode 100644 index 0000000000..f5a35ca877 --- /dev/null +++ b/src/table-row/interfaces.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders a single data row, inside `TableBody`. */ +export interface TableRowProps extends BaseComponentProps { + /** + * The row's visual state. This is visual only — set `ariaSelected` to convey selection to + * assistive technologies. + * * `default` - A standard row. + * * `selected` - Applies the selected-row styling. Pair it with `ariaSelected` and a selection + * control, such as a checkbox, in a leading cell. + * * `shaded` - Applies a shaded background, to create alternating row colors. Choose which rows + * are shaded, typically with `variant={index % 2 === 1 ? 'shaded' : 'default'}`. + * + * Defaults to `'default'`. + */ + variant?: TableRowProps.Variant; + /** Provides an accessible name for the row. Use this or `ariaLabelledby`. */ + ariaLabel?: string; + /** Sets `aria-labelledby`. Use the ID(s) of visible element(s) that label the row. */ + ariaLabelledby?: string; + /** Sets `aria-describedby`. Use the ID(s) of visible element(s) that describe the row. */ + ariaDescribedby?: string; + /** Sets `aria-selected` to reflect the row's selection state. */ + ariaSelected?: boolean; + /** + * Sets the row's `aria-rowindex` — its 1-based position in the full dataset, counting the header + * row (so a data row's value is its dataset index plus 2). Set this only when virtualizing, so + * assistive technologies report the row's true position while you render a subset of rows; in a + * standard table the position is derived from DOM order. + */ + ariaRowindex?: number; + /** + * Applies inline styles to the row element. Use this for row positioning, for example for + * virtualization or draggable rows. It is not supported to use this for general styling purposes. + */ + style?: TableRowProps.Style; + /** Called when the row is clicked. */ + onClick?: React.MouseEventHandler; + /** Called when focus moves into the row. */ + onFocus?: React.FocusEventHandler; + /** Called when the row's context menu is requested (for example, a right-click). */ + onContextMenu?: React.MouseEventHandler; + /** The row's cells, one per column, in order. */ + children?: React.ReactNode; +} + +export namespace TableRowProps { + export type Variant = 'default' | 'selected' | 'shaded'; + /** Inline styles supported on a row element, for row positioning (for example, virtualization). */ + export type Style = Pick; +} diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx new file mode 100644 index 0000000000..267abe21e4 --- /dev/null +++ b/src/table-row/internal.tsx @@ -0,0 +1,59 @@ +// 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 { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { InternalTableRow } from '../table/table-row/internal'; +import { useTableContext } from '../table-root/context'; +import { RowVariantContextProvider } from './context'; +import { TableRowProps } from './interfaces'; + +import styles from './styles.css.js'; + +// The bare
`, so a single full-width status row is a plain ` + + + )} + + + + + ); +} diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx new file mode 100644 index 0000000000..cffa24cff3 --- /dev/null +++ b/pages/table-root/selection.page.tsx @@ -0,0 +1,111 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Box from '~components/box'; +import Checkbox from '~components/checkbox'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +// A selectable + sortable table (grid layout). Selection and sorting are composed +// by the consumer — the atomic parts contribute `variant='selected'` (row surface + aria-selected) +// and `ariaSort` (the header semantic). The control column uses `disablePaddings` cells and a +// centered checkbox to match the classic Table selection column; the name column flexes. +const COLUMNS: ReadonlyArray = [{ size: 40 }, { minWidth: 160 }, { size: 140 }]; +const ITEM_COUNT = 10; + +type SortDirection = 'ascending' | 'descending'; + +export default function TableSelectionPage() { + const allItems = makeItems(ITEM_COUNT); + const [selectedIds, setSelectedIds] = useState>(new Set([allItems[1].id, allItems[2].id])); + const [direction, setDirection] = useState('ascending'); + + const items = useMemo(() => { + const sorted = [...allItems].sort((a, b) => a.name.localeCompare(b.name)); + return direction === 'ascending' ? sorted : sorted.reverse(); + }, [allItems, direction]); + + const allSelected = items.length > 0 && items.every(item => selectedIds.has(item.id)); + const someSelected = items.some(item => selectedIds.has(item.id)); + const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(items.map(item => item.id))); + const toggleRow = (id: string) => + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + const toggleSort = () => setDirection(prev => (prev === 'ascending' ? 'descending' : 'ascending')); + + return ( + + + Table atomics — selectable + sortable (grid layout) + + +
Resources
+ + + + +
+ +
+
+ + + + Status +
+
+ + {items.map((item: Item) => ( + + +
+ toggleRow(item.id)} + ariaLabel={`Select ${item.name}`} + /> +
+
+ {item.name} + {item.status} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/pages/table-root/simple.page.tsx b/pages/table-root/simple.page.tsx new file mode 100644 index 0000000000..b110d8d678 --- /dev/null +++ b/pages/table-root/simple.page.tsx @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import TableRoot from '~components/table-root'; + +import { DataBody, DataHeader, makeItems } from './common'; + +// A minimal read-only table in auto layout. `columnLayout` is omitted, so it +// defaults to `{ type: 'auto' }` — columns size to their content and the count comes from the cells. +export default function TableSimplePage() { + const items = makeItems(8); + return ( + + + Table atomics — simple (auto layout) + + +
Resources
+ + + + +
+
+
+ ); +} diff --git a/pages/table-root/single-selection.page.tsx b/pages/table-root/single-selection.page.tsx new file mode 100644 index 0000000000..59302c18ef --- /dev/null +++ b/pages/table-root/single-selection.page.tsx @@ -0,0 +1,78 @@ +// 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 RadioButton from '~components/radio-button'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +// Single selection (grid layout). It composes exactly like multi selection, but the control is a +// radio and only one row is selected at a time — the consumer tracks a single selected id. The rows +// share a radio `name`, so the browser's native radio group gives up/down arrow-key navigation +// between rows for free (matching classic Table). Each radio's accessible name comes from a +// visually-hidden label (RadioButton has no `ariaLabel` prop). The control column matches classic +// Table via `disablePaddings` cells and a centered control; the header has no select-all control. +const COLUMNS: ReadonlyArray = [{ size: 40 }, { minWidth: 160 }, { size: 140 }]; +const ITEM_COUNT = 10; + +export default function TableSingleSelectionPage() { + const items = makeItems(ITEM_COUNT); + const [selectedId, setSelectedId] = useState(items[1].id); + + return ( + + + Table atomics — single selection (grid layout) + + +
Resources
+ + + + + Name + Status + + + + {items.map((item: Item) => ( + + +
+ setSelectedId(item.id)} + > + {`Select ${item.name}`} + +
+
+ {item.name} + {item.status} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/pages/table-root/sorting.page.tsx b/pages/table-root/sorting.page.tsx new file mode 100644 index 0000000000..ee6d90f8ad --- /dev/null +++ b/pages/table-root/sorting.page.tsx @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +// Sorting (auto layout). Sorting is fully composed by the consumer — the atomic components contribute +// only `ariaSort` on each header cell. This page shows three things: sortable columns that are not +// currently sorted (a non-filled caret + `ariaSort='none'`), several independently sortable columns, +// and multi-column sort opted into from the header (shift-click adds a column to the sort chain, with +// a priority number next to each caret). Caret icons match classic Table: `caret-down` (sortable), +// `caret-up-filled` (ascending), `caret-down-filled` (descending). + +type SortKey = 'name' | 'type' | 'size' | 'status'; +type SortDirection = 'ascending' | 'descending'; +interface SortColumn { + key: SortKey; + direction: SortDirection; +} + +const COLUMNS: ReadonlyArray<{ key: SortKey; label: string }> = [ + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'size', label: 'Size' }, + { key: 'status', label: 'Status' }, +]; + +function compare(key: SortKey, a: Item, b: Item): number { + if (key === 'size') { + return parseInt(a.size, 10) - parseInt(b.size, 10); + } + return a[key].localeCompare(b[key]); +} + +export default function TableSortingPage() { + const items = makeItems(12); + const [sort, setSort] = useState>([{ key: 'name', direction: 'ascending' }]); + + const rows = useMemo(() => { + return [...items].sort((a, b) => { + for (const { key, direction } of sort) { + const result = compare(key, a, b); + if (result !== 0) { + return direction === 'ascending' ? result : -result; + } + } + return 0; + }); + }, [items, sort]); + + // Plain click sorts by this column alone (toggling direction when it is already the sole sort). + // Shift-click opts the column into a multi-column sort: it is appended to the chain, or its + // direction toggled if already present. + const handleSort = (key: SortKey, additive: boolean) => { + setSort(prev => { + const existing = prev.find(column => column.key === key); + const toggled: SortDirection = existing?.direction === 'ascending' ? 'descending' : 'ascending'; + if (additive) { + return existing + ? prev.map(column => (column.key === key ? { key, direction: toggled } : column)) + : [...prev, { key, direction: 'ascending' }]; + } + return [{ key, direction: prev.length === 1 && existing ? toggled : 'ascending' }]; + }); + }; + + const multiColumn = sort.length > 1; + + return ( + + + Table atomics — sorting (auto layout) + + Click a column to sort by it. Shift-click a column to add it to a multi-column sort. + + + +
Resources
+ + + + {COLUMNS.map(({ key, label }) => { + const index = sort.findIndex(column => column.key === key); + const active = index >= 0 ? sort[index] : undefined; + return ( + + + + ); + })} + + + + {rows.map(item => ( + + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + + +
+
+
+ ); +} diff --git a/pages/table-root/striped-rows.page.tsx b/pages/table-root/striped-rows.page.tsx new file mode 100644 index 0000000000..fb1ac4b740 --- /dev/null +++ b/pages/table-root/striped-rows.page.tsx @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableRoot from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { DataHeader, makeItems } from './common'; + +// Striped rows are composed via the row `variant`: the consumer renders the rows and knows each +// index, so it marks alternating rows `shaded`. The atomic table owns no row-parity computation. +export default function TableStripedRowsPage() { + const items = makeItems(12); + return ( + + + Table atomics — striped rows (variant='shaded') + + +
Resources
+ + + + {items.map((item, index) => ( + + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + + +
+
+
+ ); +} diff --git a/pages/table-root/styles.scss b/pages/table-root/styles.scss new file mode 100644 index 0000000000..6c0c84e04a --- /dev/null +++ b/pages/table-root/styles.scss @@ -0,0 +1,61 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +@use '~design-tokens' as tokens; + +// A minimal sort control for the demo: a native button that inherits the header cell's colour and +// typography (so the label stays the column-header colour, not link blue). It fills the cell and +// pushes the caret to the end, so the label stays left-aligned with the column while the sort +// indicator sits at the right. +.sort-button { + display: flex; + align-items: center; + justify-content: space-between; + inline-size: 100%; + gap: tokens.$space-static-xxs; + padding-block: 0; + padding-inline: 0; + border-block: none; + border-inline: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +// Centres the selection control within a disablePaddings control cell, matching classic Table. +.selection-cell { + display: flex; + justify-content: center; + align-items: center; +} + +// Screen-reader-only label text (gives a bare control an accessible name without visible text). +.visually-hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + padding-block: 0; + padding-inline: 0; + margin-block: -1px; + margin-inline: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-block: none; + border-inline: none; +} + +// Sort affordance at the right of a sortable header (caret + optional multi-sort priority badge). +.sort-indicator { + display: inline-flex; + align-items: center; + gap: tokens.$space-static-xxs; +} + +// Priority number shown next to each caret when more than one column is sorted (multi-column sort). +.sort-order { + font-size: 0.75em; + font-weight: 700; +} diff --git a/pages/table-root/virtualization.page.tsx b/pages/table-root/virtualization.page.tsx new file mode 100644 index 0000000000..4f6d1be8b2 --- /dev/null +++ b/pages/table-root/virtualization.page.tsx @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +// Bring-your-own fixed-height virtualization (grid layout). A tiny hand-rolled +// windowing calc (no external dependency) stands in for a library like @tanstack/react-virtual: the +// consumer owns the scroll container and computes which rows are in view, then positions them via the +// narrowed `style` props — `height`/`position` on TableBody (reserve the total scroll height) and +// `transform`/`position`/`height` on each TableRow (place it at its offset). `ariaRowcount` + +// `ariaRowindex` keep assistive technologies aware of the full dataset while only a window renders. +const ROW_HEIGHT = 40; +const VIEWPORT_HEIGHT = 400; +const OVERSCAN = 4; +const TOTAL = 10000; +const COLUMNS: ReadonlyArray = [{ size: 120 }, {}]; + +interface LogLine { + id: string; + timestamp: string; + message: string; +} + +const makeLine = (index: number): LogLine => ({ + id: `line-${index}`, + timestamp: new Date(1_700_000_000_000 + index * 1000).toISOString().slice(11, 19), + message: `Log message ${index} — event processed`, +}); + +export default function TableVirtualizationPage() { + const scrollRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + + const first = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visibleCount = Math.ceil(VIEWPORT_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2; + const last = Math.min(TOTAL, first + visibleCount); + const indexes: number[] = []; + for (let i = first; i < last; i++) { + indexes.push(i); + } + + return ( + + + Table atomics — bring-your-own virtualization (grid layout) + + +
Log lines
+
setScrollTop(event.currentTarget.scrollTop)} + style={{ overflowY: 'auto', height: VIEWPORT_HEIGHT }} + > + + + + Time + Message + + + + {indexes.map(index => { + const line = makeLine(index); + return ( + + {line.timestamp} + {line.message} + + ); + })} + + +
+
+
+
+ ); +} From 164df9c0ce3e930d283640d1a111e2c0623a9744 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 09:16:30 +0000 Subject: [PATCH 07/35] test(table): unit tests for public table atomic components (Inc5) --- .../basic-table-styling-props.test.tsx | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 src/table-root/__tests__/basic-table-styling-props.test.tsx diff --git a/src/table-root/__tests__/basic-table-styling-props.test.tsx b/src/table-root/__tests__/basic-table-styling-props.test.tsx new file mode 100644 index 0000000000..74705beef1 --- /dev/null +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -0,0 +1,182 @@ +// 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 TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow, { TableRowProps } from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import bodyCellStyles from '../../../lib/components/table/body-cell/styles.css.js'; +import headerCellStyles from '../../../lib/components/table-header-cell/styles.css.js'; + +// Proves the row `variant` is purely visual and reaches the cell paint without a `data-*` styling +// hook on the row, that `ariaSelected` drives `aria-selected` independently of `variant`, that the +// narrowed inline `style` props (for virtualization) reach the body and row roots, and that +// `disablePaddings` reaches the padding opt-out on the cell content and header-cell root. +// +// On this fork the row publishes its variant through RowVariantContext and each TableCell self-paints +// by reusing classic's `.body-cell-selected` / `.body-cell-shaded` classes — the row never exposes a +// `data-*` attribute as a CSS hook. Selection and shading are mutually exclusive by type. + +function Harness({ variant, ariaSelected }: { variant?: TableRowProps.Variant; ariaSelected?: boolean }) { + return ( + + + + Name + Status + + + + + Resource 0 + Available + + + + ); +} + +function renderHarness(variant?: TableRowProps.Variant, ariaSelected?: boolean) { + const { container } = render(); + return { wrapper: createWrapper(container) }; +} + +function cellClassLists(wrapper: ReturnType) { + return wrapper.findAllTableCells().map(cell => cell.getElement().classList); +} + +describe('TableRow variant is visual-only and paints through the cell (no data-* hook)', () => { + test("variant='selected' paints every cell selected without a data-* hook or aria-selected on the row", () => { + const { wrapper } = renderHarness('selected'); + const row = wrapper.findAllTableRows()[0].getElement(); + // Visual state must NOT leak into ARIA or a data-* styling hook on the row. + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).not.toHaveAttribute('data-shaded'); + // The paint arrives on the cells via context, reusing classic's own selection class. + for (const classList of cellClassLists(wrapper)) { + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(true); + expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); + } + }); + + test("variant='shaded' paints every cell shaded and never selected", () => { + const { wrapper } = renderHarness('shaded'); + const row = wrapper.findAllTableRows()[0].getElement(); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-shaded'); + for (const classList of cellClassLists(wrapper)) { + expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(true); + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + } + }); + + test('the default variant paints neither and sets no aria-selected', () => { + const { wrapper } = renderHarness(); + const row = wrapper.findAllTableRows()[0].getElement(); + expect(row).not.toHaveAttribute('aria-selected'); + for (const classList of cellClassLists(wrapper)) { + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); + } + }); + + test('a TableCell rendered outside any TableRow falls back to the default (unpainted) variant', () => { + // Guards the RowVariantContext default so a stray cell never paints itself selected/shaded. + const { container } = render( + + +
+ Loose + + + + ); + const classList = createWrapper(container).findAllTableCells()[0].getElement().classList; + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); + }); + + test('ariaSelected drives aria-selected independently of variant', () => { + const selected = renderHarness('selected', true).wrapper.findAllTableRows()[0].getElement(); + expect(selected).toHaveAttribute('aria-selected', 'true'); + + const notSelected = renderHarness('default', false).wrapper.findAllTableRows()[0].getElement(); + expect(notSelected).toHaveAttribute('aria-selected', 'false'); + }); +}); + +describe('inline style props (virtualization)', () => { + const COLUMNS: ReadonlyArray = [{ size: 100 }]; + + test('TableBody and TableRow apply their narrowed inline style to their roots', () => { + const { container } = render( + + + + Name + + + + + Row + + + + ); + const wrapper = createWrapper(container); + const body = wrapper.findTableBody()!.getElement() as HTMLElement; + expect(body.style.position).toBe('relative'); + expect(body.style.height).toBe('400px'); + + const row = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(row.style.position).toBe('absolute'); + expect(row.style.transform).toBe('translateY(40px)'); + // The row keeps its shared grid template alongside the consumer's positioning style. + expect(row.style.gridTemplateColumns).toBe('100px'); + }); +}); + +describe('disablePaddings', () => { + test('TableCell applies the no-padding hook on the cell content only when disablePaddings is set', () => { + const { container } = render( + + + + Control + Resource 0 + + + + ); + const cells = createWrapper(container).findAllTableCells(); + // The opt-out lands on the inner `.body-cell-content` wrapper carved from classic's box model. + const contentOf = (index: number) => + cells[index].getElement().getElementsByClassName(bodyCellStyles['body-cell-content'])[0]; + expect(contentOf(0).classList.contains(bodyCellStyles['disable-paddings'])).toBe(true); + expect(contentOf(1).classList.contains(bodyCellStyles['disable-paddings'])).toBe(false); + }); + + test('TableHeaderCell applies the no-padding hook on its root only when disablePaddings is set', () => { + const { container } = render( + + + + + Name + + + + ); + const headerCells = createWrapper(container).findAllTableHeaderCells(); + expect(headerCells[0].getElement().classList.contains(headerCellStyles['disable-paddings'])).toBe(true); + expect(headerCells[1].getElement().classList.contains(headerCellStyles['disable-paddings'])).toBe(false); + }); +}); From 435c3f6f51954ed65eb5eea02126e0684ab1d2e7 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 09:44:20 +0000 Subject: [PATCH 08/35] fix(table): keep selected disablePaddings control cells centred (Inc6) --- src/table/body-cell/styles.scss | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/table/body-cell/styles.scss b/src/table/body-cell/styles.scss index 510ab251e1..78a7a0bc3d 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -568,3 +568,15 @@ $cell-negative-space-vertical: 2px; margin-block: 0; margin-inline: 0; } + +// The selected-state block-start rules (`.body-cell-selected[…] > .body-cell-content`, +// specificity (0,3,0)) inject a lopsided top pad + negative margin as a border +// placeholder that the (0,2,0) reset above can't neutralise — leaving a centred control +// shoved down in a grid row (`align-items: center`, cell not stretched to the row). +// Match (0,3,0) here (and win on source order) to hold the block reset in the selected +// state. Block axis only: the first-child inline-start padding is (0,3,0) too and must +// stay identical to the unselected state, so this must not touch the inline axis. +.body-cell-selected > .body-cell-content.disable-paddings { + padding-block: 0; + margin-block: 0; +} From cb9f3fac8878181443fbdcfaf8087445ca0e16d8 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 10:39:43 +0000 Subject: [PATCH 09/35] refactor(table): trim public API (drop event props, nativeAttributes internal-only) + review fixes (Inc7b) --- src/table-header-row/internal.tsx | 6 ++++-- src/table-root/__tests__/basic-table.test.tsx | 14 ++++++++++++-- src/table-root/interfaces.ts | 6 ------ src/table-root/internal.tsx | 2 +- src/table-root/use-table-root.ts | 9 +++++++-- src/table-row/interfaces.ts | 6 ------ src/table-row/internal.tsx | 16 +++++----------- src/table-row/styles.scss | 7 ++++--- 8 files changed, 33 insertions(+), 33 deletions(-) diff --git a/src/table-header-row/internal.tsx b/src/table-header-row/internal.tsx index 2b2af77d34..abf7272b6f 100644 --- a/src/table-header-row/internal.tsx +++ b/src/table-header-row/internal.tsx @@ -12,14 +12,16 @@ import styles from './styles.css.js'; export function HeaderRow(props: TableHeaderRowProps & InternalBaseComponentProps) { const { children, __internalRootRef } = props; - const { columnLayout, gridTemplateColumns } = useTableContext(); + const { columnLayout, gridTemplateColumns, ariaRowcount } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const baseProps = getBaseProps(props); return ( { expect(dataRow.querySelectorAll('[role="cell"]')).toHaveLength(2); }); - test('the header row is aria-rowindex 1 and shares the column template with the data rows', () => { + test('the header row shares the column template with the data rows', () => { const { wrapper } = renderTable({ grid: true }); const headerRow = wrapper.findTableHead()!.find('[role="row"]')!.getElement() as HTMLElement; - expect(headerRow.getAttribute('aria-rowindex')).toBe('1'); const template = '200px minmax(0px, 1fr)'; expect(headerRow.style.gridTemplateColumns).toBe(template); const dataRow = wrapper.findAllTableRows()[0].getElement() as HTMLElement; expect(dataRow.style.gridTemplateColumns).toBe(template); }); + + test('the header row is aria-rowindex 1 only when the grid declares an aria-rowcount (virtualized)', () => { + const virtualized = renderTable({ grid: true, ariaRowcount: 500 }); + const virtualizedHeaderRow = virtualized.wrapper.findTableHead()!.find('[role="row"]')!.getElement(); + expect(virtualizedHeaderRow.getAttribute('aria-rowindex')).toBe('1'); + + // In a non-virtualized grid the row positions derive from the DOM, so no aria-rowindex is set. + const plain = renderTable({ grid: true }); + const plainHeaderRow = plain.wrapper.findTableHead()!.find('[role="row"]')!.getElement(); + expect(plainHeaderRow.hasAttribute('aria-rowindex')).toBe(false); + }); }); describe('row aria-selected is driven by ariaSelected, not variant', () => { diff --git a/src/table-root/interfaces.ts b/src/table-root/interfaces.ts index faab94bfc4..2a3a05dfb8 100644 --- a/src/table-root/interfaces.ts +++ b/src/table-root/interfaces.ts @@ -50,14 +50,8 @@ export namespace TableRootProps { export type ColumnLayout = { type: 'auto' } | { type: 'grid'; columns: ReadonlyArray }; export interface ColumnDefinition { - /** - * A number sets a fixed pixel width; `{ flex }` gives the column a weight that shares the - * remaining space in proportion. Omit it for a flexible column with the default weight of 1. - */ size?: number | { flex: number }; - /** The minimum width in pixels, for a flexible column. */ minWidth?: number; - /** The maximum width in pixels. */ maxWidth?: number; } } diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx index 959b227ca8..23f3357cd3 100644 --- a/src/table-root/internal.tsx +++ b/src/table-root/internal.tsx @@ -25,7 +25,7 @@ export function InternalRoot(props: InternalRootProps) { } = props; const isGrid = columnLayout.type === 'grid'; - const table = useTableRoot(columnLayout); + const table = useTableRoot(columnLayout, ariaRowcount); const baseProps = getBaseProps(props); return ( diff --git a/src/table-root/use-table-root.ts b/src/table-root/use-table-root.ts index 10dd763b28..8759fd1d1d 100644 --- a/src/table-root/use-table-root.ts +++ b/src/table-root/use-table-root.ts @@ -8,9 +8,11 @@ export interface UseTableRootResult { columnLayout: TableRootProps.ColumnLayout; /** The `grid-template-columns` value for `grid` layout, compiled from each column's `size` union; `undefined` in `auto` layout. */ gridTemplateColumns?: string; + /** The consumer-supplied `aria-rowcount`, present only when the table is virtualized (a grid rendering a subset of rows). */ + ariaRowcount?: number; } -export function useTableRoot(columnLayout: TableRootProps.ColumnLayout): UseTableRootResult { +export function useTableRoot(columnLayout: TableRootProps.ColumnLayout, ariaRowcount?: number): UseTableRootResult { const gridTemplateColumns = useMemo(() => { if (columnLayout.type !== 'grid') { return undefined; @@ -31,5 +33,8 @@ export function useTableRoot(columnLayout: TableRootProps.ColumnLayout): UseTabl .join(' '); }, [columnLayout]); - return { columnLayout, gridTemplateColumns }; + return useMemo( + () => ({ columnLayout, gridTemplateColumns, ariaRowcount }), + [columnLayout, gridTemplateColumns, ariaRowcount] + ); } diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts index f5a35ca877..8bc22a4f37 100644 --- a/src/table-row/interfaces.ts +++ b/src/table-row/interfaces.ts @@ -38,12 +38,6 @@ export interface TableRowProps extends BaseComponentProps { * virtualization or draggable rows. It is not supported to use this for general styling purposes. */ style?: TableRowProps.Style; - /** Called when the row is clicked. */ - onClick?: React.MouseEventHandler; - /** Called when focus moves into the row. */ - onFocus?: React.FocusEventHandler; - /** Called when the row's context menu is requested (for example, a right-click). */ - onContextMenu?: React.MouseEventHandler; /** The row's cells, one per column, in order. */ children?: React.ReactNode; } diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index 267abe21e4..d7eaa6bf18 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -12,11 +12,11 @@ import { TableRowProps } from './interfaces'; import styles from './styles.css.js'; -// The bare element, `.row` marker, ref, and event handlers are provided by the extracted -// InternalTableRow substrate (shared with the row classic's body renders). This public component -// layers the grid-mode role/layout and aria surface, and publishes the row's `variant` to its cells -// through RowVariantContext so each cell self-paints selection/shading via its own class — no -// `data-*` styling hook on the row. +// The bare element, `.row` marker, and ref are provided by the extracted InternalTableRow +// substrate (shared with classic's body renders; the substrate keeps the event-handler props that +// classic composes). This public component layers the grid-mode role/layout and aria surface, and +// publishes the row's `variant` to its cells through RowVariantContext so each cell self-paints +// selection/shading via its own class — no `data-*` styling hook on the row. export function Row(props: TableRowProps & InternalBaseComponentProps) { const { variant = 'default', @@ -27,9 +27,6 @@ export function Row(props: TableRowProps & InternalBaseComponentProps) { ariaRowindex, children, style, - onClick, - onFocus, - onContextMenu, __internalRootRef, } = props; const { columnLayout, gridTemplateColumns } = useTableContext(); @@ -39,9 +36,6 @@ export function Row(props: TableRowProps & InternalBaseComponentProps) { Date: Mon, 7 Sep 2026 11:13:35 +0000 Subject: [PATCH 10/35] fix(table): standalone atomic parity -- is-visual-refresh first-col padding + header dividers (v1/v3) --- src/table-cell/internal.tsx | 3 +++ src/table-header-cell/internal.tsx | 4 ++++ src/table-header-cell/styles.scss | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index 22115188b8..f373e715d6 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { InternalTableCell } from '../table/table-cell/internal'; import { useTableContext } from '../table-root/context'; import { useRowVariant } from '../table-row/context'; @@ -27,6 +28,7 @@ export function Cell(props: TableCellProps & InternalBaseComponentProps) { const { columnLayout } = useTableContext(); const variant = useRowVariant(); const isGrid = columnLayout.type === 'grid'; + const isVisualRefresh = useVisualRefresh(); const { className, ...restBaseProps } = getBaseProps(props); return ( element, classic's `.header-cell` box model, and ref are provided by the extracted @@ -19,6 +21,7 @@ export function HeaderCell(props: TableHeaderCellProps & InternalBaseComponentPr const { children, ariaLabel, ariaLabelledby, ariaDescribedby, ariaSort, disablePaddings, __internalRootRef } = props; const { columnLayout } = useTableContext(); const isGrid = columnLayout.type === 'grid'; + const isVisualRefresh = useVisualRefresh(); const { className, ...restBaseProps } = getBaseProps(props); return ( .divider`: +// full-height minus a top/bottom gutter, centered via `margin-block: auto`, 1px default-divider rule at the +// trailing edge. +.header-cell:not(:last-child)::after { + content: ''; + position: absolute; + inset-inline-end: 0; + inset-block-start: 0; + inset-block-end: 0; + min-block-size: awsui.$line-height-heading-xs; + max-block-size: calc(100% - (2 * #{awsui.$space-xs} + #{awsui.$space-xxxs})); + margin-block: auto; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + pointer-events: none; +} + // Lets grid columns shrink below their content size. .header-cell-grid { min-inline-size: 0; From d3025c690b6529f422fb55c49bd2394e32d3003d Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 12:05:46 +0000 Subject: [PATCH 11/35] test(table): classic-Table reference demo pages for atomic VR (table-root-classic) Recreate each pages/table-root atomic demo with the shipped classic Table as a per-scenario VR oracle. Same data (makeItems/makeLine), columns, counts and initial state so the atomic-vs-classic diff is content-identical. Auto-registered at table-root-classic/. Multi-column sort and row virtualization have no classic equivalent (see FINDINGS). Pages only; src untouched. --- pages/table-root-classic/FINDINGS.md | 87 +++++++++++++++++++ pages/table-root-classic/common.tsx | 40 +++++++++ .../loading-and-empty.page.tsx | 61 +++++++++++++ pages/table-root-classic/selection.page.tsx | 51 +++++++++++ pages/table-root-classic/simple.page.tsx | 29 +++++++ .../single-selection.page.tsx | 41 +++++++++ pages/table-root-classic/sorting.page.tsx | 58 +++++++++++++ .../table-root-classic/striped-rows.page.tsx | 29 +++++++ .../virtualization.page.tsx | 53 +++++++++++ 9 files changed, 449 insertions(+) create mode 100644 pages/table-root-classic/FINDINGS.md create mode 100644 pages/table-root-classic/common.tsx create mode 100644 pages/table-root-classic/loading-and-empty.page.tsx create mode 100644 pages/table-root-classic/selection.page.tsx create mode 100644 pages/table-root-classic/simple.page.tsx create mode 100644 pages/table-root-classic/single-selection.page.tsx create mode 100644 pages/table-root-classic/sorting.page.tsx create mode 100644 pages/table-root-classic/striped-rows.page.tsx create mode 100644 pages/table-root-classic/virtualization.page.tsx diff --git a/pages/table-root-classic/FINDINGS.md b/pages/table-root-classic/FINDINGS.md new file mode 100644 index 0000000000..f6af7065d0 --- /dev/null +++ b/pages/table-root-classic/FINDINGS.md @@ -0,0 +1,87 @@ +# Classic-Table reference demo pages (VR oracle for atomic table-root) + +Recreates each `pages/table-root/` atomic demo with the shipped classic `Table` component +(`~components/table`) so the visual-regression step has a per-scenario oracle. Content is +identical to the atomic demos: the SAME data helpers (`makeItems` from `../table-root/common`, +and a replicated `makeLine` for virtualization), same item counts, same column labels, same +initial state. + +New directory: `pages/table-root-classic/` — auto-discovered by the dev harness +(`require.context('..', true, /\.page\.tsx$/)`), so each page registers at hash route +`table-root-classic/` with no manual wiring. + +## Routes created + +| Route | File | Mirrors atomic | +|---|---|---| +| `table-root-classic/simple` | `simple.page.tsx` | `table-root/simple` | +| `table-root-classic/selection` | `selection.page.tsx` | `table-root/selection` | +| `table-root-classic/single-selection` | `single-selection.page.tsx` | `table-root/single-selection` | +| `table-root-classic/sorting` | `sorting.page.tsx` | `table-root/sorting` | +| `table-root-classic/striped-rows` | `striped-rows.page.tsx` | `table-root/striped-rows` | +| `table-root-classic/loading-and-empty` | `loading-and-empty.page.tsx` | `table-root/loading-and-empty` | +| `table-root-classic/virtualization` | `virtualization.page.tsx` | `table-root/virtualization` | + +Shared helper: `common.tsx` — re-exports `Item`/`makeItems` from `../table-root/common` (same data), +and defines classic `columnDefinitions` (`dataColumns` = Name/Type/Size/Status; `nameStatusColumns` = +Name/Status for the selection demos) plus `resourcesAriaLabels`. No `styles.scss` and no bespoke +`common` data were needed — classic Table renders its own selection control, sort caret, dividers, +striped and loading/empty chrome, so none of the atomic demos' custom SCSS (`selection-cell`, +`sort-button`, `visually-hidden`, sort badges) is required. + +## Per-page mapping + +- **simple** → `columnDefinitions={dataColumns}` + `items={makeItems(8)}`. Atomic uses auto layout, + which mirrors classic Table's default `table-layout: auto`. 1:1. +- **selection** → `selectionType="multi"` + `selectedItems`/`trackBy="id"`/`onSelectionChange`, plus + `sortingColumn`/`sortingDescending`/`onSortingChange` on the Name column (the atomic demo is + "selectable + sortable"). Classic renders the native multi-select checkbox control column — exactly + what the atomic demo hand-builds with a `disablePaddings` control cell + centered `Checkbox`. Same + `makeItems(10)`, same two rows preselected (`resource-1`, `resource-2`), same name-ascending initial + sort. Consumer sorts the data (classic Table only shows the indicator + fires the event). +- **single-selection** → `selectionType="single"` + `selectedItems`/`trackBy="id"`. Classic renders + the native radio control column (atomic hand-builds a `RadioButton` with a shared `name`). Same + `makeItems(10)`, single preselected row (`resource-1`). +- **sorting** → single-column sort via `sortingColumn`/`sortingDescending`/`onSortingChange`; + `dataColumns` carry `sortingField` (name/type/status) and a `sortingComparator` (size, numeric). + Consumer sorts the data. Same `makeItems(12)`, initial name-ascending. **Non-mapping detail below.** +- **striped-rows** → `stripedRows={true}` (classic computes row parity itself; atomic marks alternating + rows `variant='shaded'` by hand). Same `makeItems(12)`. +- **loading-and-empty** → `loading`/`loadingText` + the `empty` slot, toggled by the same + `SegmentedControl` (Loaded / Loading / Empty). Same `makeItems(20)` when loaded. The atomic demo + composes a full-width `colSpan` status row by hand; classic renders loading/empty natively. + +## Scenarios that do NOT map cleanly + +1. **virtualization** — classic `Table` has **no built-in row virtualization**. The atomic demo + windows a 10,000-row dataset (renders ~14 absolutely-positioned rows at a time via narrowed + `style` props). Classic cannot window, so the oracle renders the SAME full 10,000-row dataset + (`makeLine`, 2 columns Time[120px]/Message) as normal flow ``s. A faithful pixel comparison of + a windowed table vs a full non-virtualized table is not possible — this page exists so the row/cell + **chrome** (header, dividers, cell padding, column widths) can still be compared. + - **VR recommendation:** either compare only a bounded row window / the header+first-N-rows region, + or reduce `TOTAL` in both pages for the VR run (10k classic flow rows renders slowly). +2. **sorting — multi-column** — the atomic demo supports shift-click multi-column sort with a priority + badge next to each caret. Classic `Table` supports only single-column sort natively (one + `sortingColumn` at a time). The oracle reproduces the single-column case (initial sort + per-column + toggle); the multi-column priority-badge state has no classic equivalent and is not represented. + +## Known expected difference (all pages) + +The atomic table-root demos are **bare** (no outer Container chrome — the v2 Container decision is +still pending). Classic `Table` renders its default `container` variant (outer border / radius / +shadow). VR should scope to the grid/rows/cells/header region, not the outer container frame. This is +the pending v2 delta, not a regression. + +## Verification (logs in this directory) + +- `gulp-quick-build.log` — `npx gulp quick-build` → **exit 0**. +- `pages-tsc.log` — `npx tsc -p pages/tsconfig.json --noEmit` → only the two pre-existing + `@formatjs/ecma402-abstract` `Intl.ListFormat` drift errors (node_modules); **zero errors in the new + pages** (two `detail.isDescending` `boolean|undefined` errors were found and fixed with `?? false`). +- `eslint.log` — `npx eslint pages/table-root-classic/` → **exit 0**. +- `stylelint.log` — no new `.scss` created → **N/A**. + +`src/` (components + substrates) was not touched — pages only. Nothing blocks the atomic-vs-classic +VR step; both page sets share identical data and columns, so a VR harness can pair +`table-root/` against `table-root-classic/` directly. diff --git a/pages/table-root-classic/common.tsx b/pages/table-root-classic/common.tsx new file mode 100644 index 0000000000..a308a00af6 --- /dev/null +++ b/pages/table-root-classic/common.tsx @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '~components/table'; + +import { Item } from '../table-root/common'; + +// Reuse the SAME data helper + item shape as the atomic table-root demos so the atomic-vs-classic +// visual-regression diff is content-identical (same rows, same values, same order). +export { makeItems } from '../table-root/common'; +export type { Item } from '../table-root/common'; + +// Full 4-column definition mirroring the atomic DataHeader/DataBody (Name/Type/Size/Status). +// sortingField/sortingComparator are declared so the sorting oracle can drive classic Table's +// built-in single-column sort indicator. +export const dataColumns: ReadonlyArray> = [ + { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, + { id: 'type', header: 'Type', cell: item => item.type, sortingField: 'type' }, + { + id: 'size', + header: 'Size', + cell: item => item.size, + sortingComparator: (a, b) => parseInt(a.size, 10) - parseInt(b.size, 10), + }, + { id: 'status', header: 'Status', cell: item => item.status, sortingField: 'status' }, +]; + +// Name + Status pair used by the selection demos. The selection control column is native to the +// classic Table (rendered by the component itself), so it is not part of columnDefinitions here — +// which is exactly what the atomic demos reproduce by hand with a disablePaddings control cell. +export const nameStatusColumns: ReadonlyArray> = [ + { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, + { id: 'status', header: 'Status', cell: item => item.status }, +]; + +export const resourcesAriaLabels: TableProps['ariaLabels'] = { + tableLabel: 'Resources', + selectionGroupLabel: 'Resources selection', + allItemsSelectionLabel: ({ selectedItems }) => `${selectedItems.length} resources selected`, + itemSelectionLabel: (_data, item) => `Select ${item.name}`, +}; diff --git a/pages/table-root-classic/loading-and-empty.page.tsx b/pages/table-root-classic/loading-and-empty.page.tsx new file mode 100644 index 0000000000..d873a66197 --- /dev/null +++ b/pages/table-root-classic/loading-and-empty.page.tsx @@ -0,0 +1,61 @@ +// 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 SegmentedControl from '~components/segmented-control'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import { dataColumns, makeItems, resourcesAriaLabels } from './common'; + +type State = 'loaded' | 'loading' | 'empty'; + +// VR oracle for pages/table-root/loading-and-empty. The atomic demo composes loading/empty as a +// full-width colSpan status row by hand; classic Table renders these natively via loading + +// loadingText and the empty slot. Same three states, same makeItems(20) when loaded. +export default function TableClassicLoadingEmptyPage() { + const [state, setState] = useState('loaded'); + const items = state === 'loaded' ? makeItems(20) : []; + + return ( + + + Classic Table — loading & empty (VR oracle for table-root/loading-and-empty) + + setState(event.detail.selectedId as State)} + label="Data state" + options={[ + { id: 'loaded', text: 'Loaded' }, + { id: 'loading', text: 'Loading' }, + { id: 'empty', text: 'Empty' }, + ]} + /> + + +
Resources
+
` the consumer renders inside +// a `TableRow`. The consumer owns the data and the state; the status content is wrapped in a `Box` +// so its centered padding comes from spacing design tokens, not a standard data `TableCell`. +export default function TableLoadingEmptyPage() { + const [state, setState] = useState('loaded'); + const items = state === 'loaded' ? makeItems(20) : []; + + return ( + + + Table atomics — loading & empty states + + setState(event.detail.selectedId as State)} + label="Data state" + options={[ + { id: 'loaded', text: 'Loaded' }, + { id: 'loading', text: 'Loading' }, + { id: 'empty', text: 'Empty' }, + ]} + /> + + +
Resources
+ + + {state === 'loaded' ? ( + + ) : ( + + +
+ + {state === 'loading' ? ( + Loading resources + ) : ( + + No resources + + No resources to display. + + + )} + +
+ + No resources + + No resources to display. + + + + } + ariaLabels={resourcesAriaLabels} + /> + + + + ); +} diff --git a/pages/table-root-classic/selection.page.tsx b/pages/table-root-classic/selection.page.tsx new file mode 100644 index 0000000000..45cee406db --- /dev/null +++ b/pages/table-root-classic/selection.page.tsx @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import { Item, makeItems, nameStatusColumns, resourcesAriaLabels } from './common'; + +// VR oracle for pages/table-root/selection (selectable + sortable). The atomic demo hand-builds a +// selection control column + a sortable Name header; classic Table provides both natively via +// selectionType='multi' and sortingColumn/onSortingChange. Same makeItems(10), same two rows +// preselected (resource-1, resource-2), same name-ascending initial sort. +const ITEM_COUNT = 10; + +export default function TableClassicSelectionPage() { + const allItems = useMemo(() => makeItems(ITEM_COUNT), []); + const [selectedItems, setSelectedItems] = useState([allItems[1], allItems[2]]); + const [sortingDescending, setSortingDescending] = useState(false); + + const items = useMemo(() => { + const sorted = [...allItems].sort((a, b) => a.name.localeCompare(b.name)); + return sortingDescending ? sorted.reverse() : sorted; + }, [allItems, sortingDescending]); + + return ( + + + Classic Table — selectable + sortable (VR oracle for table-root/selection) + + +
Resources
+
setSelectedItems(detail.selectedItems)} + sortingColumn={nameStatusColumns[0]} + sortingDescending={sortingDescending} + onSortingChange={({ detail }) => setSortingDescending(detail.isDescending ?? false)} + ariaLabels={resourcesAriaLabels} + /> + + + + ); +} diff --git a/pages/table-root-classic/simple.page.tsx b/pages/table-root-classic/simple.page.tsx new file mode 100644 index 0000000000..5dd9de3e14 --- /dev/null +++ b/pages/table-root-classic/simple.page.tsx @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import { dataColumns, makeItems, resourcesAriaLabels } from './common'; + +// VR oracle for pages/table-root/simple: the same 4 columns and makeItems(8) rendered with the +// shipped classic Table. The atomic simple demo uses auto layout, which mirrors classic Table's +// default table-layout: auto. +export default function TableClassicSimplePage() { + const items = makeItems(8); + return ( + + + Classic Table — simple (VR oracle for table-root/simple) + + +
Resources
+
+ + + + ); +} diff --git a/pages/table-root-classic/single-selection.page.tsx b/pages/table-root-classic/single-selection.page.tsx new file mode 100644 index 0000000000..676b751961 --- /dev/null +++ b/pages/table-root-classic/single-selection.page.tsx @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import { Item, makeItems, nameStatusColumns, resourcesAriaLabels } from './common'; + +// VR oracle for pages/table-root/single-selection. The atomic demo hand-builds a radio control +// column; classic Table provides it natively via selectionType='single'. Same makeItems(10), same +// single preselected row (resource-1). +const ITEM_COUNT = 10; + +export default function TableClassicSingleSelectionPage() { + const items = useMemo(() => makeItems(ITEM_COUNT), []); + const [selectedItems, setSelectedItems] = useState([items[1]]); + + return ( + + + Classic Table — single selection (VR oracle for table-root/single-selection) + + +
Resources
+
setSelectedItems(detail.selectedItems)} + ariaLabels={resourcesAriaLabels} + /> + + + + ); +} diff --git a/pages/table-root-classic/sorting.page.tsx b/pages/table-root-classic/sorting.page.tsx new file mode 100644 index 0000000000..469b8ae197 --- /dev/null +++ b/pages/table-root-classic/sorting.page.tsx @@ -0,0 +1,58 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import Table, { TableProps } from '~components/table'; + +import { dataColumns, Item, makeItems } from './common'; + +// VR oracle for pages/table-root/sorting. The atomic demo composes MULTI-column sort by hand +// (shift-click adds a column + priority badge). Classic Table only supports SINGLE-column sort +// natively (one sortingColumn at a time), so this oracle reproduces the single-column case — the +// initial name-ascending sort and per-column toggling. Multi-column sort has no classic equivalent +// and is called out in FINDINGS. Same makeItems(12), same 4 columns. +export default function TableClassicSortingPage() { + const items = useMemo(() => makeItems(12), []); + const [sortingColumn, setSortingColumn] = useState>(dataColumns[0]); + const [sortingDescending, setSortingDescending] = useState(false); + + const rows = useMemo(() => { + const comparator = + sortingColumn.sortingComparator ?? + ((a: Item, b: Item) => { + const field = sortingColumn.sortingField as keyof Item; + return String(a[field]).localeCompare(String(b[field])); + }); + const sorted = [...items].sort(comparator); + return sortingDescending ? sorted.reverse() : sorted; + }, [items, sortingColumn, sortingDescending]); + + return ( + + + Classic Table — sorting (VR oracle for table-root/sorting) + + Classic Table supports single-column sort natively. Click a column header to sort by it. + + + +
Resources
+
{ + setSortingColumn(detail.sortingColumn); + setSortingDescending(detail.isDescending ?? false); + }} + ariaLabels={{ tableLabel: 'Resources' }} + /> + + + + ); +} diff --git a/pages/table-root-classic/striped-rows.page.tsx b/pages/table-root-classic/striped-rows.page.tsx new file mode 100644 index 0000000000..8b5b996358 --- /dev/null +++ b/pages/table-root-classic/striped-rows.page.tsx @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import Table from '~components/table'; + +import { dataColumns, makeItems, resourcesAriaLabels } from './common'; + +// VR oracle for pages/table-root/striped-rows. The atomic demo marks alternating rows +// variant='shaded' by hand; classic Table computes row parity itself via the stripedRows prop. +// Same makeItems(12), same 4 columns. +export default function TableClassicStripedRowsPage() { + const items = makeItems(12); + return ( + + + Classic Table — striped rows (VR oracle for table-root/striped-rows) + + +
Resources
+
+ + + + ); +} diff --git a/pages/table-root-classic/virtualization.page.tsx b/pages/table-root-classic/virtualization.page.tsx new file mode 100644 index 0000000000..96fbc0c3fc --- /dev/null +++ b/pages/table-root-classic/virtualization.page.tsx @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import Table, { TableProps } from '~components/table'; + +// VR oracle for pages/table-root/virtualization. NOTE: classic Table has NO built-in row +// virtualization. The atomic demo windows a 10,000-row dataset (renders ~14 absolutely-positioned +// rows at a time). Classic cannot window, so this oracle renders the SAME full dataset with the same +// two columns — every row is a normal flow . A faithful pixel comparison of a windowed table vs +// a full non-virtualized table is not possible; this page exists so the row/cell CHROME (header, +// dividers, cell padding, column widths) can still be compared. See FINDINGS for the non-mapping +// note and a recommendation to reduce TOTAL for the VR run if 10k rows is impractical. +const TOTAL = 10000; +const TIME_COLUMN_WIDTH = 120; + +interface LogLine { + id: string; + timestamp: string; + message: string; +} + +// Same data helper as pages/table-root/virtualization so the rows are content-identical. +const makeLine = (index: number): LogLine => ({ + id: `line-${index}`, + timestamp: new Date(1_700_000_000_000 + index * 1000).toISOString().slice(11, 19), + message: `Log message ${index} — event processed`, +}); + +const columns: ReadonlyArray> = [ + { id: 'time', header: 'Time', cell: line => line.timestamp, width: TIME_COLUMN_WIDTH }, + { id: 'message', header: 'Message', cell: line => line.message }, +]; + +export default function TableClassicVirtualizationPage() { + const items = useMemo(() => Array.from({ length: TOTAL }, (_, index) => makeLine(index)), []); + + return ( + + + Classic Table — full set, no virtualization (VR oracle for table-root/virtualization) + + +
Log lines
+
+ + + + ); +} From 2c95cd08c6724e21e731dbf639b51365b2f8845d Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 12:53:17 +0000 Subject: [PATCH 12/35] test(table): borderless classic reference pages for atomic VR Set variant="borderless" on the classic Table in every table-root-classic demo so the classic twin is chrome-free (no outer Container border/radius/ shadow), matching the atomic TableRoot. This is the agreed VR oracle for the atomic-vs-classic parity work. --- pages/table-root-classic/loading-and-empty.page.tsx | 1 + pages/table-root-classic/selection.page.tsx | 1 + pages/table-root-classic/simple.page.tsx | 2 +- pages/table-root-classic/single-selection.page.tsx | 1 + pages/table-root-classic/sorting.page.tsx | 1 + pages/table-root-classic/striped-rows.page.tsx | 8 +++++++- pages/table-root-classic/virtualization.page.tsx | 7 ++++++- 7 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pages/table-root-classic/loading-and-empty.page.tsx b/pages/table-root-classic/loading-and-empty.page.tsx index d873a66197..1d3b4dadb1 100644 --- a/pages/table-root-classic/loading-and-empty.page.tsx +++ b/pages/table-root-classic/loading-and-empty.page.tsx @@ -38,6 +38,7 @@ export default function TableClassicLoadingEmptyPage() {
Resources
Resources
Resources
-
+
diff --git a/pages/table-root-classic/single-selection.page.tsx b/pages/table-root-classic/single-selection.page.tsx index 676b751961..e39dc786ae 100644 --- a/pages/table-root-classic/single-selection.page.tsx +++ b/pages/table-root-classic/single-selection.page.tsx @@ -26,6 +26,7 @@ export default function TableClassicSingleSelectionPage() {
Resources
Resources
Resources
-
+
diff --git a/pages/table-root-classic/virtualization.page.tsx b/pages/table-root-classic/virtualization.page.tsx index 96fbc0c3fc..18feab6f00 100644 --- a/pages/table-root-classic/virtualization.page.tsx +++ b/pages/table-root-classic/virtualization.page.tsx @@ -45,7 +45,12 @@ export default function TableClassicVirtualizationPage() {
Log lines
-
+
From 768eba8a2da2244255389d976502e56cf4fbb628 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 12:54:10 +0000 Subject: [PATCH 13/35] fix(table): atomic header + edge-row height parity (a1/a2) Two dominant atomic-vs-classic (borderless) gaps from the VR punch-list: a1 header row height (29px -> 37px): the public TableHeaderCell rendered its children with no content box, so the header row was 8px short. Wrap the children in a .header-cell-content box carrying padding-block:$space-scaled-xxs (+ line-height:$line-height-body-m), reproducing classic's header-cell-content vertical sizing. No inline padding, so the v1 is-visual-refresh first-column inset and the v3 ::after divider are untouched. a2 edge-row height (first/last body rows 1px taller): TableBody now publishes each row's first/last position via a RowPositionContext (a no-DOM provider), and TableCell applies classic's own body-cell-first-row / body-cell-last-row edge classes. Reusing classic's classes means the 2px transparent placeholder border and the body-cell-last-row:not(.body-cell-selected) selection guard come for free -- no reimplementation, no data-* hook, no element/universal selector. Auto layout verified pixel-exact vs the borderless classic twin (simple page: header 37, rows 40/39/.../40). Atomic layer only; classic composition (the src/table substrates) is untouched. --- src/table-body/internal.tsx | 17 ++++++++++++++++- src/table-cell/internal.tsx | 12 +++++++++--- src/table-header-cell/internal.tsx | 2 +- src/table-header-cell/styles.scss | 17 +++++++++++++++++ src/table-row/context.ts | 19 +++++++++++++++++++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index ec289715a2..cd9fcf7a31 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -7,6 +7,7 @@ import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { InternalTableBody } from '../table/table-body/internal'; import { useTableContext } from '../table-root/context'; +import { RowPositionContextProvider } from '../table-row/context'; import { TableBodyProps } from './interfaces'; import styles from './styles.css.js'; @@ -15,18 +16,32 @@ import styles from './styles.css.js'; // with classic's body). This public component layers the standalone body class, the grid-mode role, // and the positioning `style` (for virtualization) on top. The substrate takes no `style`, so it is // passed through the native-attribute channel it already spreads onto the element. +// +// TableBody is the only part that sees all the rows, so it also publishes each row's first/last +// position through RowPositionContext. The provider renders no DOM (the keeps its +// children), letting each row's cells apply classic's `body-cell-first-row` / `body-cell-last-row` +// edge classes for the borderless 1px-taller edge rows. export function Body(props: TableBodyProps & InternalBaseComponentProps) { const { children, style, __internalRootRef } = props; const { columnLayout } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(props); + const rows = React.Children.toArray(children); + const lastIndex = rows.length - 1; return ( - {children} + {rows.map((row, index) => ( + + {row} + + ))} ); } diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index f373e715d6..80370dc045 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -8,7 +8,7 @@ import { InternalBaseComponentProps } from '../internal/hooks/use-base-component import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { InternalTableCell } from '../table/table-cell/internal'; import { useTableContext } from '../table-root/context'; -import { useRowVariant } from '../table-row/context'; +import { useRowPosition, useRowVariant } from '../table-row/context'; import { TableCellProps } from './interfaces'; import bodyCellStyles from '../table/body-cell/styles.css.js'; @@ -22,11 +22,12 @@ import styles from './styles.css.js'; // selection stylesheet is duplicated and no `data-*` styling hook is needed. // // Deferred to the visual-regression increment (Inc4c): consecutive-row border reconciliation -// (prev/next), first/last-row placeholders, sticky columns, and full visual-refresh gating. +// (prev/next), sticky columns, and full visual-refresh gating. export function Cell(props: TableCellProps & InternalBaseComponentProps) { const { children, disablePaddings, __internalRootRef } = props; const { columnLayout } = useTableContext(); const variant = useRowVariant(); + const { isFirstRow, isLastRow } = useRowPosition(); const isGrid = columnLayout.type === 'grid'; const isVisualRefresh = useVisualRefresh(); const { className, ...restBaseProps } = getBaseProps(props); @@ -41,7 +42,12 @@ export function Cell(props: TableCellProps & InternalBaseComponentProps) { isGrid && styles['cell-grid'], isVisualRefresh && bodyCellStyles['is-visual-refresh'], variant === 'selected' && bodyCellStyles['body-cell-selected'], - variant === 'shaded' && bodyCellStyles['body-cell-shaded'] + variant === 'shaded' && bodyCellStyles['body-cell-shaded'], + // Edge-row placeholder borders (1px-taller first/last rows). Reuses classic's own classes, + // whose `body-cell-last-row:not(.body-cell-selected)` guard composes with the selected class + // above, so a selected edge row keeps its selection border. + isFirstRow && bodyCellStyles['body-cell-first-row'], + isLastRow && bodyCellStyles['body-cell-last-row'] )} nativeAttributes={{ ...restBaseProps, role: isGrid ? 'cell' : undefined }} > diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx index e4b226eb08..18592af62c 100644 --- a/src/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal.tsx @@ -43,7 +43,7 @@ export function HeaderCell(props: TableHeaderCellProps & InternalBaseComponentPr 'aria-sort': ariaSort, }} > - {children} +
{children}
); } diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss index 559072edc7..c71074212e 100644 --- a/src/table-header-cell/styles.scss +++ b/src/table-header-cell/styles.scss @@ -47,3 +47,20 @@ padding-block: 0; padding-inline: 0; } + +// Header content box. The extracted substrate renders header children with no wrapper, so the +// header row was 8px shorter than classic (29px vs 37px). Classic sizes its header via a +// `.header-cell-content` box carrying `padding-block: $space-scaled-xxs` on top of the base +// `.header-cell` padding; reproducing only that vertical box here (no inline padding, so the v1 +// is-visual-refresh first-column inset on the substrate `.header-cell` is untouched) lifts the row +// to classic's 37px. See src/table/header-cell `.header-cell-content`. +.header-cell-content { + padding-block: awsui.$space-scaled-xxs; + line-height: awsui.$line-height-body-m; +} + +// disablePaddings zeroes the cell's built-in padding; carry that through to the content box so a +// composed control cell has no residual block padding. +.header-cell.disable-paddings > .header-cell-content { + padding-block: 0; +} diff --git a/src/table-row/context.ts b/src/table-row/context.ts index 2cdb591973..fe126b9d50 100644 --- a/src/table-row/context.ts +++ b/src/table-row/context.ts @@ -14,3 +14,22 @@ export const RowVariantContextProvider = RowVariantContext.Provider; export function useRowVariant(): TableRowProps.Variant { return useContext(RowVariantContext); } + +// A body→cell channel carrying the row's position within its `TableBody`, so a `TableCell` can apply +// classic's own `body-cell-first-row` / `body-cell-last-row` edge classes (which encode the 1px-taller +// edge-row placeholder borders, incl. the `:not(.body-cell-selected)` guard). `TableBody` owns the +// signal — it is the only part that sees all the rows — and publishes it transparently (the provider +// renders no DOM, so the keeps its children). A cell outside a `TableBody` reads the +// default (interior row: no edge compensation). +export interface RowPosition { + isFirstRow: boolean; + isLastRow: boolean; +} + +const RowPositionContext = createContext({ isFirstRow: false, isLastRow: false }); + +export const RowPositionContextProvider = RowPositionContext.Provider; + +export function useRowPosition(): RowPosition { + return useContext(RowPositionContext); +} From 0ac7a36b02c0d3d48c4a5be58e92dd1d715f5dd1 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 13:00:11 +0000 Subject: [PATCH 14/35] refactor(table): route internal TableRow row events via nativeAttributes (drop redundant typed props) --- src/table/internal.tsx | 26 ++++++++++++++------------ src/table/table-row/interfaces.ts | 8 +++----- src/table/table-row/internal.tsx | 11 ++--------- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 2b7712ca01..17711936c5 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -733,19 +733,21 @@ const InternalTable = React.forwardRef( { - // When an element inside table row receives focus we want to adjust the scroll. - // However, that behavior is unwanted when the focus is received as result of a click - // as it causes the click to never reach the target element. - if (!currentTarget.contains(getMouseDownTarget())) { - stickyHeaderRef.current?.scrollToRow(currentTarget); - } + nativeAttributes={{ + ...focusMarkers.item, + ...rowRoleProps, + onFocus: ({ currentTarget }) => { + // When an element inside table row receives focus we want to adjust the scroll. + // However, that behavior is unwanted when the focus is received as result of a click + // as it causes the click to never reach the target element. + if (!currentTarget.contains(getMouseDownTarget())) { + stickyHeaderRef.current?.scrollToRow(currentTarget); + } + }, + onClick: onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item), + onContextMenu: + onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item), }} - onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)} - onContextMenu={ - onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item) - } - nativeAttributes={{ ...focusMarkers.item, ...rowRoleProps }} > {selection.getItemSelectionProps && ( attributes (aria-row*/focus markers/data-*) computed by the composing component - // and spread verbatim onto the element. Kept opaque here so the substrate holds no row-feature - // logic. + // and spread verbatim onto the element. Row-level event handlers (onClick/onFocus/onContextMenu) + // ride here too, since HTMLAttributes already types them. Kept opaque here so the substrate holds + // no row-feature logic. nativeAttributes?: React.HTMLAttributes; - onClick?: React.MouseEventHandler; - onFocus?: React.FocusEventHandler; - onContextMenu?: React.MouseEventHandler; children?: React.ReactNode; } diff --git a/src/table/table-row/internal.tsx b/src/table/table-row/internal.tsx index d22613605b..e99f6a1a26 100644 --- a/src/table/table-row/internal.tsx +++ b/src/table/table-row/internal.tsx @@ -13,16 +13,9 @@ import { InternalTableRowProps } from './interfaces'; import styles from '../styles.css.js'; export const InternalTableRow = React.forwardRef( - ({ className, nativeAttributes, onClick, onFocus, onContextMenu, children }, ref) => { + ({ className, nativeAttributes, children }, ref) => { return ( - + {children} ); From 871cd37d9c830c716b2cb3d7529978c7a3f66e46 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 13:24:33 +0000 Subject: [PATCH 15/35] fix(table): atomic parity -- control column, radio centering, striped inset, grid row height (a3/a4/a5) --- pages/table-root/single-selection.page.tsx | 12 +++++++++--- src/table-cell/internal.tsx | 13 ++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/pages/table-root/single-selection.page.tsx b/pages/table-root/single-selection.page.tsx index 59302c18ef..378c356995 100644 --- a/pages/table-root/single-selection.page.tsx +++ b/pages/table-root/single-selection.page.tsx @@ -55,14 +55,20 @@ export default function TableSingleSelectionPage() { >
+ {/* The accessible name is supplied by an associated `
{item.name} diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index 80370dc045..6ed5990444 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -43,11 +43,14 @@ export function Cell(props: TableCellProps & InternalBaseComponentProps) { isVisualRefresh && bodyCellStyles['is-visual-refresh'], variant === 'selected' && bodyCellStyles['body-cell-selected'], variant === 'shaded' && bodyCellStyles['body-cell-shaded'], - // Edge-row placeholder borders (1px-taller first/last rows). Reuses classic's own classes, - // whose `body-cell-last-row:not(.body-cell-selected)` guard composes with the selected class - // above, so a selected edge row keeps its selection border. - isFirstRow && bodyCellStyles['body-cell-first-row'], - isLastRow && bodyCellStyles['body-cell-last-row'] + // Edge-row placeholder borders (1px-taller first/last rows) are a border-model construct of the + // auto (table-layout) path — they reuse classic's own classes, whose + // `body-cell-last-row:not(.body-cell-selected)` guard composes with the selected class above so a + // selected edge row keeps its selection border. In grid layout the row height is governed by + // `grid-auto-rows`, not the cell border model, so the placeholder double-counts and inflates the + // first/last row by 1px; grid rows already match classic's 39/40 without it. + !isGrid && isFirstRow && bodyCellStyles['body-cell-first-row'], + !isGrid && isLastRow && bodyCellStyles['body-cell-last-row'] )} nativeAttributes={{ ...restBaseProps, role: isGrid ? 'cell' : undefined }} > From d1378193869b1c7d443d1b37bf6771f94f5edfac Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 13:51:28 +0000 Subject: [PATCH 16/35] fix(table): don't clip focus ring in disablePaddings control cells The .body-cell-content truncation clip (overflow:hidden) also applied to disablePaddings cells, whose content box collapses to the control height. That cropped the focus ring of a checkbox/radio in a selection-control column. Opt disable-paddings cells back out of the clip (overflow:visible), mirroring the existing body-cell-edit-active idiom. Classic Table is unaffected (disable-paddings is atomic-only). VR unchanged, jest 673/673. --- src/table/body-cell/styles.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/table/body-cell/styles.scss b/src/table/body-cell/styles.scss index 78a7a0bc3d..0321a78df9 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -567,6 +567,11 @@ $cell-negative-space-vertical: 2px; padding-inline: 0; margin-block: 0; margin-inline: 0; + // A disable-paddings cell holds a control/custom node, not truncatable text, and + // collapses to the control's height. The `:not(.body-cell-wrap)` truncation clip + // above would then crop the focus ring of an interactive control (checkbox/radio), + // so opt this cell back out of clipping — same rationale as the edit-active case. + overflow: visible; } // The selected-state block-start rules (`.body-cell-selected[…] > .body-cell-content`, From 947259a52ef09354ec6f99cc528ddc7a0cdf67d5 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 14:45:24 +0000 Subject: [PATCH 17/35] fix(table): atomic header-cell inline padding to align header text with body (== classic) --- src/table-header-cell/styles.scss | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss index c71074212e..87ce909196 100644 --- a/src/table-header-cell/styles.scss +++ b/src/table-header-cell/styles.scss @@ -50,17 +50,28 @@ // Header content box. The extracted substrate renders header children with no wrapper, so the // header row was 8px shorter than classic (29px vs 37px). Classic sizes its header via a -// `.header-cell-content` box carrying `padding-block: $space-scaled-xxs` on top of the base -// `.header-cell` padding; reproducing only that vertical box here (no inline padding, so the v1 -// is-visual-refresh first-column inset on the substrate `.header-cell` is untouched) lifts the row -// to classic's 37px. See src/table/header-cell `.header-cell-content`. +// `.header-cell-content` box carrying `padding-block: $space-scaled-xxs` (height) plus an inline +// `cell-offset($space-s)` that pushes header text to the same inline start as the body content +// (base `.header-cell` 8px + 12px = 20px == body). Reproducing both here lifts the row to classic's +// 37px and aligns header text with body text. See src/table/header-cell `.header-cell-content`. .header-cell-content { padding-block: awsui.$space-scaled-xxs; + padding-inline-start: awsui.$space-s; + padding-inline-end: awsui.$space-s; line-height: awsui.$line-height-body-m; } // disablePaddings zeroes the cell's built-in padding; carry that through to the content box so a -// composed control cell has no residual block padding. +// composed control cell has no residual block or inline padding. .header-cell.disable-paddings > .header-cell-content { padding-block: 0; + padding-inline: 0; +} + +// In visual refresh the first column hugs the table's inline-start edge: classic zeroes the header +// content's inline-start offset (`cell-offset(0px)`) so the substrate's reduced first-column inset +// ($space-xxxs on `.header-cell`) is the only inset. Mirror that so the first header cell preserves +// its v1 alignment instead of gaining the +12px body offset. +.header-cell.is-visual-refresh:first-child > .header-cell-content { + padding-inline-start: 0; } From 4c649a88106f7121a44911de5f13a66fa2ba2662 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 7 Sep 2026 15:50:04 +0000 Subject: [PATCH 18/35] fix(table): merge consecutive-selected outline + enclose control cell via data-selected adjacency TableRow auto-emits data-selected from variant='selected'; the atomic cell module reads sibling adjacency to collapse the shared edge into a single 1px divider, square the inner corners, and (via has-selection + a full-height stretch) enclose the selection-control column in the box. Keyed on data-selected, a hook classic never emits, so classic VR stays 0.0000%. --- src/table-cell/internal.tsx | 10 +++- src/table-cell/styles.scss | 54 +++++++++++++++++++ .../basic-table-styling-props.test.tsx | 34 +++++++----- src/table-row/internal.tsx | 12 ++++- 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index 6ed5990444..df1ec55416 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -21,8 +21,10 @@ import styles from './styles.css.js'; // `.body-cell-selected` / `.body-cell-shaded` classes, keyed off the row's variant via context, so no // selection stylesheet is duplicated and no `data-*` styling hook is needed. // -// Deferred to the visual-regression increment (Inc4c): consecutive-row border reconciliation -// (prev/next), sticky columns, and full visual-refresh gating. +// Consecutive-selected rows merge into one rounded outline via `data-selected` adjacency in this +// component's stylesheet (mirroring classic's prev/next-selected block), and the selected control +// cell is enclosed via `has-selection` + a full-height stretch. Sticky columns and full +// visual-refresh gating remain deferred to a later increment. export function Cell(props: TableCellProps & InternalBaseComponentProps) { const { children, disablePaddings, __internalRootRef } = props; const { columnLayout } = useTableContext(); @@ -42,6 +44,10 @@ export function Cell(props: TableCellProps & InternalBaseComponentProps) { isGrid && styles['cell-grid'], isVisualRefresh && bodyCellStyles['is-visual-refresh'], variant === 'selected' && bodyCellStyles['body-cell-selected'], + // Classic's `has-selection` marker keeps the selected first (control) cell's 2px inline-start + // border + left radii: classic strips the inline-start border on a first cell + // `:not(.has-selection)`, which would otherwise leave the box open on the control column. + variant === 'selected' && bodyCellStyles['has-selection'], variant === 'shaded' && bodyCellStyles['body-cell-shaded'], // Edge-row placeholder borders (1px-taller first/last rows) are a border-model construct of the // auto (table-layout) path — they reuse classic's own classes, whose diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss index 64c9e1c320..9c9d6d8b93 100644 --- a/src/table-cell/styles.scss +++ b/src/table-cell/styles.scss @@ -3,6 +3,8 @@ SPDX-License-Identifier: Apache-2.0 */ +@use '../internal/styles/tokens' as awsui; + // The base cell box model (padding, borders, divider, selection/shading) is supplied by the shared // `.body-cell` class and classic's `.body-cell-selected` / `.body-cell-shaded` classes from the // extracted substrate (src/table/table-cell + src/table/body-cell, i.e. classic's proven stylesheet). @@ -18,3 +20,55 @@ .cell-grid { min-inline-size: 0; } + +// Consecutive-selected outline merge. Classic reconciles two adjacent selected rows into one rounded +// box via author-computed `body-cell-prev-selected` / `body-cell-next-selected` classes +// (src/table/body-cell/styles.scss). The atomic public layer can't compute adjacency in the row +// component, so it reads it straight from the DOM: `TableRow` auto-emits `data-selected` from +// `variant='selected'`, and these selectors mirror classic's edge/radius collapse. They are keyed on +// `data-selected`, a hook classic's extracted substrate never emits (it uses the prev/next-selected +// classes), so this block is inert for classic and keeps its VR at 0%. +// +// Rules are ordered base-edges first, then corner-radius overrides, so specificity never descends +// (stylelint no-descending-specificity) — no disable comments needed. + +// A selected row that FOLLOWS a selected row (classic's prev-selected): collapse the shared top edge +// to the 1px placeholder divider. +[data-selected] + [data-selected] > .cell { + border-block-start: awsui.$border-divider-list-width solid awsui.$color-border-item-placeholder; +} + +// A selected row that PRECEDES a selected row (classic's next-selected): drop the shared bottom edge +// so the row below owns the single divider. Classic keeps a 1px bottom here because its table rows +// overlap; the atomic grid rows are separate tracks that do not overlap, so 1px here + the 1px +// placeholder above would read as a doubled 2px band — dropping it yields the single 1px shared +// divider classic renders. +[data-selected]:has(+ [data-selected]) > .cell { + border-block-end-width: 0; +} + +// Enclose the selection control in the box. A `disablePaddings` control cell is content-height, so +// the row's `align-items: center` leaves it a short pill detached from the full-height data cells. +// Stretch the selected row's first (control) cell to the full row track and re-centre its content +// (grid + `align-items: center`, keeping the default `justify-items: stretch` so the control stays +// horizontally centred) so the checkbox stays centred while the 2px selection border wraps the cell. +[data-selected] > .cell:first-child { + align-self: stretch; + display: grid; + align-items: center; +} + +// Square the two INNER corners of each merged pair (outer corners keep classic's 8px radius), so the +// run reads as one rounded box. Grouped after the base edges above to keep specificity ascending. +[data-selected] + [data-selected] > .cell:first-child { + border-start-start-radius: 0; +} +[data-selected] + [data-selected] > .cell:last-child { + border-start-end-radius: 0; +} +[data-selected]:has(+ [data-selected]) > .cell:first-child { + border-end-start-radius: 0; +} +[data-selected]:has(+ [data-selected]) > .cell:last-child { + border-end-end-radius: 0; +} diff --git a/src/table-root/__tests__/basic-table-styling-props.test.tsx b/src/table-root/__tests__/basic-table-styling-props.test.tsx index 74705beef1..717e28a7fb 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -15,14 +15,17 @@ import createWrapper from '../../../lib/components/test-utils/dom'; import bodyCellStyles from '../../../lib/components/table/body-cell/styles.css.js'; import headerCellStyles from '../../../lib/components/table-header-cell/styles.css.js'; -// Proves the row `variant` is purely visual and reaches the cell paint without a `data-*` styling -// hook on the row, that `ariaSelected` drives `aria-selected` independently of `variant`, that the -// narrowed inline `style` props (for virtualization) reach the body and row roots, and that -// `disablePaddings` reaches the padding opt-out on the cell content and header-cell root. +// Proves the row `variant` is purely visual and reaches the cell paint through context, that +// `ariaSelected` drives `aria-selected` independently of `variant`, that the narrowed inline `style` +// props (for virtualization) reach the body and row roots, and that `disablePaddings` reaches the +// padding opt-out on the cell content and header-cell root. // -// On this fork the row publishes its variant through RowVariantContext and each TableCell self-paints -// by reusing classic's `.body-cell-selected` / `.body-cell-shaded` classes — the row never exposes a -// `data-*` attribute as a CSS hook. Selection and shading are mutually exclusive by type. +// On this fork each TableCell self-paints by reusing classic's `.body-cell-selected` / +// `.body-cell-shaded` classes (via RowVariantContext), so the per-cell paint needs no data-* hook. A +// selected row additionally emits `data-selected` on the
— the one sanctioned styling hook — +// which the cell stylesheet reads for the consecutive-selected outline merge (sibling adjacency a +// cell can't get from context). It is driven by `variant`, never a public prop. Selection and shading +// are mutually exclusive by type. function Harness({ variant, ariaSelected }: { variant?: TableRowProps.Variant; ariaSelected?: boolean }) { return ( @@ -52,17 +55,19 @@ function cellClassLists(wrapper: ReturnType) { return wrapper.findAllTableCells().map(cell => cell.getElement().classList); } -describe('TableRow variant is visual-only and paints through the cell (no data-* hook)', () => { - test("variant='selected' paints every cell selected without a data-* hook or aria-selected on the row", () => { +describe('TableRow variant is visual-only and paints through the cell', () => { + test("variant='selected' paints every cell selected, emits the data-selected adjacency hook, and sets no aria-selected", () => { const { wrapper } = renderHarness('selected'); const row = wrapper.findAllTableRows()[0].getElement(); - // Visual state must NOT leak into ARIA or a data-* styling hook on the row. + // Visual state must NOT leak into ARIA; it is conveyed only by the explicit ariaSelected prop. expect(row).not.toHaveAttribute('aria-selected'); - expect(row).not.toHaveAttribute('data-selected'); + // The one sanctioned styling hook: data-selected drives the consecutive-selected outline merge. + expect(row).toHaveAttribute('data-selected', 'true'); expect(row).not.toHaveAttribute('data-shaded'); - // The paint arrives on the cells via context, reusing classic's own selection class. + // The paint arrives on the cells via context, reusing classic's own selection + has-selection classes. for (const classList of cellClassLists(wrapper)) { expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(true); + expect(classList.contains(bodyCellStyles['has-selection'])).toBe(true); expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); } }); @@ -71,17 +76,20 @@ describe('TableRow variant is visual-only and paints through the cell (no data-* const { wrapper } = renderHarness('shaded'); const row = wrapper.findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-selected'); expect(row).not.toHaveAttribute('data-shaded'); for (const classList of cellClassLists(wrapper)) { expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(true); expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + expect(classList.contains(bodyCellStyles['has-selection'])).toBe(false); } }); - test('the default variant paints neither and sets no aria-selected', () => { + test('the default variant paints neither and sets no aria-selected or data-selected', () => { const { wrapper } = renderHarness(); const row = wrapper.findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-selected'); for (const classList of cellClassLists(wrapper)) { expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index d7eaa6bf18..bafeb81771 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -15,8 +15,12 @@ import styles from './styles.css.js'; // The bare element, `.row` marker, and ref are provided by the extracted InternalTableRow // substrate (shared with classic's body renders; the substrate keeps the event-handler props that // classic composes). This public component layers the grid-mode role/layout and aria surface, and -// publishes the row's `variant` to its cells through RowVariantContext so each cell self-paints -// selection/shading via its own class — no `data-*` styling hook on the row. +// publishes the row's `variant` to its cells through RowVariantContext so each cell self-paints its +// own selection/shading class. A selected row additionally emits `data-selected` on the : the +// consecutive-selected outline merge (see table-cell/styles.scss) needs sibling adjacency, which a +// cell can only read from the DOM, not from context. This is the one sanctioned `data-*` styling +// hook — driven by the existing `variant` (never a public prop), keyed on a selector classic never +// emits (classic uses its own prev/next-selected classes), so it stays inert for classic. export function Row(props: TableRowProps & InternalBaseComponentProps) { const { variant = 'default', @@ -32,12 +36,16 @@ export function Row(props: TableRowProps & InternalBaseComponentProps) { const { columnLayout, gridTemplateColumns } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(props); + // Adjacency hook for the consecutive-selected outline merge. Spread (not a literal key) so it is + // exempt from excess-property checking against the substrate's React.HTMLAttributes native-attr type. + const selectedDataAttribute = variant === 'selected' ? { 'data-selected': 'true' } : undefined; return ( Date: Mon, 7 Sep 2026 16:04:08 +0000 Subject: [PATCH 19/35] test(table): faithful classic twin pages (match atomic feature set) The simple, striped-rows and single-selection atomic demos have no sort UI, but their classic twins reused sorting-capable column sets. Classic Table renders a sort caret for any column declaring sortingField/sortingComparator (independent of sortingColumn/onSortingChange), so those twins showed carets the atomic lacks. Add non-sorting column sets (dataColumnsPlain, nameStatusColumnsPlain) and point the no-sort twins at them; sorting twins keep the sortable sets. --- pages/table-root-classic/common.tsx | 16 ++++++++++++++++ pages/table-root-classic/simple.page.tsx | 12 +++++++++--- .../table-root-classic/single-selection.page.tsx | 7 ++++--- pages/table-root-classic/striped-rows.page.tsx | 7 ++++--- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/pages/table-root-classic/common.tsx b/pages/table-root-classic/common.tsx index a308a00af6..91991caee9 100644 --- a/pages/table-root-classic/common.tsx +++ b/pages/table-root-classic/common.tsx @@ -32,6 +32,22 @@ export const nameStatusColumns: ReadonlyArray> { id: 'status', header: 'Status', cell: item => item.status }, ]; +// Non-sorting column variants. Classic Table renders a sort caret on any column that declares +// sortingField/sortingComparator (independent of the sortingColumn/onSortingChange props), so the +// twins whose atomic demo has NO sorting UI (simple, striped-rows, single-selection) must use column +// definitions WITHOUT sorting metadata — otherwise the twin shows carets the atomic lacks. +export const dataColumnsPlain: ReadonlyArray> = [ + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'type', header: 'Type', cell: item => item.type }, + { id: 'size', header: 'Size', cell: item => item.size }, + { id: 'status', header: 'Status', cell: item => item.status }, +]; + +export const nameStatusColumnsPlain: ReadonlyArray> = [ + { id: 'name', header: 'Name', cell: item => item.name }, + { id: 'status', header: 'Status', cell: item => item.status }, +]; + export const resourcesAriaLabels: TableProps['ariaLabels'] = { tableLabel: 'Resources', selectionGroupLabel: 'Resources selection', diff --git a/pages/table-root-classic/simple.page.tsx b/pages/table-root-classic/simple.page.tsx index ae38c8d352..599d64927b 100644 --- a/pages/table-root-classic/simple.page.tsx +++ b/pages/table-root-classic/simple.page.tsx @@ -7,11 +7,12 @@ import Header from '~components/header'; import SpaceBetween from '~components/space-between'; import Table from '~components/table'; -import { dataColumns, makeItems, resourcesAriaLabels } from './common'; +import { dataColumnsPlain, makeItems, resourcesAriaLabels } from './common'; // VR oracle for pages/table-root/simple: the same 4 columns and makeItems(8) rendered with the // shipped classic Table. The atomic simple demo uses auto layout, which mirrors classic Table's -// default table-layout: auto. +// default table-layout: auto. Columns carry no sorting metadata (dataColumnsPlain) because the +// atomic simple demo has no sort UI — a sortable column set would render carets the atomic lacks. export default function TableClassicSimplePage() { const items = makeItems(8); return ( @@ -21,7 +22,12 @@ export default function TableClassicSimplePage() {
Resources
-
+
diff --git a/pages/table-root-classic/single-selection.page.tsx b/pages/table-root-classic/single-selection.page.tsx index e39dc786ae..e686e7a499 100644 --- a/pages/table-root-classic/single-selection.page.tsx +++ b/pages/table-root-classic/single-selection.page.tsx @@ -7,11 +7,12 @@ import Header from '~components/header'; import SpaceBetween from '~components/space-between'; import Table from '~components/table'; -import { Item, makeItems, nameStatusColumns, resourcesAriaLabels } from './common'; +import { Item, makeItems, nameStatusColumnsPlain, resourcesAriaLabels } from './common'; // VR oracle for pages/table-root/single-selection. The atomic demo hand-builds a radio control // column; classic Table provides it natively via selectionType='single'. Same makeItems(10), same -// single preselected row (resource-1). +// single preselected row (resource-1). The atomic demo has no sort UI, so the columns carry no +// sorting metadata (nameStatusColumnsPlain) to avoid a Name caret the atomic lacks. const ITEM_COUNT = 10; export default function TableClassicSingleSelectionPage() { @@ -27,7 +28,7 @@ export default function TableClassicSingleSelectionPage() {
Resources
Resources
Date: Mon, 7 Sep 2026 16:04:10 +0000 Subject: [PATCH 20/35] fix(table): selection/single-selection demo column widths to match classic The grid columns [{size:40},{minWidth:160},{size:140}] flexed Name to fill and pinned Status to a fixed 140px, shoving Status far right. Classic Table splits the non-control space ~53:47 (measured, proportional across viewports). Reproduce with proportional flex weights [{size:40},{size:{flex:53}},{size:{flex:47}}] so atomic per-column widths match classic to the pixel. --- pages/table-root/selection.page.tsx | 10 +++++++++- pages/table-root/single-selection.page.tsx | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx index cffa24cff3..b8a70127a7 100644 --- a/pages/table-root/selection.page.tsx +++ b/pages/table-root/selection.page.tsx @@ -23,7 +23,15 @@ import styles from './styles.scss'; // by the consumer — the atomic parts contribute `variant='selected'` (row surface + aria-selected) // and `ariaSort` (the header semantic). The control column uses `disablePaddings` cells and a // centered checkbox to match the classic Table selection column; the name column flexes. -const COLUMNS: ReadonlyArray = [{ size: 40 }, { minWidth: 160 }, { size: 140 }]; +// Selection control column is a fixed 40px; the Name and Status columns share the remaining width +// with proportional flex weights (~53:47), reproducing classic Table's balanced auto-layout split at +// the demo viewport. (Flexing Name to fill and pinning Status to a fixed width would shove Status to +// the far right with a large gap, unlike classic.) +const COLUMNS: ReadonlyArray = [ + { size: 40 }, + { size: { flex: 53 } }, + { size: { flex: 47 } }, +]; const ITEM_COUNT = 10; type SortDirection = 'ascending' | 'descending'; diff --git a/pages/table-root/single-selection.page.tsx b/pages/table-root/single-selection.page.tsx index 378c356995..027671490f 100644 --- a/pages/table-root/single-selection.page.tsx +++ b/pages/table-root/single-selection.page.tsx @@ -24,7 +24,15 @@ import styles from './styles.scss'; // between rows for free (matching classic Table). Each radio's accessible name comes from a // visually-hidden label (RadioButton has no `ariaLabel` prop). The control column matches classic // Table via `disablePaddings` cells and a centered control; the header has no select-all control. -const COLUMNS: ReadonlyArray = [{ size: 40 }, { minWidth: 160 }, { size: 140 }]; +// Selection control column is a fixed 40px; the Name and Status columns share the remaining width +// with proportional flex weights (~53:47), reproducing classic Table's balanced auto-layout split at +// the demo viewport. (Flexing Name to fill and pinning Status to a fixed width would shove Status to +// the far right with a large gap, unlike classic.) +const COLUMNS: ReadonlyArray = [ + { size: 40 }, + { size: { flex: 53 } }, + { size: { flex: 47 } }, +]; const ITEM_COUNT = 10; export default function TableSingleSelectionPage() { From 433d877965afd2b6289afa6ab28ce74cfb65a328 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 8 Sep 2026 07:14:20 +0000 Subject: [PATCH 21/35] test(table): faithful loading-and-empty twin Point the classic loading-and-empty VR oracle at dataColumnsPlain (no sortingField/sortingComparator) so it renders no sort carets, matching the non-sortable atomic demo. Removes the stray down-carets on all 4 headers and converges column widths to the atomic (was Size -26.5px). --- pages/table-root-classic/loading-and-empty.page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pages/table-root-classic/loading-and-empty.page.tsx b/pages/table-root-classic/loading-and-empty.page.tsx index 1d3b4dadb1..8b4a51a224 100644 --- a/pages/table-root-classic/loading-and-empty.page.tsx +++ b/pages/table-root-classic/loading-and-empty.page.tsx @@ -8,7 +8,7 @@ import SegmentedControl from '~components/segmented-control'; import SpaceBetween from '~components/space-between'; import Table from '~components/table'; -import { dataColumns, makeItems, resourcesAriaLabels } from './common'; +import { dataColumnsPlain, makeItems, resourcesAriaLabels } from './common'; type State = 'loaded' | 'loading' | 'empty'; @@ -39,7 +39,7 @@ export default function TableClassicLoadingEmptyPage() {
Resources
Date: Tue, 8 Sep 2026 07:14:22 +0000 Subject: [PATCH 22/35] fix(table): suppress header divider on control column The header column-divider ::after painted a 1px divider (border-inline-start, color-border-divider-default) on disablePaddings header cells (the selection control column). Classic Table draws no divider there. Add .header-cell.disable-paddings::after { display: none }. Classic never emits disable-paddings, so this is inert on the classic path (selection-single VR 0.0000% vs oracle). --- src/table-header-cell/styles.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss index 87ce909196..df9a714aa7 100644 --- a/src/table-header-cell/styles.scss +++ b/src/table-header-cell/styles.scss @@ -48,6 +48,14 @@ padding-inline: 0; } +// A disablePaddings header cell is a composed control column (e.g. the selection checkbox/radio +// header). Classic Table draws no vertical divider on its selection control column, so the atomic +// `::after` column-divider must not render there either. Classic never emits `disable-paddings`, so +// this suppression is inert on the classic path. +.header-cell.disable-paddings::after { + display: none; +} + // Header content box. The extracted substrate renders header children with no wrapper, so the // header row was 8px shorter than classic (29px vs 37px). Classic sizes its header via a // `.header-cell-content` box carrying `padding-block: $space-scaled-xxs` (height) plus an inline From 090d4ef176c5269d3639f7fbfd200a3baad45d04 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 8 Sep 2026 09:16:57 +0000 Subject: [PATCH 23/35] fix(table): center control cell on all rows, darker striped divider via data-shaded, align first-column header --- pages/table-root/styles.scss | 5 +++ src/table-cell/internal.tsx | 4 ++ src/table-cell/styles.scss | 42 ++++++++++++++----- src/table-header-cell/internal.tsx | 6 +++ .../basic-table-styling-props.test.tsx | 3 +- src/table-row/internal.tsx | 18 ++++---- 6 files changed, 59 insertions(+), 19 deletions(-) diff --git a/pages/table-root/styles.scss b/pages/table-root/styles.scss index 6c0c84e04a..5468fe95d0 100644 --- a/pages/table-root/styles.scss +++ b/pages/table-root/styles.scss @@ -25,10 +25,15 @@ } // Centres the selection control within a disablePaddings control cell, matching classic Table. +// The Cloudscape checkbox/radio control carries an intrinsic 2px top margin (it aligns the box with +// the first line of a label); with no visible label in a centred control column that margin biases +// the control ~1px below centre. Classic's own SelectionControl absorbs it with a compensating +// block-end padding on the control label; mirror that here so the control lands on the row centre. .selection-cell { display: flex; justify-content: center; align-items: center; + padding-block-end: 2px; } // Screen-reader-only label text (gives a bare control an accessible name without visible text). diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index df1ec55416..c2df6149ef 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -42,6 +42,10 @@ export function Cell(props: TableCellProps & InternalBaseComponentProps) { className, styles.cell, isGrid && styles['cell-grid'], + // Marks the selection-control (disablePaddings) cell so it centers its control on every row + // (see table-cell/styles.scss). The substrate applies its own disable-paddings class to the + // inner content wrapper; this one is on the : the -// consecutive-selected outline merge (see table-cell/styles.scss) needs sibling adjacency, which a -// cell can only read from the DOM, not from context. This is the one sanctioned `data-*` styling -// hook — driven by the existing `variant` (never a public prop), keyed on a selector classic never -// emits (classic uses its own prev/next-selected classes), so it stays inert for classic. +// own selection/shading class. A selected row additionally emits `data-selected`, and a shaded row +// `data-shaded`, on the : the consecutive-selected outline merge and the striped-row divider +// darkening (see table-cell/styles.scss) need sibling adjacency, which a cell can only read from the +// DOM, not from context. These are the sanctioned `data-*` styling hooks — driven by the existing +// `variant` (never a public prop), keyed on selectors classic never emits (classic uses its own +// prev/next-selected and has-striped-rows classes), so they stay inert for classic. export function Row(props: TableRowProps & InternalBaseComponentProps) { const { variant = 'default', @@ -36,9 +37,11 @@ export function Row(props: TableRowProps & InternalBaseComponentProps) { const { columnLayout, gridTemplateColumns } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(props); - // Adjacency hook for the consecutive-selected outline merge. Spread (not a literal key) so it is - // exempt from excess-property checking against the substrate's React.HTMLAttributes native-attr type. + // Adjacency hooks for the consecutive-selected outline merge and the striped-row divider + // darkening. Spread (not literal keys) so they are exempt from excess-property checking against the + // substrate's React.HTMLAttributes native-attr type. const selectedDataAttribute = variant === 'selected' ? { 'data-selected': 'true' } : undefined; + const shadedDataAttribute = variant === 'shaded' ? { 'data-shaded': 'true' } : undefined; return ( Date: Tue, 8 Sep 2026 11:35:57 +0000 Subject: [PATCH 24/35] fix(table-atomic): match classic on selection outline, seam, control-header divider, and equal-height cells Standalone visual-parity fixes surfaced by eyeballing the demo pages against borderless classic Table twins (percentages proved unreliable for sparse table UI): - Equal-height cells: move the stretch+center treatment onto the grid-mode markers (.cell-grid / .header-cell-grid) instead of the unconditional .cell, so auto-layout tables keep native display:table-cell (fixes the collapsed simple/auto layout) while grid tables get colinear control/data boxes and centered controls. - Consecutive-selected seam: set the shared bottom edge to the 1px list divider (mirroring classic body-cell-next-selected) so the two-row seam paints 2px like classic, not 1px. - Control-column header divider: remove the erroneous .header-cell.disable-paddings::after { display:none } suppression - mainline classic DOES draw this divider. The empty single-selection control header no longer collapses (equal-height fix), so the restored divider renders full-cell like classic with no stray streak. All keyed on atomic-only selectors classic never emits; classic Table VR byte-identical (0%), jest green. --- src/table-cell/styles.scss | 49 ++++++++++++++++--------------- src/table-header-cell/styles.scss | 18 ++++++------ 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss index 10e08ef1a5..f402f97968 100644 --- a/src/table-cell/styles.scss +++ b/src/table-cell/styles.scss @@ -8,28 +8,28 @@ // The base cell box model (padding, borders, divider, selection/shading) is supplied by the shared // `.body-cell` class and classic's `.body-cell-selected` / `.body-cell-shaded` classes from the // extracted substrate (src/table/table-cell + src/table/body-cell, i.e. classic's proven stylesheet). -// This standalone module only adds the test-utils marker and the grid-mode min-inline-size — it must -// NOT re-declare the base geometry, which would double the substrate's padding. Pixel reconciliation -// of these layers is verified in the visual-regression increment (Inc4c). +// This standalone module only adds the test-utils marker and the grid-mode equal-height treatment — it +// must NOT re-declare the base geometry, which would double the substrate's padding. Pixel +// reconciliation of these layers is verified in the visual-regression increment (Inc4c). +// The equal-height treatment applies ONLY in grid mode (`.cell-grid`). In auto mode the cell is a real +// ` element + ref is provided by the extracted InternalTableBody substrate (shared -// with classic's body). This public component layers the standalone body class, the grid-mode role, -// and the positioning `style` (for virtualization) on top. The substrate takes no `style`, so it is -// passed through the native-attribute channel it already spreads onto the element. +// The element + ref come from the InternalTableBody substrate (shared with the existing Table). +// This public layer adds the body class, grid-mode role, and the positioning `style` (virtualization); +// the substrate takes no `style`, so it goes through the native-attribute channel it already spreads. // -// TableBody is the only part that sees all the rows, so it also publishes each row's first/last -// position through RowPositionContext. The provider renders no DOM (the keeps its -// children), letting each row's cells apply classic's `body-cell-first-row` / `body-cell-last-row` -// edge classes for the borderless 1px-taller edge rows. +// TableBody is the only part that sees all rows, so it also publishes each row's first/last position +// via RowPositionContext (provider renders no DOM), letting cells apply the existing Table's +// `body-cell-first-row` / `body-cell-last-row` edge classes. export function Body(props: TableBodyProps & InternalBaseComponentProps) { const { children, style, __internalRootRef } = props; const { columnLayout } = useTableContext(); diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index c2df6149ef..35788580c9 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -14,17 +14,12 @@ import { TableCellProps } from './interfaces'; import bodyCellStyles from '../table/body-cell/styles.css.js'; import styles from './styles.css.js'; -// The element + ref is provided by the extracted InternalTableHead substrate (shared -// with classic's thead). This public component layers the standalone head class and the grid-mode -// role/layout on top via className/nativeAttributes. +// The element + ref come from the InternalTableHead substrate (shared with the existing +// Table). This public layer adds the head class and grid-mode role/layout via className/nativeAttributes. export function Head(props: TableHeadProps & InternalBaseComponentProps) { const { children, __internalRootRef } = props; const { columnLayout } = useTableContext(); diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx index 9c3d467f33..a95c3f0c51 100644 --- a/src/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal.tsx @@ -13,10 +13,9 @@ import { TableHeaderCellProps } from './interfaces'; import headerCellStyles from '../table/header-cell/styles.css.js'; import styles from './styles.css.js'; -// The — the one sanctioned styling hook — // which the cell stylesheet reads for the consecutive-selected outline merge (sibling adjacency a @@ -64,7 +64,7 @@ describe('TableRow variant is visual-only and paints through the cell', () => { // The one sanctioned styling hook: data-selected drives the consecutive-selected outline merge. expect(row).toHaveAttribute('data-selected', 'true'); expect(row).not.toHaveAttribute('data-shaded'); - // The paint arrives on the cells via context, reusing classic's own selection + has-selection classes. + // The paint arrives on the cells via context, reusing the existing Table's selection + has-selection classes. for (const classList of cellClassLists(wrapper)) { expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(true); expect(classList.contains(bodyCellStyles['has-selection'])).toBe(true); @@ -166,7 +166,7 @@ describe('disablePaddings', () => { ); const cells = createWrapper(container).findAllTableCells(); - // The opt-out lands on the inner `.body-cell-content` wrapper carved from classic's box model. + // The opt-out lands on the inner `.body-cell-content` wrapper carved from the existing Table's box model. const contentOf = (index: number) => cells[index].getElement().getElementsByClassName(bodyCellStyles['body-cell-content'])[0]; expect(contentOf(0).classList.contains(bodyCellStyles['disable-paddings'])).toBe(true); diff --git a/src/table-root/styles.scss b/src/table-root/styles.scss index d5723ee2c5..67a72d6503 100644 --- a/src/table-root/styles.scss +++ b/src/table-root/styles.scss @@ -29,11 +29,9 @@ .table { inline-size: 100%; - // Separate borders (matching classic Table, which is also separate) so every cell paints its own - // top/bottom border instead of merging with its neighbour's. Required for sticky columns — the - // collapsed model breaks the sticky separator — and it gives the per-cell selected-row outline - // well-defined corner radii. Only takes effect in auto layout; grid mode sets `display: block` - // via `.table-grid`, so the border model does not apply there. + // Separate borders (matching the existing Table) so each cell paints its own divider: the collapsed + // model breaks sticky columns and blurs the selected-row outline corners. Auto layout only; grid mode + // is display:block via `.table-grid`. border-collapse: separate; border-spacing: 0; } diff --git a/src/table-row/context.ts b/src/table-row/context.ts index fe126b9d50..6df8b5ada8 100644 --- a/src/table-row/context.ts +++ b/src/table-row/context.ts @@ -15,12 +15,10 @@ export function useRowVariant(): TableRowProps.Variant { return useContext(RowVariantContext); } -// A body→cell channel carrying the row's position within its `TableBody`, so a `TableCell` can apply -// classic's own `body-cell-first-row` / `body-cell-last-row` edge classes (which encode the 1px-taller -// edge-row placeholder borders, incl. the `:not(.body-cell-selected)` guard). `TableBody` owns the -// signal — it is the only part that sees all the rows — and publishes it transparently (the provider -// renders no DOM, so the keeps its children). A cell outside a `TableBody` reads the -// default (interior row: no edge compensation). +// A body→cell channel carrying the row's first/last position, so a `TableCell` can apply the existing +// Table's `body-cell-first-row` / `body-cell-last-row` edge classes (the 1px-taller edge-row +// placeholders). `TableBody` owns the signal (it sees all rows) and publishes it with no DOM. A cell +// outside a `TableBody` reads the default (interior row). export interface RowPosition { isFirstRow: boolean; isLastRow: boolean; diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index 2d5230f1ce..aa6ae82dd9 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -12,16 +12,15 @@ import { TableRowProps } from './interfaces'; import styles from './styles.css.js'; -// The bare element, `.row` marker, and ref are provided by the extracted InternalTableRow -// substrate (shared with classic's body renders; the substrate keeps the event-handler props that -// classic composes). This public component layers the grid-mode role/layout and aria surface, and -// publishes the row's `variant` to its cells through RowVariantContext so each cell self-paints its -// own selection/shading class. A selected row additionally emits `data-selected`, and a shaded row -// `data-shaded`, on the : the consecutive-selected outline merge and the striped-row divider -// darkening (see table-cell/styles.scss) need sibling adjacency, which a cell can only read from the -// DOM, not from context. These are the sanctioned `data-*` styling hooks — driven by the existing -// `variant` (never a public prop), keyed on selectors classic never emits (classic uses its own -// prev/next-selected and has-striped-rows classes), so they stay inert for classic. +// The element, `.row` marker, and ref come from the InternalTableRow substrate (shared with the +// existing Table). This public layer adds the grid-mode role/layout and aria surface, and publishes the +// row's `variant` via RowVariantContext so each cell self-paints its selection/shading class. +// +// A selected row also emits `data-selected` (and a shaded row `data-shaded`) on the : the +// consecutive-selected outline merge and striped-divider darkening need sibling adjacency, which a cell +// can only read from the DOM, not context. These are sanctioned data-* hooks — driven by the internal +// `variant` (never a public prop) and keyed on selectors the existing Table never emits, so they stay +// inert for it. export function Row(props: TableRowProps & InternalBaseComponentProps) { const { variant = 'default', @@ -37,9 +36,8 @@ export function Row(props: TableRowProps & InternalBaseComponentProps) { const { columnLayout, gridTemplateColumns } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(props); - // Adjacency hooks for the consecutive-selected outline merge and the striped-row divider - // darkening. Spread (not literal keys) so they are exempt from excess-property checking against the - // substrate's React.HTMLAttributes native-attr type. + // Spread (not literal keys) so these adjacency hooks are exempt from excess-property checking against + // the substrate's React.HTMLAttributes native-attr type. const selectedDataAttribute = variant === 'selected' ? { 'data-selected': 'true' } : undefined; const shadedDataAttribute = variant === 'shaded' ? { 'data-shaded': 'true' } : undefined; return ( diff --git a/src/table-row/styles.scss b/src/table-row/styles.scss index 85f890fb2b..2930ed3ce9 100644 --- a/src/table-row/styles.scss +++ b/src/table-row/styles.scss @@ -8,10 +8,8 @@ .row { position: relative; box-sizing: border-box; - // The row divider and selection/shading paint live on the cell (see table-cell/styles.scss), - // reusing classic's body-cell classes. Each cell reads the row's `variant` through - // RowVariantContext and applies those classes itself — no data-* styling hook on the row. The - // row only carries the grid layout (grid mode). + // The divider and selection/shading paint live on the cell (see table-cell/styles.scss); the row only + // carries the grid layout. Cells read the row's `variant` via RowVariantContext. } .row-grid { From a54a2245b3ddafbc7d3879d6659e67ff6b3a58d1 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 8 Sep 2026 15:42:03 +0000 Subject: [PATCH 28/35] chore(table-atomic): remove table-root-classic VR-oracle demo pages These borderless-Table reference pages were scaffolding for the visual-parity work (an apples-to-apples oracle for eyeball comparison), not part of the shipped component. Removing them now that parity is established. Pages are auto-discovered, and nothing outside the directory referenced it. --- pages/table-root-classic/FINDINGS.md | 87 ------------------- pages/table-root-classic/common.tsx | 56 ------------ .../loading-and-empty.page.tsx | 62 ------------- pages/table-root-classic/selection.page.tsx | 52 ----------- pages/table-root-classic/simple.page.tsx | 35 -------- .../single-selection.page.tsx | 43 --------- pages/table-root-classic/sorting.page.tsx | 59 ------------- .../table-root-classic/striped-rows.page.tsx | 36 -------- .../virtualization.page.tsx | 58 ------------- 9 files changed, 488 deletions(-) delete mode 100644 pages/table-root-classic/FINDINGS.md delete mode 100644 pages/table-root-classic/common.tsx delete mode 100644 pages/table-root-classic/loading-and-empty.page.tsx delete mode 100644 pages/table-root-classic/selection.page.tsx delete mode 100644 pages/table-root-classic/simple.page.tsx delete mode 100644 pages/table-root-classic/single-selection.page.tsx delete mode 100644 pages/table-root-classic/sorting.page.tsx delete mode 100644 pages/table-root-classic/striped-rows.page.tsx delete mode 100644 pages/table-root-classic/virtualization.page.tsx diff --git a/pages/table-root-classic/FINDINGS.md b/pages/table-root-classic/FINDINGS.md deleted file mode 100644 index f6af7065d0..0000000000 --- a/pages/table-root-classic/FINDINGS.md +++ /dev/null @@ -1,87 +0,0 @@ -# Classic-Table reference demo pages (VR oracle for atomic table-root) - -Recreates each `pages/table-root/` atomic demo with the shipped classic `Table` component -(`~components/table`) so the visual-regression step has a per-scenario oracle. Content is -identical to the atomic demos: the SAME data helpers (`makeItems` from `../table-root/common`, -and a replicated `makeLine` for virtualization), same item counts, same column labels, same -initial state. - -New directory: `pages/table-root-classic/` — auto-discovered by the dev harness -(`require.context('..', true, /\.page\.tsx$/)`), so each page registers at hash route -`table-root-classic/` with no manual wiring. - -## Routes created - -| Route | File | Mirrors atomic | -|---|---|---| -| `table-root-classic/simple` | `simple.page.tsx` | `table-root/simple` | -| `table-root-classic/selection` | `selection.page.tsx` | `table-root/selection` | -| `table-root-classic/single-selection` | `single-selection.page.tsx` | `table-root/single-selection` | -| `table-root-classic/sorting` | `sorting.page.tsx` | `table-root/sorting` | -| `table-root-classic/striped-rows` | `striped-rows.page.tsx` | `table-root/striped-rows` | -| `table-root-classic/loading-and-empty` | `loading-and-empty.page.tsx` | `table-root/loading-and-empty` | -| `table-root-classic/virtualization` | `virtualization.page.tsx` | `table-root/virtualization` | - -Shared helper: `common.tsx` — re-exports `Item`/`makeItems` from `../table-root/common` (same data), -and defines classic `columnDefinitions` (`dataColumns` = Name/Type/Size/Status; `nameStatusColumns` = -Name/Status for the selection demos) plus `resourcesAriaLabels`. No `styles.scss` and no bespoke -`common` data were needed — classic Table renders its own selection control, sort caret, dividers, -striped and loading/empty chrome, so none of the atomic demos' custom SCSS (`selection-cell`, -`sort-button`, `visually-hidden`, sort badges) is required. - -## Per-page mapping - -- **simple** → `columnDefinitions={dataColumns}` + `items={makeItems(8)}`. Atomic uses auto layout, - which mirrors classic Table's default `table-layout: auto`. 1:1. -- **selection** → `selectionType="multi"` + `selectedItems`/`trackBy="id"`/`onSelectionChange`, plus - `sortingColumn`/`sortingDescending`/`onSortingChange` on the Name column (the atomic demo is - "selectable + sortable"). Classic renders the native multi-select checkbox control column — exactly - what the atomic demo hand-builds with a `disablePaddings` control cell + centered `Checkbox`. Same - `makeItems(10)`, same two rows preselected (`resource-1`, `resource-2`), same name-ascending initial - sort. Consumer sorts the data (classic Table only shows the indicator + fires the event). -- **single-selection** → `selectionType="single"` + `selectedItems`/`trackBy="id"`. Classic renders - the native radio control column (atomic hand-builds a `RadioButton` with a shared `name`). Same - `makeItems(10)`, single preselected row (`resource-1`). -- **sorting** → single-column sort via `sortingColumn`/`sortingDescending`/`onSortingChange`; - `dataColumns` carry `sortingField` (name/type/status) and a `sortingComparator` (size, numeric). - Consumer sorts the data. Same `makeItems(12)`, initial name-ascending. **Non-mapping detail below.** -- **striped-rows** → `stripedRows={true}` (classic computes row parity itself; atomic marks alternating - rows `variant='shaded'` by hand). Same `makeItems(12)`. -- **loading-and-empty** → `loading`/`loadingText` + the `empty` slot, toggled by the same - `SegmentedControl` (Loaded / Loading / Empty). Same `makeItems(20)` when loaded. The atomic demo - composes a full-width `colSpan` status row by hand; classic renders loading/empty natively. - -## Scenarios that do NOT map cleanly - -1. **virtualization** — classic `Table` has **no built-in row virtualization**. The atomic demo - windows a 10,000-row dataset (renders ~14 absolutely-positioned rows at a time via narrowed - `style` props). Classic cannot window, so the oracle renders the SAME full 10,000-row dataset - (`makeLine`, 2 columns Time[120px]/Message) as normal flow ``s. A faithful pixel comparison of - a windowed table vs a full non-virtualized table is not possible — this page exists so the row/cell - **chrome** (header, dividers, cell padding, column widths) can still be compared. - - **VR recommendation:** either compare only a bounded row window / the header+first-N-rows region, - or reduce `TOTAL` in both pages for the VR run (10k classic flow rows renders slowly). -2. **sorting — multi-column** — the atomic demo supports shift-click multi-column sort with a priority - badge next to each caret. Classic `Table` supports only single-column sort natively (one - `sortingColumn` at a time). The oracle reproduces the single-column case (initial sort + per-column - toggle); the multi-column priority-badge state has no classic equivalent and is not represented. - -## Known expected difference (all pages) - -The atomic table-root demos are **bare** (no outer Container chrome — the v2 Container decision is -still pending). Classic `Table` renders its default `container` variant (outer border / radius / -shadow). VR should scope to the grid/rows/cells/header region, not the outer container frame. This is -the pending v2 delta, not a regression. - -## Verification (logs in this directory) - -- `gulp-quick-build.log` — `npx gulp quick-build` → **exit 0**. -- `pages-tsc.log` — `npx tsc -p pages/tsconfig.json --noEmit` → only the two pre-existing - `@formatjs/ecma402-abstract` `Intl.ListFormat` drift errors (node_modules); **zero errors in the new - pages** (two `detail.isDescending` `boolean|undefined` errors were found and fixed with `?? false`). -- `eslint.log` — `npx eslint pages/table-root-classic/` → **exit 0**. -- `stylelint.log` — no new `.scss` created → **N/A**. - -`src/` (components + substrates) was not touched — pages only. Nothing blocks the atomic-vs-classic -VR step; both page sets share identical data and columns, so a VR harness can pair -`table-root/` against `table-root-classic/` directly. diff --git a/pages/table-root-classic/common.tsx b/pages/table-root-classic/common.tsx deleted file mode 100644 index 91991caee9..0000000000 --- a/pages/table-root-classic/common.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { TableProps } from '~components/table'; - -import { Item } from '../table-root/common'; - -// Reuse the SAME data helper + item shape as the atomic table-root demos so the atomic-vs-classic -// visual-regression diff is content-identical (same rows, same values, same order). -export { makeItems } from '../table-root/common'; -export type { Item } from '../table-root/common'; - -// Full 4-column definition mirroring the atomic DataHeader/DataBody (Name/Type/Size/Status). -// sortingField/sortingComparator are declared so the sorting oracle can drive classic Table's -// built-in single-column sort indicator. -export const dataColumns: ReadonlyArray> = [ - { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, - { id: 'type', header: 'Type', cell: item => item.type, sortingField: 'type' }, - { - id: 'size', - header: 'Size', - cell: item => item.size, - sortingComparator: (a, b) => parseInt(a.size, 10) - parseInt(b.size, 10), - }, - { id: 'status', header: 'Status', cell: item => item.status, sortingField: 'status' }, -]; - -// Name + Status pair used by the selection demos. The selection control column is native to the -// classic Table (rendered by the component itself), so it is not part of columnDefinitions here — -// which is exactly what the atomic demos reproduce by hand with a disablePaddings control cell. -export const nameStatusColumns: ReadonlyArray> = [ - { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, - { id: 'status', header: 'Status', cell: item => item.status }, -]; - -// Non-sorting column variants. Classic Table renders a sort caret on any column that declares -// sortingField/sortingComparator (independent of the sortingColumn/onSortingChange props), so the -// twins whose atomic demo has NO sorting UI (simple, striped-rows, single-selection) must use column -// definitions WITHOUT sorting metadata — otherwise the twin shows carets the atomic lacks. -export const dataColumnsPlain: ReadonlyArray> = [ - { id: 'name', header: 'Name', cell: item => item.name }, - { id: 'type', header: 'Type', cell: item => item.type }, - { id: 'size', header: 'Size', cell: item => item.size }, - { id: 'status', header: 'Status', cell: item => item.status }, -]; - -export const nameStatusColumnsPlain: ReadonlyArray> = [ - { id: 'name', header: 'Name', cell: item => item.name }, - { id: 'status', header: 'Status', cell: item => item.status }, -]; - -export const resourcesAriaLabels: TableProps['ariaLabels'] = { - tableLabel: 'Resources', - selectionGroupLabel: 'Resources selection', - allItemsSelectionLabel: ({ selectedItems }) => `${selectedItems.length} resources selected`, - itemSelectionLabel: (_data, item) => `Select ${item.name}`, -}; diff --git a/pages/table-root-classic/loading-and-empty.page.tsx b/pages/table-root-classic/loading-and-empty.page.tsx deleted file mode 100644 index 8b4a51a224..0000000000 --- a/pages/table-root-classic/loading-and-empty.page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// 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 SegmentedControl from '~components/segmented-control'; -import SpaceBetween from '~components/space-between'; -import Table from '~components/table'; - -import { dataColumnsPlain, makeItems, resourcesAriaLabels } from './common'; - -type State = 'loaded' | 'loading' | 'empty'; - -// VR oracle for pages/table-root/loading-and-empty. The atomic demo composes loading/empty as a -// full-width colSpan status row by hand; classic Table renders these natively via loading + -// loadingText and the empty slot. Same three states, same makeItems(20) when loaded. -export default function TableClassicLoadingEmptyPage() { - const [state, setState] = useState('loaded'); - const items = state === 'loaded' ? makeItems(20) : []; - - return ( - - - Classic Table — loading & empty (VR oracle for table-root/loading-and-empty) - - setState(event.detail.selectedId as State)} - label="Data state" - options={[ - { id: 'loaded', text: 'Loaded' }, - { id: 'loading', text: 'Loading' }, - { id: 'empty', text: 'Empty' }, - ]} - /> - - -
Resources
-
itself. + disablePaddings && styles['disable-paddings'], isVisualRefresh && bodyCellStyles['is-visual-refresh'], variant === 'selected' && bodyCellStyles['body-cell-selected'], // Classic's `has-selection` marker keeps the selected first (control) cell's 2px inline-start diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss index 9c9d6d8b93..10e08ef1a5 100644 --- a/src/table-cell/styles.scss +++ b/src/table-cell/styles.scss @@ -21,6 +21,37 @@ min-inline-size: 0; } +// The selection-control cell (a `disablePaddings` cell) centres its control on EVERY row, matching +// classic Table's natural table-cell vertical centring. The row grid's `align-items: center` would +// otherwise shrink-wrap the short control cell, leaving the control low and inconsistent between +// selected and unselected rows; stretching it to the full row track and re-centring its content +// (keeping the default `justify-items: stretch` so the control stays horizontally centred) places the +// control at the row centre uniformly. The stretch also encloses the selected control cell so the 2px +// selection border wraps its full height. Keyed on `.cell.disable-paddings`, which classic never +// emits, so this is inert for classic. +.cell.disable-paddings { + align-self: stretch; + display: grid; + align-items: center; +} + +// Striped-row divider darkening. Classic darkens EVERY body-cell divider to `$color-border-cell-shaded` +// when the table has striped rows (a table-level `has-striped-rows` flag on every cell). The atomic +// public layer has no table-level striped signal — it only knows per-row `variant='shaded'` via +// context — so it derives the striping from row adjacency: the divider ABOVE a shaded row, plus a +// shaded row's own bottom divider, are darkened, so in alternating striping every internal boundary is +// darkened, matching classic's table-wide look. The last row is excluded (its bottom border is the +// transparent container-edge placeholder), mirroring classic's `:not(.body-cell-last-row)`. Reuses the +// shared token; keyed on `data-shaded`, which classic never emits (it uses `has-striped-rows`), so +// this is inert for classic. Ordered before the `data-selected` edge rules below so specificity never +// descends within the `.cell` group. +tr:has(+ [data-shaded]) > .cell { + border-block-end-color: awsui.$color-border-cell-shaded; +} +[data-shaded]:not(:last-child) > .cell { + border-block-end-color: awsui.$color-border-cell-shaded; +} + // Consecutive-selected outline merge. Classic reconciles two adjacent selected rows into one rounded // box via author-computed `body-cell-prev-selected` / `body-cell-next-selected` classes // (src/table/body-cell/styles.scss). The atomic public layer can't compute adjacency in the row @@ -47,17 +78,6 @@ border-block-end-width: 0; } -// Enclose the selection control in the box. A `disablePaddings` control cell is content-height, so -// the row's `align-items: center` leaves it a short pill detached from the full-height data cells. -// Stretch the selected row's first (control) cell to the full row track and re-centre its content -// (grid + `align-items: center`, keeping the default `justify-items: stretch` so the control stays -// horizontally centred) so the checkbox stays centred while the 2px selection border wraps the cell. -[data-selected] > .cell:first-child { - align-self: stretch; - display: grid; - align-items: center; -} - // Square the two INNER corners of each merged pair (outer corners keep classic's 8px radius), so the // run reads as one rounded box. Grouped after the base edges above to keep specificity ascending. [data-selected] + [data-selected] > .cell:first-child { diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx index 18592af62c..9c3d467f33 100644 --- a/src/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal.tsx @@ -31,6 +31,12 @@ export function HeaderCell(props: TableHeaderCellProps & InternalBaseComponentPr styles['header-cell'], isGrid && styles['header-cell-grid'], isVisualRefresh && headerCellStyles['is-visual-refresh'], + // Also emit this module's own is-visual-refresh marker: the substrate class above drives the + // substrate's VR rules, but the atomic first-column content-offset reset lives in THIS module + // (`.header-cell.is-visual-refresh:first-child > .header-cell-content`) and is keyed on this + // module's hashed class, so without it the reset never matches and the first header column keeps + // its 12px content inset (header text sits right of the body text). + isVisualRefresh && styles['is-visual-refresh'], disablePaddings && styles['disable-paddings'] )} nativeAttributes={{ diff --git a/src/table-root/__tests__/basic-table-styling-props.test.tsx b/src/table-root/__tests__/basic-table-styling-props.test.tsx index 717e28a7fb..a1a785673f 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -77,7 +77,8 @@ describe('TableRow variant is visual-only and paints through the cell', () => { const row = wrapper.findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); expect(row).not.toHaveAttribute('data-selected'); - expect(row).not.toHaveAttribute('data-shaded'); + // data-shaded drives the striped-row divider darkening (sibling adjacency), mirroring data-selected. + expect(row).toHaveAttribute('data-shaded', 'true'); for (const classList of cellClassLists(wrapper)) { expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(true); expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index bafeb81771..2d5230f1ce 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -16,11 +16,12 @@ import styles from './styles.css.js'; // substrate (shared with classic's body renders; the substrate keeps the event-handler props that // classic composes). This public component layers the grid-mode role/layout and aria surface, and // publishes the row's `variant` to its cells through RowVariantContext so each cell self-paints its -// own selection/shading class. A selected row additionally emits `data-selected` on the
` with native `display: table-cell`, which already gives colinear equal-height column boxes, so +// forcing `display: grid` there would eject the cell from table layout and collapse the columns. +// In grid mode the row grid's `align-items: center` would otherwise leave each grid item +// content-height, so a stretched control cell (see below) and a content-height data cell no longer +// share top/bottom edges (a ~0.5px step at the control|data seam on the taller selected row). +// Stretching every cell and centring its own content keeps all column boxes colinear without changing +// the row track height (`grid-auto-rows` still governs 39/40). The default `justify-items: stretch` +// keeps content full-width (data text and the centred selection wrapper both behave as before). No +// overflow/clip — that would crop the focus ring of an interactive control (checkbox, radio, link). +// This also encloses the selected control cell so its 2px selection border wraps the full row height. .cell { box-sizing: border-box; } -// Lets grid columns shrink below their content size. No overflow/clip here — that would crop the -// focus ring of an interactive control (checkbox, radio, link) inside the cell. +// Grid-mode only: lets grid columns shrink below their content size and applies the equal-height +// treatment described above. Auto mode uses native `display: table-cell` and is untouched. .cell-grid { min-inline-size: 0; -} - -// The selection-control cell (a `disablePaddings` cell) centres its control on EVERY row, matching -// classic Table's natural table-cell vertical centring. The row grid's `align-items: center` would -// otherwise shrink-wrap the short control cell, leaving the control low and inconsistent between -// selected and unselected rows; stretching it to the full row track and re-centring its content -// (keeping the default `justify-items: stretch` so the control stays horizontally centred) places the -// control at the row centre uniformly. The stretch also encloses the selected control cell so the 2px -// selection border wraps its full height. Keyed on `.cell.disable-paddings`, which classic never -// emits, so this is inert for classic. -.cell.disable-paddings { align-self: stretch; display: grid; align-items: center; @@ -63,19 +63,20 @@ tr:has(+ [data-shaded]) > .cell { // Rules are ordered base-edges first, then corner-radius overrides, so specificity never descends // (stylelint no-descending-specificity) — no disable comments needed. -// A selected row that FOLLOWS a selected row (classic's prev-selected): collapse the shared top edge -// to the 1px placeholder divider. +// A selected row that FOLLOWS a selected row (classic's prev-selected): keep the shared top edge as +// the 1px placeholder divider. [data-selected] + [data-selected] > .cell { border-block-start: awsui.$border-divider-list-width solid awsui.$color-border-item-placeholder; } -// A selected row that PRECEDES a selected row (classic's next-selected): drop the shared bottom edge -// so the row below owns the single divider. Classic keeps a 1px bottom here because its table rows -// overlap; the atomic grid rows are separate tracks that do not overlap, so 1px here + the 1px -// placeholder above would read as a doubled 2px band — dropping it yields the single 1px shared -// divider classic renders. +// The first selected row's shared bottom edge is narrowed to the 1px list divider (mirroring classic's +// `body-cell-next-selected { border-block-end-width: $border-divider-list-width }`), NOT dropped to 0 +// and NOT left at the full 2px selection border. Classic renders the seam between two adjacent selected +// rows as 2px = this 1px bottom edge + the following row's 1px top placeholder (its rows are adjacent +// non-overlapping boxes, like the atomic grid tracks). Leaving the full 2px here overshoots to a 3px +// seam; dropping to 0 undershoots to 1px. 1px reproduces classic's 2px seam exactly. [data-selected]:has(+ [data-selected]) > .cell { - border-block-end-width: 0; + border-block-end-width: awsui.$border-divider-list-width; } // Square the two INNER corners of each merged pair (outer corners keep classic's 8px radius), so the diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss index df9a714aa7..05bf3f2ae1 100644 --- a/src/table-header-cell/styles.scss +++ b/src/table-header-cell/styles.scss @@ -36,9 +36,17 @@ pointer-events: none; } -// Lets grid columns shrink below their content size. +// Grid-mode only: lets grid columns shrink below their content size, and applies the equal-height +// treatment (mirroring the body `.cell-grid`) so every header cell fills the header-row track and +// centres its content. Without it an empty/short control header (e.g. single-select's control column, +// which has no select-all) collapses to ~1px, leaving the column divider a short stub with a stray +// streak; classic keeps that header at full row height. Auto mode uses native `display: table-cell` +// and is untouched (this class is only emitted in grid mode). .header-cell-grid { min-inline-size: 0; + align-self: stretch; + display: grid; + align-items: center; } // Opt out of the built-in padding so the consumer can compose exact spacing (e.g. a selection @@ -48,14 +56,6 @@ padding-inline: 0; } -// A disablePaddings header cell is a composed control column (e.g. the selection checkbox/radio -// header). Classic Table draws no vertical divider on its selection control column, so the atomic -// `::after` column-divider must not render there either. Classic never emits `disable-paddings`, so -// this suppression is inert on the classic path. -.header-cell.disable-paddings::after { - display: none; -} - // Header content box. The extracted substrate renders header children with no wrapper, so the // header row was 8px shorter than classic (29px vs 37px). Classic sizes its header via a // `.header-cell-content` box carrying `padding-block: $space-scaled-xxs` (height) plus an inline From ff8428b82076ac58aff33b98c166252136dfa35c Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 8 Sep 2026 12:31:24 +0000 Subject: [PATCH 25/35] fix(table-atomic): constant-height selected rows (zero shift on toggle) An isolated selected row was +1px taller (40px) than unselected (39px): the selection border grows +2px total while classic's body-cell-selected reclaim returns only 1px. In grid mode each row is its own grid-auto-rows track sized by cell border-box, so that +1px grew the track and cascaded every row below by 1px on each select/deselect, resizing the table. Consecutive-run rows already net 39px via the seam/placeholder-start rules, so only the isolated selected state leaked. Absorb its surplus pixel into the selected cell's own padding-block-end, grid-mode only: [data-selected]:not([data-selected] + [data-selected]):not(:has(+ [data-selected])) > .cell-grid { padding-block-end: 0 } Result: every selection state is 39px; select/deselect causes zero row-height change, zero table resize, zero cascade (measured, both selection pages). 2px selection border, merged-pair outline, and control centering unchanged. Grid-scoped, so auto-layout tables and classic (never emit .cell-grid) are inert. Constant-height chosen over classic's neighbor-compensation (user decision) since it eliminates all movement, including classic's own 1px nudge. --- src/table-cell/styles.scss | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss index f402f97968..05f438dd34 100644 --- a/src/table-cell/styles.scss +++ b/src/table-cell/styles.scss @@ -93,3 +93,21 @@ tr:has(+ [data-shaded]) > .cell { [data-selected]:has(+ [data-selected]) > .cell:last-child { border-end-end-radius: 0; } + +// Constant-height selected row (grid mode). A selected cell carries the full 2px selection border on +// both block edges (+2px over the 1px unselected dividers); classic's `body-cell-selected` padding +// reclaim only returns one of those pixels, so in grid mode — where each row is an independent +// `grid-auto-rows` track sized by its cell border-box — an ISOLATED selected row lands at 40px and +// grows the track by 1px, cascading every row below on select/deselect. A selected row that abuts +// another selected row has one block edge narrowed by the seam rules above (or the placeholder-top +// rule), so it already nets the unselected 39px. Classic keeps the neighbour-of-a-selected-row height +// stable with its `body-cell-next/prev-selected` neighbour compensation; the grid track's minmax floor +// makes that neighbour trick unavailable here, so instead the surplus pixel is absorbed into the +// isolated cell's own block-end padding (the same edge classic reclaims from), holding its border-box +// at 39px with zero row-height change on toggle. Scoped to `.cell-grid`, so auto-layout `` tables +// and classic (which never emit the grid class) are inert; the 2px border and merged-pair outline are +// untouched. `:not([data-selected] + [data-selected])` excludes a selected row preceded by a selected +// row and `:not(:has(+ [data-selected]))` a selected row followed by one, leaving only the isolated case. +[data-selected]:not([data-selected] + [data-selected]):not(:has(+ [data-selected])) > .cell-grid { + padding-block-end: 0; +} From 1fbd1339f665de98faf57bf74aa129991153c515 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 8 Sep 2026 15:06:53 +0000 Subject: [PATCH 26/35] fix(table-atomic): repaint selection ring as layout-neutral overlay (zero content shift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the real-border selection outline with an absolutely-positioned ::after ring on the selected row, and neutralize the reused body-cell selection border to a constant 1px so real border widths never change between selected/unselected. Grid content-centering is then border-insensitive: on select/deselect the cell content, control, row top, and table height are all invariant (measured dy=0, every row 39px), matching classic's zero content movement — which the real-border approach could not do under grid layout. - reset the selected cell's content-wrapper bottom padding to the unselected value (classic's reclaim compensated for a real border the atomic no longer has), removing the last 0.5px text nudge; - draw the consecutive-selected seam from the first-of-pair ring's own 2px bottom edge (matches the outer ring width) instead of a 1px tinted divider; - suppress (transparent, width unchanged) the unselected-above row's divider so no grey line abuts the ring top, matching classic. Retires the constant-height padding-block-end:0 hack and per-state seam border-width juggling. Atomic-only hooks (data-selected, .cell-grid); classic-inert. --- src/table-cell/styles.scss | 150 ++++++++++++++++++++++++------------- 1 file changed, 100 insertions(+), 50 deletions(-) diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss index 05f438dd34..2db2a1f7fd 100644 --- a/src/table-cell/styles.scss +++ b/src/table-cell/styles.scss @@ -35,6 +35,48 @@ align-items: center; } +// Selection is painted as a LAYOUT-NEUTRAL overlay ring, not real cell borders. Classic paints the +// selection as a real 2px border on the selected cells and reclaims padding to keep content in place; +// in grid mode each row is an independent `grid-auto-rows` track sized by its cells' border-box, so +// the extra border grows the track and moves content on toggle (needing per-state seam border-width +// juggling and a constant-height padding hack). Instead the atomic selected cell KEEPS the unselected +// geometry — constant 1px block dividers, no selection border, no radius — so selecting changes no +// layout dimension (row stays 39px, cell content does not move), and the 2px ring is drawn as an +// absolutely-positioned `::after` on the row (below). `body-cell-selected` is still applied by the +// cell (its background-color is reused, and the styling-props unit test asserts the class); the rules +// here override only its border/radius, keyed on the atomic `data-selected` / `.cell` hooks the +// extracted substrate never emits, so classic stays inert. All `.cell`-target rules are ordered by +// ascending specificity (stylelint no-descending-specificity) — neutralization, then the shaded +// divider, then the consecutive-selected seam colour, then the first/last-child edge overrides. + +// Neutralize the selection border back to the unselected 1px block dividers (top transparent, bottom +// the list divider), so a selected cell's block border-box equals the unselected cell's. +[data-selected] > .cell { + border-block-start: awsui.$border-divider-list-width solid transparent; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-secondary; +} + +// Undo classic's selected-state padding reclaim on the reused content wrapper (grid mode). Classic's +// `body-cell-selected` shrinks the wrapper's block-end padding by (border-item-width − divider-width) = +// 1px to offset the real 2px selection border it paints. The atomic paints selection as the +// layout-neutral `::after` and keeps the 1px dividers (above), so that reclaim is spurious; under the +// grid row's `align-items: center` it drops the centred content ~0.5px on select. Restore the wrapper's +// block-end padding to classic's UNSELECTED value — its `cell-padding-block-end($cell-vertical-padding-w-border)` +// output expanded with the same awsui tokens; the trailing `2px` mirrors classic's +// `$cell-negative-space-vertical`, which pairs with the unchanged `margin-block-end: -2px` — so the +// selected and unselected wrapper heights match and content does not move on toggle. The +// `[class*='body-cell-content']` attribute selector targets the reused `body-cell-content` wrapper by +// its readable class fragment: its hashed class lives in another CSS module and is not selectable by +// name from here, and a bare type selector is disallowed by stylelint. The disablePaddings control cell +// keeps its own zeroed padding via classic's higher-specificity `body-cell-selected > +// .body-cell-content.disable-paddings` rule, so control centring is unaffected. +[data-selected] > .cell-grid > [class*='body-cell-content'] { + padding-block-end: calc( + #{awsui.$space-scaled-xs} + (#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}) - + #{awsui.$border-divider-list-width} + 2px + ); +} + // Striped-row divider darkening. Classic darkens EVERY body-cell divider to `$color-border-cell-shaded` // when the table has striped rows (a table-level `has-striped-rows` flag on every cell). The atomic // public layer has no table-level striped signal — it only knows per-row `variant='shaded'` via @@ -43,8 +85,7 @@ // darkened, matching classic's table-wide look. The last row is excluded (its bottom border is the // transparent container-edge placeholder), mirroring classic's `:not(.body-cell-last-row)`. Reuses the // shared token; keyed on `data-shaded`, which classic never emits (it uses `has-striped-rows`), so -// this is inert for classic. Ordered before the `data-selected` edge rules below so specificity never -// descends within the `.cell` group. +// this is inert for classic. tr:has(+ [data-shaded]) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; } @@ -52,62 +93,71 @@ tr:has(+ [data-shaded]) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; } -// Consecutive-selected outline merge. Classic reconciles two adjacent selected rows into one rounded -// box via author-computed `body-cell-prev-selected` / `body-cell-next-selected` classes -// (src/table/body-cell/styles.scss). The atomic public layer can't compute adjacency in the row -// component, so it reads it straight from the DOM: `TableRow` auto-emits `data-selected` from -// `variant='selected'`, and these selectors mirror classic's edge/radius collapse. They are keyed on -// `data-selected`, a hook classic's extracted substrate never emits (it uses the prev/next-selected -// classes), so this block is inert for classic and keeps its VR at 0%. -// -// Rules are ordered base-edges first, then corner-radius overrides, so specificity never descends -// (stylelint no-descending-specificity) — no disable comments needed. - -// A selected row that FOLLOWS a selected row (classic's prev-selected): keep the shared top edge as -// the 1px placeholder divider. -[data-selected] + [data-selected] > .cell { - border-block-start: awsui.$border-divider-list-width solid awsui.$color-border-item-placeholder; -} - -// The first selected row's shared bottom edge is narrowed to the 1px list divider (mirroring classic's -// `body-cell-next-selected { border-block-end-width: $border-divider-list-width }`), NOT dropped to 0 -// and NOT left at the full 2px selection border. Classic renders the seam between two adjacent selected -// rows as 2px = this 1px bottom edge + the following row's 1px top placeholder (its rows are adjacent -// non-overlapping boxes, like the atomic grid tracks). Leaving the full 2px here overshoots to a 3px -// seam; dropping to 0 undershoots to 1px. 1px reproduces classic's 2px seam exactly. +// Hide the first-of-pair cell's own 1px block divider inside a consecutive-selected run. The seam is +// now drawn by the ring itself — the first-of-pair KEEPS its `::after` 2px block-end edge (see the +// merge rules below), matching the 2px outer ring — so the cell's underlying grey divider must not +// show a sliver under it. Width stays 1px (transparent), so this is layout-neutral. Specificity +// (0,3,0) matches the shaded/edge rules, keeping the `.cell` group ascending (no-descending-specificity). [data-selected]:has(+ [data-selected]) > .cell { - border-block-end-width: awsui.$border-divider-list-width; + border-block-end-color: transparent; } -// Square the two INNER corners of each merged pair (outer corners keep classic's 8px radius), so the -// run reads as one rounded box. Grouped after the base edges above to keep specificity ascending. -[data-selected] + [data-selected] > .cell:first-child { +// Drop the inline selection border + item radius that `body-cell-selected` adds to the first/last +// cell, so the row's inline geometry equals the unselected row — the first column's 2px inline-start +// selection border would otherwise shove its content 2px on select. The ring draws these edges. Inline +// extents mirror the unselected base cell: no inline-start border on the first column (visual refresh), +// and the last column keeps its 2px transparent edge placeholder. +[data-selected] > .cell:first-child { + border-inline-start: none; border-start-start-radius: 0; + border-end-start-radius: 0; } -[data-selected] + [data-selected] > .cell:last-child { +[data-selected] > .cell:last-child { + border-inline-end: awsui.$border-item-width solid transparent; border-start-end-radius: 0; + border-end-end-radius: 0; } -[data-selected]:has(+ [data-selected]) > .cell:first-child { - border-end-start-radius: 0; + +// Suppress the grey list divider on the UNSELECTED row directly above a selection: its 1px bottom +// divider otherwise abuts the ring's top edge as a grey line (classic suppresses the divider immediately +// above a selection). Colour-only — width stays 1px, so layout is unchanged. Keyed on the atomic +// `data-selected` hook (classic never emits it), so classic stays inert. Specificity (0,3,1) sits above +// the (0,3,0) `.cell` rules, keeping the `.cell` group ascending (no-descending-specificity). +tr:not([data-selected]):has(+ [data-selected]) > .cell { + border-block-end-color: transparent; } -[data-selected]:has(+ [data-selected]) > .cell:last-child { - border-end-end-radius: 0; + +// The overlay ring. `.row` is position:relative; the `::after` is position:absolute so it is NOT a +// grid item in grid-mode rows. inset:0 traces the row's padding box — the cells' outer edge, where +// classic paints its selection border — at classic's 2px width, item radius, and selection color. +[data-selected]::after { + content: ''; + position: absolute; + inset: 0; + border-block-start: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-block-end: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-inline-start: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-inline-end: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-start-start-radius: awsui.$border-radius-item; + border-start-end-radius: awsui.$border-radius-item; + border-end-start-radius: awsui.$border-radius-item; + border-end-end-radius: awsui.$border-radius-item; + pointer-events: none; } -// Constant-height selected row (grid mode). A selected cell carries the full 2px selection border on -// both block edges (+2px over the 1px unselected dividers); classic's `body-cell-selected` padding -// reclaim only returns one of those pixels, so in grid mode — where each row is an independent -// `grid-auto-rows` track sized by its cell border-box — an ISOLATED selected row lands at 40px and -// grows the track by 1px, cascading every row below on select/deselect. A selected row that abuts -// another selected row has one block edge narrowed by the seam rules above (or the placeholder-top -// rule), so it already nets the unselected 39px. Classic keeps the neighbour-of-a-selected-row height -// stable with its `body-cell-next/prev-selected` neighbour compensation; the grid track's minmax floor -// makes that neighbour trick unavailable here, so instead the surplus pixel is absorbed into the -// isolated cell's own block-end padding (the same edge classic reclaims from), holding its border-box -// at 39px with zero row-height change on toggle. Scoped to `.cell-grid`, so auto-layout `` tables -// and classic (which never emit the grid class) are inert; the 2px border and merged-pair outline are -// untouched. `:not([data-selected] + [data-selected])` excludes a selected row preceded by a selected -// row and `:not(:has(+ [data-selected]))` a selected row followed by one, leaving only the isolated case. -[data-selected]:not([data-selected] + [data-selected]):not(:has(+ [data-selected])) > .cell-grid { - padding-block-end: 0; +// Merge a run of selected rows into one continuous rounded outline, with the internal seams drawn at +// the full 2px ring width (matching the outer ring). The first row of a pair KEEPS its 2px `::after` +// block-end edge (that edge IS the seam) and only squares its two bottom corners; a row following a +// selected row zeroes its `::after` top edge (so the seam is a single 2px edge, never doubled) and +// squares its two top corners; a middle row keeps its 2px bottom edge, zeroes its top edge, and +// squares all four corners — leaving only the run's outer corners rounded. Ordered after the base ring +// so specificity ascends. +[data-selected]:has(+ [data-selected])::after { + border-end-start-radius: 0; + border-end-end-radius: 0; +} +[data-selected] + [data-selected]::after { + border-block-start-width: 0; + border-start-start-radius: 0; + border-start-end-radius: 0; } From 6001ea0bc78ee96b86866be53706bdc740353384 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 8 Sep 2026 15:31:09 +0000 Subject: [PATCH 27/35] chore(table-atomic): trim comments, drop dead class, de-ambiguate 'classic' wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behaviour-neutral cleanup of the 7 atomic components: - Trim multi-paragraph comments to concise WHY/gotchas; remove comments that restate the code or narrate edit history (row-px values, 'earlier fix', etc.). - Remove a dead `styles['disable-paddings']` class from the body (no such rule exists in the table-cell module; disablePaddings is handled by the substrate on the inner content wrapper) — a remnant of the removed per-state control-cell padding compensation. - Replace ambiguous 'classic'/'classic Table' wording with 'the existing Table' ('classic' conventionally denotes the classic theme). Reused hashed class names, import paths, and the is-visual-refresh theme token are unchanged. No functional or CSS-selector changes. jest 37/37, gulp quick-build, and stylelint all green. --- src/table-body/internal.tsx | 14 +- src/table-cell/internal.tsx | 35 ++--- src/table-cell/styles.scss | 128 +++++++----------- src/table-head/internal.tsx | 5 +- src/table-header-cell/internal.tsx | 15 +- src/table-header-cell/styles.scss | 45 +++--- .../basic-table-styling-props.test.tsx | 6 +- src/table-root/styles.scss | 8 +- src/table-row/context.ts | 10 +- src/table-row/internal.tsx | 24 ++-- src/table-row/styles.scss | 6 +- 11 files changed, 113 insertions(+), 183 deletions(-) diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index cd9fcf7a31..3ee5f2b24b 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -12,15 +12,13 @@ import { TableBodyProps } from './interfaces'; import styles from './styles.css.js'; -// The bare
element, classic's proven `.body-cell` box model (base padding + `.body-cell-content` -// wrapper), `disablePaddings`, and ref are all provided by the extracted InternalTableCell substrate -// (the single shared piece with classic's td-element). This public component layers its own test-utils -// marker, the grid-mode role/layout, and selection/shading — painted by reusing classic's own -// `.body-cell-selected` / `.body-cell-shaded` classes, keyed off the row's variant via context, so no -// selection stylesheet is duplicated and no `data-*` styling hook is needed. -// -// Consecutive-selected rows merge into one rounded outline via `data-selected` adjacency in this -// component's stylesheet (mirroring classic's prev/next-selected block), and the selected control -// cell is enclosed via `has-selection` + a full-height stretch. Sticky columns and full -// visual-refresh gating remain deferred to a later increment. +// The element, the `.body-cell` box model (base padding + `.body-cell-content` wrapper), +// `disablePaddings`, and ref all come from the InternalTableCell substrate (the single shared piece +// with the existing Table). This public layer adds the test-utils marker, grid-mode role/layout, and +// selection/shading — painted by reusing the existing Table's `.body-cell-selected` / +// `.body-cell-shaded` classes off the row's variant via context, so no selection stylesheet is +// duplicated. Sticky columns and full visual-refresh gating are deferred. export function Cell(props: TableCellProps & InternalBaseComponentProps) { const { children, disablePaddings, __internalRootRef } = props; const { columnLayout } = useTableContext(); @@ -42,23 +37,15 @@ export function Cell(props: TableCellProps & InternalBaseComponentProps) { className, styles.cell, isGrid && styles['cell-grid'], - // Marks the selection-control (disablePaddings) cell so it centers its control on every row - // (see table-cell/styles.scss). The substrate applies its own disable-paddings class to the - // inner content wrapper; this one is on the itself. - disablePaddings && styles['disable-paddings'], isVisualRefresh && bodyCellStyles['is-visual-refresh'], variant === 'selected' && bodyCellStyles['body-cell-selected'], - // Classic's `has-selection` marker keeps the selected first (control) cell's 2px inline-start - // border + left radii: classic strips the inline-start border on a first cell - // `:not(.has-selection)`, which would otherwise leave the box open on the control column. + // The existing Table's `has-selection` marker: without it a first cell `:not(.has-selection)` + // strips its inline-start selection border, leaving the control column's box open. variant === 'selected' && bodyCellStyles['has-selection'], variant === 'shaded' && bodyCellStyles['body-cell-shaded'], - // Edge-row placeholder borders (1px-taller first/last rows) are a border-model construct of the - // auto (table-layout) path — they reuse classic's own classes, whose - // `body-cell-last-row:not(.body-cell-selected)` guard composes with the selected class above so a - // selected edge row keeps its selection border. In grid layout the row height is governed by - // `grid-auto-rows`, not the cell border model, so the placeholder double-counts and inflates the - // first/last row by 1px; grid rows already match classic's 39/40 without it. + // Edge-row placeholder borders (1px-taller first/last rows) belong to the auto/table-layout + // path, reusing the existing Table's classes. In grid layout the row height comes from + // `grid-auto-rows`, so the placeholder would double-count and inflate the edge row by 1px. !isGrid && isFirstRow && bodyCellStyles['body-cell-first-row'], !isGrid && isLastRow && bodyCellStyles['body-cell-last-row'] )} diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss index 2db2a1f7fd..45eaa245d1 100644 --- a/src/table-cell/styles.scss +++ b/src/table-cell/styles.scss @@ -5,29 +5,20 @@ @use '../internal/styles/tokens' as awsui; -// The base cell box model (padding, borders, divider, selection/shading) is supplied by the shared -// `.body-cell` class and classic's `.body-cell-selected` / `.body-cell-shaded` classes from the -// extracted substrate (src/table/table-cell + src/table/body-cell, i.e. classic's proven stylesheet). -// This standalone module only adds the test-utils marker and the grid-mode equal-height treatment — it -// must NOT re-declare the base geometry, which would double the substrate's padding. Pixel -// reconciliation of these layers is verified in the visual-regression increment (Inc4c). -// The equal-height treatment applies ONLY in grid mode (`.cell-grid`). In auto mode the cell is a real -// `` with native `display: table-cell`, which already gives colinear equal-height column boxes, so -// forcing `display: grid` there would eject the cell from table layout and collapse the columns. -// In grid mode the row grid's `align-items: center` would otherwise leave each grid item -// content-height, so a stretched control cell (see below) and a content-height data cell no longer -// share top/bottom edges (a ~0.5px step at the control|data seam on the taller selected row). -// Stretching every cell and centring its own content keeps all column boxes colinear without changing -// the row track height (`grid-auto-rows` still governs 39/40). The default `justify-items: stretch` -// keeps content full-width (data text and the centred selection wrapper both behave as before). No -// overflow/clip — that would crop the focus ring of an interactive control (checkbox, radio, link). -// This also encloses the selected control cell so its 2px selection border wraps the full row height. +// The base cell box model (padding, borders, divider, selection/shading) comes from the substrate's +// `.body-cell` + `.body-cell-selected` / `.body-cell-shaded` classes (src/table/table-cell, +// src/table/body-cell). This module must NOT re-declare that geometry — it only adds the test-utils +// marker and the grid-mode equal-height treatment. .cell { box-sizing: border-box; } -// Grid-mode only: lets grid columns shrink below their content size and applies the equal-height -// treatment described above. Auto mode uses native `display: table-cell` and is untouched. +// Grid-mode only. Auto mode uses native display:table-cell (already equal-height). In grid mode the +// row's `align-items: center` leaves each item content-height, so a stretched control cell and a +// content-height data cell would step ~0.5px at the seam; stretching every cell and centring its own +// content keeps the column boxes colinear without changing the row track. `min-inline-size: 0` lets +// columns shrink below content size. No overflow/clip here — it would crop an interactive control's +// focus ring. .cell-grid { min-inline-size: 0; align-self: stretch; @@ -35,19 +26,15 @@ align-items: center; } -// Selection is painted as a LAYOUT-NEUTRAL overlay ring, not real cell borders. Classic paints the -// selection as a real 2px border on the selected cells and reclaims padding to keep content in place; -// in grid mode each row is an independent `grid-auto-rows` track sized by its cells' border-box, so -// the extra border grows the track and moves content on toggle (needing per-state seam border-width -// juggling and a constant-height padding hack). Instead the atomic selected cell KEEPS the unselected -// geometry — constant 1px block dividers, no selection border, no radius — so selecting changes no -// layout dimension (row stays 39px, cell content does not move), and the 2px ring is drawn as an -// absolutely-positioned `::after` on the row (below). `body-cell-selected` is still applied by the -// cell (its background-color is reused, and the styling-props unit test asserts the class); the rules -// here override only its border/radius, keyed on the atomic `data-selected` / `.cell` hooks the -// extracted substrate never emits, so classic stays inert. All `.cell`-target rules are ordered by -// ascending specificity (stylelint no-descending-specificity) — neutralization, then the shaded -// divider, then the consecutive-selected seam colour, then the first/last-child edge overrides. +// Selection is painted as a LAYOUT-NEUTRAL overlay ring, not real cell borders. The existing Table +// paints a real 2px border on selected cells; in grid mode each row is an independent `grid-auto-rows` +// track sized by its cells' border-box, so a real border would grow the track and shift content on +// toggle. Instead the selected cell KEEPS the unselected geometry (constant 1px dividers, no border, no +// radius) and the 2px ring is drawn as an absolutely-positioned `::after` on the row (below). +// `body-cell-selected` is still applied for its background (and the styling-props test asserts it); the +// rules here override only its border/radius, keyed on the `data-selected` / `.cell` hooks the substrate +// never emits, so the existing Table stays inert. `.cell`-target rules are ordered by ascending +// specificity (stylelint no-descending-specificity). // Neutralize the selection border back to the unselected 1px block dividers (top transparent, bottom // the list divider), so a selected cell's block border-box equals the unselected cell's. @@ -56,20 +43,14 @@ border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-secondary; } -// Undo classic's selected-state padding reclaim on the reused content wrapper (grid mode). Classic's -// `body-cell-selected` shrinks the wrapper's block-end padding by (border-item-width − divider-width) = -// 1px to offset the real 2px selection border it paints. The atomic paints selection as the -// layout-neutral `::after` and keeps the 1px dividers (above), so that reclaim is spurious; under the -// grid row's `align-items: center` it drops the centred content ~0.5px on select. Restore the wrapper's -// block-end padding to classic's UNSELECTED value — its `cell-padding-block-end($cell-vertical-padding-w-border)` -// output expanded with the same awsui tokens; the trailing `2px` mirrors classic's -// `$cell-negative-space-vertical`, which pairs with the unchanged `margin-block-end: -2px` — so the -// selected and unselected wrapper heights match and content does not move on toggle. The -// `[class*='body-cell-content']` attribute selector targets the reused `body-cell-content` wrapper by -// its readable class fragment: its hashed class lives in another CSS module and is not selectable by -// name from here, and a bare type selector is disallowed by stylelint. The disablePaddings control cell -// keeps its own zeroed padding via classic's higher-specificity `body-cell-selected > -// .body-cell-content.disable-paddings` rule, so control centring is unaffected. +// Undo the existing Table's selected-state padding reclaim on the reused content wrapper (grid mode). +// `body-cell-selected` shrinks the wrapper's block-end padding by ~1px to offset the real 2px border it +// paints; the atomic keeps the 1px dividers and paints the ring as `::after`, so that reclaim is +// spurious and drops centred content ~0.5px on select. Restore the wrapper's UNSELECTED block-end +// padding (the trailing `2px` mirrors `$cell-negative-space-vertical`, pairing with the unchanged +// `margin-block-end: -2px`). The `[class*='body-cell-content']` selector matches the cross-module hashed +// wrapper by fragment (its hashed name isn't selectable here, and stylelint bans a bare type selector). +// The disablePaddings control cell keeps its zeroed padding via a higher-specificity substrate rule. [data-selected] > .cell-grid > [class*='body-cell-content'] { padding-block-end: calc( #{awsui.$space-scaled-xs} + (#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}) - @@ -77,15 +58,12 @@ ); } -// Striped-row divider darkening. Classic darkens EVERY body-cell divider to `$color-border-cell-shaded` -// when the table has striped rows (a table-level `has-striped-rows` flag on every cell). The atomic -// public layer has no table-level striped signal — it only knows per-row `variant='shaded'` via -// context — so it derives the striping from row adjacency: the divider ABOVE a shaded row, plus a -// shaded row's own bottom divider, are darkened, so in alternating striping every internal boundary is -// darkened, matching classic's table-wide look. The last row is excluded (its bottom border is the -// transparent container-edge placeholder), mirroring classic's `:not(.body-cell-last-row)`. Reuses the -// shared token; keyed on `data-shaded`, which classic never emits (it uses `has-striped-rows`), so -// this is inert for classic. +// Striped-row divider darkening. The existing Table darkens every divider to `$color-border-cell-shaded` +// via a table-level `has-striped-rows` flag; the atomic has no table-level signal, only per-row +// `variant='shaded'` via context, so it derives striping from adjacency — the divider above a shaded row +// and a shaded row's own bottom divider — which darkens every internal boundary in alternating striping. +// The last row is excluded (its bottom border is the transparent edge placeholder). Keyed on +// `data-shaded`, which the existing Table never emits, so it stays inert there. tr:has(+ [data-shaded]) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; } @@ -93,20 +71,17 @@ tr:has(+ [data-shaded]) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; } -// Hide the first-of-pair cell's own 1px block divider inside a consecutive-selected run. The seam is -// now drawn by the ring itself — the first-of-pair KEEPS its `::after` 2px block-end edge (see the -// merge rules below), matching the 2px outer ring — so the cell's underlying grey divider must not -// show a sliver under it. Width stays 1px (transparent), so this is layout-neutral. Specificity -// (0,3,0) matches the shaded/edge rules, keeping the `.cell` group ascending (no-descending-specificity). +// Inside a consecutive-selected run, hide the first-of-pair cell's own grey divider (transparent, width +// unchanged): the seam is drawn by the first-of-pair's 2px `::after` block-end edge (merge rules below), +// so the underlying divider must not show a sliver under it. [data-selected]:has(+ [data-selected]) > .cell { border-block-end-color: transparent; } -// Drop the inline selection border + item radius that `body-cell-selected` adds to the first/last -// cell, so the row's inline geometry equals the unselected row — the first column's 2px inline-start -// selection border would otherwise shove its content 2px on select. The ring draws these edges. Inline -// extents mirror the unselected base cell: no inline-start border on the first column (visual refresh), -// and the last column keeps its 2px transparent edge placeholder. +// Drop the inline selection border + item radius that `body-cell-selected` adds to the first/last cell, +// so inline geometry equals the unselected row (the first column's 2px border would otherwise shove +// content 2px on select). The ring draws these edges; the last column keeps its 2px transparent +// placeholder. [data-selected] > .cell:first-child { border-inline-start: none; border-start-start-radius: 0; @@ -118,18 +93,16 @@ tr:has(+ [data-shaded]) > .cell { border-end-end-radius: 0; } -// Suppress the grey list divider on the UNSELECTED row directly above a selection: its 1px bottom -// divider otherwise abuts the ring's top edge as a grey line (classic suppresses the divider immediately -// above a selection). Colour-only — width stays 1px, so layout is unchanged. Keyed on the atomic -// `data-selected` hook (classic never emits it), so classic stays inert. Specificity (0,3,1) sits above -// the (0,3,0) `.cell` rules, keeping the `.cell` group ascending (no-descending-specificity). +// Suppress the grey divider on the UNSELECTED row directly above a selection (colour-only, width +// unchanged): it would otherwise abut the ring's top edge as a grey line. Keyed on `data-selected`, so +// the existing Table stays inert. tr:not([data-selected]):has(+ [data-selected]) > .cell { border-block-end-color: transparent; } -// The overlay ring. `.row` is position:relative; the `::after` is position:absolute so it is NOT a -// grid item in grid-mode rows. inset:0 traces the row's padding box — the cells' outer edge, where -// classic paints its selection border — at classic's 2px width, item radius, and selection color. +// The overlay ring. `.row` is position:relative and this `::after` is position:absolute, so it is not a +// grid item. inset:0 traces the row's padding box (the cells' outer edge) at the 2px selection width, +// item radius, and colour. [data-selected]::after { content: ''; position: absolute; @@ -145,12 +118,9 @@ tr:not([data-selected]):has(+ [data-selected]) > .cell { pointer-events: none; } -// Merge a run of selected rows into one continuous rounded outline, with the internal seams drawn at -// the full 2px ring width (matching the outer ring). The first row of a pair KEEPS its 2px `::after` -// block-end edge (that edge IS the seam) and only squares its two bottom corners; a row following a -// selected row zeroes its `::after` top edge (so the seam is a single 2px edge, never doubled) and -// squares its two top corners; a middle row keeps its 2px bottom edge, zeroes its top edge, and -// squares all four corners — leaving only the run's outer corners rounded. Ordered after the base ring +// Merge a run of selected rows into one continuous rounded outline. The first-of-pair keeps its 2px +// `::after` bottom edge (that edge is the seam) and squares its bottom corners; a following row zeroes +// its top edge (so the seam is never doubled) and squares its top corners. Ordered after the base ring // so specificity ascends. [data-selected]:has(+ [data-selected])::after { border-end-start-radius: 0; diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx index 90e58f1b3a..e51c400e75 100644 --- a/src/table-head/internal.tsx +++ b/src/table-head/internal.tsx @@ -11,9 +11,8 @@ import { TableHeadProps } from './interfaces'; import styles from './styles.css.js'; -// The bare
element, classic's `.header-cell` box model, and ref are provided by the extracted -// InternalTableHeaderCell substrate (shared with classic's th-element). This public component layers -// its own test-utils marker class, the grid-mode role/layout, the optional padding opt-out, and the -// header aria surface (`aria-sort`, labelling) on top via className/nativeAttributes. +// The element, `.header-cell` box model, and ref come from the InternalTableHeaderCell substrate +// (shared with the existing Table). This public layer adds the test-utils marker, grid-mode +// role/layout, the padding opt-out, and the header aria surface (aria-sort, labelling). export function HeaderCell(props: TableHeaderCellProps & InternalBaseComponentProps) { const { children, ariaLabel, ariaLabelledby, ariaDescribedby, ariaSort, disablePaddings, __internalRootRef } = props; const { columnLayout } = useTableContext(); @@ -31,11 +30,9 @@ export function HeaderCell(props: TableHeaderCellProps & InternalBaseComponentPr styles['header-cell'], isGrid && styles['header-cell-grid'], isVisualRefresh && headerCellStyles['is-visual-refresh'], - // Also emit this module's own is-visual-refresh marker: the substrate class above drives the - // substrate's VR rules, but the atomic first-column content-offset reset lives in THIS module - // (`.header-cell.is-visual-refresh:first-child > .header-cell-content`) and is keyed on this - // module's hashed class, so without it the reset never matches and the first header column keeps - // its 12px content inset (header text sits right of the body text). + // This module's own is-visual-refresh marker: the first-column content-offset reset + // (`.header-cell.is-visual-refresh:first-child > .header-cell-content`) is keyed on this + // module's hashed class, so the substrate's marker alone won't match it. isVisualRefresh && styles['is-visual-refresh'], disablePaddings && styles['disable-paddings'] )} diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss index 05bf3f2ae1..d29aff6e19 100644 --- a/src/table-header-cell/styles.scss +++ b/src/table-header-cell/styles.scss @@ -5,23 +5,17 @@ @use '../internal/styles/tokens' as awsui; -// The base header-cell box model (padding, divider, background, first-column placeholder) is supplied -// by the shared `.header-cell` class from the extracted substrate (src/table/table-header-cell, which -// reuses classic's proven header-cell stylesheet). This standalone module only adds the test-utils -// marker, the grid-mode min-inline-size, and the padding opt-out — it must NOT re-declare the base -// geometry, which would double the substrate's padding. Pixel reconciliation of these layers is -// verified in the visual-regression increment (Inc4c). +// The base header-cell box model (padding, divider, background) comes from the substrate's +// `.header-cell` (src/table/table-header-cell). This module must NOT re-declare that geometry — it only +// adds the test-utils marker, grid-mode sizing, and the padding opt-out. .header-cell { box-sizing: border-box; } -// Vertical column divider between adjacent header cells. Classic draws this via a composed -// `Resizer`/`Divider` element (src/table/resizer/styles.scss); the extracted substrate renders no such -// child, so the standalone header reproduces the same centered, gutter-inset rule as a presentation-only -// `::after` — no DOM node, no prop, no API change. The substrate's `.header-cell` is `position: relative`, -// so the pseudo-element anchors to the cell. Geometry matches classic's `th:not([data-rightmost]) > .divider`: -// full-height minus a top/bottom gutter, centered via `margin-block: auto`, 1px default-divider rule at the -// trailing edge. +// Vertical divider between adjacent header cells. The existing Table draws it with a composed +// Resizer/Divider element; the substrate renders no such child, so we reproduce the centered, +// gutter-inset divider as a presentation-only `::after` (no DOM node, no prop). The substrate's +// `.header-cell` is position:relative, so the pseudo-element anchors to the cell. .header-cell:not(:last-child)::after { content: ''; position: absolute; @@ -36,12 +30,9 @@ pointer-events: none; } -// Grid-mode only: lets grid columns shrink below their content size, and applies the equal-height -// treatment (mirroring the body `.cell-grid`) so every header cell fills the header-row track and -// centres its content. Without it an empty/short control header (e.g. single-select's control column, -// which has no select-all) collapses to ~1px, leaving the column divider a short stub with a stray -// streak; classic keeps that header at full row height. Auto mode uses native `display: table-cell` -// and is untouched (this class is only emitted in grid mode). +// Grid-mode only: allow columns to shrink below content size and stretch every header cell to fill the +// row track (mirroring body `.cell-grid`). Without it an empty control header collapses to ~1px, leaving +// the divider a stray stub. Auto mode uses native display:table-cell and is untouched. .header-cell-grid { min-inline-size: 0; align-self: stretch; @@ -56,12 +47,9 @@ padding-inline: 0; } -// Header content box. The extracted substrate renders header children with no wrapper, so the -// header row was 8px shorter than classic (29px vs 37px). Classic sizes its header via a -// `.header-cell-content` box carrying `padding-block: $space-scaled-xxs` (height) plus an inline -// `cell-offset($space-s)` that pushes header text to the same inline start as the body content -// (base `.header-cell` 8px + 12px = 20px == body). Reproducing both here lifts the row to classic's -// 37px and aligns header text with body text. See src/table/header-cell `.header-cell-content`. +// Header content box. The substrate renders header children with no wrapper, so we reproduce the +// existing Table's `.header-cell-content` block padding (row height) and inline offset (aligns header +// text to the body content's inline start). See src/table/header-cell `.header-cell-content`. .header-cell-content { padding-block: awsui.$space-scaled-xxs; padding-inline-start: awsui.$space-s; @@ -76,10 +64,9 @@ padding-inline: 0; } -// In visual refresh the first column hugs the table's inline-start edge: classic zeroes the header -// content's inline-start offset (`cell-offset(0px)`) so the substrate's reduced first-column inset -// ($space-xxxs on `.header-cell`) is the only inset. Mirror that so the first header cell preserves -// its v1 alignment instead of gaining the +12px body offset. +// Visual refresh: the first column hugs the table's inline-start edge. Zero the content's inline-start +// offset so the substrate's reduced first-column inset is the only one (matching the existing Table), +// instead of adding the +12px body offset. .header-cell.is-visual-refresh:first-child > .header-cell-content { padding-inline-start: 0; } diff --git a/src/table-root/__tests__/basic-table-styling-props.test.tsx b/src/table-root/__tests__/basic-table-styling-props.test.tsx index a1a785673f..db2e2c6c7e 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -20,7 +20,7 @@ import headerCellStyles from '../../../lib/components/table-header-cell/styles.c // props (for virtualization) reach the body and row roots, and that `disablePaddings` reaches the // padding opt-out on the cell content and header-cell root. // -// On this fork each TableCell self-paints by reusing classic's `.body-cell-selected` / +// On this fork each TableCell self-paints by reusing the existing Table's `.body-cell-selected` / // `.body-cell-shaded` classes (via RowVariantContext), so the per-cell paint needs no data-* hook. A // selected row additionally emits `data-selected` on the
- - No resources - - No resources to display. - - - - } - ariaLabels={resourcesAriaLabels} - /> - - - - ); -} diff --git a/pages/table-root-classic/selection.page.tsx b/pages/table-root-classic/selection.page.tsx deleted file mode 100644 index 4ed2b2c67e..0000000000 --- a/pages/table-root-classic/selection.page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React, { useMemo, useState } from 'react'; - -import Box from '~components/box'; -import Header from '~components/header'; -import SpaceBetween from '~components/space-between'; -import Table from '~components/table'; - -import { Item, makeItems, nameStatusColumns, resourcesAriaLabels } from './common'; - -// VR oracle for pages/table-root/selection (selectable + sortable). The atomic demo hand-builds a -// selection control column + a sortable Name header; classic Table provides both natively via -// selectionType='multi' and sortingColumn/onSortingChange. Same makeItems(10), same two rows -// preselected (resource-1, resource-2), same name-ascending initial sort. -const ITEM_COUNT = 10; - -export default function TableClassicSelectionPage() { - const allItems = useMemo(() => makeItems(ITEM_COUNT), []); - const [selectedItems, setSelectedItems] = useState([allItems[1], allItems[2]]); - const [sortingDescending, setSortingDescending] = useState(false); - - const items = useMemo(() => { - const sorted = [...allItems].sort((a, b) => a.name.localeCompare(b.name)); - return sortingDescending ? sorted.reverse() : sorted; - }, [allItems, sortingDescending]); - - return ( - - - Classic Table — selectable + sortable (VR oracle for table-root/selection) - - -
Resources
-
setSelectedItems(detail.selectedItems)} - sortingColumn={nameStatusColumns[0]} - sortingDescending={sortingDescending} - onSortingChange={({ detail }) => setSortingDescending(detail.isDescending ?? false)} - ariaLabels={resourcesAriaLabels} - /> - - - - ); -} diff --git a/pages/table-root-classic/simple.page.tsx b/pages/table-root-classic/simple.page.tsx deleted file mode 100644 index 599d64927b..0000000000 --- a/pages/table-root-classic/simple.page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -import Box from '~components/box'; -import Header from '~components/header'; -import SpaceBetween from '~components/space-between'; -import Table from '~components/table'; - -import { dataColumnsPlain, makeItems, resourcesAriaLabels } from './common'; - -// VR oracle for pages/table-root/simple: the same 4 columns and makeItems(8) rendered with the -// shipped classic Table. The atomic simple demo uses auto layout, which mirrors classic Table's -// default table-layout: auto. Columns carry no sorting metadata (dataColumnsPlain) because the -// atomic simple demo has no sort UI — a sortable column set would render carets the atomic lacks. -export default function TableClassicSimplePage() { - const items = makeItems(8); - return ( - - - Classic Table — simple (VR oracle for table-root/simple) - - -
Resources
-
- - - - ); -} diff --git a/pages/table-root-classic/single-selection.page.tsx b/pages/table-root-classic/single-selection.page.tsx deleted file mode 100644 index e686e7a499..0000000000 --- a/pages/table-root-classic/single-selection.page.tsx +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React, { useMemo, useState } from 'react'; - -import Box from '~components/box'; -import Header from '~components/header'; -import SpaceBetween from '~components/space-between'; -import Table from '~components/table'; - -import { Item, makeItems, nameStatusColumnsPlain, resourcesAriaLabels } from './common'; - -// VR oracle for pages/table-root/single-selection. The atomic demo hand-builds a radio control -// column; classic Table provides it natively via selectionType='single'. Same makeItems(10), same -// single preselected row (resource-1). The atomic demo has no sort UI, so the columns carry no -// sorting metadata (nameStatusColumnsPlain) to avoid a Name caret the atomic lacks. -const ITEM_COUNT = 10; - -export default function TableClassicSingleSelectionPage() { - const items = useMemo(() => makeItems(ITEM_COUNT), []); - const [selectedItems, setSelectedItems] = useState([items[1]]); - - return ( - - - Classic Table — single selection (VR oracle for table-root/single-selection) - - -
Resources
-
setSelectedItems(detail.selectedItems)} - ariaLabels={resourcesAriaLabels} - /> - - - - ); -} diff --git a/pages/table-root-classic/sorting.page.tsx b/pages/table-root-classic/sorting.page.tsx deleted file mode 100644 index ddbcedec08..0000000000 --- a/pages/table-root-classic/sorting.page.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React, { useMemo, useState } from 'react'; - -import Box from '~components/box'; -import Header from '~components/header'; -import SpaceBetween from '~components/space-between'; -import Table, { TableProps } from '~components/table'; - -import { dataColumns, Item, makeItems } from './common'; - -// VR oracle for pages/table-root/sorting. The atomic demo composes MULTI-column sort by hand -// (shift-click adds a column + priority badge). Classic Table only supports SINGLE-column sort -// natively (one sortingColumn at a time), so this oracle reproduces the single-column case — the -// initial name-ascending sort and per-column toggling. Multi-column sort has no classic equivalent -// and is called out in FINDINGS. Same makeItems(12), same 4 columns. -export default function TableClassicSortingPage() { - const items = useMemo(() => makeItems(12), []); - const [sortingColumn, setSortingColumn] = useState>(dataColumns[0]); - const [sortingDescending, setSortingDescending] = useState(false); - - const rows = useMemo(() => { - const comparator = - sortingColumn.sortingComparator ?? - ((a: Item, b: Item) => { - const field = sortingColumn.sortingField as keyof Item; - return String(a[field]).localeCompare(String(b[field])); - }); - const sorted = [...items].sort(comparator); - return sortingDescending ? sorted.reverse() : sorted; - }, [items, sortingColumn, sortingDescending]); - - return ( - - - Classic Table — sorting (VR oracle for table-root/sorting) - - Classic Table supports single-column sort natively. Click a column header to sort by it. - - - -
Resources
-
{ - setSortingColumn(detail.sortingColumn); - setSortingDescending(detail.isDescending ?? false); - }} - ariaLabels={{ tableLabel: 'Resources' }} - /> - - - - ); -} diff --git a/pages/table-root-classic/striped-rows.page.tsx b/pages/table-root-classic/striped-rows.page.tsx deleted file mode 100644 index eac23aca82..0000000000 --- a/pages/table-root-classic/striped-rows.page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -import Box from '~components/box'; -import Header from '~components/header'; -import SpaceBetween from '~components/space-between'; -import Table from '~components/table'; - -import { dataColumnsPlain, makeItems, resourcesAriaLabels } from './common'; - -// VR oracle for pages/table-root/striped-rows. The atomic demo marks alternating rows -// variant='shaded' by hand; classic Table computes row parity itself via the stripedRows prop. -// Same makeItems(12), same 4 columns. The atomic demo has no sort UI, so the columns carry no -// sorting metadata (dataColumnsPlain) to avoid rendering carets the atomic lacks. -export default function TableClassicStripedRowsPage() { - const items = makeItems(12); - return ( - - - Classic Table — striped rows (VR oracle for table-root/striped-rows) - - -
Resources
-
- - - - ); -} diff --git a/pages/table-root-classic/virtualization.page.tsx b/pages/table-root-classic/virtualization.page.tsx deleted file mode 100644 index 18feab6f00..0000000000 --- a/pages/table-root-classic/virtualization.page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React, { useMemo } from 'react'; - -import Box from '~components/box'; -import Header from '~components/header'; -import SpaceBetween from '~components/space-between'; -import Table, { TableProps } from '~components/table'; - -// VR oracle for pages/table-root/virtualization. NOTE: classic Table has NO built-in row -// virtualization. The atomic demo windows a 10,000-row dataset (renders ~14 absolutely-positioned -// rows at a time). Classic cannot window, so this oracle renders the SAME full dataset with the same -// two columns — every row is a normal flow . A faithful pixel comparison of a windowed table vs -// a full non-virtualized table is not possible; this page exists so the row/cell CHROME (header, -// dividers, cell padding, column widths) can still be compared. See FINDINGS for the non-mapping -// note and a recommendation to reduce TOTAL for the VR run if 10k rows is impractical. -const TOTAL = 10000; -const TIME_COLUMN_WIDTH = 120; - -interface LogLine { - id: string; - timestamp: string; - message: string; -} - -// Same data helper as pages/table-root/virtualization so the rows are content-identical. -const makeLine = (index: number): LogLine => ({ - id: `line-${index}`, - timestamp: new Date(1_700_000_000_000 + index * 1000).toISOString().slice(11, 19), - message: `Log message ${index} — event processed`, -}); - -const columns: ReadonlyArray> = [ - { id: 'time', header: 'Time', cell: line => line.timestamp, width: TIME_COLUMN_WIDTH }, - { id: 'message', header: 'Message', cell: line => line.message }, -]; - -export default function TableClassicVirtualizationPage() { - const items = useMemo(() => Array.from({ length: TOTAL }, (_, index) => makeLine(index)), []); - - return ( - - - Classic Table — full set, no virtualization (VR oracle for table-root/virtualization) - - -
Log lines
-
- - - - ); -} From 7fc7d1531c8f39e3618d8b7a92eab1cb224526b6 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 8 Sep 2026 16:16:35 +0000 Subject: [PATCH 29/35] test(table-atomic): regenerate test-utils/documenter snapshots + fix y->ies finder derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the 7 public atomic components regenerated three global snapshots (documenter, test-utils-wrappers, test-utils-selectors) — additive only (the new component definitions + their generated finders). Also fixes a latent bug in the shared functional-tests/test-utils.test.tsx helper: it derived the expected findAll finder name by regex-scanning the wrapper prototype for `findAll.*`, which fails when the plural is not singular+suffix. TableBody is the first component whose finder is pluralized y->ies (findAllTableBodies), so the naive match resolved to undefined and threw `wrapper[findAllName] is not a function`. Derive the name from the same build-tools/utils/pluralize helper the generator uses, so the test can never drift from the generated finder names; drop the now-unused DomElementWrapper import. Full snapshot + functional + table-root suites: 10 suites / 730 tests / 107 snapshots green. --- .../functional-tests/test-utils.test.tsx | 21 +- .../__snapshots__/documenter.test.ts.snap | 602 ++++++++++++++++++ .../test-utils-selectors.test.tsx.snap | 21 + .../test-utils-wrappers.test.tsx.snap | 560 ++++++++++++++++ 4 files changed, 1195 insertions(+), 9 deletions(-) diff --git a/src/__tests__/functional-tests/test-utils.test.tsx b/src/__tests__/functional-tests/test-utils.test.tsx index 2eeca1dd8f..1fe0dca326 100644 --- a/src/__tests__/functional-tests/test-utils.test.tsx +++ b/src/__tests__/functional-tests/test-utils.test.tsx @@ -13,11 +13,16 @@ import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/in import { Modal } from '../../../lib/components'; import Button from '../../../lib/components/button'; -import createWrapperDom, { ElementWrapper as DomElementWrapper } from '../../../lib/components/test-utils/dom'; +import createWrapperDom from '../../../lib/components/test-utils/dom'; import createWrapperSelectors from '../../../lib/components/test-utils/selectors'; import { getRequiredPropsForComponent } from '../required-props-for-components'; import { getAllComponents, requireComponent } from '../utils'; +// Authoritative pluralization used by the test-utils generator (build-tools/tasks/test-utils.js), +// so the finder-name derivation here can never drift from the generated finder names. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { pluralizeComponentName } = require('../../../build-tools/utils/pluralize'); + const globalWithFlags = globalThis as any; beforeEach(() => { @@ -82,16 +87,14 @@ function renderComponents(componentName: string, props = RENDER_COMPONENTS_DEFAU function getComponentSelectors(componentName: string) { const componentNamePascalCase = pascalCase(componentName); - const findAllRegex = new RegExp(`findAll${componentNamePascalCase}.*`); - - // The same set of selector functions are present in both dom and selectors. - // For this reason, looking into DOM is representative of both groups. - const wrapperPropsList = Object.keys(DomElementWrapper.prototype); - // Every component has the same set of selector functions. - // For this reason, casting the function names into the Alert component. + // The findAll finder uses the pluralized component name, which is not always the + // singular name plus a suffix (e.g. TableBody -> TableBodies). Derive it from the + // same pluralization map the test-utils generator uses so the two never diverge. + // Every component has the same set of selector functions, so casting to the Alert + // component's finder names is representative. const findName = `find${componentNamePascalCase}` as 'findAlert'; - const findAllName = wrapperPropsList.find(selector => findAllRegex.test(selector)) as 'findAllAlerts'; + const findAllName = `findAll${pluralizeComponentName(componentNamePascalCase)}` as 'findAllAlerts'; return { findName, findAllName }; } diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a4e25d7b8e..792e79a960 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29570,6 +29570,552 @@ multiple lines instead of being truncated with an ellipsis.", } `; +exports[`Components definition for table-body matches the snapshot: table-body 1`] = ` +{ + "dashCaseName": "table-body", + "events": [], + "functions": [], + "name": "TableBody", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "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 +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Applies inline styles to the body element. Use this to enable row positioning, for example for +virtualization or draggable rows. It is not supported to use this for general styling purposes.", + "inlineType": { + "name": "TableBodyProps.Style", + "properties": [ + { + "inlineType": { + "name": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + "type": "union", + "values": [ + ""auto"", + ""inherit"", + "string & {}", + ""-moz-initial"", + ""initial"", + ""revert"", + ""revert-layer"", + ""unset"", + ""-moz-max-content"", + ""-moz-min-content"", + ""fit-content"", + ""max-content"", + ""min-content"", + ""-webkit-fit-content"", + "NonNullable", + ], + }, + "name": "height", + "optional": true, + "type": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + }, + { + "inlineType": { + "name": "Property.Position", + "type": "union", + "values": [ + "fixed", + "absolute", + "inherit", + "-moz-initial", + "initial", + "revert", + "revert-layer", + "unset", + "-webkit-sticky", + "relative", + "static", + "sticky", + ], + }, + "name": "position", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "style", + "optional": true, + "type": "TableBodyProps.Style", + }, + ], + "regions": [ + { + "description": "The body rows.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-cell matches the snapshot: table-cell 1`] = ` +{ + "dashCaseName": "table-cell", + "events": [], + "functions": [], + "name": "TableCell", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Removes the cell's built-in padding so you can compose your own spacing, for example to match a +selection-control column. Defaults to \`false\`.", + "name": "disablePaddings", + "optional": true, + "type": "boolean", + }, + { + "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 +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The cell content.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-head matches the snapshot: table-head 1`] = ` +{ + "dashCaseName": "table-head", + "events": [], + "functions": [], + "name": "TableHead", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "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 +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The header row: a \`TableHeaderRow\` whose cells are \`TableHeaderCell\` components.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-header-cell matches the snapshot: table-header-cell 1`] = ` +{ + "dashCaseName": "table-header-cell", + "events": [], + "functions": [], + "name": "TableHeaderCell", + "properties": [ + { + "description": "Sets \`aria-describedby\`. Use the ID(s) of visible element(s) that describe the header cell.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the header cell. Use this or \`ariaLabelledby\`.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets \`aria-labelledby\`. Use the ID(s) of visible element(s) that label the header cell.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "Sets the column's sort direction on the cell's \`aria-sort\` attribute. Use it on a sortable +column and render your own sort control in \`children\`; the table does not manage sort state.", + "inlineType": { + "name": ""none" | "other" | "ascending" | "descending"", + "type": "union", + "values": [ + "none", + "other", + "ascending", + "descending", + ], + }, + "name": "ariaSort", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Removes the cell's built-in padding so you can compose your own spacing, for example to match a +selection-control column. Defaults to \`false\`.", + "name": "disablePaddings", + "optional": true, + "type": "boolean", + }, + { + "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 +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The header content, such as a column label or a sort control.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-header-row matches the snapshot: table-header-row 1`] = ` +{ + "dashCaseName": "table-header-row", + "events": [], + "functions": [], + "name": "TableHeaderRow", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "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 +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The header cells, one per column, in order.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-root matches the snapshot: table-root 1`] = ` +{ + "dashCaseName": "table-root", + "events": [], + "functions": [], + "name": "TableRoot", + "properties": [ + { + "description": "Sets the \`aria-describedby\` attribute. Use the ID of a visible element that describes the table.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the table. Use this or \`ariaLabelledby\` to label the table.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets the \`aria-labelledby\` attribute. Use the ID of a visible element that labels the table.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "The total number of rows in the full dataset, set on the table's \`aria-rowcount\`. Provide it +only when you render a subset of rows, such as with virtualization, so assistive technologies +report the whole table. Omit it when you render every row, and the count is derived from the DOM.", + "name": "ariaRowcount", + "optional": true, + "type": "number", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "defaultValue": "{ type: 'auto' }", + "description": "Determines how column widths are calculated. +* \`{ type: 'auto' }\` - Renders a standard HTML table whose columns size to their content. No + column configuration is required. +* \`{ type: 'grid'; columns }\` - Renders a CSS grid and applies each column's \`size\`, \`minWidth\`, + and \`maxWidth\`. Provide one \`columns\` entry per column, in display order; cells bind to columns + by position. Virtualization requires this layout. + * \`size\` (number | { flex: number }) - A number sets a fixed pixel width; \`{ flex }\` gives the + column a weight that shares the remaining space in proportion. Omit it for a flexible column + with the default weight of 1. + * \`minWidth\` (number) - The minimum width in pixels, for a flexible column. + * \`maxWidth\` (number) - The maximum width in pixels. + +Defaults to \`{ type: 'auto' }\`.", + "inlineType": { + "name": "TableRootProps.ColumnLayout", + "type": "union", + "values": [ + "{ type: "auto"; }", + "{ type: "grid"; columns: ReadonlyArray; }", + ], + }, + "name": "columnLayout", + "optional": true, + "type": "TableRootProps.ColumnLayout", + }, + { + "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 +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The table's content. Provide a \`TableHead\` followed by a \`TableBody\` that contains the rows.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-row matches the snapshot: table-row 1`] = ` +{ + "dashCaseName": "table-row", + "events": [], + "functions": [], + "name": "TableRow", + "properties": [ + { + "description": "Sets \`aria-describedby\`. Use the ID(s) of visible element(s) that describe the row.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the row. Use this or \`ariaLabelledby\`.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets \`aria-labelledby\`. Use the ID(s) of visible element(s) that label the row.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "Sets the row's \`aria-rowindex\` — its 1-based position in the full dataset, counting the header +row (so a data row's value is its dataset index plus 2). Set this only when virtualizing, so +assistive technologies report the row's true position while you render a subset of rows; in a +standard table the position is derived from DOM order.", + "name": "ariaRowindex", + "optional": true, + "type": "number", + }, + { + "description": "Sets \`aria-selected\` to reflect the row's selection state.", + "name": "ariaSelected", + "optional": true, + "type": "boolean", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "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 +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Applies inline styles to the row element. Use this for row positioning, for example for +virtualization or draggable rows. It is not supported to use this for general styling purposes.", + "inlineType": { + "name": "TableRowProps.Style", + "properties": [ + { + "inlineType": { + "name": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + "type": "union", + "values": [ + ""auto"", + ""inherit"", + "string & {}", + ""-moz-initial"", + ""initial"", + ""revert"", + ""revert-layer"", + ""unset"", + ""-moz-max-content"", + ""-moz-min-content"", + ""fit-content"", + ""max-content"", + ""min-content"", + ""-webkit-fit-content"", + "NonNullable", + ], + }, + "name": "height", + "optional": true, + "type": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + }, + { + "inlineType": { + "name": "Property.Position", + "type": "union", + "values": [ + "fixed", + "absolute", + "inherit", + "-moz-initial", + "initial", + "revert", + "revert-layer", + "unset", + "-webkit-sticky", + "relative", + "static", + "sticky", + ], + }, + "name": "position", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "Property.Transform", + "type": "union", + "values": [ + ""none"", + ""inherit"", + "string & {}", + ""-moz-initial"", + ""initial"", + ""revert"", + ""revert-layer"", + ""unset"", + ], + }, + "name": "transform", + "optional": true, + "type": "Property.Transform", + }, + ], + "type": "object", + }, + "name": "style", + "optional": true, + "type": "TableRowProps.Style", + }, + { + "description": "The row's visual state. This is visual only — set \`ariaSelected\` to convey selection to +assistive technologies. +* \`default\` - A standard row. +* \`selected\` - Applies the selected-row styling. Pair it with \`ariaSelected\` and a selection + control, such as a checkbox, in a leading cell. +* \`shaded\` - Applies a shaded background, to create alternating row colors. Choose which rows + are shaded, typically with \`variant={index % 2 === 1 ? 'shaded' : 'default'}\`. + +Defaults to \`'default'\`.", + "inlineType": { + "name": "TableRowProps.Variant", + "type": "union", + "values": [ + "default", + "selected", + "shaded", + ], + }, + "name": "variant", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The row's cells, one per column, in order.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`Components definition for tabs matches the snapshot: tabs 1`] = ` { "dashCaseName": "tabs", @@ -45867,6 +46413,34 @@ Returns the current value of the input.", ], "name": "StepWrapper", }, + { + "methods": [], + "name": "TableBodyWrapper", + }, + { + "methods": [], + "name": "TableCellWrapper", + }, + { + "methods": [], + "name": "TableHeadWrapper", + }, + { + "methods": [], + "name": "TableHeaderCellWrapper", + }, + { + "methods": [], + "name": "TableHeaderRowWrapper", + }, + { + "methods": [], + "name": "TableRootWrapper", + }, + { + "methods": [], + "name": "TableRowWrapper", + }, { "methods": [ { @@ -55625,6 +56199,34 @@ Supported options: ], "name": "StepWrapper", }, + { + "methods": [], + "name": "TableBodyWrapper", + }, + { + "methods": [], + "name": "TableCellWrapper", + }, + { + "methods": [], + "name": "TableHeadWrapper", + }, + { + "methods": [], + "name": "TableHeaderCellWrapper", + }, + { + "methods": [], + "name": "TableHeaderRowWrapper", + }, + { + "methods": [], + "name": "TableRootWrapper", + }, + { + "methods": [], + "name": "TableRowWrapper", + }, { "methods": [ { 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 9dcc563904..8665059177 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 @@ -679,6 +679,27 @@ exports[`test-utils selectors 1`] = ` "awsui_tools-preferences_wih1l", "awsui_wrapper_wih1l", ], + "table-body": [ + "awsui_body_1i6l7", + ], + "table-cell": [ + "awsui_cell_1reth", + ], + "table-head": [ + "awsui_head_1otu2", + ], + "table-header-cell": [ + "awsui_header-cell_uzgsh", + ], + "table-header-row": [ + "awsui_header-row_1wc8l", + ], + "table-root": [ + "awsui_root_1pkvc", + ], + "table-row": [ + "awsui_row_3yyds", + ], "tabs": [ "awsui_actions-container_14rmt", "awsui_disabled-reason-tooltip_14rmt", diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 118ac8d1b2..72816e0809 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -86,6 +86,13 @@ import SplitPanelWrapper from './split-panel'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; +import TableBodyWrapper from './table-body'; +import TableCellWrapper from './table-cell'; +import TableHeadWrapper from './table-head'; +import TableHeaderCellWrapper from './table-header-cell'; +import TableHeaderRowWrapper from './table-header-row'; +import TableRootWrapper from './table-root'; +import TableRowWrapper from './table-row'; import TabsWrapper from './tabs'; import TagEditorWrapper from './tag-editor'; import TextContentWrapper from './text-content'; @@ -182,6 +189,13 @@ export { SplitPanelWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; +export { TableBodyWrapper }; +export { TableCellWrapper }; +export { TableHeadWrapper }; +export { TableHeaderCellWrapper }; +export { TableHeaderRowWrapper }; +export { TableRootWrapper }; +export { TableRowWrapper }; export { TabsWrapper }; export { TagEditorWrapper }; export { TextContentWrapper }; @@ -2359,6 +2373,202 @@ findAllTables(selector?: string): Array; * @returns {TableWrapper | null} */ findClosestTable(): TableWrapper | null; +/** + * Returns the wrapper of the first TableBody that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableBody. + * If no matching TableBody is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableBodyWrapper | null} + */ +findTableBody(selector?: string): TableBodyWrapper | null; + +/** + * Returns an array of TableBody wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableBodies inside the current wrapper. + * If no matching TableBody is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableBodies(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableBody for the current element, + * or the element itself if it is an instance of TableBody. + * If no TableBody is found, returns \`null\`. + * + * @returns {TableBodyWrapper | null} + */ +findClosestTableBody(): TableBodyWrapper | null; +/** + * Returns the wrapper of the first TableCell that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableCell. + * If no matching TableCell is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableCellWrapper | null} + */ +findTableCell(selector?: string): TableCellWrapper | null; + +/** + * Returns an array of TableCell wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableCells inside the current wrapper. + * If no matching TableCell is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableCells(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableCell for the current element, + * or the element itself if it is an instance of TableCell. + * If no TableCell is found, returns \`null\`. + * + * @returns {TableCellWrapper | null} + */ +findClosestTableCell(): TableCellWrapper | null; +/** + * Returns the wrapper of the first TableHead that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHead. + * If no matching TableHead is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeadWrapper | null} + */ +findTableHead(selector?: string): TableHeadWrapper | null; + +/** + * Returns an array of TableHead wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeads inside the current wrapper. + * If no matching TableHead is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeads(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHead for the current element, + * or the element itself if it is an instance of TableHead. + * If no TableHead is found, returns \`null\`. + * + * @returns {TableHeadWrapper | null} + */ +findClosestTableHead(): TableHeadWrapper | null; +/** + * Returns the wrapper of the first TableHeaderCell that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHeaderCell. + * If no matching TableHeaderCell is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderCellWrapper | null} + */ +findTableHeaderCell(selector?: string): TableHeaderCellWrapper | null; + +/** + * Returns an array of TableHeaderCell wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeaderCells inside the current wrapper. + * If no matching TableHeaderCell is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeaderCells(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHeaderCell for the current element, + * or the element itself if it is an instance of TableHeaderCell. + * If no TableHeaderCell is found, returns \`null\`. + * + * @returns {TableHeaderCellWrapper | null} + */ +findClosestTableHeaderCell(): TableHeaderCellWrapper | null; +/** + * Returns the wrapper of the first TableHeaderRow that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHeaderRow. + * If no matching TableHeaderRow is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderRowWrapper | null} + */ +findTableHeaderRow(selector?: string): TableHeaderRowWrapper | null; + +/** + * Returns an array of TableHeaderRow wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeaderRows inside the current wrapper. + * If no matching TableHeaderRow is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeaderRows(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHeaderRow for the current element, + * or the element itself if it is an instance of TableHeaderRow. + * If no TableHeaderRow is found, returns \`null\`. + * + * @returns {TableHeaderRowWrapper | null} + */ +findClosestTableHeaderRow(): TableHeaderRowWrapper | null; +/** + * Returns the wrapper of the first TableRoot that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableRoot. + * If no matching TableRoot is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableRootWrapper | null} + */ +findTableRoot(selector?: string): TableRootWrapper | null; + +/** + * Returns an array of TableRoot wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableRoots inside the current wrapper. + * If no matching TableRoot is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableRoots(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableRoot for the current element, + * or the element itself if it is an instance of TableRoot. + * If no TableRoot is found, returns \`null\`. + * + * @returns {TableRootWrapper | null} + */ +findClosestTableRoot(): TableRootWrapper | null; +/** + * Returns the wrapper of the first TableRow that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableRow. + * If no matching TableRow is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableRowWrapper | null} + */ +findTableRow(selector?: string): TableRowWrapper | null; + +/** + * Returns an array of TableRow wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableRows inside the current wrapper. + * If no matching TableRow is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableRows(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableRow for the current element, + * or the element itself if it is an instance of TableRow. + * If no TableRow is found, returns \`null\`. + * + * @returns {TableRowWrapper | null} + */ +findClosestTableRow(): TableRowWrapper | null; /** * Returns the wrapper of the first Tabs that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first Tabs. @@ -3840,6 +4050,97 @@ ElementWrapper.prototype.findTable = function(selector) { ElementWrapper.prototype.findAllTables = function(selector) { return this.findAllComponents(TableWrapper, selector); }; +ElementWrapper.prototype.findTableBody = function(selector) { + let rootSelector = \`.\${TableBodyWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyWrapper && TableBodyWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyWrapper.rootSelector}, .\${TableBodyWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyWrapper); +}; + +ElementWrapper.prototype.findAllTableBodies = function(selector) { + return this.findAllComponents(TableBodyWrapper, selector); +}; +ElementWrapper.prototype.findTableCell = function(selector) { + let rootSelector = \`.\${TableCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableCellWrapper && TableCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableCellWrapper.rootSelector}, .\${TableCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableCellWrapper); +}; + +ElementWrapper.prototype.findAllTableCells = function(selector) { + return this.findAllComponents(TableCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHead = function(selector) { + let rootSelector = \`.\${TableHeadWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeadWrapper && TableHeadWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeadWrapper.rootSelector}, .\${TableHeadWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeadWrapper); +}; + +ElementWrapper.prototype.findAllTableHeads = function(selector) { + return this.findAllComponents(TableHeadWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderCell = function(selector) { + let rootSelector = \`.\${TableHeaderCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderCellWrapper && TableHeaderCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderCellWrapper.rootSelector}, .\${TableHeaderCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderCellWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderCells = function(selector) { + return this.findAllComponents(TableHeaderCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderRow = function(selector) { + let rootSelector = \`.\${TableHeaderRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderRowWrapper && TableHeaderRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderRowWrapper.rootSelector}, .\${TableHeaderRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderRowWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderRows = function(selector) { + return this.findAllComponents(TableHeaderRowWrapper, selector); +}; +ElementWrapper.prototype.findTableRoot = function(selector) { + let rootSelector = \`.\${TableRootWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRootWrapper && TableRootWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRootWrapper.rootSelector}, .\${TableRootWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRootWrapper); +}; + +ElementWrapper.prototype.findAllTableRoots = function(selector) { + return this.findAllComponents(TableRootWrapper, selector); +}; +ElementWrapper.prototype.findTableRow = function(selector) { + let rootSelector = \`.\${TableRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRowWrapper && TableRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRowWrapper.rootSelector}, .\${TableRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRowWrapper); +}; + +ElementWrapper.prototype.findAllTableRows = function(selector) { + return this.findAllComponents(TableRowWrapper, selector); +}; ElementWrapper.prototype.findTabs = function(selector) { let rootSelector = \`.\${TabsWrapper.rootSelector}\`; if("legacyRootSelector" in TabsWrapper && TabsWrapper.legacyRootSelector){ @@ -4447,6 +4748,41 @@ ElementWrapper.prototype.findClosestTable = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(TableWrapper); }; +ElementWrapper.prototype.findClosestTableBody = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableBodyWrapper); +}; +ElementWrapper.prototype.findClosestTableCell = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableCellWrapper); +}; +ElementWrapper.prototype.findClosestTableHead = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeadWrapper); +}; +ElementWrapper.prototype.findClosestTableHeaderCell = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeaderCellWrapper); +}; +ElementWrapper.prototype.findClosestTableHeaderRow = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeaderRowWrapper); +}; +ElementWrapper.prototype.findClosestTableRoot = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableRootWrapper); +}; +ElementWrapper.prototype.findClosestTableRow = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableRowWrapper); +}; ElementWrapper.prototype.findClosestTabs = function() { // casting to 'any' is needed to avoid this issue with generics // https://github.com/microsoft/TypeScript/issues/29132 @@ -4628,6 +4964,13 @@ import SplitPanelWrapper from './split-panel'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; +import TableBodyWrapper from './table-body'; +import TableCellWrapper from './table-cell'; +import TableHeadWrapper from './table-head'; +import TableHeaderCellWrapper from './table-header-cell'; +import TableHeaderRowWrapper from './table-header-row'; +import TableRootWrapper from './table-root'; +import TableRowWrapper from './table-row'; import TabsWrapper from './tabs'; import TagEditorWrapper from './tag-editor'; import TextContentWrapper from './text-content'; @@ -4724,6 +5067,13 @@ export { SplitPanelWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; +export { TableBodyWrapper }; +export { TableCellWrapper }; +export { TableHeadWrapper }; +export { TableHeaderCellWrapper }; +export { TableHeaderRowWrapper }; +export { TableRootWrapper }; +export { TableRowWrapper }; export { TabsWrapper }; export { TagEditorWrapper }; export { TextContentWrapper }; @@ -6054,6 +6404,125 @@ findTable(selector?: string): TableWrapper; * @returns {MultiElementWrapper} */ findAllTables(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableBodies with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableBodies. + * + * @param {string} [selector] CSS Selector + * @returns {TableBodyWrapper} + */ +findTableBody(selector?: string): TableBodyWrapper; + +/** + * Returns a multi-element wrapper that matches TableBodies with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableBodies. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableBodies(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableCells with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableCells. + * + * @param {string} [selector] CSS Selector + * @returns {TableCellWrapper} + */ +findTableCell(selector?: string): TableCellWrapper; + +/** + * Returns a multi-element wrapper that matches TableCells with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableCells. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableCells(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeads with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeads. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeadWrapper} + */ +findTableHead(selector?: string): TableHeadWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeads with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeads. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeads(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeaderCells with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeaderCells. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderCellWrapper} + */ +findTableHeaderCell(selector?: string): TableHeaderCellWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeaderCells with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeaderCells. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeaderCells(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeaderRows with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeaderRows. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderRowWrapper} + */ +findTableHeaderRow(selector?: string): TableHeaderRowWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeaderRows with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeaderRows. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeaderRows(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableRoots with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableRoots. + * + * @param {string} [selector] CSS Selector + * @returns {TableRootWrapper} + */ +findTableRoot(selector?: string): TableRootWrapper; + +/** + * Returns a multi-element wrapper that matches TableRoots with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableRoots. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableRoots(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableRows with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableRows. + * + * @param {string} [selector] CSS Selector + * @returns {TableRowWrapper} + */ +findTableRow(selector?: string): TableRowWrapper; + +/** + * Returns a multi-element wrapper that matches TableRows with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableRows. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableRows(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the Tabs with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches Tabs. @@ -7348,6 +7817,97 @@ ElementWrapper.prototype.findTable = function(selector) { ElementWrapper.prototype.findAllTables = function(selector) { return this.findAllComponents(TableWrapper, selector); }; +ElementWrapper.prototype.findTableBody = function(selector) { + let rootSelector = \`.\${TableBodyWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyWrapper && TableBodyWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyWrapper.rootSelector}, .\${TableBodyWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyWrapper); +}; + +ElementWrapper.prototype.findAllTableBodies = function(selector) { + return this.findAllComponents(TableBodyWrapper, selector); +}; +ElementWrapper.prototype.findTableCell = function(selector) { + let rootSelector = \`.\${TableCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableCellWrapper && TableCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableCellWrapper.rootSelector}, .\${TableCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableCellWrapper); +}; + +ElementWrapper.prototype.findAllTableCells = function(selector) { + return this.findAllComponents(TableCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHead = function(selector) { + let rootSelector = \`.\${TableHeadWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeadWrapper && TableHeadWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeadWrapper.rootSelector}, .\${TableHeadWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeadWrapper); +}; + +ElementWrapper.prototype.findAllTableHeads = function(selector) { + return this.findAllComponents(TableHeadWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderCell = function(selector) { + let rootSelector = \`.\${TableHeaderCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderCellWrapper && TableHeaderCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderCellWrapper.rootSelector}, .\${TableHeaderCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderCellWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderCells = function(selector) { + return this.findAllComponents(TableHeaderCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderRow = function(selector) { + let rootSelector = \`.\${TableHeaderRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderRowWrapper && TableHeaderRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderRowWrapper.rootSelector}, .\${TableHeaderRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderRowWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderRows = function(selector) { + return this.findAllComponents(TableHeaderRowWrapper, selector); +}; +ElementWrapper.prototype.findTableRoot = function(selector) { + let rootSelector = \`.\${TableRootWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRootWrapper && TableRootWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRootWrapper.rootSelector}, .\${TableRootWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRootWrapper); +}; + +ElementWrapper.prototype.findAllTableRoots = function(selector) { + return this.findAllComponents(TableRootWrapper, selector); +}; +ElementWrapper.prototype.findTableRow = function(selector) { + let rootSelector = \`.\${TableRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRowWrapper && TableRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRowWrapper.rootSelector}, .\${TableRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRowWrapper); +}; + +ElementWrapper.prototype.findAllTableRows = function(selector) { + return this.findAllComponents(TableRowWrapper, selector); +}; ElementWrapper.prototype.findTabs = function(selector) { let rootSelector = \`.\${TabsWrapper.rootSelector}\`; if("legacyRootSelector" in TabsWrapper && TabsWrapper.legacyRootSelector){ From 434718a1bf96d745cb18c72a2b91c70136eefdc8 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 9 Sep 2026 07:27:16 +0000 Subject: [PATCH 30/35] refactor(table-atomic): manually type Style props instead of Pick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace TableRowProps.Style / TableBodyProps.Style Pick with explicit per-property type aliases using indexed access (React.CSSProperties['position'] etc.) — reuses the canonical property types without redefining unions. Uses a type alias (not an interface) deliberately: a type-alias object literal gets an implicit index signature and stays assignable to React.CSSProperties (the style prop), whereas an interface lacks the CSS custom-property (--${string}) index signature and would need a boundary cast. No cast needed. Regenerated the documenter snapshot: table-row/table-body now record the named csstype aliases (Property.Height/Property.Position) instead of Pick's fully inlined unions — cleaner generated docs, same underlying type. Full snapshot + functional + table-root suites: 10 suites / 730 tests green. --- .../__snapshots__/documenter.test.ts.snap | 40 ++++--------------- src/table-body/interfaces.ts | 5 ++- src/table-row/interfaces.ts | 6 ++- 3 files changed, 17 insertions(+), 34 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 792e79a960..3dbcf24443 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29601,29 +29601,17 @@ virtualization or draggable rows. It is not supported to use this for general st "properties": [ { "inlineType": { - "name": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + "name": "Property.Height", "type": "union", "values": [ - ""auto"", - ""inherit"", + "string", + "number", "string & {}", - ""-moz-initial"", - ""initial"", - ""revert"", - ""revert-layer"", - ""unset"", - ""-moz-max-content"", - ""-moz-min-content"", - ""fit-content"", - ""max-content"", - ""min-content"", - ""-webkit-fit-content"", - "NonNullable", ], }, "name": "height", "optional": true, - "type": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + "type": "Property.Height", }, { "inlineType": { @@ -30008,29 +29996,17 @@ virtualization or draggable rows. It is not supported to use this for general st "properties": [ { "inlineType": { - "name": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + "name": "Property.Height", "type": "union", "values": [ - ""auto"", - ""inherit"", + "string", + "number", "string & {}", - ""-moz-initial"", - ""initial"", - ""revert"", - ""revert-layer"", - ""unset"", - ""-moz-max-content"", - ""-moz-min-content"", - ""fit-content"", - ""max-content"", - ""min-content"", - ""-webkit-fit-content"", - "NonNullable", ], }, "name": "height", "optional": true, - "type": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + "type": "Property.Height", }, { "inlineType": { diff --git a/src/table-body/interfaces.ts b/src/table-body/interfaces.ts index b5a2c84c44..b6b0effebe 100644 --- a/src/table-body/interfaces.ts +++ b/src/table-body/interfaces.ts @@ -17,5 +17,8 @@ export interface TableBodyProps extends BaseComponentProps { export namespace TableBodyProps { /** Inline styles supported on the body element, for row positioning (for example, virtualization). */ - export type Style = Pick; + export interface Style { + position?: React.CSSProperties['position']; + height?: React.CSSProperties['height']; + } } diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts index 8bc22a4f37..56af6176cf 100644 --- a/src/table-row/interfaces.ts +++ b/src/table-row/interfaces.ts @@ -45,5 +45,9 @@ export interface TableRowProps extends BaseComponentProps { export namespace TableRowProps { export type Variant = 'default' | 'selected' | 'shaded'; /** Inline styles supported on a row element, for row positioning (for example, virtualization). */ - export type Style = Pick; + export interface Style { + position?: React.CSSProperties['position']; + transform?: React.CSSProperties['transform']; + height?: React.CSSProperties['height']; + } } From a58e70a37a62f67dd7fd2acd6317eaa270bc004b Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 9 Sep 2026 10:52:49 +0000 Subject: [PATCH 31/35] refactor(table-atomic): relocate element substrates into atomic dirs (invert table/ -> table-*) Move the 5 shared element substrates (InternalTableBody/Head/Row/Cell/HeaderCell) out of src/table/ into their atomic src/table-*/ dirs, and re-point every importer so the existing Table builds ON TOP OF the atomic layer. No atomic file imports a JS/TS module from src/table/ anymore; the only remaining table/ imports are the two one-way .css.js style-module reuses (body-cell / header-cell feature stylesheets), which stay in table/ by design. This breaks the previous two-way dependency cycle (td-element -> atomic Cell -> table/ substrate) into clean one-directional layering. InternalTableRow's base .row is re-based onto the atomic table-row stylesheet; the classic row render sites stamp their own .row, so classic rows are unaffected. Also restores the two `as React.CSSProperties` boundary casts on TableRow/TableBody style application: manually-typed Style (per the manual-typing preference) drops CSSProperties' `--${string}` index signature that Pick's homomorphic mapped type preserved, so a widening cast at the DOM boundary is required. Fixes the CI build that was red on 434718a1b for the missing cast. Verified: gulp build clean; jest 1158 + documenter 104 snapshots pass (no regen); classic table/__tests__ green; classic VR 0% on all render-bearing states (one 0.0744%/472px sub-pixel AA delta on the sticky-pinned header label at fractional scroll, outside the changed surface, accepted as benign noise). --- .../internal-table-body.tsx} | 9 +++++- src/table-body/internal.tsx | 8 +++-- .../internal-table-cell.tsx} | 30 ++++++++++++++++--- src/table-cell/internal.tsx | 2 +- .../internal-table-head.tsx} | 10 ++++++- src/table-head/internal.tsx | 2 +- .../internal-table-header-cell.tsx} | 21 ++++++++++--- src/table-header-cell/internal.tsx | 2 +- src/table-row/internal-table-row.tsx | 30 +++++++++++++++++++ src/table-row/internal.tsx | 4 +-- src/table/body-cell/td-element.tsx | 2 +- src/table/header-cell/th-element.tsx | 2 +- src/table/internal.tsx | 7 +++-- src/table/table-body/interfaces.ts | 12 -------- src/table/table-cell/interfaces.ts | 26 ---------------- src/table/table-head/interfaces.ts | 13 -------- src/table/table-header-cell/interfaces.ts | 17 ----------- src/table/table-row/interfaces.ts | 14 --------- src/table/table-row/internal.tsx | 23 -------------- src/table/thead.tsx | 2 +- 20 files changed, 108 insertions(+), 128 deletions(-) rename src/{table/table-body/internal.tsx => table-body/internal-table-body.tsx} (65%) rename src/{table/table-cell/internal.tsx => table-cell/internal-table-cell.tsx} (53%) rename src/{table/table-head/internal.tsx => table-head/internal-table-head.tsx} (62%) rename src/{table/table-header-cell/internal.tsx => table-header-cell/internal-table-header-cell.tsx} (50%) create mode 100644 src/table-row/internal-table-row.tsx delete mode 100644 src/table/table-body/interfaces.ts delete mode 100644 src/table/table-cell/interfaces.ts delete mode 100644 src/table/table-head/interfaces.ts delete mode 100644 src/table/table-header-cell/interfaces.ts delete mode 100644 src/table/table-row/interfaces.ts delete mode 100644 src/table/table-row/internal.tsx diff --git a/src/table/table-body/internal.tsx b/src/table-body/internal-table-body.tsx similarity index 65% rename from src/table/table-body/internal.tsx rename to src/table-body/internal-table-body.tsx index 3d872c17a1..6688ac1221 100644 --- a/src/table/table-body/internal.tsx +++ b/src/table-body/internal-table-body.tsx @@ -2,7 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { InternalTableBodyProps } from './interfaces'; +export interface InternalTableBodyProps { + // Feature classes layered on top of the base body substrate by the composing component. + className?: string; + // Native attributes computed by the composing component and spread verbatim onto the + // element. Kept opaque here so the substrate holds no body-feature logic. + nativeAttributes?: React.HTMLAttributes; + children?: React.ReactNode; +} // The atomic body is the neutral `` substrate: it forwards the raw node // ref and spreads any classes/attributes the composing component computes. The diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index 3ee5f2b24b..528912e5d5 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -5,10 +5,10 @@ import clsx from 'clsx'; import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; -import { InternalTableBody } from '../table/table-body/internal'; import { useTableContext } from '../table-root/context'; import { RowPositionContextProvider } from '../table-row/context'; import { TableBodyProps } from './interfaces'; +import { InternalTableBody } from './internal-table-body'; import styles from './styles.css.js'; @@ -30,7 +30,11 @@ export function Body(props: TableBodyProps & InternalBaseComponentProps) { {rows.map((row, index) => ( | React.ThHTMLAttributes, + 'style' | 'className' | 'onClick' + >; + tabIndex?: number; + onClick?: () => void; + onFocus?: () => void; + onBlur?: () => void; + // Rendered inside the cell before the content wrapper (e.g. an expand toggle). + beforeContent?: React.ReactNode; + children?: React.ReactNode; +} export const InternalTableCell = React.forwardRef( ( diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index 35788580c9..0f9b07116e 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -6,10 +6,10 @@ import clsx from 'clsx'; import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; -import { InternalTableCell } from '../table/table-cell/internal'; import { useTableContext } from '../table-root/context'; import { useRowPosition, useRowVariant } from '../table-row/context'; import { TableCellProps } from './interfaces'; +import { InternalTableCell } from './internal-table-cell'; import bodyCellStyles from '../table/body-cell/styles.css.js'; import styles from './styles.css.js'; diff --git a/src/table/table-head/internal.tsx b/src/table-head/internal-table-head.tsx similarity index 62% rename from src/table/table-head/internal.tsx rename to src/table-head/internal-table-head.tsx index f808216e12..dd7df740d5 100644 --- a/src/table/table-head/internal.tsx +++ b/src/table-head/internal-table-head.tsx @@ -2,7 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { InternalTableHeadProps } from './interfaces'; +export interface InternalTableHeadProps { + // Feature classes layered on top of the base head substrate by the composing component + // (e.g. the `thead-active` marker gated on the sticky-header `hidden` flag). + className?: string; + // Native attributes computed by the composing component and spread verbatim onto the + // element. Kept opaque here so the substrate holds no head-feature logic. + nativeAttributes?: React.HTMLAttributes; + children?: React.ReactNode; +} // The atomic head is the neutral `` substrate: it forwards the raw node // ref and spreads any classes/attributes the composing component computes. The diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx index e51c400e75..d7275cc2cd 100644 --- a/src/table-head/internal.tsx +++ b/src/table-head/internal.tsx @@ -5,9 +5,9 @@ import clsx from 'clsx'; import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; -import { InternalTableHead } from '../table/table-head/internal'; import { useTableContext } from '../table-root/context'; import { TableHeadProps } from './interfaces'; +import { InternalTableHead } from './internal-table-head'; import styles from './styles.css.js'; diff --git a/src/table/table-header-cell/internal.tsx b/src/table-header-cell/internal-table-header-cell.tsx similarity index 50% rename from src/table/table-header-cell/internal.tsx rename to src/table-header-cell/internal-table-header-cell.tsx index ecc2028c41..6d3b721034 100644 --- a/src/table/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal-table-header-cell.tsx @@ -3,12 +3,25 @@ import React from 'react'; import clsx from 'clsx'; -import { InternalTableHeaderCellProps } from './interfaces'; - // The atomic header cell reuses classic's proven box model: the base `.header-cell` // padding lives in the shared header-cell stylesheet, so the extracted substrate is -// pixel-identical to the attributes (aria-row*/focus markers/data-*) computed by the composing component + // and spread verbatim onto the element. Row-level event handlers (onClick/onFocus/onContextMenu) + // ride here too, since HTMLAttributes already types them. Kept opaque here so the substrate holds + // no row-feature logic. + nativeAttributes?: React.HTMLAttributes; + children?: React.ReactNode; +} + +// The atomic row is the neutral `` substrate: it forwards the raw node ref and spreads whatever +// classes/attributes the composing component computes. Each composer stamps its own `.row` marker +// (from its own stylesheet) — the existing Table applies the classic `.row`, the atomic TableRow the +// atomic one — so the substrate owns no stylesheet and is DOM-identical to the inline `` each +// composer used to render. +export const InternalTableRow = React.forwardRef( + ({ className, nativeAttributes, children }, ref) => { + return ( + + {children} + + ); + } +); diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index aa6ae82dd9..b32610924c 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -5,10 +5,10 @@ import clsx from 'clsx'; import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; -import { InternalTableRow } from '../table/table-row/internal'; import { useTableContext } from '../table-root/context'; import { RowVariantContextProvider } from './context'; import { TableRowProps } from './interfaces'; +import { InternalTableRow } from './internal-table-row'; import styles from './styles.css.js'; @@ -54,7 +54,7 @@ export function Row(props: TableRowProps & InternalBaseComponentProps) { 'aria-describedby': ariaDescribedby, 'aria-selected': ariaSelected, 'aria-rowindex': ariaRowindex, - style: isGrid ? { gridTemplateColumns, ...style } : style, + style: (isGrid ? { gridTemplateColumns, ...style } : style) as React.CSSProperties, }} > {children} diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index 13f0119bf9..5e8d7a33d0 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -9,10 +9,10 @@ import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-too import { ExpandToggleButton } from '../../internal/components/expand-toggle-button'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; +import { InternalTableCell } from '../../table-cell/internal-table-cell'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces.js'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; -import { InternalTableCell } from '../table-cell/internal'; import { getTableCellRoleProps, TableRole } from '../table-role'; import { getStickyClassNames } from '../utils'; diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index a03ad85690..90ed90175c 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -8,10 +8,10 @@ import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; +import { InternalTableHeaderCell } from '../../table-header-cell/internal-table-header-cell'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; -import { InternalTableHeaderCell } from '../table-header-cell/internal'; import { getTableColHeaderRoleProps, TableRole } from '../table-role'; import { getStickyClassNames } from '../utils'; import { SortingStatus } from './utils'; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 17711936c5..2ff0d3b9f4 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -35,6 +35,8 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { isDevelopment } from '../internal/is-development'; import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; +import { InternalTableBody } from '../table-body/internal-table-body'; +import { InternalTableRow } from '../table-row/internal-table-row'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; import { ClearSortButton } from './clear-sort'; @@ -56,7 +58,6 @@ import { SkeletonRows } from './skeleton-rows'; import { useStickyColumns } from './sticky-columns'; import StickyHeader, { StickyHeaderRef } from './sticky-header'; import { StickyScrollbar } from './sticky-scrollbar'; -import { InternalTableBody } from './table-body/internal'; import { getTableRoleProps, getTableRowRoleProps, @@ -64,7 +65,6 @@ import { GridNavigationProvider, TableRole, } from './table-role'; -import { InternalTableRow } from './table-row/internal'; import Thead, { TheadProps } from './thead'; import ToolsHeader from './tools-header'; import { useAutoSkeletonRows } from './use-auto-skeleton-rows'; @@ -732,7 +732,7 @@ const InternalTable = React.forwardRef( return ( {selectionType ? ( diff --git a/src/table/table-body/interfaces.ts b/src/table/table-body/interfaces.ts deleted file mode 100644 index 0ad82cc8a4..0000000000 --- a/src/table/table-body/interfaces.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -export interface InternalTableBodyProps { - // Feature classes layered on top of the base body substrate by the composing component. - className?: string; - // Native attributes computed by the composing component and spread verbatim onto the - // element. Kept opaque here so the substrate holds no body-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} diff --git a/src/table/table-cell/interfaces.ts b/src/table/table-cell/interfaces.ts deleted file mode 100644 index dcc3d7fccc..0000000000 --- a/src/table/table-cell/interfaces.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -export interface InternalTableCellProps { - // The rendered element: 'td' for a data cell, 'th' for a row-header cell. - tag: 'td' | 'th'; - // Feature classes layered on top of the base cell substrate by the composing component. - className?: string; - style?: React.CSSProperties; - // When true the cell content wraps; otherwise it is truncated with an ellipsis. - wrapLines?: boolean; - // Removes the cell's built-in block/inline padding and defeats the first-column padding reduction. - disablePaddings?: boolean; - nativeAttributes?: Omit< - React.TdHTMLAttributes | React.ThHTMLAttributes, - 'style' | 'className' | 'onClick' - >; - tabIndex?: number; - onClick?: () => void; - onFocus?: () => void; - onBlur?: () => void; - // Rendered inside the cell before the content wrapper (e.g. an expand toggle). - beforeContent?: React.ReactNode; - children?: React.ReactNode; -} diff --git a/src/table/table-head/interfaces.ts b/src/table/table-head/interfaces.ts deleted file mode 100644 index 9660a4c278..0000000000 --- a/src/table/table-head/interfaces.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -export interface InternalTableHeadProps { - // Feature classes layered on top of the base head substrate by the composing component - // (e.g. the `thead-active` marker gated on the sticky-header `hidden` flag). - className?: string; - // Native attributes computed by the composing component and spread verbatim onto the - // element. Kept opaque here so the substrate holds no head-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} diff --git a/src/table/table-header-cell/interfaces.ts b/src/table/table-header-cell/interfaces.ts deleted file mode 100644 index 0cf7b0afb3..0000000000 --- a/src/table/table-header-cell/interfaces.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -export interface InternalTableHeaderCellProps { - // Feature classes layered on top of the base header-cell substrate by the composing component. - className?: string; - style?: React.CSSProperties; - // Native attributes (aria-row*/focus markers/data-*) computed by the composing component - // and spread verbatim onto the element. Row-level event handlers (onClick/onFocus/onContextMenu) - // ride here too, since HTMLAttributes already types them. Kept opaque here so the substrate holds - // no row-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} diff --git a/src/table/table-row/internal.tsx b/src/table/table-row/internal.tsx deleted file mode 100644 index e99f6a1a26..0000000000 --- a/src/table/table-row/internal.tsx +++ /dev/null @@ -1,23 +0,0 @@ -// 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 { InternalTableRowProps } from './interfaces'; - -// The atomic row is the neutral `` substrate: it owns only the base `.row` -// marker class (used in test-utils) and forwards the raw node ref. All feature -// layering (selection, striping, sticky, expandable, focus/role wiring) stays on -// the composing component, so the extracted substrate is DOM-identical to the -// inline `` the table body used to render. -import styles from '../styles.css.js'; - -export const InternalTableRow = React.forwardRef( - ({ className, nativeAttributes, children }, ref) => { - return ( - - {children} - - ); - } -); diff --git a/src/table/thead.tsx b/src/table/thead.tsx index fa7e641ac8..d02ebe7a03 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx'; import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; import { fireNonCancelableEvent } from '../internal/events'; +import { InternalTableHead } from '../table-head/internal-table-head'; import { NonCancelableEventHandler } from '../types/events'; import { getGroupColumnIds, getGroupSplit } from './column-groups/split-utils'; import { ColumnGroupsLayout } from './column-groups/utils'; @@ -16,7 +17,6 @@ import { InternalSelectionType } from './internal-interfaces'; import { focusMarkers, ItemSelectionProps } from './selection'; import { TableHeaderSelectionCell } from './selection/selection-cell'; import { StickyColumnsModel } from './sticky-columns'; -import { InternalTableHead } from './table-head/internal'; import { getTableHeaderRowRoleProps, TableRole } from './table-role'; import { DEFAULT_COLUMN_WIDTH, useColumnWidths } from './use-column-widths'; import { getColumnKey } from './utils'; From 7ad6b803406e58d7f37b46e416e05d4c7b247c21 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 9 Sep 2026 11:19:51 +0000 Subject: [PATCH 32/35] refactor(table-atomic): fold substrates into internal.tsx; drop children inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) Fold each relocated InternalTable substrate back into its component's internal.tsx (one file exports both the public wrapper and the substrate); delete the 5 internal-table-.tsx files and repoint the classic consumers. (2) Remove the TableBody React.Children inspection and the RowPositionContext it fed. That context existed only so cells could emit body-cell-first-row/last-row in auto mode. The edge treatment is reproduced directly in CSS (tr:first-child/:last-child > .cell:not(.cell-grid), a transparent placeholder border) — auto-mode only, keyed on atomic classes the existing Table never emits, so classic-inert. TableBody now renders {children} directly; RowPositionContext / useRowPosition / the provider are deleted. Verified: gulp build clean; jest 1262 tests / 107 snapshots green (no regen); all 7 atomic auto pages 0.0000% EXACT vs pre-change baseline (edge rows restored); classic VR 16/16 0.0000% EXACT (no new non-zero state). --- src/table-body/internal-table-body.tsx | 27 ------ src/table-body/internal.tsx | 41 +++++---- src/table-cell/internal-table-cell.tsx | 79 ----------------- src/table-cell/internal.tsx | 84 +++++++++++++++++-- src/table-cell/styles.scss | 16 ++++ src/table-head/internal-table-head.tsx | 29 ------- src/table-head/internal.tsx | 27 +++++- .../internal-table-header-cell.tsx | 40 --------- src/table-header-cell/internal.tsx | 35 +++++++- src/table-row/context.ts | 17 ---- src/table-row/internal-table-row.tsx | 30 ------- src/table-row/internal.tsx | 28 ++++++- src/table/body-cell/td-element.tsx | 2 +- src/table/header-cell/th-element.tsx | 2 +- src/table/internal.tsx | 4 +- src/table/thead.tsx | 2 +- 16 files changed, 208 insertions(+), 255 deletions(-) delete mode 100644 src/table-body/internal-table-body.tsx delete mode 100644 src/table-cell/internal-table-cell.tsx delete mode 100644 src/table-head/internal-table-head.tsx delete mode 100644 src/table-header-cell/internal-table-header-cell.tsx delete mode 100644 src/table-row/internal-table-row.tsx diff --git a/src/table-body/internal-table-body.tsx b/src/table-body/internal-table-body.tsx deleted file mode 100644 index 6688ac1221..0000000000 --- a/src/table-body/internal-table-body.tsx +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -export interface InternalTableBodyProps { - // Feature classes layered on top of the base body substrate by the composing component. - className?: string; - // Native attributes computed by the composing component and spread verbatim onto the - // element. Kept opaque here so the substrate holds no body-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} - -// The atomic body is the neutral `` substrate: it forwards the raw node -// ref and spreads any classes/attributes the composing component computes. The -// classic table body carries no base class or wiring on `` itself (rows -// and cells own all feature layering), so the extracted substrate is -// DOM-identical to the inline `` the table used to render. -export const InternalTableBody = React.forwardRef( - ({ className, nativeAttributes, children }, ref) => { - return ( - - {children} - - ); - } -); diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index 528912e5d5..c409f8c391 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -6,26 +6,42 @@ import clsx from 'clsx'; import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useTableContext } from '../table-root/context'; -import { RowPositionContextProvider } from '../table-row/context'; import { TableBodyProps } from './interfaces'; -import { InternalTableBody } from './internal-table-body'; import styles from './styles.css.js'; +export interface InternalTableBodyProps { + // Feature classes layered on top of the base body substrate by the composing component. + className?: string; + // Native attributes computed by the composing component and spread verbatim onto the + // element. Kept opaque here so the substrate holds no body-feature logic. + nativeAttributes?: React.HTMLAttributes; + children?: React.ReactNode; +} + +// The atomic body is the neutral `` substrate: it forwards the raw node +// ref and spreads any classes/attributes the composing component computes. The +// classic table body carries no base class or wiring on `` itself (rows +// and cells own all feature layering), so the extracted substrate is +// DOM-identical to the inline `` the table used to render. +export const InternalTableBody = React.forwardRef( + ({ className, nativeAttributes, children }, ref) => { + return ( + + {children} + + ); + } +); + // The element + ref come from the InternalTableBody substrate (shared with the existing Table). // This public layer adds the body class, grid-mode role, and the positioning `style` (virtualization); // the substrate takes no `style`, so it goes through the native-attribute channel it already spreads. -// -// TableBody is the only part that sees all rows, so it also publishes each row's first/last position -// via RowPositionContext (provider renders no DOM), letting cells apply the existing Table's -// `body-cell-first-row` / `body-cell-last-row` edge classes. export function Body(props: TableBodyProps & InternalBaseComponentProps) { const { children, style, __internalRootRef } = props; const { columnLayout } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(props); - const rows = React.Children.toArray(children); - const lastIndex = rows.length - 1; return ( - {rows.map((row, index) => ( - - {row} - - ))} + {children} ); } diff --git a/src/table-cell/internal-table-cell.tsx b/src/table-cell/internal-table-cell.tsx deleted file mode 100644 index a964a7a7d6..0000000000 --- a/src/table-cell/internal-table-cell.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; -import clsx from 'clsx'; - -// The atomic cell reuses classic's proven box model: the base `.body-cell` -// padding and the `.body-cell-content` truncation wrapper live in the shared -// body-cell stylesheet, so the extracted substrate is pixel-identical to the -// element TableTdElement used to render inline. This one-way stylesheet reuse of -// the existing Table's body-cell module is the accepted style edge. -import styles from '../table/body-cell/styles.css.js'; - -export interface InternalTableCellProps { - // The rendered element: 'td' for a data cell, 'th' for a row-header cell. - tag: 'td' | 'th'; - // Feature classes layered on top of the base cell substrate by the composing component. - className?: string; - style?: React.CSSProperties; - // When true the cell content wraps; otherwise it is truncated with an ellipsis. - wrapLines?: boolean; - // Removes the cell's built-in block/inline padding and defeats the first-column padding reduction. - disablePaddings?: boolean; - nativeAttributes?: Omit< - React.TdHTMLAttributes | React.ThHTMLAttributes, - 'style' | 'className' | 'onClick' - >; - tabIndex?: number; - onClick?: () => void; - onFocus?: () => void; - onBlur?: () => void; - // Rendered inside the cell before the content wrapper (e.g. an expand toggle). - beforeContent?: React.ReactNode; - children?: React.ReactNode; -} - -export const InternalTableCell = React.forwardRef( - ( - { - tag, - className, - style, - wrapLines, - disablePaddings, - nativeAttributes, - tabIndex, - onClick, - onFocus, - onBlur, - beforeContent, - children, - }, - ref - ) => { - const Element = tag; - return ( - - {beforeContent} -
- {children} -
-
- ); - } -); diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index 0f9b07116e..f6119cfb40 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -7,13 +7,85 @@ import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; -import { useRowPosition, useRowVariant } from '../table-row/context'; +import { useRowVariant } from '../table-row/context'; import { TableCellProps } from './interfaces'; -import { InternalTableCell } from './internal-table-cell'; +// The atomic cell reuses classic's proven box model: the base `.body-cell` +// padding and the `.body-cell-content` truncation wrapper live in the shared +// body-cell stylesheet, so the extracted substrate is pixel-identical to the +// element TableTdElement used to render inline. This one-way stylesheet reuse of +// the existing Table's body-cell module is the accepted style edge. import bodyCellStyles from '../table/body-cell/styles.css.js'; import styles from './styles.css.js'; +export interface InternalTableCellProps { + // The rendered element: 'td' for a data cell, 'th' for a row-header cell. + tag: 'td' | 'th'; + // Feature classes layered on top of the base cell substrate by the composing component. + className?: string; + style?: React.CSSProperties; + // When true the cell content wraps; otherwise it is truncated with an ellipsis. + wrapLines?: boolean; + // Removes the cell's built-in block/inline padding and defeats the first-column padding reduction. + disablePaddings?: boolean; + nativeAttributes?: Omit< + React.TdHTMLAttributes | React.ThHTMLAttributes, + 'style' | 'className' | 'onClick' + >; + tabIndex?: number; + onClick?: () => void; + onFocus?: () => void; + onBlur?: () => void; + // Rendered inside the cell before the content wrapper (e.g. an expand toggle). + beforeContent?: React.ReactNode; + children?: React.ReactNode; +} + +export const InternalTableCell = React.forwardRef( + ( + { + tag, + className, + style, + wrapLines, + disablePaddings, + nativeAttributes, + tabIndex, + onClick, + onFocus, + onBlur, + beforeContent, + children, + }, + ref + ) => { + const Element = tag; + return ( + + {beforeContent} +
+ {children} +
+
+ ); + } +); + // The
attributes computed by the composing component and spread verbatim onto the - // element. Kept opaque here so the substrate holds no head-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} - -// The atomic head is the neutral `` substrate: it forwards the raw node -// ref and spreads any classes/attributes the composing component computes. The -// classic thead's only class is the `thead-active` marker (gated on the -// sticky-header `hidden` flag), which the composing component keeps computing and -// passes through here, so the extracted substrate is DOM-identical to the inline -// `` the header used to render. -export const InternalTableHead = React.forwardRef( - ({ className, nativeAttributes, children }, ref) => { - return ( - - {children} - - ); - } -); diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx index d7275cc2cd..2cf0cdd020 100644 --- a/src/table-head/internal.tsx +++ b/src/table-head/internal.tsx @@ -7,10 +7,35 @@ import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useTableContext } from '../table-root/context'; import { TableHeadProps } from './interfaces'; -import { InternalTableHead } from './internal-table-head'; import styles from './styles.css.js'; +export interface InternalTableHeadProps { + // Feature classes layered on top of the base head substrate by the composing component + // (e.g. the `thead-active` marker gated on the sticky-header `hidden` flag). + className?: string; + // Native attributes computed by the composing component and spread verbatim onto the + // element. Kept opaque here so the substrate holds no head-feature logic. + nativeAttributes?: React.HTMLAttributes; + children?: React.ReactNode; +} + +// The atomic head is the neutral `` substrate: it forwards the raw node +// ref and spreads any classes/attributes the composing component computes. The +// classic thead's only class is the `thead-active` marker (gated on the +// sticky-header `hidden` flag), which the composing component keeps computing and +// passes through here, so the extracted substrate is DOM-identical to the inline +// `` the header used to render. +export const InternalTableHead = React.forwardRef( + ({ className, nativeAttributes, children }, ref) => { + return ( + + {children} + + ); + } +); + // The element + ref come from the InternalTableHead substrate (shared with the existing // Table). This public layer adds the head class and grid-mode role/layout via className/nativeAttributes. export function Head(props: TableHeadProps & InternalBaseComponentProps) { diff --git a/src/table-header-cell/internal-table-header-cell.tsx b/src/table-header-cell/internal-table-header-cell.tsx deleted file mode 100644 index 6d3b721034..0000000000 --- a/src/table-header-cell/internal-table-header-cell.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; -import clsx from 'clsx'; - -// The atomic header cell reuses classic's proven box model: the base `.header-cell` -// padding lives in the shared header-cell stylesheet, so the extracted substrate is -// pixel-identical to the - ); - } -); diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx index 2b96284db0..241ac725b6 100644 --- a/src/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal.tsx @@ -8,11 +8,44 @@ import { InternalBaseComponentProps } from '../internal/hooks/use-base-component import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; import { TableHeaderCellProps } from './interfaces'; -import { InternalTableHeaderCell } from './internal-table-header-cell'; +// The atomic header cell reuses classic's proven box model: the base `.header-cell` +// padding lives in the shared header-cell stylesheet, so the extracted substrate is +// pixel-identical to the + ); + } +); + // The attributes (aria-row*/focus markers/data-*) computed by the composing component - // and spread verbatim onto the element. Row-level event handlers (onClick/onFocus/onContextMenu) - // ride here too, since HTMLAttributes already types them. Kept opaque here so the substrate holds - // no row-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} - -// The atomic row is the neutral `` substrate: it forwards the raw node ref and spreads whatever -// classes/attributes the composing component computes. Each composer stamps its own `.row` marker -// (from its own stylesheet) — the existing Table applies the classic `.row`, the atomic TableRow the -// atomic one — so the substrate owns no stylesheet and is DOM-identical to the inline `` each -// composer used to render. -export const InternalTableRow = React.forwardRef( - ({ className, nativeAttributes, children }, ref) => { - return ( - - {children} - - ); - } -); diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index b32610924c..3063c4651f 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -8,10 +8,36 @@ import { InternalBaseComponentProps } from '../internal/hooks/use-base-component import { useTableContext } from '../table-root/context'; import { RowVariantContextProvider } from './context'; import { TableRowProps } from './interfaces'; -import { InternalTableRow } from './internal-table-row'; import styles from './styles.css.js'; +export interface InternalTableRowProps { + // Feature classes (including the composer's own `.row` marker) layered on top of the base row + // substrate by the composing component. + className?: string; + // Native attributes (aria-row*/focus markers/data-*) computed by the composing component + // and spread verbatim onto the element. Row-level event handlers (onClick/onFocus/onContextMenu) + // ride here too, since HTMLAttributes already types them. Kept opaque here so the substrate holds + // no row-feature logic. + nativeAttributes?: React.HTMLAttributes; + children?: React.ReactNode; +} + +// The atomic row is the neutral `` substrate: it forwards the raw node ref and spreads whatever +// classes/attributes the composing component computes. Each composer stamps its own `.row` marker +// (from its own stylesheet) — the existing Table applies the classic `.row`, the atomic TableRow the +// atomic one — so the substrate owns no stylesheet and is DOM-identical to the inline `` each +// composer used to render. +export const InternalTableRow = React.forwardRef( + ({ className, nativeAttributes, children }, ref) => { + return ( + + {children} + + ); + } +); + // The element, `.row` marker, and ref come from the InternalTableRow substrate (shared with the // existing Table). This public layer adds the grid-mode role/layout and aria surface, and publishes the // row's `variant` via RowVariantContext so each cell self-paints its selection/shading class. diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index 5e8d7a33d0..50aaac296b 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -9,7 +9,7 @@ import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-too import { ExpandToggleButton } from '../../internal/components/expand-toggle-button'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; -import { InternalTableCell } from '../../table-cell/internal-table-cell'; +import { InternalTableCell } from '../../table-cell/internal'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces.js'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 90ed90175c..21828ad3fc 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -8,7 +8,7 @@ import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; -import { InternalTableHeaderCell } from '../../table-header-cell/internal-table-header-cell'; +import { InternalTableHeaderCell } from '../../table-header-cell/internal'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 2ff0d3b9f4..4979df32dc 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -35,8 +35,8 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { isDevelopment } from '../internal/is-development'; import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; -import { InternalTableBody } from '../table-body/internal-table-body'; -import { InternalTableRow } from '../table-row/internal-table-row'; +import { InternalTableBody } from '../table-body/internal'; +import { InternalTableRow } from '../table-row/internal'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; import { ClearSortButton } from './clear-sort'; diff --git a/src/table/thead.tsx b/src/table/thead.tsx index d02ebe7a03..c6d39e04bd 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx'; import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; import { fireNonCancelableEvent } from '../internal/events'; -import { InternalTableHead } from '../table-head/internal-table-head'; +import { InternalTableHead } from '../table-head/internal'; import { NonCancelableEventHandler } from '../types/events'; import { getGroupColumnIds, getGroupSplit } from './column-groups/split-utils'; import { ColumnGroupsLayout } from './column-groups/utils'; From 77edc58c5d178f24428659fe4d92f58ca5a75adc Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 9 Sep 2026 11:59:34 +0000 Subject: [PATCH 33/35] refactor(table-atomic): one internal component per file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse each atomic internal.tsx from two exports (substrate + wrapper) to one: - Body/Head/Row: the InternalTable{Body,Head,Row} substrates were near-empty element wrappers sharing nothing, so they're removed. The atomic component renders its // directly, and classic inlines the bare element at its call sites (byte-identical to what the substrate produced). - Cell/HeaderCell: merge substrate + wrapper into a single InternalTableCell / InternalTableHeaderCell that serves BOTH the public index.tsx and classic's td-element/th-element. It keeps the low-level composition API (tag/beforeContent/wrapLines/handlers/etc.) and reads the atomic contexts, but all atomic-only output (.cell/.header-cell marker, grid role, selection/shading, is-visual-refresh, header content wrapper) is gated behind a new internal `atomic` flag that only index.tsx sets. With atomic off (classic's path) the component collapses to `body-cell + className`, so classic renders identically — no leaked marker, no duplicate is-visual-refresh (classic adds its own). Verified: gulp build clean; jest 1158 + documenter/test-utils 693/107 green (0 regen); classic VR 15/16 EXACT (the 1 non-zero is the known held-scroll pinned-header AA non-determinism, self-diff larger); atomic pages 7/7 EXACT. --- src/table-body/internal.tsx | 43 +++----------- src/table-cell/index.tsx | 19 +++++- src/table-cell/internal.tsx | 83 ++++++++++++--------------- src/table-head/internal.tsx | 38 +++--------- src/table-header-cell/index.tsx | 26 ++++++++- src/table-header-cell/internal.tsx | 92 +++++++++++++----------------- src/table-row/internal.tsx | 63 ++++++-------------- src/table/internal.tsx | 43 +++++++------- src/table/thead.tsx | 9 ++- 9 files changed, 173 insertions(+), 243 deletions(-) diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index c409f8c391..52eef83894 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -10,49 +10,24 @@ import { TableBodyProps } from './interfaces'; import styles from './styles.css.js'; -export interface InternalTableBodyProps { - // Feature classes layered on top of the base body substrate by the composing component. - className?: string; - // Native attributes computed by the composing component and spread verbatim onto the - // element. Kept opaque here so the substrate holds no body-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} - -// The atomic body is the neutral `` substrate: it forwards the raw node -// ref and spreads any classes/attributes the composing component computes. The -// classic table body carries no base class or wiring on `` itself (rows -// and cells own all feature layering), so the extracted substrate is -// DOM-identical to the inline `` the table used to render. -export const InternalTableBody = React.forwardRef( - ({ className, nativeAttributes, children }, ref) => { - return ( - - {children} - - ); - } -); - -// The element + ref come from the InternalTableBody substrate (shared with the existing Table). -// This public layer adds the body class, grid-mode role, and the positioning `style` (virtualization); -// the substrate takes no `style`, so it goes through the native-attribute channel it already spreads. +// The atomic body renders the `` directly: it forwards the raw node ref, adds the body class and +// grid-mode role, and applies the positioning `style` (virtualization). The classic table body carries +// no base class or wiring on `` itself (rows and cells own all feature layering), so the classic +// call site inlines its own bare ``. export function Body(props: TableBodyProps & InternalBaseComponentProps) { const { children, style, __internalRootRef } = props; const { columnLayout } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(props); return ( - {children} - + ); } diff --git a/src/table-cell/index.tsx b/src/table-cell/index.tsx index e0d006fd39..269bd53301 100644 --- a/src/table-cell/index.tsx +++ b/src/table-cell/index.tsx @@ -3,16 +3,31 @@ 'use client'; import React from 'react'; +import { getBaseProps } from '../internal/base-component'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { TableCellProps } from './interfaces'; -import { Cell } from './internal'; +import { InternalTableCell } from './internal'; export type { TableCellProps }; function TableCell(props: TableCellProps) { const baseComponentProps = useBaseComponent('TableCell'); - return ; + const mergedProps = { ...props, ...baseComponentProps }; + const { children, disablePaddings, __internalRootRef } = mergedProps; + const { className, ...restBaseProps } = getBaseProps(mergedProps); + return ( + + {children} + + ); } applyDisplayName(TableCell, 'TableCell'); diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index f6119cfb40..3975298f5d 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -3,25 +3,21 @@ import React from 'react'; import clsx from 'clsx'; -import { getBaseProps } from '../internal/base-component'; -import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; import { useRowVariant } from '../table-row/context'; -import { TableCellProps } from './interfaces'; -// The atomic cell reuses classic's proven box model: the base `.body-cell` -// padding and the `.body-cell-content` truncation wrapper live in the shared -// body-cell stylesheet, so the extracted substrate is pixel-identical to the -// element TableTdElement used to render inline. This one-way stylesheet reuse of -// the existing Table's body-cell module is the accepted style edge. +// The cell reuses classic's proven box model: the base `.body-cell` padding and the +// `.body-cell-content` truncation wrapper live in the shared body-cell stylesheet, so this component is +// pixel-identical to the element TableTdElement composes. This one-way stylesheet reuse of the existing +// Table's body-cell module is the accepted style edge. import bodyCellStyles from '../table/body-cell/styles.css.js'; import styles from './styles.css.js'; export interface InternalTableCellProps { // The rendered element: 'td' for a data cell, 'th' for a row-header cell. tag: 'td' | 'th'; - // Feature classes layered on top of the base cell substrate by the composing component. + // Feature classes layered on top of the base `.body-cell` by the composing component. className?: string; style?: React.CSSProperties; // When true the cell content wraps; otherwise it is truncated with an ellipsis. @@ -39,8 +35,19 @@ export interface InternalTableCellProps { // Rendered inside the cell before the content wrapper (e.g. an expand toggle). beforeContent?: React.ReactNode; children?: React.ReactNode; + // Set by the public `TableCell` (index.tsx). When true this cell reads the atomic row/table contexts + // to self-paint the test-utils marker, grid-mode role/layout, selection ring, and shading. Unset for + // the existing Table, which composes the bare `.body-cell` substrate and layers its own features via + // `className` — so with `atomic` off this component is byte-identical to the element the Table used + // to render inline. + atomic?: boolean; } +// One component serves both the public `TableCell` (index.tsx, `atomic`) and the existing Table's +// TableTdElement (which composes it as the bare `.body-cell` substrate). The atomic-only classes and +// grid role are gated on `atomic`, and the atomic selection/shading/edge CSS is keyed on the `.cell` +// marker + the row's `data-selected` / `data-shaded` hooks — none of which the existing Table emits — +// so the Table path is unaffected. export const InternalTableCell = React.forwardRef( ( { @@ -56,19 +63,37 @@ export const InternalTableCell = React.forwardRef { + const { columnLayout } = useTableContext(); + const variant = useRowVariant(); + const isVisualRefresh = useVisualRefresh(); + const isGrid = columnLayout.type === 'grid'; const Element = tag; + // The atomic grid role rides in the native attributes so it never clobbers the Table's own role. + const mergedNativeAttributes = atomic && isGrid ? { ...nativeAttributes, role: 'cell' as const } : nativeAttributes; return ( {beforeContent} @@ -85,39 +110,3 @@ export const InternalTableCell = React.forwardRef element, the `.body-cell` box model (base padding + `.body-cell-content` wrapper), -// `disablePaddings`, and ref all come from the InternalTableCell substrate (the single shared piece -// with the existing Table). This public layer adds the test-utils marker, grid-mode role/layout, and -// selection/shading — painted by reusing the existing Table's `.body-cell-selected` / -// `.body-cell-shaded` classes off the row's variant via context, so no selection stylesheet is -// duplicated. Sticky columns and full visual-refresh gating are deferred. -export function Cell(props: TableCellProps & InternalBaseComponentProps) { - const { children, disablePaddings, __internalRootRef } = props; - const { columnLayout } = useTableContext(); - const variant = useRowVariant(); - const isGrid = columnLayout.type === 'grid'; - const isVisualRefresh = useVisualRefresh(); - const { className, ...restBaseProps } = getBaseProps(props); - return ( - - {children} - - ); -} diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx index 2cf0cdd020..56fdc3d6b5 100644 --- a/src/table-head/internal.tsx +++ b/src/table-head/internal.tsx @@ -10,46 +10,22 @@ import { TableHeadProps } from './interfaces'; import styles from './styles.css.js'; -export interface InternalTableHeadProps { - // Feature classes layered on top of the base head substrate by the composing component - // (e.g. the `thead-active` marker gated on the sticky-header `hidden` flag). - className?: string; - // Native attributes computed by the composing component and spread verbatim onto the - // element. Kept opaque here so the substrate holds no head-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} - -// The atomic head is the neutral `` substrate: it forwards the raw node -// ref and spreads any classes/attributes the composing component computes. The -// classic thead's only class is the `thead-active` marker (gated on the -// sticky-header `hidden` flag), which the composing component keeps computing and -// passes through here, so the extracted substrate is DOM-identical to the inline -// `` the header used to render. -export const InternalTableHead = React.forwardRef( - ({ className, nativeAttributes, children }, ref) => { - return ( - - {children} - - ); - } -); - -// The element + ref come from the InternalTableHead substrate (shared with the existing -// Table). This public layer adds the head class and grid-mode role/layout via className/nativeAttributes. +// The atomic head renders the `` directly: it forwards the raw node ref, adds the head class and +// grid-mode role/layout. The classic thead's only class is the `thead-active` marker (gated on the +// sticky-header `hidden` flag), which the classic call site inlines onto its own bare ``. export function Head(props: TableHeadProps & InternalBaseComponentProps) { const { children, __internalRootRef } = props; const { columnLayout } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(props); return ( - {children} - + ); } diff --git a/src/table-header-cell/index.tsx b/src/table-header-cell/index.tsx index 577ea9097b..655c500d15 100644 --- a/src/table-header-cell/index.tsx +++ b/src/table-header-cell/index.tsx @@ -3,16 +3,38 @@ 'use client'; import React from 'react'; +import { getBaseProps } from '../internal/base-component'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { TableHeaderCellProps } from './interfaces'; -import { HeaderCell } from './internal'; +import { InternalTableHeaderCell } from './internal'; export type { TableHeaderCellProps }; function TableHeaderCell(props: TableHeaderCellProps) { const baseComponentProps = useBaseComponent('TableHeaderCell'); - return ; + const mergedProps = { ...props, ...baseComponentProps }; + const { children, ariaLabel, ariaLabelledby, ariaDescribedby, ariaSort, disablePaddings, __internalRootRef } = + mergedProps; + const { className, ...restBaseProps } = getBaseProps(mergedProps); + return ( + + {children} + + ); } applyDisplayName(TableHeaderCell, 'TableHeaderCell'); diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx index 241ac725b6..01e8d01740 100644 --- a/src/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal.tsx @@ -3,83 +3,69 @@ import React from 'react'; import clsx from 'clsx'; -import { getBaseProps } from '../internal/base-component'; -import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; -import { TableHeaderCellProps } from './interfaces'; -// The atomic header cell reuses classic's proven box model: the base `.header-cell` -// padding lives in the shared header-cell stylesheet, so the extracted substrate is -// pixel-identical to the ); } ); - -// The attributes (aria-row*/focus markers/data-*) computed by the composing component - // and spread verbatim onto the element. Row-level event handlers (onClick/onFocus/onContextMenu) - // ride here too, since HTMLAttributes already types them. Kept opaque here so the substrate holds - // no row-feature logic. - nativeAttributes?: React.HTMLAttributes; - children?: React.ReactNode; -} - -// The atomic row is the neutral `` substrate: it forwards the raw node ref and spreads whatever -// classes/attributes the composing component computes. Each composer stamps its own `.row` marker -// (from its own stylesheet) — the existing Table applies the classic `.row`, the atomic TableRow the -// atomic one — so the substrate owns no stylesheet and is DOM-identical to the inline `` each -// composer used to render. -export const InternalTableRow = React.forwardRef( - ({ className, nativeAttributes, children }, ref) => { - return ( - - {children} - - ); - } -); - -// The element, `.row` marker, and ref come from the InternalTableRow substrate (shared with the -// existing Table). This public layer adds the grid-mode role/layout and aria surface, and publishes the -// row's `variant` via RowVariantContext so each cell self-paints its selection/shading class. +// The atomic row renders the `` directly: it forwards the raw node ref, adds the `.row` marker and +// grid-mode role/layout and aria surface, and publishes the row's `variant` via RowVariantContext so +// each cell self-paints its selection/shading class. The classic call site inlines its own bare `` +// with the classic `.row` class and its own native attributes/handlers. // // A selected row also emits `data-selected` (and a shaded row `data-shaded`) on the : the // consecutive-selected outline merge and striped-divider darkening need sibling adjacency, which a cell @@ -62,28 +36,25 @@ export function Row(props: TableRowProps & InternalBaseComponentProps) { const { columnLayout, gridTemplateColumns } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(props); - // Spread (not literal keys) so these adjacency hooks are exempt from excess-property checking against - // the substrate's React.HTMLAttributes native-attr type. + // Spread (not literal keys) so these adjacency hooks are exempt from excess-property checking. const selectedDataAttribute = variant === 'selected' ? { 'data-selected': 'true' } : undefined; const shadedDataAttribute = variant === 'shaded' ? { 'data-shaded': 'true' } : undefined; return ( - {children} - + ); } diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 4979df32dc..870b4d8bb2 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -35,8 +35,6 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { isDevelopment } from '../internal/is-development'; import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; -import { InternalTableBody } from '../table-body/internal'; -import { InternalTableRow } from '../table-row/internal'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; import { ClearSortButton } from './clear-sort'; @@ -664,7 +662,7 @@ const InternalTable = React.forwardRef( onFocusedComponentChange={focusId => stickyHeaderRef.current?.setFocus(focusId)} {...theadProps} /> - + {skeleton && allItems.length === 0 && loading ? ( { - // When an element inside table row receives focus we want to adjust the scroll. - // However, that behavior is unwanted when the focus is received as result of a click - // as it causes the click to never reach the target element. - if (!currentTarget.contains(getMouseDownTarget())) { - stickyHeaderRef.current?.scrollToRow(currentTarget); - } - }, - onClick: onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item), - onContextMenu: - onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item), + {...focusMarkers.item} + {...rowRoleProps} + onFocus={({ currentTarget }) => { + // When an element inside table row receives focus we want to adjust the scroll. + // However, that behavior is unwanted when the focus is received as result of a click + // as it causes the click to never reach the target element. + if (!currentTarget.contains(getMouseDownTarget())) { + stickyHeaderRef.current?.scrollToRow(currentTarget); + } }} + onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)} + onContextMenu={ + onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item) + } > {selection.getItemSelectionProps && ( ); })} - + ); } const loaderSelectionProps = @@ -845,10 +842,10 @@ const InternalTable = React.forwardRef( }); return ( loaderContent && ( - {selectionType ? ( ))} - + ) ); }) @@ -902,7 +899,7 @@ const InternalTable = React.forwardRef( renderCell={skeleton?.renderCell} /> )} - +
that TableThElement used to render inline. -import styles from '../header-cell/styles.css.js'; +// pixel-identical to the that TableThElement used to render inline. This one-way +// stylesheet reuse of the existing Table's header-cell module is the accepted style edge. +import styles from '../table/header-cell/styles.css.js'; + +export interface InternalTableHeaderCellProps { + // Feature classes layered on top of the base header-cell substrate by the composing component. + className?: string; + style?: React.CSSProperties; + // Native attributes (role/scope/aria-sort/analytics/data-*) computed by the composing + // component and spread verbatim onto the element. Kept opaque here so the substrate holds no + // header-specific logic. + nativeAttributes?: React.ThHTMLAttributes & { + [key: `data-${string}`]: string | number | boolean | undefined; + }; + tabIndex?: number; + children?: React.ReactNode; +} export const InternalTableHeaderCell = React.forwardRef( ({ className, style, nativeAttributes, tabIndex, children }, ref) => { diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx index a95c3f0c51..2b96284db0 100644 --- a/src/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal.tsx @@ -6,9 +6,9 @@ import clsx from 'clsx'; import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; -import { InternalTableHeaderCell } from '../table/table-header-cell/internal'; import { useTableContext } from '../table-root/context'; import { TableHeaderCellProps } from './interfaces'; +import { InternalTableHeaderCell } from './internal-table-header-cell'; import headerCellStyles from '../table/header-cell/styles.css.js'; import styles from './styles.css.js'; diff --git a/src/table-row/internal-table-row.tsx b/src/table-row/internal-table-row.tsx new file mode 100644 index 0000000000..2d6fdd8474 --- /dev/null +++ b/src/table-row/internal-table-row.tsx @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +export interface InternalTableRowProps { + // Feature classes (including the composer's own `.row` marker) layered on top of the base row + // substrate by the composing component. + className?: string; + // Native
attributes (role/scope/aria-sort/analytics/data-*) computed by the composing - // component and spread verbatim onto the element. Kept opaque here so the substrate holds no - // header-specific logic. - nativeAttributes?: React.ThHTMLAttributes & { - [key: `data-${string}`]: string | number | boolean | undefined; - }; - tabIndex?: number; - children?: React.ReactNode; -} diff --git a/src/table/table-row/interfaces.ts b/src/table/table-row/interfaces.ts deleted file mode 100644 index 88cd4fac96..0000000000 --- a/src/table/table-row/interfaces.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -export interface InternalTableRowProps { - // Feature classes layered on top of the base row substrate by the composing component. - className?: string; - // Native
element, the `.body-cell` box model (base padding + `.body-cell-content` wrapper), // `disablePaddings`, and ref all come from the InternalTableCell substrate (the single shared piece // with the existing Table). This public layer adds the test-utils marker, grid-mode role/layout, and @@ -24,7 +96,6 @@ export function Cell(props: TableCellProps & InternalBaseComponentProps) { const { children, disablePaddings, __internalRootRef } = props; const { columnLayout } = useTableContext(); const variant = useRowVariant(); - const { isFirstRow, isLastRow } = useRowPosition(); const isGrid = columnLayout.type === 'grid'; const isVisualRefresh = useVisualRefresh(); const { className, ...restBaseProps } = getBaseProps(props); @@ -42,12 +113,7 @@ export function Cell(props: TableCellProps & InternalBaseComponentProps) { // The existing Table's `has-selection` marker: without it a first cell `:not(.has-selection)` // strips its inline-start selection border, leaving the control column's box open. variant === 'selected' && bodyCellStyles['has-selection'], - variant === 'shaded' && bodyCellStyles['body-cell-shaded'], - // Edge-row placeholder borders (1px-taller first/last rows) belong to the auto/table-layout - // path, reusing the existing Table's classes. In grid layout the row height comes from - // `grid-auto-rows`, so the placeholder would double-count and inflate the edge row by 1px. - !isGrid && isFirstRow && bodyCellStyles['body-cell-first-row'], - !isGrid && isLastRow && bodyCellStyles['body-cell-last-row'] + variant === 'shaded' && bodyCellStyles['body-cell-shaded'] )} nativeAttributes={{ ...restBaseProps, role: isGrid ? 'cell' : undefined }} > diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss index 45eaa245d1..aa281a00b8 100644 --- a/src/table-cell/styles.scss +++ b/src/table-cell/styles.scss @@ -100,6 +100,22 @@ tr:not([data-selected]):has(+ [data-selected]) > .cell { border-block-end-color: transparent; } +// Auto-layout edge-row placeholder borders. The extract reuses classic's `.body-cell` box model but not +// its `body-cell-first-row` / `body-cell-last-row` edge classes (which required `TableBody` to inspect +// its children to learn each row's position). In auto layout the row height is intrinsic, so classic +// renders the first/last rows 1px taller via a 2px transparent placeholder on the outer block edge, and +// the last row drops its divider (the table container draws that edge). Reproduce that structurally off +// the row's DOM position. Grid layout sizes rows from `grid-auto-rows`, where the placeholder would +// double-count, so `.cell-grid` is excluded (matching classic's `!isGrid` gating). Keyed on a bare `tr` +// plus the atomic `.cell` hash, so the existing Table (body cells carry `.body-cell`, never `.cell`) and +// the header row (cells carry `.header-cell`) both stay inert. +tr:first-child > .cell:not(.cell-grid) { + border-block-start: awsui.$border-item-width solid transparent; +} +tr:last-child > .cell:not(.cell-grid) { + border-block-end: awsui.$border-item-width solid transparent; +} + // The overlay ring. `.row` is position:relative and this `::after` is position:absolute, so it is not a // grid item. inset:0 traces the row's padding box (the cells' outer edge) at the 2px selection width, // item radius, and colour. diff --git a/src/table-head/internal-table-head.tsx b/src/table-head/internal-table-head.tsx deleted file mode 100644 index dd7df740d5..0000000000 --- a/src/table-head/internal-table-head.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -export interface InternalTableHeadProps { - // Feature classes layered on top of the base head substrate by the composing component - // (e.g. the `thead-active` marker gated on the sticky-header `hidden` flag). - className?: string; - // Native
that TableThElement used to render inline. This one-way -// stylesheet reuse of the existing Table's header-cell module is the accepted style edge. -import styles from '../table/header-cell/styles.css.js'; - -export interface InternalTableHeaderCellProps { - // Feature classes layered on top of the base header-cell substrate by the composing component. - className?: string; - style?: React.CSSProperties; - // Native attributes (role/scope/aria-sort/analytics/data-*) computed by the composing - // component and spread verbatim onto the element. Kept opaque here so the substrate holds no - // header-specific logic. - nativeAttributes?: React.ThHTMLAttributes & { - [key: `data-${string}`]: string | number | boolean | undefined; - }; - tabIndex?: number; - children?: React.ReactNode; -} - -export const InternalTableHeaderCell = React.forwardRef( - ({ className, style, nativeAttributes, tabIndex, children }, ref) => { - return ( - - {children} - that TableThElement used to render inline. This one-way +// stylesheet reuse of the existing Table's header-cell module is the accepted style edge. import headerCellStyles from '../table/header-cell/styles.css.js'; import styles from './styles.css.js'; +export interface InternalTableHeaderCellProps { + // Feature classes layered on top of the base header-cell substrate by the composing component. + className?: string; + style?: React.CSSProperties; + // Native attributes (role/scope/aria-sort/analytics/data-*) computed by the composing + // component and spread verbatim onto the element. Kept opaque here so the substrate holds no + // header-specific logic. + nativeAttributes?: React.ThHTMLAttributes & { + [key: `data-${string}`]: string | number | boolean | undefined; + }; + tabIndex?: number; + children?: React.ReactNode; +} + +export const InternalTableHeaderCell = React.forwardRef( + ({ className, style, nativeAttributes, tabIndex, children }, ref) => { + return ( + + {children} + element, `.header-cell` box model, and ref come from the InternalTableHeaderCell substrate // (shared with the existing Table). This public layer adds the test-utils marker, grid-mode // role/layout, the padding opt-out, and the header aria surface (aria-sort, labelling). diff --git a/src/table-row/context.ts b/src/table-row/context.ts index 6df8b5ada8..2cdb591973 100644 --- a/src/table-row/context.ts +++ b/src/table-row/context.ts @@ -14,20 +14,3 @@ export const RowVariantContextProvider = RowVariantContext.Provider; export function useRowVariant(): TableRowProps.Variant { return useContext(RowVariantContext); } - -// A body→cell channel carrying the row's first/last position, so a `TableCell` can apply the existing -// Table's `body-cell-first-row` / `body-cell-last-row` edge classes (the 1px-taller edge-row -// placeholders). `TableBody` owns the signal (it sees all rows) and publishes it with no DOM. A cell -// outside a `TableBody` reads the default (interior row). -export interface RowPosition { - isFirstRow: boolean; - isLastRow: boolean; -} - -const RowPositionContext = createContext({ isFirstRow: false, isLastRow: false }); - -export const RowPositionContextProvider = RowPositionContext.Provider; - -export function useRowPosition(): RowPosition { - return useContext(RowPositionContext); -} diff --git a/src/table-row/internal-table-row.tsx b/src/table-row/internal-table-row.tsx deleted file mode 100644 index 2d6fdd8474..0000000000 --- a/src/table-row/internal-table-row.tsx +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React from 'react'; - -export interface InternalTableRowProps { - // Feature classes (including the composer's own `.row` marker) layered on top of the base row - // substrate by the composing component. - className?: string; - // Native
that TableThElement used to render inline. This one-way -// stylesheet reuse of the existing Table's header-cell module is the accepted style edge. +// The header cell reuses classic's proven box model: the base `.header-cell` padding lives in the +// shared header-cell stylesheet, so this component is pixel-identical to the that TableThElement +// composes. This one-way stylesheet reuse of the existing Table's header-cell module is the accepted +// style edge. import headerCellStyles from '../table/header-cell/styles.css.js'; import styles from './styles.css.js'; export interface InternalTableHeaderCellProps { - // Feature classes layered on top of the base header-cell substrate by the composing component. + // Feature classes layered on top of the base `.header-cell` by the composing component. className?: string; style?: React.CSSProperties; - // Native attributes (role/scope/aria-sort/analytics/data-*) computed by the composing - // component and spread verbatim onto the element. Kept opaque here so the substrate holds no - // header-specific logic. + // Native attributes (role/scope/aria-sort/analytics/data-*) computed by the composing component + // and spread verbatim onto the element. nativeAttributes?: React.ThHTMLAttributes & { [key: `data-${string}`]: string | number | boolean | undefined; }; tabIndex?: number; + // Removes the cell's built-in block/inline padding (atomic control-column composition). + disablePaddings?: boolean; children?: React.ReactNode; + // Set by the public `TableHeaderCell` (index.tsx). When true this cell reads the atomic table context + // to add the test-utils marker, grid-mode role/layout, the visual-refresh first-column reset, the + // padding opt-out, and the header content wrapper. Unset for the existing Table, which composes the + // bare `.header-cell` substrate and layers its own features via `className` — so with `atomic` off + // this component is byte-identical to the the Table used to render inline. + atomic?: boolean; } +// One component serves both the public `TableHeaderCell` (index.tsx, `atomic`) and the existing Table's +// TableThElement (which composes it as the bare `.header-cell` substrate). All atomic-only classes, the +// grid role, and the content wrapper are gated on `atomic`, so the Table path is unaffected. export const InternalTableHeaderCell = React.forwardRef( - ({ className, style, nativeAttributes, tabIndex, children }, ref) => { + ({ className, style, nativeAttributes, tabIndex, disablePaddings, atomic, children }, ref) => { + const { columnLayout } = useTableContext(); + const isVisualRefresh = useVisualRefresh(); + const isGrid = columnLayout.type === 'grid'; + // The atomic grid role rides in the native attributes so it never clobbers the Table's own role. + const mergedNativeAttributes = + atomic && isGrid ? { ...nativeAttributes, role: 'columnheader' as const } : nativeAttributes; return ( .header-cell-content`) is keyed on this + // module's hashed class. + atomic && isVisualRefresh && styles['is-visual-refresh'], + atomic && disablePaddings && styles['disable-paddings'] + )} style={style} tabIndex={tabIndex} - {...nativeAttributes} + {...mergedNativeAttributes} > - {children} + {atomic ?
{children}
: children}
element, `.header-cell` box model, and ref come from the InternalTableHeaderCell substrate -// (shared with the existing Table). This public layer adds the test-utils marker, grid-mode -// role/layout, the padding opt-out, and the header aria surface (aria-sort, labelling). -export function HeaderCell(props: TableHeaderCellProps & InternalBaseComponentProps) { - const { children, ariaLabel, ariaLabelledby, ariaDescribedby, ariaSort, disablePaddings, __internalRootRef } = props; - const { columnLayout } = useTableContext(); - const isGrid = columnLayout.type === 'grid'; - const isVisualRefresh = useVisualRefresh(); - const { className, ...restBaseProps } = getBaseProps(props); - return ( - .header-cell-content`) is keyed on this - // module's hashed class, so the substrate's marker alone won't match it. - isVisualRefresh && styles['is-visual-refresh'], - disablePaddings && styles['disable-paddings'] - )} - nativeAttributes={{ - ...restBaseProps, - role: isGrid ? 'columnheader' : undefined, - scope: 'col', - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, - 'aria-describedby': ariaDescribedby, - 'aria-sort': ariaSort, - }} - > -
{children}
-
- ); -} diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index 3063c4651f..0d9fed6300 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -11,36 +11,10 @@ import { TableRowProps } from './interfaces'; import styles from './styles.css.js'; -export interface InternalTableRowProps { - // Feature classes (including the composer's own `.row` marker) layered on top of the base row - // substrate by the composing component. - className?: string; - // Native
diff --git a/src/table/thead.tsx b/src/table/thead.tsx index c6d39e04bd..9286e4fb91 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -6,7 +6,6 @@ import clsx from 'clsx'; import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; import { fireNonCancelableEvent } from '../internal/events'; -import { InternalTableHead } from '../table-head/internal'; import { NonCancelableEventHandler } from '../types/events'; import { getGroupColumnIds, getGroupSplit } from './column-groups/split-utils'; import { ColumnGroupsLayout } from './column-groups/utils'; @@ -146,7 +145,7 @@ const Thead = React.forwardRef( // No grouping - render single row if (!columnGroupsLayout || columnGroupsLayout.rows.length <= 1) { return ( - + - + ); } // Grouped columns const totalColumns = columnDefinitions.length; return ( - + {columnGroupsLayout.rows.map((row, rowIndex) => ( ))} - + ); } ); From cf22161a46bbef09911658c8a69b484d75885209 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 9 Sep 2026 13:06:41 +0000 Subject: [PATCH 34/35] chore(table-atomic): sweep low-value comments Remove refactor-history narrative and duplicate-rationale comments across the 7 atomic components (e.g. 'renders directly', 'extracted substrate', 'classic call site inlines'), and compress kept rationale to 1-2 lines. Preserve public JSDoc and the load-bearing WHY (classic-inert / atomic-flag gating / data-selected adjacency / one-way SCSS reuse / selection-ring + edge-placeholder geometry). Behavior-neutral: gulp build clean, 569 unit tests green, no code change. --- src/table-body/internal.tsx | 4 ---- src/table-cell/internal.tsx | 23 +++++++++-------------- src/table-cell/styles.scss | 16 +++++++--------- src/table-head/internal.tsx | 3 --- src/table-header-cell/internal.tsx | 22 +++++++--------------- src/table-root/index.tsx | 4 +--- src/table-root/internal.tsx | 5 ++--- src/table-row/context.ts | 5 ++--- src/table-row/internal.tsx | 8 ++------ 9 files changed, 30 insertions(+), 60 deletions(-) diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index 52eef83894..ec070725d5 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -10,10 +10,6 @@ import { TableBodyProps } from './interfaces'; import styles from './styles.css.js'; -// The atomic body renders the `` directly: it forwards the raw node ref, adds the body class and -// grid-mode role, and applies the positioning `style` (virtualization). The classic table body carries -// no base class or wiring on `` itself (rows and cells own all feature layering), so the classic -// call site inlines its own bare ``. export function Body(props: TableBodyProps & InternalBaseComponentProps) { const { children, style, __internalRootRef } = props; const { columnLayout } = useTableContext(); diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index 3975298f5d..1a88f527f0 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -7,10 +7,9 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; import { useRowVariant } from '../table-row/context'; -// The cell reuses classic's proven box model: the base `.body-cell` padding and the -// `.body-cell-content` truncation wrapper live in the shared body-cell stylesheet, so this component is -// pixel-identical to the element TableTdElement composes. This one-way stylesheet reuse of the existing -// Table's body-cell module is the accepted style edge. +// Reuses classic's proven box model: the base `.body-cell` padding and `.body-cell-content` truncation +// wrapper live in the shared body-cell stylesheet, so this is pixel-identical to the element +// TableTdElement composes. Accepted one-way SCSS reuse. import bodyCellStyles from '../table/body-cell/styles.css.js'; import styles from './styles.css.js'; @@ -35,19 +34,15 @@ export interface InternalTableCellProps { // Rendered inside the cell before the content wrapper (e.g. an expand toggle). beforeContent?: React.ReactNode; children?: React.ReactNode; - // Set by the public `TableCell` (index.tsx). When true this cell reads the atomic row/table contexts - // to self-paint the test-utils marker, grid-mode role/layout, selection ring, and shading. Unset for - // the existing Table, which composes the bare `.body-cell` substrate and layers its own features via - // `className` — so with `atomic` off this component is byte-identical to the element the Table used - // to render inline. + // Set by the public `TableCell`. When true the cell reads the atomic row/table contexts to self-paint + // the test-utils marker, grid role/layout, selection ring, and shading. With `atomic` off it is + // byte-identical to the bare `.body-cell` substrate the Table composes. atomic?: boolean; } -// One component serves both the public `TableCell` (index.tsx, `atomic`) and the existing Table's -// TableTdElement (which composes it as the bare `.body-cell` substrate). The atomic-only classes and -// grid role are gated on `atomic`, and the atomic selection/shading/edge CSS is keyed on the `.cell` -// marker + the row's `data-selected` / `data-shaded` hooks — none of which the existing Table emits — -// so the Table path is unaffected. +// The atomic-only classes and grid role are gated on `atomic`; the selection/shading/edge CSS is keyed +// on the `.cell` marker + the row's `data-selected` / `data-shaded` hooks, none of which the existing +// Table emits — so the Table path is unaffected. export const InternalTableCell = React.forwardRef( ( { diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss index aa281a00b8..a4eef98040 100644 --- a/src/table-cell/styles.scss +++ b/src/table-cell/styles.scss @@ -26,15 +26,13 @@ align-items: center; } -// Selection is painted as a LAYOUT-NEUTRAL overlay ring, not real cell borders. The existing Table -// paints a real 2px border on selected cells; in grid mode each row is an independent `grid-auto-rows` -// track sized by its cells' border-box, so a real border would grow the track and shift content on -// toggle. Instead the selected cell KEEPS the unselected geometry (constant 1px dividers, no border, no -// radius) and the 2px ring is drawn as an absolutely-positioned `::after` on the row (below). -// `body-cell-selected` is still applied for its background (and the styling-props test asserts it); the -// rules here override only its border/radius, keyed on the `data-selected` / `.cell` hooks the substrate -// never emits, so the existing Table stays inert. `.cell`-target rules are ordered by ascending -// specificity (stylelint no-descending-specificity). +// Selection is painted as a LAYOUT-NEUTRAL overlay ring, not real cell borders. In grid mode each row is +// an independent `grid-auto-rows` track sized by its cells' border-box, so a real 2px border would grow +// the track and shift content on toggle. The selected cell keeps the unselected geometry (1px dividers, +// no border/radius) and the 2px ring is an absolutely-positioned `::after` on the row (below). +// `body-cell-selected` is still applied for its background; the rules here override only its +// border/radius, keyed on the `data-selected` / `.cell` hooks the substrate never emits (Table stays +// inert). `.cell`-target rules are ordered by ascending specificity (stylelint no-descending-specificity). // Neutralize the selection border back to the unselected 1px block dividers (top transparent, bottom // the list divider), so a selected cell's block border-box equals the unselected cell's. diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx index 56fdc3d6b5..94c080b7f2 100644 --- a/src/table-head/internal.tsx +++ b/src/table-head/internal.tsx @@ -10,9 +10,6 @@ import { TableHeadProps } from './interfaces'; import styles from './styles.css.js'; -// The atomic head renders the `` directly: it forwards the raw node ref, adds the head class and -// grid-mode role/layout. The classic thead's only class is the `thead-active` marker (gated on the -// sticky-header `hidden` flag), which the classic call site inlines onto its own bare ``. export function Head(props: TableHeadProps & InternalBaseComponentProps) { const { children, __internalRootRef } = props; const { columnLayout } = useTableContext(); diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx index 01e8d01740..c490f852eb 100644 --- a/src/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal.tsx @@ -6,10 +6,8 @@ import clsx from 'clsx'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; -// The header cell reuses classic's proven box model: the base `.header-cell` padding lives in the -// shared header-cell stylesheet, so this component is pixel-identical to the that TableThElement -// composes. This one-way stylesheet reuse of the existing Table's header-cell module is the accepted -// style edge. +// Reuses classic's proven box model: the base `.header-cell` padding lives in the shared header-cell +// stylesheet, so this is pixel-identical to the TableThElement composes. Accepted one-way SCSS reuse. import headerCellStyles from '../table/header-cell/styles.css.js'; import styles from './styles.css.js'; @@ -26,17 +24,12 @@ export interface InternalTableHeaderCellProps { // Removes the cell's built-in block/inline padding (atomic control-column composition). disablePaddings?: boolean; children?: React.ReactNode; - // Set by the public `TableHeaderCell` (index.tsx). When true this cell reads the atomic table context - // to add the test-utils marker, grid-mode role/layout, the visual-refresh first-column reset, the - // padding opt-out, and the header content wrapper. Unset for the existing Table, which composes the - // bare `.header-cell` substrate and layers its own features via `className` — so with `atomic` off - // this component is byte-identical to the the Table used to render inline. + // Set by the public `TableHeaderCell`. When true the cell reads the atomic table context to add the + // test-utils marker, grid role/layout, visual-refresh first-column reset, padding opt-out, and content + // wrapper. With `atomic` off it is byte-identical to the bare `.header-cell` substrate the Table composes. atomic?: boolean; } -// One component serves both the public `TableHeaderCell` (index.tsx, `atomic`) and the existing Table's -// TableThElement (which composes it as the bare `.header-cell` substrate). All atomic-only classes, the -// grid role, and the content wrapper are gated on `atomic`, so the Table path is unaffected. export const InternalTableHeaderCell = React.forwardRef( ({ className, style, nativeAttributes, tabIndex, disablePaddings, atomic, children }, ref) => { const { columnLayout } = useTableContext(); @@ -54,9 +47,8 @@ export const InternalTableHeaderCell = React.forwardRef .header-cell-content`) is keyed on this - // module's hashed class. + // This module's own is-visual-refresh marker keys the first-column content-offset reset + // (`.header-cell.is-visual-refresh:first-child > .header-cell-content`). atomic && isVisualRefresh && styles['is-visual-refresh'], atomic && disablePaddings && styles['disable-paddings'] )} diff --git a/src/table-root/index.tsx b/src/table-root/index.tsx index b2fa1c4fb4..6e065cb234 100644 --- a/src/table-root/index.tsx +++ b/src/table-root/index.tsx @@ -8,9 +8,7 @@ import { applyDisplayName } from '../internal/utils/apply-display-name'; import { TableRootProps } from './interfaces'; import { InternalRoot } from './internal'; -// Root of the atomic table. The parts (TableHead, TableRow, …) are sibling top-level components; -// keeping each dir single-component (one default export + its props type) is what lets the -// documenter treat every part as its own documented component. +// Each part is its own top-level component (one default export + props type) so the documenter documents it separately. export type { TableRootProps }; function TableRoot({ columnLayout = { type: 'auto' }, ...props }: TableRootProps) { diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx index 23f3357cd3..7129f8c234 100644 --- a/src/table-root/internal.tsx +++ b/src/table-root/internal.tsx @@ -35,9 +35,8 @@ export function InternalRoot(props: InternalRootProps) {
role is used (an explicit - // role="table" there is redundant and flagged by a11y validators). + // Grid mode only: display:grid drops the table's implicit role. Auto mode keeps the + // native role (an explicit role="table" there is redundant and flagged by a11y validators). role={isGrid ? 'table' : undefined} aria-label={ariaLabel} aria-labelledby={ariaLabelledby} diff --git a/src/table-row/context.ts b/src/table-row/context.ts index 2cdb591973..1a1d166586 100644 --- a/src/table-row/context.ts +++ b/src/table-row/context.ts @@ -4,9 +4,8 @@ import { createContext, useContext } from 'react'; import { TableRowProps } from './interfaces'; -// A row→cell channel that lets a `TableCell` learn its row's visual state and paint its own selection -// via its own module class, instead of the row exposing a `data-*` attribute as a CSS styling hook -// (an anti-pattern). A cell rendered outside a `TableRow` reads the default (`'default'`). +// A row→cell channel so a `TableCell` learns its row's visual state and paints selection via its own +// module class, avoiding a `data-*` styling hook. A cell rendered outside a `TableRow` reads `'default'`. const RowVariantContext = createContext('default'); export const RowVariantContextProvider = RowVariantContext.Provider; diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index 0d9fed6300..b16db6cb9f 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -11,12 +11,8 @@ import { TableRowProps } from './interfaces'; import styles from './styles.css.js'; -// The atomic row renders the `` directly: it forwards the raw node ref, adds the `.row` marker and -// grid-mode role/layout and aria surface, and publishes the row's `variant` via RowVariantContext so -// each cell self-paints its selection/shading class. The classic call site inlines its own bare `` -// with the classic `.row` class and its own native attributes/handlers. -// -// A selected row also emits `data-selected` (and a shaded row `data-shaded`) on the : the +// The row publishes its `variant` via RowVariantContext so each cell self-paints its selection/shading. +// A selected row also emits `data-selected` (a shaded row `data-shaded`) on the : the // consecutive-selected outline merge and striped-divider darkening need sibling adjacency, which a cell // can only read from the DOM, not context. These are sanctioned data-* hooks — driven by the internal // `variant` (never a public prop) and keyed on selectors the existing Table never emits, so they stay From e023ca49b20ef9d799e2698f7583cc35b273dcc6 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 9 Sep 2026 15:01:54 +0000 Subject: [PATCH 35/35] refactor(table): align atomic internal.tsx with the canonical internal-component idiom Match the src/status-indicator idiom across all 7 atomic components: - named InternalTableProps interfaces (extends TableProps, InternalBaseComponentProps) instead of inline intersection types in the signature - uniform InternalTable* internal names - default-export idiom (export default function InternalTable(...)) - value-style export { TableProps } in index.tsx - rename the merged cell/header-cell flag atomic -> __atomic (house __-prefix convention) Behavior-neutral: the existing Table's td-/th-element are untouched and never pass __atomic, so classic output is unchanged by construction. Verified against the fresh mainline merge: gulp build clean, jest 45 suites / 1262 tests / 107 snapshots green (zero snapshot drift), eslint + stylelint clean. --- src/table-body/index.tsx | 6 +++--- src/table-body/internal.tsx | 7 ++++--- src/table-cell/index.tsx | 4 ++-- src/table-cell/internal.tsx | 25 ++++++++++++++----------- src/table-head/index.tsx | 6 +++--- src/table-head/internal.tsx | 7 ++++--- src/table-header-cell/index.tsx | 4 ++-- src/table-header-cell/internal.tsx | 22 ++++++++++++---------- src/table-header-row/index.tsx | 6 +++--- src/table-header-row/internal.tsx | 7 ++++--- src/table-root/index.tsx | 6 +++--- src/table-root/internal.tsx | 25 ++++++++++++------------- src/table-row/index.tsx | 6 +++--- src/table-row/internal.tsx | 28 +++++++++++++++------------- 14 files changed, 84 insertions(+), 75 deletions(-) diff --git a/src/table-body/index.tsx b/src/table-body/index.tsx index b5d956d043..b38b4f3ac2 100644 --- a/src/table-body/index.tsx +++ b/src/table-body/index.tsx @@ -6,13 +6,13 @@ import React from 'react'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { TableBodyProps } from './interfaces'; -import { Body } from './internal'; +import InternalTableBody from './internal'; -export type { TableBodyProps }; +export { TableBodyProps }; function TableBody(props: TableBodyProps) { const baseComponentProps = useBaseComponent('TableBody'); - return ; + return ; } applyDisplayName(TableBody, 'TableBody'); diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index ec070725d5..d081843eb4 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -10,11 +10,12 @@ import { TableBodyProps } from './interfaces'; import styles from './styles.css.js'; -export function Body(props: TableBodyProps & InternalBaseComponentProps) { - const { children, style, __internalRootRef } = props; +export interface InternalTableBodyProps extends TableBodyProps, InternalBaseComponentProps {} + +export default function InternalTableBody({ children, style, __internalRootRef, ...rest }: InternalTableBodyProps) { const { columnLayout } = useTableContext(); const isGrid = columnLayout.type === 'grid'; - const { className, ...restBaseProps } = getBaseProps(props); + const { className, ...restBaseProps } = getBaseProps(rest); return ( ( @@ -58,7 +60,7 @@ export const InternalTableCell = React.forwardRef { @@ -68,7 +70,8 @@ export const InternalTableCell = React.forwardRef; + return ; } applyDisplayName(TableHead, 'TableHead'); diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx index 94c080b7f2..a9cd26dac2 100644 --- a/src/table-head/internal.tsx +++ b/src/table-head/internal.tsx @@ -10,11 +10,12 @@ import { TableHeadProps } from './interfaces'; import styles from './styles.css.js'; -export function Head(props: TableHeadProps & InternalBaseComponentProps) { - const { children, __internalRootRef } = props; +export interface InternalTableHeadProps extends TableHeadProps, InternalBaseComponentProps {} + +export default function InternalTableHead({ children, __internalRootRef, ...rest }: InternalTableHeadProps) { const { columnLayout } = useTableContext(); const isGrid = columnLayout.type === 'grid'; - const { className, ...restBaseProps } = getBaseProps(props); + const { className, ...restBaseProps } = getBaseProps(rest); return ( ( - ({ className, style, nativeAttributes, tabIndex, disablePaddings, atomic, children }, ref) => { + ({ className, style, nativeAttributes, tabIndex, disablePaddings, __atomic, children }, ref) => { const { columnLayout } = useTableContext(); const isVisualRefresh = useVisualRefresh(); const isGrid = columnLayout.type === 'grid'; // The atomic grid role rides in the native attributes so it never clobbers the Table's own role. const mergedNativeAttributes = - atomic && isGrid ? { ...nativeAttributes, role: 'columnheader' as const } : nativeAttributes; + __atomic && isGrid ? { ...nativeAttributes, role: 'columnheader' as const } : nativeAttributes; return ( ); } diff --git a/src/table-header-row/index.tsx b/src/table-header-row/index.tsx index e3be64ae8f..539da44381 100644 --- a/src/table-header-row/index.tsx +++ b/src/table-header-row/index.tsx @@ -6,13 +6,13 @@ import React from 'react'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { TableHeaderRowProps } from './interfaces'; -import { HeaderRow } from './internal'; +import InternalTableHeaderRow from './internal'; -export type { TableHeaderRowProps }; +export { TableHeaderRowProps }; function TableHeaderRow(props: TableHeaderRowProps) { const baseComponentProps = useBaseComponent('TableHeaderRow'); - return ; + return ; } applyDisplayName(TableHeaderRow, 'TableHeaderRow'); diff --git a/src/table-header-row/internal.tsx b/src/table-header-row/internal.tsx index abf7272b6f..3a8641acd7 100644 --- a/src/table-header-row/internal.tsx +++ b/src/table-header-row/internal.tsx @@ -10,11 +10,12 @@ import { TableHeaderRowProps } from './interfaces'; import styles from './styles.css.js'; -export function HeaderRow(props: TableHeaderRowProps & InternalBaseComponentProps) { - const { children, __internalRootRef } = props; +export interface InternalTableHeaderRowProps extends TableHeaderRowProps, InternalBaseComponentProps {} + +export default function InternalTableHeaderRow({ children, __internalRootRef, ...rest }: InternalTableHeaderRowProps) { const { columnLayout, gridTemplateColumns, ariaRowcount } = useTableContext(); const isGrid = columnLayout.type === 'grid'; - const baseProps = getBaseProps(props); + const baseProps = getBaseProps(rest); return ( ; + return ; } applyDisplayName(TableRoot, 'TableRoot'); diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx index 7129f8c234..58a913d2bf 100644 --- a/src/table-root/internal.tsx +++ b/src/table-root/internal.tsx @@ -11,22 +11,21 @@ import { useTableRoot } from './use-table-root'; import styles from './styles.css.js'; -type InternalRootProps = TableRootProps & InternalBaseComponentProps; - -export function InternalRoot(props: InternalRootProps) { - const { - columnLayout = { type: 'auto' }, - ariaRowcount, - ariaLabel, - ariaLabelledby, - ariaDescribedby, - children, - __internalRootRef, - } = props; +export interface InternalTableRootProps extends TableRootProps, InternalBaseComponentProps {} +export default function InternalTableRoot({ + columnLayout = { type: 'auto' }, + ariaRowcount, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + children, + __internalRootRef, + ...rest +}: InternalTableRootProps) { const isGrid = columnLayout.type === 'grid'; const table = useTableRoot(columnLayout, ariaRowcount); - const baseProps = getBaseProps(props); + const baseProps = getBaseProps(rest); return (
diff --git a/src/table-row/index.tsx b/src/table-row/index.tsx index 914efa08f2..8eda6b30f7 100644 --- a/src/table-row/index.tsx +++ b/src/table-row/index.tsx @@ -6,13 +6,13 @@ import React from 'react'; import useBaseComponent from '../internal/hooks/use-base-component'; import { applyDisplayName } from '../internal/utils/apply-display-name'; import { TableRowProps } from './interfaces'; -import { Row } from './internal'; +import InternalTableRow from './internal'; -export type { TableRowProps }; +export { TableRowProps }; function TableRow(props: TableRowProps) { const baseComponentProps = useBaseComponent('TableRow', { props: { variant: props.variant } }); - return ; + return ; } applyDisplayName(TableRow, 'TableRow'); diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index b16db6cb9f..0b52e15b30 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -17,21 +17,23 @@ import styles from './styles.css.js'; // can only read from the DOM, not context. These are sanctioned data-* hooks — driven by the internal // `variant` (never a public prop) and keyed on selectors the existing Table never emits, so they stay // inert for it. -export function Row(props: TableRowProps & InternalBaseComponentProps) { - const { - variant = 'default', - ariaLabel, - ariaLabelledby, - ariaDescribedby, - ariaSelected, - ariaRowindex, - children, - style, - __internalRootRef, - } = props; +export interface InternalTableRowProps extends TableRowProps, InternalBaseComponentProps {} + +export default function InternalTableRow({ + variant = 'default', + ariaLabel, + ariaLabelledby, + ariaDescribedby, + ariaSelected, + ariaRowindex, + children, + style, + __internalRootRef, + ...rest +}: InternalTableRowProps) { const { columnLayout, gridTemplateColumns } = useTableContext(); const isGrid = columnLayout.type === 'grid'; - const { className, ...restBaseProps } = getBaseProps(props); + const { className, ...restBaseProps } = getBaseProps(rest); // Spread (not literal keys) so these adjacency hooks are exempt from excess-property checking. const selectedDataAttribute = variant === 'selected' ? { 'data-selected': 'true' } : undefined; const shadedDataAttribute = variant === 'shaded' ? { 'data-shaded': 'true' } : undefined;
.header-cell-content`). - atomic && isVisualRefresh && styles['is-visual-refresh'], - atomic && disablePaddings && styles['disable-paddings'] + __atomic && isVisualRefresh && styles['is-visual-refresh'], + __atomic && disablePaddings && styles['disable-paddings'] )} style={style} tabIndex={tabIndex} {...mergedNativeAttributes} > - {atomic ?
{children}
: children} + {__atomic ?
{children}
: children}