From bb3ca2ec7f20a823e193460292dc18083c9285d6 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 11 Sep 2026 07:58:51 +0000 Subject: [PATCH 01/23] feat(table): add atomic table components (TableRoot/Head/HeaderRow/HeaderCell/Body/Row/Cell) Public atomic table components built on internal substrates extracted from the existing Table, plus the shared cell-base geometry partial, demo pages, unit tests, and generated test-utils/documenter snapshots. --- pages/table-root/column-sizing.page.tsx | 153 ++++++++++ pages/table-root/common.tsx | 67 +++++ pages/table-root/loading-and-empty.page.tsx | 75 +++++ .../table-root/selection-edge-cases.page.tsx | 118 ++++++++ pages/table-root/selection.page.tsx | 116 ++++++++ pages/table-root/simple.page.tsx | 31 ++ pages/table-root/single-selection.page.tsx | 88 ++++++ pages/table-root/sorting.page.tsx | 141 ++++++++++ pages/table-root/striped-rows.page.tsx | 43 +++ pages/table-root/styles.scss | 66 +++++ src/table-body/index.tsx | 19 ++ src/table-body/interfaces.ts | 24 ++ src/table-body/internal.tsx | 30 ++ src/table-body/styles.scss | 12 + src/table-cell/index.tsx | 33 +++ src/table-cell/interfaces.ts | 15 + src/table-cell/internal.tsx | 92 ++++++ src/table-cell/styles.scss | 38 +++ src/table-head/index.tsx | 19 ++ src/table-head/interfaces.ts | 11 + src/table-head/internal.tsx | 29 ++ src/table-head/styles.scss | 12 + src/table-header-cell/index.tsx | 42 +++ src/table-header-cell/interfaces.ts | 25 ++ src/table-header-cell/internal.tsx | 59 ++++ src/table-header-cell/styles.scss | 57 ++++ src/table-header-row/index.tsx | 19 ++ src/table-header-row/interfaces.ts | 11 + src/table-header-row/internal.tsx | 34 +++ src/table-header-row/styles.scss | 16 ++ .../__tests__/basic-table-aria-label.test.tsx | 62 ++++ .../__tests__/basic-table-roles.test.tsx | 150 ++++++++++ .../basic-table-styling-props.test.tsx | 230 +++++++++++++++ src/table-root/__tests__/basic-table.test.tsx | 264 ++++++++++++++++++ .../__tests__/use-table-root.test.tsx | 90 ++++++ src/table-root/context.ts | 21 ++ src/table-root/index.tsx | 24 ++ src/table-root/interfaces.ts | 59 ++++ src/table-root/internal.tsx | 95 +++++++ src/table-root/styles.scss | 43 +++ src/table-root/use-table-root.ts | 53 ++++ src/table-row/context.ts | 15 + src/table-row/index.tsx | 19 ++ src/table-row/interfaces.ts | 47 ++++ src/table-row/internal.tsx | 54 ++++ src/table-row/styles.scss | 57 ++++ 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 + 53 files changed, 2841 insertions(+) create mode 100644 pages/table-root/column-sizing.page.tsx 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-edge-cases.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 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-styling-props.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/pages/table-root/column-sizing.page.tsx b/pages/table-root/column-sizing.page.tsx new file mode 100644 index 0000000000..424ab68166 --- /dev/null +++ b/pages/table-root/column-sizing.page.tsx @@ -0,0 +1,153 @@ +// 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 ColumnLayout from '~components/column-layout'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Input from '~components/input'; +import Select, { SelectProps } from '~components/select'; +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'; + +// Column-sizing playground (grid layout). Adjust each column's sizing mode and widths to explore how +// `columnLayout: 'grid'` compiles `ColumnDefinition`s into a grid-template-columns track list. A CSS grid +// track can't be both fr-weighted and px-capped, so `flex` and `maxWidth` are mutually exclusive in the +// type: use `flex` (weighted, shares free space) or `capped` (grows only up to maxWidth), not both. + +type Mode = 'fixed' | 'flex' | 'capped'; +interface ColConfig { + label: string; + field: keyof Item; + mode: Mode; + value: number; // pixels when fixed, flex weight when flex; unused when capped + minWidth: string; // raw input text; '' means unset + maxWidth: string; +} + +const MODE_OPTIONS: ReadonlyArray = [ + { value: 'fixed', label: 'Fixed (px)' }, + { value: 'flex', label: 'Flex (weight)' }, + { value: 'capped', label: 'Capped (maxWidth)' }, +]; + +const INITIAL: ColConfig[] = [ + { label: 'Name', field: 'name', mode: 'flex', value: 2, minWidth: '160', maxWidth: '' }, + { label: 'Type', field: 'type', mode: 'flex', value: 1, minWidth: '', maxWidth: '' }, + { label: 'Size', field: 'size', mode: 'fixed', value: 120, minWidth: '', maxWidth: '' }, + { label: 'Status', field: 'status', mode: 'capped', value: 1, minWidth: '', maxWidth: '200' }, +]; + +const ITEM_COUNT = 8; + +function toColumnDefinition(config: ColConfig): TableRootProps.ColumnDefinition { + const min = parseInt(config.minWidth, 10); + const max = parseInt(config.maxWidth, 10); + if (config.mode === 'fixed') { + return { size: config.value }; + } + if (config.mode === 'capped') { + // Non-weighted, hard-capped track (grows up to maxWidth). No flex weight. + return { ...(Number.isNaN(min) ? {} : { minWidth: min }), ...(Number.isNaN(max) ? {} : { maxWidth: max }) }; + } + // flex: weighted track, optional minWidth (a fr track can carry a min but not a hard cap). + return { size: { flex: config.value }, ...(Number.isNaN(min) ? {} : { minWidth: min }) }; +} + +export default function TableColumnSizingPlaygroundPage() { + const items = makeItems(ITEM_COUNT); + const [configs, setConfigs] = useState(INITIAL); + + const update = (index: number, patch: Partial) => + setConfigs(prev => prev.map((config, i) => (i === index ? { ...config, ...patch } : config))); + + const columns = useMemo(() => configs.map(toColumnDefinition), [configs]); + + return ( + + + Table atomics — column-sizing playground (grid layout) + + Adjust each column below and watch the table re-lay out. A CSS grid track can't be both weighted and + hard-capped, so a column is either flex (shares free space by weight) or capped (grows only + up to maxWidth) — never both. + + + + {configs.map((config, index) => ( + + {config.label} + + update(index, { value: Number(detail.value) || 0 })} + /> + + )} + + update(index, { minWidth: detail.value })} + /> + + + update(index, { maxWidth: detail.value })} + /> + + + ))} + + +
+          {`columnLayout = { type: 'grid', columns: ${JSON.stringify(columns)} }`}
+        
+ + +
Resources
+ + + + {configs.map(config => ( + {config.label} + ))} + + + + {items.map((item: Item) => ( + + {configs.map(config => ( + {item[config.field]} + ))} + + ))} + + +
+
+
+ ); +} 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 a single full-width status row is a plain ` + + + )} + + + + + ); +} diff --git a/pages/table-root/selection-edge-cases.page.tsx b/pages/table-root/selection-edge-cases.page.tsx new file mode 100644 index 0000000000..045a7d10af --- /dev/null +++ b/pages/table-root/selection-edge-cases.page.tsx @@ -0,0 +1,118 @@ +// 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 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, { TableRowProps } from '~components/table-row'; + +import ScreenshotArea from '../utils/screenshot-area'; + +// Visual coverage for the grid-layout selection-outline edge cases: the selected-row outline is an +// abspos `::after` placed into the row's grid area (`grid-column: 1 / -1`), so it hugs the column extent +// regardless of how the tracks relate to the row box. These permutations pin the states that regressed +// during development: +// - FILL: flex tracks fill the viewport — outline ends at the last column (== viewport). +// - UNDERFILL: capped tracks are narrower than the row — outline stops at the last column, not the row edge. +// - OVERFLOW: fixed tracks exceed the scroll viewport — outline follows the tracks past the fold. +// - MERGE: two consecutive selected rows render as one continuous rounded outline. +// - SHADED: striped-row divider darkening via adjacency. +// - AUTO: in auto layout the row is not a grid, so the outline falls back to the row box. + +interface Row { + name: string; + type: string; + status: string; +} + +const ROWS: Row[] = [ + { name: 'Resource 0', type: 'Compute', status: 'Available' }, + { name: 'Resource 1', type: 'Storage', status: 'Pending' }, + { name: 'Resource 2', type: 'Network', status: 'Available' }, +]; + +const LONG = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function Grid({ + label, + columnLayout, + selected, + shaded, + longFirstCell, + width, +}: { + label: string; + columnLayout?: TableRootProps.ColumnLayout; + selected?: number[]; + shaded?: number[]; + longFirstCell?: boolean; + width?: number; +}) { + const variantOf = (i: number): TableRowProps.Variant => + selected?.includes(i) ? 'selected' : shaded?.includes(i) ? 'shaded' : 'default'; + return ( + + {label} +
+ + + + Name + Type + Status + + + + {ROWS.map((row, i) => ( + + {longFirstCell && i === 1 ? LONG : row.name} + {row.type} + {row.status} + + ))} + + +
+
+ ); +} + +const flex3: TableRootProps.ColumnLayout = { + type: 'grid', + columns: [{ size: { flex: 1 } }, { size: { flex: 1 } }, { size: { flex: 1 } }], +}; +const capped3: TableRootProps.ColumnLayout = { + type: 'grid', + columns: [{ maxWidth: 120 }, { maxWidth: 120 }, { maxWidth: 120 }], +}; +const fixedWide: TableRootProps.ColumnLayout = { + type: 'grid', + columns: [{ size: 260 }, { size: 260 }, { size: 260 }], +}; + +export default function TableSelectionEdgeCasesPage() { + return ( + + + Table atomics — grid selection edge cases + + + + + + + + + + ); +} diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx new file mode 100644 index 0000000000..a135da6793 --- /dev/null +++ b/pages/table-root/selection.page.tsx @@ -0,0 +1,116 @@ +// 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'` (visual row surface only; the +// checkbox conveys selection to assistive technologies) 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. +// 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'; + +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..29b4ee6db5 --- /dev/null +++ b/pages/table-root/single-selection.page.tsx @@ -0,0 +1,88 @@ +// 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. +// 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() { + 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) => ( + + +
+ {/* The accessible name is supplied by an associated `
+
+ {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..4d2be25760 --- /dev/null +++ b/pages/table-root/sorting.page.tsx @@ -0,0 +1,141 @@ +// 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). Only the primary sort column declares `aria-sort` (ARIA +// permits a single sorted column); secondary columns keep the visual caret + priority number only. +// 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..5468fe95d0 --- /dev/null +++ b/pages/table-root/styles.scss @@ -0,0 +1,66 @@ +/* + 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. +// 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). +.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/src/table-body/index.tsx b/src/table-body/index.tsx new file mode 100644 index 0000000000..b38b4f3ac2 --- /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 InternalTableBody from './internal'; + +export { 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..b6b0effebe --- /dev/null +++ b/src/table-body/interfaces.ts @@ -0,0 +1,24 @@ +// 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 interface Style { + position?: React.CSSProperties['position']; + height?: React.CSSProperties['height']; + } +} diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx new file mode 100644 index 0000000000..d081843eb4 --- /dev/null +++ b/src/table-body/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 { TableBodyProps } from './interfaces'; + +import styles from './styles.css.js'; + +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(rest); + 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..66bbeb769f --- /dev/null +++ b/src/table-cell/index.tsx @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'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 { InternalTableCell } from './internal'; + +export { TableCellProps }; + +function TableCell(props: TableCellProps) { + const baseComponentProps = useBaseComponent('TableCell', { props: { disablePaddings: props.disablePaddings } }); + const mergedProps = { ...props, ...baseComponentProps }; + const { children, disablePaddings, __internalRootRef } = mergedProps; + const { className, ...restBaseProps } = getBaseProps(mergedProps); + return ( + + {children} + + ); +} + +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..673a00f424 --- /dev/null +++ b/src/table-cell/interfaces.ts @@ -0,0 +1,15 @@ +// 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. 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..ff7b351a03 --- /dev/null +++ b/src/table-cell/internal.tsx @@ -0,0 +1,92 @@ +// 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 { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useTableContext } from '../table-root/context'; +import { useRowVariant } from '../table-row/context'; + +import bodyCellStyles from '../table/body-cell/styles.css.js'; +import styles from './styles.css.js'; + +export interface InternalTableCellProps { + tag: 'td' | 'th'; + className?: string; + style?: React.CSSProperties; + wrapLines?: boolean; + disablePaddings?: boolean; + nativeAttributes?: Omit< + React.TdHTMLAttributes | React.ThHTMLAttributes, + 'style' | 'className' | 'onClick' + >; + tabIndex?: number; + onClick?: React.MouseEventHandler; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; + 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 { columnLayout } = useTableContext(); + const variant = useRowVariant(); + const isVisualRefresh = useVisualRefresh(); + const isGrid = columnLayout.type === 'grid'; + const Element = tag; + // Grid mode drops the table's implicit cell role, so default to 'cell' — but let a consumer that + // computes a more specific role (e.g. 'rowheader') via nativeAttributes keep it rather than overriding. + const mergedNativeAttributes = isGrid + ? { ...nativeAttributes, role: nativeAttributes?.role ?? 'cell' } + : nativeAttributes; + return ( + + {beforeContent} +
+ {children} +
+
+ ); + } +); diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss new file mode 100644 index 0000000000..b9a4f2ffb5 --- /dev/null +++ b/src/table-cell/styles.scss @@ -0,0 +1,38 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +.cell { + box-sizing: border-box; +} + +// Selection is a background fill plus a layout-neutral `::after` ring: the cell keeps the base +// `.body-cell` borders/padding, so the row never grows and content never shifts on toggle. +[data-variant-selected] > .cell { + background-color: awsui.$color-background-item-selected; +} + +tr:has(+ [data-variant-shaded]) > .cell { + border-block-end-color: awsui.$color-border-cell-shaded; +} +[data-variant-shaded]:not(:last-child) > .cell { + border-block-end-color: awsui.$color-border-cell-shaded; +} + +[data-variant-selected]:has(+ [data-variant-selected]) > .cell { + border-block-end-color: transparent; +} + +tr:not([data-variant-selected]):has(+ [data-variant-selected]) > .cell { + border-block-end-color: transparent; +} + +.cell-grid { + min-inline-size: 0; + align-self: stretch; + display: grid; + align-items: center; +} diff --git a/src/table-head/index.tsx b/src/table-head/index.tsx new file mode 100644 index 0000000000..fe30c688ca --- /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 InternalTableHead from './internal'; + +export { 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..a9cd26dac2 --- /dev/null +++ b/src/table-head/internal.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 clsx from 'clsx'; + +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 styles from './styles.css.js'; + +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(rest); + 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..b8df69ce63 --- /dev/null +++ b/src/table-header-cell/index.tsx @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'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 { InternalTableHeaderCell } from './internal'; + +export { TableHeaderCellProps }; + +function TableHeaderCell(props: TableHeaderCellProps) { + const baseComponentProps = useBaseComponent('TableHeaderCell', { + props: { disablePaddings: props.disablePaddings }, + }); + const mergedProps = { ...props, ...baseComponentProps }; + const { children, ariaLabel, ariaLabelledby, ariaDescribedby, ariaSort, disablePaddings, __internalRootRef } = + mergedProps; + const { className, ...restBaseProps } = getBaseProps(mergedProps); + return ( + + {children} + + ); +} + +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..c5943cc790 --- /dev/null +++ b/src/table-header-cell/interfaces.ts @@ -0,0 +1,25 @@ +// 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. + */ + ariaSort?: React.AriaAttributes['aria-sort']; + /** + * Removes the cell's built-in padding so you can compose your own spacing. 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..f98aae35b9 --- /dev/null +++ b/src/table-header-cell/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 { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useTableContext } from '../table-root/context'; + +import headerCellStyles from '../table/header-cell/styles.css.js'; +import styles from './styles.css.js'; + +export interface InternalTableHeaderCellProps { + className?: string; + style?: React.CSSProperties; + nativeAttributes?: React.ThHTMLAttributes & { + [key: `data-${string}`]: string | number | boolean | undefined; + }; + tabIndex?: number; + disablePaddings?: boolean; + disableContentWrapper?: boolean; + disableDivider?: boolean; + children?: React.ReactNode; +} + +export const InternalTableHeaderCell = React.forwardRef( + ( + { className, style, nativeAttributes, tabIndex, disablePaddings, disableContentWrapper, disableDivider, children }, + ref + ) => { + const { columnLayout } = useTableContext(); + const isVisualRefresh = useVisualRefresh(); + const isGrid = columnLayout.type === 'grid'; + const mergedNativeAttributes = isGrid ? { ...nativeAttributes, role: 'columnheader' as const } : nativeAttributes; + return ( + + ); + } +); diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss new file mode 100644 index 0000000000..dfae49be29 --- /dev/null +++ b/src/table-header-cell/styles.scss @@ -0,0 +1,57 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; +@use '../table/cell-base/cell-box' as cell-base; + +.header-cell { + box-sizing: border-box; +} + +// Presentation-only divider `::after` (the substrate has no Resizer child); geometry mirrors the existing +// Table's resizer `.divider` via the shared `$divider-block-gap`. +.header-cell:not(:last-child):not(.no-divider)::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% - #{cell-base.$divider-block-gap}); + margin-block: auto; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + pointer-events: none; +} + +.header-cell.disable-paddings { + padding-block: 0; + padding-inline: 0; +} + +.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; +} + +.header-cell.disable-paddings > .header-cell-content { + padding-block: 0; + padding-inline: 0; +} + +// VR-gated: the substrate is shared with the existing Table, which still supports non-VR mode. +.header-cell.is-visual-refresh:first-child > .header-cell-content { + padding-inline-start: 0; +} + +// Stretch to the row track, else an empty control header collapses to ~1px, leaving a stray divider stub. +.header-cell-grid { + min-inline-size: 0; + align-self: stretch; + display: grid; + align-items: center; +} diff --git a/src/table-header-row/index.tsx b/src/table-header-row/index.tsx new file mode 100644 index 0000000000..539da44381 --- /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 InternalTableHeaderRow from './internal'; + +export { 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..79f9423114 --- /dev/null +++ b/src/table-header-row/internal.tsx @@ -0,0 +1,34 @@ +// 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 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(rest); + 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..76979bf70b --- /dev/null +++ b/src/table-root/__tests__/basic-table-roles.test.tsx @@ -0,0 +1,150 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { act, 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 +//
` 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. + + + )} + +
+ {disableContentWrapper ? children :
{children}
} +
//// (a shaded row emits +// `data-variant-shaded`) — the one sanctioned styling hook — and the cell stylesheet reads it to paint the +// background and draw the selection outline (a layout-neutral `::after` ring) and to merge consecutive +// selected rows via sibling adjacency. It is driven by `variant`, never a public prop. A shaded row still +// reuses the existing Table's `.body-cell-shaded` background class. Selection and shading are mutually +// exclusive by type. + +function Harness({ variant }: { variant?: TableRowProps.Variant }) { + return ( + + + + Name + Status + + + + + Resource 0 + Available + + + + ); +} + +function renderHarness(variant?: TableRowProps.Variant) { + 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', () => { + test("variant='selected' paints every cell selected, emits the data-variant-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; selection is conveyed by the selection control. + expect(row).not.toHaveAttribute('aria-selected'); + // The one sanctioned styling hook: data-variant-selected drives the consecutive-selected outline merge. + expect(row).toHaveAttribute('data-variant-selected', 'true'); + expect(row).not.toHaveAttribute('data-variant-shaded'); + // Selection paints via the row's data-variant-selected hook (background + ::after ring), not by reusing + // the existing Table's body-cell-selected — so no per-cell selection/has-selection class is emitted. + for (const classList of cellClassLists(wrapper)) { + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + expect(classList.contains(bodyCellStyles['has-selection'])).toBe(false); + 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-variant-selected'); + // data-variant-shaded drives the striped-row divider darkening (sibling adjacency), mirroring data-variant-selected. + expect(row).toHaveAttribute('data-variant-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); + expect(classList.contains(bodyCellStyles['has-selection'])).toBe(false); + } + }); + + test('the default variant paints neither and sets no aria-selected or data-variant-selected', () => { + const { wrapper } = renderHarness(); + const row = wrapper.findAllTableRows()[0].getElement(); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-variant-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 consumer-passed data-variant-* cannot spoof the selection/shading hooks; variant is authoritative', () => { + const { container } = render( + + + + Spoof + + + + ); + const row = createWrapper(container).findAllTableRows()[0].getElement(); + // variant defaults to 'default', so both reserved hooks must be absent despite the consumer values. + expect(row).not.toHaveAttribute('data-variant-selected'); + expect(row).not.toHaveAttribute('data-variant-shaded'); + }); + + 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); + }); +}); + +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 content opts into padding unless disablePaddings is set (mutually exclusive)', () => { + const { container } = render( + + + + Control + Resource 0 + + + + ); + const cells = createWrapper(container).findAllTableCells(); + // Padding is opt-in on the inner `.body-cell-content` wrapper: `with-paddings` normally, the + // `disable-paddings` overflow opt-out when disablePaddings is set — never both. + 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(0).classList.contains(bodyCellStyles['with-paddings'])).toBe(false); + expect(contentOf(1).classList.contains(bodyCellStyles['disable-paddings'])).toBe(false); + expect(contentOf(1).classList.contains(bodyCellStyles['with-paddings'])).toBe(true); + }); + + test('TableHeaderCell opts into padding unless disablePaddings is set (mutually exclusive)', () => { + const { container } = render( + + + + + Name + + + + ); + const headerCells = createWrapper(container).findAllTableHeaderCells(); + const rootOf = (index: number) => headerCells[index].getElement(); + expect(rootOf(0).classList.contains(headerCellStyles['disable-paddings'])).toBe(true); + expect(rootOf(0).classList.contains(legacyHeaderCellStyles['with-paddings'])).toBe(false); + expect(rootOf(1).classList.contains(headerCellStyles['disable-paddings'])).toBe(false); + expect(rootOf(1).classList.contains(legacyHeaderCellStyles['with-paddings'])).toBe(true); + }); +}); + +describe('nested content is insulated from the table/row context', () => { + test('a classic Table nested in a selected grid cell inherits neither the outer grid layout nor the selected variant', () => { + const { container } = render( + + + + +
/
, 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); + }); + }); +}); + +describe('horizontal-overflow scroll region', () => { + let resizeCallback: ResizeObserverCallback; + const originalResizeObserver = global.ResizeObserver; + + beforeEach(() => { + global.ResizeObserver = class { + constructor(cb: ResizeObserverCallback) { + resizeCallback = cb; + } + observe() {} + unobserve() {} + disconnect() {} + }; + }); + afterEach(() => { + global.ResizeObserver = originalResizeObserver; + }); + + const setScrollerGeometry = (scroller: Element, scrollWidth: number, clientWidth: number) => { + Object.defineProperty(scroller, 'scrollWidth', { configurable: true, value: scrollWidth }); + Object.defineProperty(scroller, 'clientWidth', { configurable: true, value: clientWidth }); + }; + + test('exposes a focusable labeled region only while the content overflows', () => { + const { table } = renderTable(makeItems(5), true); + const scroller = table().parentElement!; // root > scroll-container > body-scroller > table + + // Not overflowing (jsdom default 0/0): no region, not a tab stop. + expect(scroller.hasAttribute('role')).toBe(false); + expect(scroller.hasAttribute('tabindex')).toBe(false); + + // Overflows -> focusable labeled region for keyboard scrolling. + setScrollerGeometry(scroller, 1200, 400); + act(() => resizeCallback([], {} as ResizeObserver)); + expect(scroller.getAttribute('role')).toBe('region'); + expect(scroller.getAttribute('tabindex')).toBe('0'); + expect(scroller.getAttribute('aria-label')).toBe('Log events'); + + // Back within bounds -> the region and tab stop are removed. + setScrollerGeometry(scroller, 400, 400); + act(() => resizeCallback([], {} as ResizeObserver)); + expect(scroller.hasAttribute('role')).toBe(false); + expect(scroller.hasAttribute('tabindex')).toBe(false); + }); +}); 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..87179522db --- /dev/null +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -0,0 +1,230 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Table from '../../../lib/components/table'; +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 legacyHeaderCellStyles from '../../../lib/components/table/header-cell/styles.css.js'; +import cellStyles from '../../../lib/components/table-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 through context, sets no +// `aria-selected` (selection is conveyed by the selection control), 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 a selected row emits `data-variant-selected` on the
item.v }]} items={[{ v: 'nested' }]} /> + + + + + ); + // The existing Table resets both atomic contexts at its root, so its own cells read auto layout and + // default variant — the outer grid class and selected paint do not leak into the nested table. + const nestedCell = createWrapper(container).findTable()!.findBodyCell(1, 1)!.getElement(); + expect(nestedCell.classList.contains(cellStyles['cell-grid'])).toBe(false); + expect(nestedCell.classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + }); +}); 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..be85491234 --- /dev/null +++ b/src/table-root/__tests__/basic-table.test.tsx @@ -0,0 +1,264 @@ +// 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 shares the column template with the data rows', () => { + const { wrapper } = renderTable({ grid: true }); + const headerRow = wrapper.findTableHead()!.find('[role="row"]')!.getElement() as HTMLElement; + 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 ariaRowcount is provided (explicit row numbering)', () => { + const withRowcount = renderTable({ grid: true, ariaRowcount: 500 }); + const withRowcountHeaderRow = withRowcount.wrapper.findTableHead()!.find('[role="row"]')!.getElement(); + expect(withRowcountHeaderRow.getAttribute('aria-rowindex')).toBe('1'); + + // Without ariaRowcount the consumer isn't managing row numbering, so positions derive from the DOM and + // 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 variant is visual-only', () => { + test('variant="selected" applies no aria-selected (selection is conveyed by the control)', () => { + const { container } = render( + + + + Name + + + + + Selected visual only + + + Default + + + + ); + const rows = createWrapper(container).findAllTableRows(); + expect(rows[0].getElement().hasAttribute('aria-selected')).toBe(false); + expect(rows[1].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..a947d8cf25 --- /dev/null +++ b/src/table-root/__tests__/use-table-root.test.tsx @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../__tests__/render-hook'; +import { 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 non-weighted track at a px ceiling', () => { + expect(gridTemplate([{ maxWidth: 300 }])).toBe('minmax(0px, 300px)'); + expect(gridTemplate([{ minWidth: 100, maxWidth: 300 }])).toBe('minmax(100px, 300px)'); + }); + + test('flex and maxWidth are mutually exclusive at the type level', () => { + // @ts-expect-error — a weighted (flex) track cannot also be hard-capped (maxWidth); the union forbids it. + const invalid: TableRootProps.ColumnDefinition = { size: { flex: 2 }, maxWidth: 300 }; + expect(invalid).toBeDefined(); + }); + + describe('malformed numeric values do not invalidate the whole template', () => { + test('a negative fixed size clamps to 0px', () => { + expect(gridTemplate([{ size: -50 }, { size: 100 }])).toBe('0px 100px'); + }); + + test('a non-finite fixed size falls back to the default growable track', () => { + expect(gridTemplate([{ size: NaN }, { size: 100 }])).toBe('minmax(0px, 1fr) 100px'); + expect(gridTemplate([{ size: Infinity }])).toBe('minmax(0px, 1fr)'); + }); + + test('a negative flex weight clamps to 0fr', () => { + expect(gridTemplate([{ size: { flex: -2 } }])).toBe('minmax(0px, 0fr)'); + }); + + test('a non-finite flex weight falls back to the default growable track', () => { + expect(gridTemplate([{ size: { flex: NaN } }])).toBe('minmax(0px, 1fr)'); + }); + + test('a negative minWidth clamps to 0px, a non-finite minWidth is dropped', () => { + expect(gridTemplate([{ minWidth: -10 }])).toBe('minmax(0px, 1fr)'); + expect(gridTemplate([{ minWidth: NaN, maxWidth: 300 }])).toBe('minmax(0px, 300px)'); + }); + + test('a negative maxWidth clamps to 0px, a non-finite maxWidth is dropped', () => { + expect(gridTemplate([{ maxWidth: -300 }])).toBe('minmax(0px, 0px)'); + expect(gridTemplate([{ maxWidth: Infinity }])).toBe('minmax(0px, 1fr)'); + }); + }); + }); +}); diff --git a/src/table-root/context.ts b/src/table-root/context.ts new file mode 100644 index 0000000000..3136267d99 --- /dev/null +++ b/src/table-root/context.ts @@ -0,0 +1,21 @@ +// 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. It is also +// the value the existing `Table` resets to at its root, so a Table nested inside an atomic grid cell +// renders from auto layout rather than inheriting the outer table's. +export 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..c0a3412500 --- /dev/null +++ b/src/table-root/index.tsx @@ -0,0 +1,24 @@ +// 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 InternalTableRoot from './internal'; + +// Each part is its own top-level component (one default export + props type) so the documenter documents it separately. +export { 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..91930f55dd --- /dev/null +++ b/src/table-root/interfaces.ts @@ -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 { 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) - Caps a flexible column's width in pixels (it grows up to the cap). A capped + * column can't also carry a proportional `flex` weight, so weighting applies to the uncapped columns. + * + * 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; + + /** + * Sets the table's `aria-rowcount`, counting the header row. Provide it only when you render a + * subset of rows, such as with virtualization; otherwise it is derived from the DOM. + */ + ariaRowcount?: number; +} + +export namespace TableRootProps { + export type ColumnLayout = { type: 'auto' } | { type: 'grid'; columns: ReadonlyArray }; + + // A grid column is sized in one of two mutually exclusive ways. `flex` (weighted) and `maxWidth` + // (hard cap) are intentionally exclusive: a CSS grid track cannot be both fr-weighted and px-capped. The + // `maxWidth?: never` on the flex variant enforces this — without it, a union's excess-property check would + // still permit `maxWidth` (since it is valid on the other variant) and silently drop the weight. + export type ColumnDefinition = + | { size?: number; minWidth?: number; maxWidth?: number } + | { size: { flex: number }; minWidth?: number; maxWidth?: never }; +} diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx new file mode 100644 index 0000000000..a219373bed --- /dev/null +++ b/src/table-root/internal.tsx @@ -0,0 +1,95 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { RowVariantContextProvider } from '../table-row/context'; +import { TableContextProvider } from './context'; +import { TableRootProps } from './interfaces'; +import { useTableRoot } from './use-table-root'; + +import styles from './styles.css.js'; + +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(rest); + + // A wide table's horizontal scroller isn't keyboard-reachable on its own, so a read-only table with no + // focusable cell content can't be scrolled by keyboard. When the content overflows, expose the scroller + // as a focusable labeled region (matching the existing Table's getTableWrapperRoleProps). + const scrollerRef = useRef(null); + const [isScrollable, setIsScrollable] = useState(false); + const measureScrollable = useCallback(() => { + const node = scrollerRef.current; + if (node) { + setIsScrollable(node.scrollWidth - node.clientWidth > 1); + } + }, []); + // Observer stays tied to the stable scroller node (and its child) so it isn't reallocated on every render. + useEffect(() => { + const node = scrollerRef.current; + if (!node || typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver(measureScrollable); + observer.observe(node); + if (node.firstElementChild) { + observer.observe(node.firstElementChild); + } + return () => observer.disconnect(); + }, [measureScrollable]); + // Re-measure on layout template and content changes: overflow can start/stop without a box-size change + // (dynamic grid content), which the ResizeObserver alone would miss. Cheap read, no observer churn. + useEffect(() => { + measureScrollable(); + }, [table.gridTemplateColumns, children, measureScrollable]); + + const scrollRegionProps = isScrollable + ? { + role: ariaLabel || ariaLabelledby ? ('region' as const) : undefined, + tabIndex: 0, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + } + : {}; + + return ( +
+ + + {/* The page owns vertical scroll; this wrapper reintroduces an inline scroll viewport so a wide table scrolls horizontally instead of spilling out. */} +
+
+
+ {children} +
+ + + + + + ); +} diff --git a/src/table-root/styles.scss b/src/table-root/styles.scss new file mode 100644 index 0000000000..4288e616ae --- /dev/null +++ b/src/table-root/styles.scss @@ -0,0 +1,43 @@ +/* + 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: the collapsed model breaks sticky columns and blurs the selected-row outline corners. + border-collapse: separate; + border-spacing: 0; +} + +.table-auto { + table-layout: auto; +} + +.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..8e2394a34b --- /dev/null +++ b/src/table-root/use-table-root.ts @@ -0,0 +1,53 @@ +// 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; + /** The consumer-supplied `aria-rowcount`, present only when the table is virtualized (a grid rendering a subset of rows). */ + ariaRowcount?: number; +} + +// Clamp negatives to 0 so one malformed dimension can't invalidate the whole +// grid-template-columns string; a non-finite value (NaN/Infinity) returns undefined +// so the caller drops that dimension and falls back to a sensible track. +function clamp(value: number | undefined): number | undefined { + return value === undefined || !Number.isFinite(value) ? undefined : Math.max(0, value); +} + +export function useTableRoot(columnLayout: TableRootProps.ColumnLayout, ariaRowcount?: number): UseTableRootResult { + const gridTemplateColumns = useMemo(() => { + if (columnLayout.type !== 'grid') { + return undefined; + } + return columnLayout.columns + .map(column => { + const size = typeof column.size === 'number' ? clamp(column.size) : undefined; + if (size !== undefined) { + return `${size}px`; + } + const min = `${clamp(column.minWidth) ?? 0}px`; + const flex = typeof column.size === 'object' ? clamp(column.size.flex) : undefined; + if (flex !== undefined) { + // Weighted track — `{ flex: number }`. The type forbids a maxWidth here (can't cap an fr track). + return `minmax(${min}, ${flex}fr)`; + } + // No explicit size: a hard-capped track (maxWidth) or the default growable track. + const max = clamp(column.maxWidth); + if (max !== undefined) { + return `minmax(${min}, ${max}px)`; + } + return `minmax(${min}, 1fr)`; + }) + .join(' '); + }, [columnLayout]); + + return useMemo( + () => ({ columnLayout, gridTemplateColumns, ariaRowcount }), + [columnLayout, gridTemplateColumns, ariaRowcount] + ); +} diff --git a/src/table-row/context.ts b/src/table-row/context.ts new file mode 100644 index 0000000000..1a1d166586 --- /dev/null +++ b/src/table-row/context.ts @@ -0,0 +1,15 @@ +// 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 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; + +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..8eda6b30f7 --- /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 InternalTableRow from './internal'; + +export { 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..281b8d7757 --- /dev/null +++ b/src/table-row/interfaces.ts @@ -0,0 +1,47 @@ +// 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. Visual only — it does not set `aria-selected`; convey selection to + * assistive technologies via the selection control in a leading cell. + * * `default` - A standard row. + * * `selected` - Applies selected-row styling. + * * `shaded` - Applies a shaded background for alternating row colors. + * + * 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 the row's `aria-rowindex`, its position in the full dataset counting the header row. Set + * this only when virtualizing; otherwise it is derived from DOM order. + */ + ariaRowindex?: number; + /** + * Applies inline styles to the row element for positioning, such as virtualization or draggable + * rows. Not intended for general styling. + */ + style?: TableRowProps.Style; + /** 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 interface Style { + position?: React.CSSProperties['position']; + transform?: React.CSSProperties['transform']; + height?: React.CSSProperties['height']; + } +} diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx new file mode 100644 index 0000000000..a16ac16f21 --- /dev/null +++ b/src/table-row/internal.tsx @@ -0,0 +1,54 @@ +// 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 { RowVariantContextProvider } from './context'; +import { TableRowProps } from './interfaces'; + +import styles from './styles.css.js'; + +// Sanctioned data-* hooks: `data-variant-selected` / `data-variant-shaded` on the let sibling-adjacency +// CSS (consecutive-selected merge, striped divider) work, which a cell can't do from context. Inert for the Table. +export interface InternalTableRowProps extends TableRowProps, InternalBaseComponentProps {} + +export default function InternalTableRow({ + variant = 'default', + ariaLabel, + ariaLabelledby, + ariaDescribedby, + ariaRowindex, + children, + style, + __internalRootRef, + ...rest +}: InternalTableRowProps) { + const { columnLayout, gridTemplateColumns } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(rest); + // `variant` is the sole source of truth for these hooks: emit both unconditionally after the base-prop + // spread (true|undefined) so a consumer-passed data-variant-* can't spoof the selection/shading paint. + const reservedVariantAttributes = { + 'data-variant-selected': variant === 'selected' ? 'true' : undefined, + 'data-variant-shaded': variant === 'shaded' ? 'true' : undefined, + }; + return ( + + {children} + + ); +} diff --git a/src/table-row/styles.scss b/src/table-row/styles.scss new file mode 100644 index 0000000000..3270c7a28b --- /dev/null +++ b/src/table-row/styles.scss @@ -0,0 +1,57 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; +@use '../table/cell-base/cell-box' as cell-base; + +.row { + position: relative; + box-sizing: border-box; +} + +.row-grid { + display: grid; + inline-size: 100%; + align-items: center; + // Min row track = the cell's box height, derived from the same cell-base tokens as the cell padding so + // the two can't drift. + grid-auto-rows: minmax( + calc( + #{awsui.$line-height-body-m} + 2 * #{cell-base.$cell-vertical-padding} + 2 * + #{cell-base.$cell-negative-space-vertical} - #{awsui.$border-divider-list-width} + ), + auto + ); +} + +// Selection outline drawn as a layout-neutral `::after` on the row. Scoped to the hashed `.row` (not the +// bare `data-variant-selected` attribute) so it can't leak onto unrelated consumer elements. In grid mode +// the abs-pos `::after` is a grid item — its containing block is the column tracks, so `inset:0` hugs the +// columns (stops at underfill, follows overflow); auto mode falls back to the row box. +.row[data-variant-selected]::after { + content: ''; + position: absolute; + inset: 0; + grid-column: 1 / -1; + grid-row: 1; + 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; +} +.row[data-variant-selected]:has(+ .row[data-variant-selected])::after { + border-end-start-radius: 0; + border-end-end-radius: 0; +} +.row[data-variant-selected] + .row[data-variant-selected]::after { + border-block-start-width: 0; + border-start-start-radius: 0; + border-start-end-radius: 0; +} 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 d93805b7e83614a45dc9be4c97afe4e333dfe13c Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 11 Sep 2026 07:59:10 +0000 Subject: [PATCH 02/23] refactor(table): build the classic Table on the atomic substrates + cell dedup Delegate the existing Table's td/th elements to InternalTableCell/ InternalTableHeaderCell (atomic-default-on, with disableContentWrapper/ disableDivider/suppressBlock*Placeholder opt-outs for the classic path), extract shared cell geometry into the cell-base partial, migrate first/last-row edges to positional CSS, and fix atomic-grid selection-control centering. Also regenerate the generated-artifact snapshots and fix the test-utils finder pluralization for the new components. --- build-tools/utils/pluralize.js | 7 + .../functional-tests/test-utils.test.tsx | 21 +- .../__snapshots__/documenter.test.ts.snap | 565 ++++++++++++++++++ .../test-utils-selectors.test.tsx.snap | 21 + .../test-utils-wrappers.test.tsx.snap | 560 +++++++++++++++++ src/table/body-cell/styles.scss | 13 +- src/table/body-cell/td-element.tsx | 67 ++- src/table/cell-base/_cell-box.scss | 24 +- src/table/header-cell/styles.scss | 8 +- src/table/header-cell/th-element.tsx | 48 +- src/table/internal.tsx | 14 +- src/table/resizer/styles.scss | 3 +- 12 files changed, 1272 insertions(+), 79 deletions(-) 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/__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 bd4c9dd0b3..2796bf75d1 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29578,6 +29578,515 @@ 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": "Property.Height", + "type": "union", + "values": [ + "string", + "number", + "string & {}", + ], + }, + "name": "height", + "optional": true, + "type": "Property.Height", + }, + { + "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. 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.", + "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. 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": "Sets the table's \`aria-rowcount\`, counting the header row. Provide it only when you render a +subset of rows, such as with virtualization; otherwise it 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) - Caps a flexible column's width in pixels (it grows up to the cap). A capped + column can't also carry a proportional \`flex\` weight, so weighting applies to the uncapped columns. + +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 position in the full dataset counting the header row. Set +this only when virtualizing; otherwise it is derived from DOM order.", + "name": "ariaRowindex", + "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", + }, + { + "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 for positioning, such as virtualization or draggable +rows. Not intended for general styling.", + "inlineType": { + "name": "TableRowProps.Style", + "properties": [ + { + "inlineType": { + "name": "Property.Height", + "type": "union", + "values": [ + "string", + "number", + "string & {}", + ], + }, + "name": "height", + "optional": true, + "type": "Property.Height", + }, + { + "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. Visual only — it does not set \`aria-selected\`; convey selection to +assistive technologies via the selection control in a leading cell. +* \`default\` - A standard row. +* \`selected\` - Applies selected-row styling. +* \`shaded\` - Applies a shaded background for alternating row colors. + +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", @@ -45920,6 +46429,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": [ { @@ -55708,6 +56245,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 0828da7738..dbe864243d 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 @@ -680,6 +680,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){ diff --git a/src/table/body-cell/styles.scss b/src/table/body-cell/styles.scss index 3e141b7ea9..b3fabc663e 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -135,8 +135,10 @@ $editing-cell-padding-block: awsui.$space-scaled-xxxs; border-inline-start: none; } } - // Guard selected out: a selected first row must keep its (thicker, coloured) selected - // top border, which the transparent placeholder would otherwise cover. + // First/last-row transparent placeholder. `:not(.body-cell-selected)` keeps a selected row's own border. + // Ungrid-guarded on `.body-cell`, so it is the single source of the placeholder for every cell — the + // existing Table's rows and the atomic cells (grid and auto) alike; load-bearing for grid + // selection-control centering. tr:first-child > &:not(.body-cell-selected) { border-block-start: cell-base.$border-placeholder; } @@ -505,3 +507,10 @@ $editing-cell-padding-block: awsui.$space-scaled-xxxs; @include cell-focus-outline; } } + +// A paddingless control cell has no reserved negative space, so the base truncation `overflow: hidden` +// would crop the control's focus ring. (No padding/margin reset needed: a control cell omits the +// `with-paddings` opt-in, so the padding mixins never apply.) +.body-cell-content.disable-paddings { + overflow: visible; +} diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index 7d10849652..0d42af4fa3 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -8,7 +8,7 @@ import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import { ExpandToggleButton } from '../../internal/components/expand-toggle-button'; -import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; +import { InternalTableCell } from '../../table-cell/internal'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces.js'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; @@ -100,12 +100,15 @@ export const TableTdElement = React.forwardRef { - const Element = isRowHeader ? 'th' : 'td'; - const isVisualRefresh = useVisualRefresh(); + const tag = isRowHeader ? 'th' : 'td'; resizableStyle = resizableColumns ? {} : resizableStyle; - nativeAttributes = { ...nativeAttributes, ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }) }; + const cellNativeAttributes = { + ...nativeAttributes, + ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }), + ...copyAnalyticsMetadataAttribute(rest), + }; const stickyStyles = useStickyCellStyles({ stickyColumns: stickyState, @@ -118,17 +121,21 @@ 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/cell-base/_cell-box.scss b/src/table/cell-base/_cell-box.scss index f3c2fe53fe..94ed2ac4ca 100644 --- a/src/table/cell-base/_cell-box.scss +++ b/src/table/cell-base/_cell-box.scss @@ -4,23 +4,27 @@ @use '../../internal/styles/tokens' as awsui; $cell-vertical-padding: awsui.$space-scaled-xs; -// Calculate padding to prevent a shift in content after selection due to the difference -// between selected border widths and normal row divider widths (visual refresh). +// Extra padding so content doesn't shift on selection (VR: the selected border is wider than the divider). $cell-vertical-padding-w-border: calc( #{$cell-vertical-padding} + (#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}) ); $cell-horizontal-padding: awsui.$space-scaled-l; $cell-edge-horizontal-padding: calc(#{awsui.$space-l} - #{awsui.$border-item-width}); $border-placeholder: awsui.$border-item-width solid transparent; +// Divider inset from the cell's top/bottom edges. Shared with the existing Table's resizer so the two match. +$divider-block-gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); $cell-offset: calc(#{awsui.$space-m} + #{awsui.$space-xs}); -// Ensuring enough space for absolute-positioned focus outlines of focus-able cell content elements. +// Space reserved for focus outlines of focusable cell content. $cell-negative-space-vertical: 2px; +// Padding is opt-in: only content carrying `.with-paddings` gets it (stamped unless `disablePaddings`). +// A control cell omits the class, so no padding/negative-margin applies — nothing to override, at any +// specificity, even as new edge/state rules are added. @mixin cell-padding-inline-start($padding) { $max-nesting-levels: 9; $offset-padding: calc($padding - 1 * awsui.$border-divider-list-width); - > .body-cell-content { + > .body-cell-content.with-paddings { padding-inline-start: $offset-padding; } > .expandable-toggle-wrapper { @@ -29,7 +33,7 @@ $cell-negative-space-vertical: 2px; @for $i from 0 through $max-nesting-levels { &.expandable-level-#{$i} { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-inline-start: calc(#{$offset-padding} / 2); margin-inline-start: calc(#{$offset-padding} / 2 + #{$i} * #{$cell-offset}); } @@ -39,7 +43,7 @@ $cell-negative-space-vertical: 2px; } } &.expandable-level-next { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-inline-start: calc(#{$offset-padding} / 2); margin-inline-start: calc(#{$offset-padding} / 2 + #{$max-nesting-levels} * #{$cell-offset}); } @@ -49,24 +53,24 @@ $cell-negative-space-vertical: 2px; } } @mixin cell-padding-inline-end($padding) { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-inline-end: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width}); } } @mixin cell-padding-block($padding) { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-block: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical}); margin-block: calc(-1 * #{$cell-negative-space-vertical}); } } @mixin cell-padding-block-start($padding) { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-block-start: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical}); margin-block-start: calc(-1 * #{$cell-negative-space-vertical}); } } @mixin cell-padding-block-end($padding) { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-block-end: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical}); margin-block-end: calc(-1 * #{$cell-negative-space-vertical}); } diff --git a/src/table/header-cell/styles.scss b/src/table/header-cell/styles.scss index cb1852de61..6a10d749d8 100644 --- a/src/table/header-cell/styles.scss +++ b/src/table/header-cell/styles.scss @@ -283,7 +283,9 @@ settings icon in the pagination slot. @include header-cell-focus-outline-first(awsui.$space-table-header-focus-outline-gutter); } - &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( + // Opt-in gate: a control header (`disablePaddings`) omits `.with-paddings`, so this high-specificity + // offset simply doesn't apply — a low-specificity reset could not override it. + &:first-child.with-paddings:not(.has-striped-rows):not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( .header-cell-grouped ) { @include cell-offset(awsui.$space-xxxs); @@ -294,7 +296,9 @@ settings icon in the pagination slot. shaded background makes the child content appear too close to the table edge. */ - &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start):not(.header-cell-group):not(.header-cell-grouped) { + &:first-child.with-paddings.has-striped-rows:not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( + .header-cell-grouped + ) { @include cell-offset(awsui.$space-xxs); } diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 68dd326b54..227b1408c2 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -7,7 +7,7 @@ import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; 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'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; @@ -86,8 +86,6 @@ export function TableThElement({ stickyBoundaryColumnId, ...props }: TableThElementProps) { - const isVisualRefresh = useVisualRefresh(); - const stickyStyles = useStickyCellStyles({ stickyColumns: stickyState, columnId, @@ -99,17 +97,35 @@ 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/internal.tsx b/src/table/internal.tsx index 966df2e29f..13f80cc9de 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 { defaultTableContext, TableContextProvider } from '../table-root/context'; +import { RowVariantContextProvider } from '../table-row/context'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; import { ClearSortButton } from './clear-sort'; @@ -510,7 +512,7 @@ const InternalTable = React.forwardRef( const totalColumnsCount = visibleColumnDefinitions.length + colIndexOffset; const headerRowCount = columnGroupsLayout?.rows.length || 1; - return ( + const tableContent = ( { // 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 @@ -732,12 +736,10 @@ 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} > {selection.getItemSelectionProps && ( ); + + return ( + + {tableContent} + + ); } ) as TableForwardRefType; diff --git a/src/table/resizer/styles.scss b/src/table/resizer/styles.scss index 5dcdb0ab46..4c5eda6d1d 100644 --- a/src/table/resizer/styles.scss +++ b/src/table/resizer/styles.scss @@ -5,6 +5,7 @@ @use '../../internal/styles/index' as styles; @use '../../internal/styles/tokens' as awsui; +@use '../cell-base/cell-box' as cell-base; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; //stylelint-disable-next-line selector-combinator-disallowed-list,selector-max-universal @@ -15,7 +16,7 @@ $handle-width: awsui.$space-xl; $active-separator-width: 2px; -$block-gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); +$block-gap: cell-base.$divider-block-gap; .resizer-wrapper { inset-block: 0; From 8be61fffdc649815512c2779bcb45f91c9c442c3 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 17 Sep 2026 11:39:06 +0000 Subject: [PATCH 03/23] feat: Add ariaLabel, ariaLabelledby, and ariaDescribedby to RadioButton --- .../__snapshots__/documenter.test.ts.snap | 20 +++++++++++++++++++ .../components/radio-button/index.tsx | 6 ++++++ .../__tests__/radio-button.test.tsx | 15 ++++++++++++++ src/radio-button/interfaces.ts | 17 ++++++++++++++++ 4 files changed, 58 insertions(+) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 2796bf75d1..585b96fe84 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -23995,6 +23995,26 @@ exports[`Components definition for radio-button matches the snapshot: radio-butt ], "name": "RadioButton", "properties": [ + { + "description": "Adds \`aria-describedby\` to the native control.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Adds an \`aria-label\` to the native control. + +Use this if you don't have a visible label for this control.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-labelledby\` to the native control.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, { "description": "Specifies if the component is selected.", "name": "checked", diff --git a/src/internal/components/radio-button/index.tsx b/src/internal/components/radio-button/index.tsx index e5c4ac47b5..2eadde5901 100644 --- a/src/internal/components/radio-button/index.tsx +++ b/src/internal/components/radio-button/index.tsx @@ -28,6 +28,9 @@ export default React.forwardRef(function RadioButton( children, value, checked, + ariaLabel, + ariaLabelledby, + ariaDescribedby, description, disabled, controlId, @@ -54,6 +57,9 @@ export default React.forwardRef(function RadioButton( controlClassName={styles['radio-control']} outlineClassName={styles.outline} label={children} + ariaLabel={ariaLabel} + ariaLabelledby={ariaLabelledby} + ariaDescribedby={ariaDescribedby} description={description} disabled={disabled} readOnly={readOnly} diff --git a/src/radio-button/__tests__/radio-button.test.tsx b/src/radio-button/__tests__/radio-button.test.tsx index 8d153b167c..afd198a848 100644 --- a/src/radio-button/__tests__/radio-button.test.tsx +++ b/src/radio-button/__tests__/radio-button.test.tsx @@ -28,6 +28,21 @@ describe('Radio Button native attributes from props', () => { const radioButton = renderRadioButton(); expect(radioButton.findNativeInput()!.getElement().getAttribute('value')).toBe('my-radio-button-value'); }); + + test('applies the `ariaLabel` prop as `aria-label` on the native element', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement()).toHaveAttribute('aria-label', 'Select resource'); + }); + + test('applies the `ariaLabelledby` prop as `aria-labelledby` on the native element', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement()).toHaveAttribute('aria-labelledby', 'label-id'); + }); + + test('applies the `ariaDescribedby` prop as `aria-describedby` on the native element', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement()).toHaveAttribute('aria-describedby', 'desc-id'); + }); }); describe('Radio Button events', () => { diff --git a/src/radio-button/interfaces.ts b/src/radio-button/interfaces.ts index f23e7a8901..057db31817 100644 --- a/src/radio-button/interfaces.ts +++ b/src/radio-button/interfaces.ts @@ -15,6 +15,23 @@ export interface RadioButtonProps extends BaseComponentProps { */ checked: boolean; + /** + * Adds an `aria-label` to the native control. + * + * Use this if you don't have a visible label for this control. + */ + ariaLabel?: string; + + /** + * Adds `aria-labelledby` to the native control. + */ + ariaLabelledby?: string; + + /** + * Adds `aria-describedby` to the native control. + */ + ariaDescribedby?: string; + /** * Specifies the ID of the native form element. You can use it to relate * a label element's `for` attribute to this control. From a9bd1d33994b9b383ea0592401932b6b9e05789b Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 17 Sep 2026 11:39:08 +0000 Subject: [PATCH 04/23] chore: Refactor table atomic dev pages and persist page settings in URL --- pages/table-root/column-sizing.page.tsx | 31 ++++- pages/table-root/loading-and-empty.page.tsx | 9 +- pages/table-root/selection.page.tsx | 133 ++++++++++-------- pages/table-root/simple.page.tsx | 22 ++- pages/table-root/single-selection.page.tsx | 88 ------------ pages/table-root/sorting.page.tsx | 147 ++++++++------------ pages/table-root/styles.scss | 24 +--- 7 files changed, 176 insertions(+), 278 deletions(-) delete mode 100644 pages/table-root/single-selection.page.tsx diff --git a/pages/table-root/column-sizing.page.tsx b/pages/table-root/column-sizing.page.tsx index 424ab68166..1e0503bd3a 100644 --- a/pages/table-root/column-sizing.page.tsx +++ b/pages/table-root/column-sizing.page.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import Box from '~components/box'; import ColumnLayout from '~components/column-layout'; @@ -17,6 +17,7 @@ import TableHeaderRow from '~components/table-header-row'; import TableRoot, { TableRootProps } from '~components/table-root'; import TableRow from '~components/table-row'; +import { useAppContext } from '../app/app-context'; import { Item, makeItems } from './common'; // Column-sizing playground (grid layout). Adjust each column's sizing mode and widths to explore how @@ -49,6 +50,27 @@ const INITIAL: ColConfig[] = [ const ITEM_COUNT = 8; +// The per-column mutable fields persisted to the URL; label/field are fixed and restored from INITIAL. +type StoredCol = Pick; + +function serializeConfigs(configs: ColConfig[]): string { + return JSON.stringify(configs.map(({ mode, value, minWidth, maxWidth }) => ({ mode, value, minWidth, maxWidth }))); +} + +function parseConfigs(raw: string | boolean | undefined): ColConfig[] { + if (typeof raw === 'string') { + try { + const stored = JSON.parse(raw) as Partial[]; + if (Array.isArray(stored) && stored.length === INITIAL.length) { + return INITIAL.map((base, index) => ({ ...base, ...stored[index] })); + } + } catch { + // Malformed URL value — fall back to defaults. + } + } + return INITIAL; +} + function toColumnDefinition(config: ColConfig): TableRootProps.ColumnDefinition { const min = parseInt(config.minWidth, 10); const max = parseInt(config.maxWidth, 10); @@ -65,10 +87,13 @@ function toColumnDefinition(config: ColConfig): TableRootProps.ColumnDefinition export default function TableColumnSizingPlaygroundPage() { const items = makeItems(ITEM_COUNT); - const [configs, setConfigs] = useState(INITIAL); + const { urlParams, setUrlParams } = useAppContext<'columns'>(); + const configs = useMemo(() => parseConfigs(urlParams.columns), [urlParams.columns]); const update = (index: number, patch: Partial) => - setConfigs(prev => prev.map((config, i) => (i === index ? { ...config, ...patch } : config))); + setUrlParams({ + columns: serializeConfigs(configs.map((config, i) => (i === index ? { ...config, ...patch } : config))), + }); const columns = useMemo(() => configs.map(toColumnDefinition), [configs]); diff --git a/pages/table-root/loading-and-empty.page.tsx b/pages/table-root/loading-and-empty.page.tsx index 82499fe852..aa8e2a1ec5 100644 --- a/pages/table-root/loading-and-empty.page.tsx +++ b/pages/table-root/loading-and-empty.page.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useState } from 'react'; +import React from 'react'; import Box from '~components/box'; import Header from '~components/header'; @@ -11,6 +11,7 @@ import TableBody from '~components/table-body'; import TableRoot from '~components/table-root'; import TableRow from '~components/table-row'; +import { useAppContext } from '../app/app-context'; import { DataBody, DataHeader, makeItems } from './common'; type State = 'loaded' | 'loading' | 'empty'; @@ -22,7 +23,9 @@ const COLUMN_COUNT = 4; // 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 { urlParams, setUrlParams } = useAppContext<'dataState'>(); + const state: State = + urlParams.dataState === 'loading' || urlParams.dataState === 'empty' ? urlParams.dataState : 'loaded'; const items = state === 'loaded' ? makeItems(20) : []; return ( @@ -32,7 +35,7 @@ export default function TableLoadingEmptyPage() { setState(event.detail.selectedId as State)} + onChange={event => setUrlParams({ dataState: event.detail.selectedId as State })} label="Data state" options={[ { id: 'loaded', text: 'Loaded' }, diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx index a135da6793..20f26b653d 100644 --- a/pages/table-root/selection.page.tsx +++ b/pages/table-root/selection.page.tsx @@ -1,11 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; -import Box from '~components/box'; import Checkbox from '~components/checkbox'; import Header from '~components/header'; -import Icon from '~components/icon'; +import RadioButton from '~components/radio-button'; +import SegmentedControl from '~components/segmented-control'; import SpaceBetween from '~components/space-between'; import TableBody from '~components/table-body'; import TableCell from '~components/table-cell'; @@ -15,19 +15,18 @@ import TableHeaderRow from '~components/table-header-row'; import TableRoot, { TableRootProps } from '~components/table-root'; import TableRow from '~components/table-row'; +import { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; 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'` (visual row surface only; the -// checkbox conveys selection to assistive technologies) 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. -// 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.) +// Selection (grid layout), multi and single, composed by the consumer — the atomic parts contribute +// only `variant='selected'` (the visual row surface; the checkbox/radio conveys selection to +// assistive technologies). The control column uses `disablePaddings` cells with a centred control to +// match the existing Table's selection column. +// Control column is fixed; Name and Status share the remaining width via flex weights (rather than +// flexing Name alone) so Status stays adjacent instead of being pushed to the far edge. const COLUMNS: ReadonlyArray = [ { size: 40 }, { size: { flex: 53 } }, @@ -35,20 +34,17 @@ const COLUMNS: ReadonlyArray = [ ]; const ITEM_COUNT = 10; -type SortDirection = 'ascending' | 'descending'; +type SelectionMode = 'multi' | 'single'; 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 items = makeItems(ITEM_COUNT); + const { urlParams, setUrlParams } = useAppContext<'selectionMode'>(); + const mode: SelectionMode = urlParams.selectionMode === 'single' ? 'single' : 'multi'; + const [selectedIds, setSelectedIds] = useState>(new Set([items[1].id])); 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 => { @@ -60,19 +56,37 @@ export default function TableSelectionPage() { } return next; }); - const toggleSort = () => setDirection(prev => (prev === 'ascending' ? 'descending' : 'ascending')); + const selectSingle = (id: string) => setSelectedIds(new Set([id])); - return ( - - - Table atomics — selectable + sortable (grid layout) + const changeMode = (next: SelectionMode) => { + setUrlParams({ selectionMode: next }); + // Single selection permits at most one row, so collapse the current selection when switching to it. + setSelectedIds(prev => (next === 'single' ? new Set([...prev].slice(0, 1)) : prev)); + }; - -
Resources
- - - - + return ( + changeMode(detail.selectedId as SelectionMode)} + options={[ + { id: 'multi', text: 'Multi-select' }, + { id: 'single', text: 'Single-select' }, + ]} + /> + } + screenshotArea={{}} + > + +
Resources
+ + + + + {mode === 'multi' ? (
-
- - - - Status -
-
- - {items.map((item: Item) => ( - - -
+ ) : null} + + Name + Status + + + + {items.map((item: Item) => ( + + +
+ {mode === 'multi' ? ( toggleRow(item.id)} ariaLabel={`Select ${item.name}`} /> -
-
- {item.name} - {item.status} -
- ))} -
- - + ) : ( + selectSingle(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 index b110d8d678..052df8e4a9 100644 --- a/pages/table-root/simple.page.tsx +++ b/pages/table-root/simple.page.tsx @@ -2,11 +2,11 @@ // 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 { SimplePage } from '../app/templates'; import { DataBody, DataHeader, makeItems } from './common'; // A minimal read-only table in auto layout. `columnLayout` is omitted, so it @@ -14,18 +14,14 @@ import { DataBody, DataHeader, makeItems } from './common'; export default function TableSimplePage() { const items = makeItems(8); return ( - - - Table atomics — simple (auto layout) - - -
Resources
- - - - -
+ + +
Resources
+ + + +
-
+ ); } diff --git a/pages/table-root/single-selection.page.tsx b/pages/table-root/single-selection.page.tsx deleted file mode 100644 index 29b4ee6db5..0000000000 --- a/pages/table-root/single-selection.page.tsx +++ /dev/null @@ -1,88 +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 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. -// 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() { - 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) => ( - - -
- {/* The accessible name is supplied by an associated `
-
- {item.name} - {item.status} -
- ))} -
-
-
-
-
- ); -} diff --git a/pages/table-root/sorting.page.tsx b/pages/table-root/sorting.page.tsx index 4d2be25760..982047aa6f 100644 --- a/pages/table-root/sorting.page.tsx +++ b/pages/table-root/sorting.page.tsx @@ -2,7 +2,6 @@ // 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'; @@ -14,25 +13,18 @@ import TableHeaderRow from '~components/table-header-row'; import TableRoot from '~components/table-root'; import TableRow from '~components/table-row'; +import { SimplePage } from '../app/templates'; 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). Only the primary sort column declares `aria-sort` (ARIA -// permits a single sorted column); secondary columns keep the visual caret + priority number only. -// Caret icons match classic Table: `caret-down` (sortable), +// only `ariaSort` on each header cell. Clicking a column sorts by it, toggling direction when it is +// already the active column. Caret icons match the existing Table: `caret-down` (sortable, inactive), // `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' }, @@ -50,92 +42,65 @@ function compare(key: SortKey, a: Item, b: Item): number { export default function TableSortingPage() { const items = makeItems(12); - const [sort, setSort] = useState>([{ key: 'name', direction: 'ascending' }]); + const [sortKey, setSortKey] = useState('name'); + const [direction, setDirection] = useState('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]); + const sorted = [...items].sort((a, b) => compare(sortKey, a, b)); + return direction === 'ascending' ? sorted : sorted.reverse(); + }, [items, sortKey, direction]); - // 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 handleSort = (key: SortKey) => { + if (key === sortKey) { + setDirection(prev => (prev === 'ascending' ? 'descending' : 'ascending')); + } else { + setSortKey(key); + setDirection('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} - - ))} - - -
+ + +
Resources
+ + + + {COLUMNS.map(({ key, label }) => { + const active = key === sortKey; + return ( + + + + ); + })} + + + + {rows.map(item => ( + + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + +
-
+ ); } diff --git a/pages/table-root/styles.scss b/pages/table-root/styles.scss index 5468fe95d0..22258759a4 100644 --- a/pages/table-root/styles.scss +++ b/pages/table-root/styles.scss @@ -36,31 +36,9 @@ padding-block-end: 2px; } -// 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 affordance at the right of a sortable header (the caret icon). .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; -} From e89fe6257968ed05b436c10b762633f1ad3e4edd Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 17 Sep 2026 11:57:53 +0000 Subject: [PATCH 05/23] chore: Adopt SimplePage helper for remaining table atomic dev pages --- pages/table-root/column-sizing.page.tsx | 6 +- pages/table-root/loading-and-empty.page.tsx | 69 +++++++++++---------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/pages/table-root/column-sizing.page.tsx b/pages/table-root/column-sizing.page.tsx index 1e0503bd3a..9cfb2756f0 100644 --- a/pages/table-root/column-sizing.page.tsx +++ b/pages/table-root/column-sizing.page.tsx @@ -18,6 +18,7 @@ import TableRoot, { TableRootProps } from '~components/table-root'; import TableRow from '~components/table-row'; import { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; import { Item, makeItems } from './common'; // Column-sizing playground (grid layout). Adjust each column's sizing mode and widths to explore how @@ -98,9 +99,8 @@ export default function TableColumnSizingPlaygroundPage() { const columns = useMemo(() => configs.map(toColumnDefinition), [configs]); return ( - + - Table atomics — column-sizing playground (grid layout) Adjust each column below and watch the table re-lay out. A CSS grid track can't be both weighted and hard-capped, so a column is either flex (shares free space by weight) or capped (grows only @@ -173,6 +173,6 @@ export default function TableColumnSizingPlaygroundPage() { - + ); } diff --git a/pages/table-root/loading-and-empty.page.tsx b/pages/table-root/loading-and-empty.page.tsx index aa8e2a1ec5..9f0c4ff861 100644 --- a/pages/table-root/loading-and-empty.page.tsx +++ b/pages/table-root/loading-and-empty.page.tsx @@ -12,6 +12,7 @@ import TableRoot from '~components/table-root'; import TableRow from '~components/table-row'; import { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; import { DataBody, DataHeader, makeItems } from './common'; type State = 'loaded' | 'loading' | 'empty'; @@ -29,10 +30,9 @@ export default function TableLoadingEmptyPage() { const items = state === 'loaded' ? makeItems(20) : []; return ( - - - Table atomics — loading & empty states - + setUrlParams({ dataState: event.detail.selectedId as State })} @@ -43,36 +43,37 @@ export default function TableLoadingEmptyPage() { { id: 'empty', text: 'Empty' }, ]} /> - - -
Resources
- - - {state === 'loaded' ? ( - - ) : ( - - - - - {state === 'loading' ? ( - Loading resources - ) : ( - - No resources - - No resources to display. - - - )} - - - - - )} - -
+ } + screenshotArea={{}} + > + +
Resources
+ + + {state === 'loaded' ? ( + + ) : ( + + + + + {state === 'loading' ? ( + Loading resources + ) : ( + + No resources + + No resources to display. + + + )} + + + + + )} +
-
+ ); } From a5238477cfc1d1930459e7828273190226b60ac9 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 17 Sep 2026 12:38:09 +0000 Subject: [PATCH 06/23] refactor: Rename TableBody and TableRow style prop to positionStyle --- .../__snapshots__/documenter.test.ts.snap | 12 ++++++------ src/table-body/interfaces.ts | 4 ++-- src/table-body/internal.tsx | 9 +++++++-- .../__tests__/basic-table-styling-props.test.tsx | 6 +++--- src/table-row/interfaces.ts | 4 ++-- src/table-row/internal.tsx | 4 ++-- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 585b96fe84..2fe0a0eb43 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29625,7 +29625,7 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "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", + "name": "TableBodyProps.PositionStyle", "properties": [ { "inlineType": { @@ -29667,9 +29667,9 @@ virtualization or draggable rows. It is not supported to use this for general st ], "type": "object", }, - "name": "style", + "name": "positionStyle", "optional": true, - "type": "TableBodyProps.Style", + "type": "TableBodyProps.PositionStyle", }, ], "regions": [ @@ -30009,7 +30009,7 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "description": "Applies inline styles to the row element for positioning, such as virtualization or draggable rows. Not intended for general styling.", "inlineType": { - "name": "TableRowProps.Style", + "name": "TableRowProps.PositionStyle", "properties": [ { "inlineType": { @@ -30070,9 +30070,9 @@ rows. Not intended for general styling.", ], "type": "object", }, - "name": "style", + "name": "positionStyle", "optional": true, - "type": "TableRowProps.Style", + "type": "TableRowProps.PositionStyle", }, { "description": "The row's visual state. Visual only — it does not set \`aria-selected\`; convey selection to diff --git a/src/table-body/interfaces.ts b/src/table-body/interfaces.ts index b6b0effebe..72f6d1cc5d 100644 --- a/src/table-body/interfaces.ts +++ b/src/table-body/interfaces.ts @@ -10,14 +10,14 @@ 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; + positionStyle?: TableBodyProps.PositionStyle; /** The body rows. */ children?: React.ReactNode; } export namespace TableBodyProps { /** Inline styles supported on the body element, for row positioning (for example, virtualization). */ - export interface Style { + export interface PositionStyle { position?: React.CSSProperties['position']; height?: React.CSSProperties['height']; } diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index d081843eb4..8969293d08 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -12,7 +12,12 @@ import styles from './styles.css.js'; export interface InternalTableBodyProps extends TableBodyProps, InternalBaseComponentProps {} -export default function InternalTableBody({ children, style, __internalRootRef, ...rest }: InternalTableBodyProps) { +export default function InternalTableBody({ + children, + positionStyle, + __internalRootRef, + ...rest +}: InternalTableBodyProps) { const { columnLayout } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(rest); @@ -22,7 +27,7 @@ export default function InternalTableBody({ children, style, __internalRootRef, className={clsx(className, styles.body, isGrid && styles['body-grid'])} {...restBaseProps} role={isGrid ? 'rowgroup' : undefined} - style={style as React.CSSProperties} + style={positionStyle as React.CSSProperties} > {children} 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 87179522db..9153d3c34e 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -19,7 +19,7 @@ import cellStyles from '../../../lib/components/table-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 through context, sets no -// `aria-selected` (selection is conveyed by the selection control), that the narrowed inline `style` +// `aria-selected` (selection is conveyed by the selection control), that the narrowed inline `positionStyle` // 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. // @@ -145,8 +145,8 @@ describe('inline style props (virtualization)', () => { Name - - + + Row diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts index 281b8d7757..fc6031ac36 100644 --- a/src/table-row/interfaces.ts +++ b/src/table-row/interfaces.ts @@ -31,7 +31,7 @@ export interface TableRowProps extends BaseComponentProps { * Applies inline styles to the row element for positioning, such as virtualization or draggable * rows. Not intended for general styling. */ - style?: TableRowProps.Style; + positionStyle?: TableRowProps.PositionStyle; /** The row's cells, one per column, in order. */ children?: React.ReactNode; } @@ -39,7 +39,7 @@ 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 interface Style { + export interface PositionStyle { position?: React.CSSProperties['position']; transform?: React.CSSProperties['transform']; height?: React.CSSProperties['height']; diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index a16ac16f21..f31cb10381 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -22,7 +22,7 @@ export default function InternalTableRow({ ariaDescribedby, ariaRowindex, children, - style, + positionStyle, __internalRootRef, ...rest }: InternalTableRowProps) { @@ -46,7 +46,7 @@ export default function InternalTableRow({ aria-labelledby={ariaLabelledby} aria-describedby={ariaDescribedby} aria-rowindex={ariaRowindex} - style={(isGrid ? { gridTemplateColumns, ...style } : style) as React.CSSProperties} + style={(isGrid ? { gridTemplateColumns, ...positionStyle } : positionStyle) as React.CSSProperties} > {children} From 5970480bf5a2c56b942e199405a00f3aef129507 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 17 Sep 2026 12:55:26 +0000 Subject: [PATCH 07/23] chore: Clean up review-flagged comments in table atomic components --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 4 +--- src/table-body/interfaces.ts | 2 -- src/table-row/interfaces.ts | 4 ---- src/table/body-cell/styles.scss | 6 +++--- src/table/body-cell/td-element.tsx | 4 ---- 5 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 2fe0a0eb43..d320c6978b 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -30079,9 +30079,7 @@ rows. Not intended for general styling.", assistive technologies via the selection control in a leading cell. * \`default\` - A standard row. * \`selected\` - Applies selected-row styling. -* \`shaded\` - Applies a shaded background for alternating row colors. - -Defaults to \`'default'\`.", +* \`shaded\` - Applies a shaded background for alternating row colors.", "inlineType": { "name": "TableRowProps.Variant", "type": "union", diff --git a/src/table-body/interfaces.ts b/src/table-body/interfaces.ts index 72f6d1cc5d..5315b34d98 100644 --- a/src/table-body/interfaces.ts +++ b/src/table-body/interfaces.ts @@ -4,7 +4,6 @@ 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 @@ -16,7 +15,6 @@ export interface TableBodyProps extends BaseComponentProps { } export namespace TableBodyProps { - /** Inline styles supported on the body element, for row positioning (for example, virtualization). */ export interface PositionStyle { position?: React.CSSProperties['position']; height?: React.CSSProperties['height']; diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts index fc6031ac36..e26a758eb5 100644 --- a/src/table-row/interfaces.ts +++ b/src/table-row/interfaces.ts @@ -4,7 +4,6 @@ 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. Visual only — it does not set `aria-selected`; convey selection to @@ -12,8 +11,6 @@ export interface TableRowProps extends BaseComponentProps { * * `default` - A standard row. * * `selected` - Applies selected-row styling. * * `shaded` - Applies a shaded background for alternating row colors. - * - * Defaults to `'default'`. */ variant?: TableRowProps.Variant; /** Provides an accessible name for the row. Use this or `ariaLabelledby`. */ @@ -38,7 +35,6 @@ 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 interface PositionStyle { position?: React.CSSProperties['position']; transform?: React.CSSProperties['transform']; diff --git a/src/table/body-cell/styles.scss b/src/table/body-cell/styles.scss index b3fabc663e..b526aa6480 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -136,9 +136,9 @@ $editing-cell-padding-block: awsui.$space-scaled-xxxs; } } // First/last-row transparent placeholder. `:not(.body-cell-selected)` keeps a selected row's own border. - // Ungrid-guarded on `.body-cell`, so it is the single source of the placeholder for every cell — the - // existing Table's rows and the atomic cells (grid and auto) alike; load-bearing for grid - // selection-control centering. + // Keyed on `.body-cell` with no grid-mode guard, so it is the single source of the placeholder for + // every cell — the existing Table's rows and the atomic cells (grid and auto) alike; load-bearing for + // grid selection-control centering. tr:first-child > &:not(.body-cell-selected) { border-block-start: cell-base.$border-placeholder; } diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index 0d42af4fa3..12d35ad830 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -121,10 +121,6 @@ export const TableTdElement = React.forwardRef` CSS continues to match unchanged. return ( Date: Thu, 17 Sep 2026 13:34:38 +0000 Subject: [PATCH 08/23] refactor: Match existing Table scrollable region role and document context-provider resets --- src/table-root/internal.tsx | 10 ++++++++-- src/table/internal.tsx | 3 +++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx index a219373bed..5bf27317c4 100644 --- a/src/table-root/internal.tsx +++ b/src/table-root/internal.tsx @@ -30,7 +30,8 @@ export default function InternalTableRoot({ // A wide table's horizontal scroller isn't keyboard-reachable on its own, so a read-only table with no // focusable cell content can't be scrolled by keyboard. When the content overflows, expose the scroller - // as a focusable labeled region (matching the existing Table's getTableWrapperRoleProps). + // as a focusable region so keyboard users can scroll it horizontally, matching the existing Table's + // getTableWrapperRoleProps. const scrollerRef = useRef(null); const [isScrollable, setIsScrollable] = useState(false); const measureScrollable = useCallback(() => { @@ -58,9 +59,11 @@ export default function InternalTableRoot({ measureScrollable(); }, [table.gridTemplateColumns, children, measureScrollable]); + // Set role="region" whenever scrollable (label passes through even if undefined), matching the + // existing Table's getTableWrapperRoleProps rather than gating the role on a label. const scrollRegionProps = isScrollable ? { - role: ariaLabel || ariaLabelledby ? ('region' as const) : undefined, + role: 'region' as const, tabIndex: 0, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, @@ -69,6 +72,9 @@ export default function InternalTableRoot({ return (
+ {/* Reset the shared cell contexts at each table boundary: the cell substrate reads column layout + and row variant from context, so a table nested inside another table's cell must start from + this table's own layout and a `default` row variant rather than inheriting the outer table's. */} {/* The page owns vertical scroll; this wrapper reintroduces an inline scroll viewport so a wide table scrolls horizontally instead of spilling out. */} diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 13f80cc9de..376dc390ad 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -914,6 +914,9 @@ const InternalTable = React.forwardRef( ); return ( + // Reset the shared cell contexts to known defaults: the extracted cell substrate reads column + // layout and row variant from context, so the existing Table pins them here (it drives its own + // selection/striping paint directly, not via the atomic row-variant context). {tableContent} From fdb52ae1c8081a93d639e76aa95d9040114ea1e0 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 17 Sep 2026 13:34:40 +0000 Subject: [PATCH 09/23] feat: Add TableCell isRowHeader and prefix row-variant data attributes with data-awsui- --- .../__snapshots__/documenter.test.ts.snap | 6 ++ src/table-cell/index.tsx | 7 +- src/table-cell/interfaces.ts | 4 ++ src/table-cell/internal.tsx | 18 +++-- src/table-cell/styles.scss | 10 +-- .../basic-table-styling-props.test.tsx | 68 ++++++++++++++----- src/table-row/internal.tsx | 8 +-- src/table-row/styles.scss | 8 +-- 8 files changed, 93 insertions(+), 36 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index d320c6978b..923b1cfbab 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29712,6 +29712,12 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "optional": true, "type": "string", }, + { + "description": "Renders the cell as a row header (\`\`) instead of a data cell. Defaults to \`false\`.", + "name": "isRowHeader", + "optional": true, + "type": "boolean", + }, ], "regions": [ { diff --git a/src/table-cell/index.tsx b/src/table-cell/index.tsx index 66bbeb769f..86093636eb 100644 --- a/src/table-cell/index.tsx +++ b/src/table-cell/index.tsx @@ -12,9 +12,11 @@ import { InternalTableCell } from './internal'; export { TableCellProps }; function TableCell(props: TableCellProps) { - const baseComponentProps = useBaseComponent('TableCell', { props: { disablePaddings: props.disablePaddings } }); + const baseComponentProps = useBaseComponent('TableCell', { + props: { disablePaddings: props.disablePaddings, isRowHeader: props.isRowHeader }, + }); const mergedProps = { ...props, ...baseComponentProps }; - const { children, disablePaddings, __internalRootRef } = mergedProps; + const { children, disablePaddings, isRowHeader, __internalRootRef } = mergedProps; const { className, ...restBaseProps } = getBaseProps(mergedProps); return ( {children} diff --git a/src/table-cell/interfaces.ts b/src/table-cell/interfaces.ts index 673a00f424..49f7ca56e2 100644 --- a/src/table-cell/interfaces.ts +++ b/src/table-cell/interfaces.ts @@ -10,6 +10,10 @@ export interface TableCellProps extends BaseComponentProps { * Removes the cell's built-in padding so you can compose your own spacing. Defaults to `false`. */ disablePaddings?: boolean; + /** + * Renders the cell as a row header (``) instead of a data cell. Defaults to `false`. + */ + isRowHeader?: boolean; /** The cell content. */ children?: React.ReactNode; } diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx index ff7b351a03..37c727b060 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-cell/internal.tsx @@ -16,6 +16,7 @@ export interface InternalTableCellProps { style?: React.CSSProperties; wrapLines?: boolean; disablePaddings?: boolean; + isRowHeader?: boolean; nativeAttributes?: Omit< React.TdHTMLAttributes | React.ThHTMLAttributes, 'style' | 'className' | 'onClick' @@ -36,6 +37,7 @@ export const InternalTableCell = React.forwardRef`. Grid mode drops the implicit cell role, so set it + // explicitly (rowheader for a row header, otherwise cell); a role supplied via nativeAttributes wins. const mergedNativeAttributes = isGrid - ? { ...nativeAttributes, role: nativeAttributes?.role ?? 'cell' } - : nativeAttributes; + ? { + ...nativeAttributes, + ...(isRowHeader ? { scope: 'row' as const } : {}), + role: nativeAttributes?.role ?? (isRowHeader ? 'rowheader' : 'cell'), + } + : isRowHeader + ? { ...nativeAttributes, scope: 'row' as const } + : nativeAttributes; return ( .cell { +[data-awsui-variant-selected] > .cell { background-color: awsui.$color-background-item-selected; } -tr:has(+ [data-variant-shaded]) > .cell { +tr:has(+ [data-awsui-variant-shaded]) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; } -[data-variant-shaded]:not(:last-child) > .cell { +[data-awsui-variant-shaded]:not(:last-child) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; } -[data-variant-selected]:has(+ [data-variant-selected]) > .cell { +[data-awsui-variant-selected]:has(+ [data-awsui-variant-selected]) > .cell { border-block-end-color: transparent; } -tr:not([data-variant-selected]):has(+ [data-variant-selected]) > .cell { +tr:not([data-awsui-variant-selected]):has(+ [data-awsui-variant-selected]) > .cell { border-block-end-color: transparent; } 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 9153d3c34e..0e7ee859b8 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -23,8 +23,8 @@ 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 a selected row emits `data-variant-selected` on the (a shaded row emits -// `data-variant-shaded`) — the one sanctioned styling hook — and the cell stylesheet reads it to paint the +// On this fork a selected row emits `data-awsui-variant-selected` on the (a shaded row emits +// `data-awsui-variant-shaded`) — the one sanctioned styling hook — and the cell stylesheet reads it to paint the // background and draw the selection outline (a layout-neutral `::after` ring) and to merge consecutive // selected rows via sibling adjacency. It is driven by `variant`, never a public prop. A shaded row still // reuses the existing Table's `.body-cell-shaded` background class. Selection and shading are mutually @@ -59,15 +59,15 @@ function cellClassLists(wrapper: ReturnType) { } describe('TableRow variant is visual-only and paints through the cell', () => { - test("variant='selected' paints every cell selected, emits the data-variant-selected adjacency hook, and sets no aria-selected", () => { + test("variant='selected' paints every cell selected, emits the data-awsui-variant-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; selection is conveyed by the selection control. expect(row).not.toHaveAttribute('aria-selected'); - // The one sanctioned styling hook: data-variant-selected drives the consecutive-selected outline merge. - expect(row).toHaveAttribute('data-variant-selected', 'true'); - expect(row).not.toHaveAttribute('data-variant-shaded'); - // Selection paints via the row's data-variant-selected hook (background + ::after ring), not by reusing + // The one sanctioned styling hook: data-awsui-variant-selected drives the consecutive-selected outline merge. + expect(row).toHaveAttribute('data-awsui-variant-selected', 'true'); + expect(row).not.toHaveAttribute('data-awsui-variant-shaded'); + // Selection paints via the row's data-awsui-variant-selected hook (background + ::after ring), not by reusing // the existing Table's body-cell-selected — so no per-cell selection/has-selection class is emitted. for (const classList of cellClassLists(wrapper)) { expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); @@ -80,9 +80,9 @@ describe('TableRow variant is visual-only and paints through the cell', () => { const { wrapper } = renderHarness('shaded'); const row = wrapper.findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); - expect(row).not.toHaveAttribute('data-variant-selected'); - // data-variant-shaded drives the striped-row divider darkening (sibling adjacency), mirroring data-variant-selected. - expect(row).toHaveAttribute('data-variant-shaded', 'true'); + expect(row).not.toHaveAttribute('data-awsui-variant-selected'); + // data-awsui-variant-shaded drives the striped-row divider darkening (sibling adjacency), mirroring data-awsui-variant-selected. + expect(row).toHaveAttribute('data-awsui-variant-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); @@ -90,22 +90,22 @@ describe('TableRow variant is visual-only and paints through the cell', () => { } }); - test('the default variant paints neither and sets no aria-selected or data-variant-selected', () => { + test('the default variant paints neither and sets no aria-selected or data-awsui-variant-selected', () => { const { wrapper } = renderHarness(); const row = wrapper.findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); - expect(row).not.toHaveAttribute('data-variant-selected'); + expect(row).not.toHaveAttribute('data-awsui-variant-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 consumer-passed data-variant-* cannot spoof the selection/shading hooks; variant is authoritative', () => { + test('a consumer-passed data-awsui-variant-* cannot spoof the selection/shading hooks; variant is authoritative', () => { const { container } = render( - + Spoof @@ -113,8 +113,8 @@ describe('TableRow variant is visual-only and paints through the cell', () => { ); const row = createWrapper(container).findAllTableRows()[0].getElement(); // variant defaults to 'default', so both reserved hooks must be absent despite the consumer values. - expect(row).not.toHaveAttribute('data-variant-selected'); - expect(row).not.toHaveAttribute('data-variant-shaded'); + expect(row).not.toHaveAttribute('data-awsui-variant-selected'); + expect(row).not.toHaveAttribute('data-awsui-variant-shaded'); }); test('a TableCell rendered outside any TableRow falls back to the default (unpainted) variant', () => { @@ -208,6 +208,42 @@ describe('disablePaddings', () => { }); }); +describe('isRowHeader', () => { + test('renders a row header as with role="rowheader" in grid mode', () => { + const { container } = render( + + + + Name + Value + + + + ); + const cell = createWrapper(container).findAllTableCells()[0].getElement(); + expect(cell.tagName).toBe('TH'); + expect(cell).toHaveAttribute('scope', 'row'); + expect(cell).toHaveAttribute('role', 'rowheader'); + }); + + test('renders a row header as a native without an explicit role in auto mode', () => { + const { container } = render( + + + + Name + Value + + + + ); + const cell = createWrapper(container).findAllTableCells()[0].getElement(); + expect(cell.tagName).toBe('TH'); + expect(cell).toHaveAttribute('scope', 'row'); + expect(cell).not.toHaveAttribute('role'); + }); +}); + describe('nested content is insulated from the table/row context', () => { test('a classic Table nested in a selected grid cell inherits neither the outer grid layout nor the selected variant', () => { const { container } = render( diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index f31cb10381..6230339aef 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -11,7 +11,7 @@ import { TableRowProps } from './interfaces'; import styles from './styles.css.js'; -// Sanctioned data-* hooks: `data-variant-selected` / `data-variant-shaded` on the let sibling-adjacency +// Sanctioned data-* hooks: `data-awsui-variant-selected` / `data-awsui-variant-shaded` on the let sibling-adjacency // CSS (consecutive-selected merge, striped divider) work, which a cell can't do from context. Inert for the Table. export interface InternalTableRowProps extends TableRowProps, InternalBaseComponentProps {} @@ -30,10 +30,10 @@ export default function InternalTableRow({ const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(rest); // `variant` is the sole source of truth for these hooks: emit both unconditionally after the base-prop - // spread (true|undefined) so a consumer-passed data-variant-* can't spoof the selection/shading paint. + // spread (true|undefined) so a consumer-passed data-awsui-variant-* can't spoof the selection/shading paint. const reservedVariantAttributes = { - 'data-variant-selected': variant === 'selected' ? 'true' : undefined, - 'data-variant-shaded': variant === 'shaded' ? 'true' : undefined, + 'data-awsui-variant-selected': variant === 'selected' ? 'true' : undefined, + 'data-awsui-variant-shaded': variant === 'shaded' ? 'true' : undefined, }; return ( Date: Fri, 18 Sep 2026 07:02:17 +0000 Subject: [PATCH 10/23] chore: Fold striped-rows demo into the simple page as a toggle; SimplePage for selection-edge-cases --- .../table-root/selection-edge-cases.page.tsx | 9 ++-- pages/table-root/simple.page.tsx | 38 +++++++++++++--- pages/table-root/striped-rows.page.tsx | 43 ------------------- 3 files changed, 35 insertions(+), 55 deletions(-) delete mode 100644 pages/table-root/striped-rows.page.tsx diff --git a/pages/table-root/selection-edge-cases.page.tsx b/pages/table-root/selection-edge-cases.page.tsx index 045a7d10af..5f3044c21c 100644 --- a/pages/table-root/selection-edge-cases.page.tsx +++ b/pages/table-root/selection-edge-cases.page.tsx @@ -11,7 +11,7 @@ import TableHeaderRow from '~components/table-header-row'; import TableRoot, { TableRootProps } from '~components/table-root'; import TableRow, { TableRowProps } from '~components/table-row'; -import ScreenshotArea from '../utils/screenshot-area'; +import { SimplePage } from '../app/templates'; // Visual coverage for the grid-layout selection-outline edge cases: the selected-row outline is an // abspos `::after` placed into the row's grid area (`grid-column: 1 / -1`), so it hugs the column extent @@ -97,10 +97,7 @@ const fixedWide: TableRootProps.ColumnLayout = { export default function TableSelectionEdgeCasesPage() { return ( - - - Table atomics — grid selection edge cases - + @@ -113,6 +110,6 @@ export default function TableSelectionEdgeCasesPage() { - + ); } diff --git a/pages/table-root/simple.page.tsx b/pages/table-root/simple.page.tsx index 052df8e4a9..b60924339e 100644 --- a/pages/table-root/simple.page.tsx +++ b/pages/table-root/simple.page.tsx @@ -4,22 +4,48 @@ import React from 'react'; 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 Toggle from '~components/toggle'; +import { useAppContext } from '../app/app-context'; import { SimplePage } from '../app/templates'; -import { DataBody, DataHeader, makeItems } from './common'; +import { 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. +// A minimal read-only table in auto layout (`columnLayout` omitted, so it defaults to `{ type: 'auto' }`). +// Striping is composed by the consumer via the row `variant`: with the toggle on, alternating rows are +// marked `shaded` — the atomic table owns no row-parity computation. export default function TableSimplePage() { - const items = makeItems(8); + const items = makeItems(10); + const { urlParams, setUrlParams } = useAppContext<'stripedRows'>(); + const striped = urlParams.stripedRows === true || urlParams.stripedRows === 'true'; + return ( - + setUrlParams({ stripedRows: detail.checked })}> + Striped rows + + } + screenshotArea={{}} + >
Resources
- + + {items.map((item, index) => ( + + {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 deleted file mode 100644 index fb1ac4b740..0000000000 --- a/pages/table-root/striped-rows.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 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} - - ))} - - -
-
-
- ); -} From 2f95d548c07723481ab831a7b4aa5d59d2d11d88 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 18 Sep 2026 09:44:56 +0000 Subject: [PATCH 11/23] refactor: Consolidate table atomic parts into TableRow variants and TableBodyCell - Rename TableCell -> TableBodyCell; it now renders row headers too via isRowHeader (th scope="row"), so TableHeaderCell is column-headers only (drops its scope prop). - Remove TableHeaderRow; the header row is TableRow variant="header", rendered through one unified TableRow path that shares the base .row / .row-grid box and CSS (header adds only a background). - Drop the head/body section context: variant (default|selected|shaded| header) is the single row discriminant, stamped dynamically as a data-awsui-variant-* hook (default carries none; new variants need no change here). - ariaRowcount no longer travels through table context; header rows take an explicit ariaRowindex like body rows, and gain aria-*/positionStyle. - Remove the TableHeaderRow test-util wrapper (not a component). - Migrate dev pages and unit tests; regenerate documenter + test-utils snapshots. --- build-tools/utils/pluralize.js | 3 +- pages/table-root/column-sizing.page.tsx | 9 +- pages/table-root/common.tsx | 22 +-- pages/table-root/loading-and-empty.page.tsx | 2 +- .../table-root/selection-edge-cases.page.tsx | 13 +- pages/table-root/selection.page.tsx | 15 +- pages/table-root/simple.page.tsx | 10 +- pages/table-root/sorting.page.tsx | 15 +- .../__snapshots__/documenter.test.ts.snap | 68 ++----- .../test-utils-selectors.test.tsx.snap | 7 +- .../test-utils-wrappers.test.tsx.snap | 166 +++++------------- src/{table-cell => table-body-cell}/index.tsx | 23 ++- .../interfaces.ts | 13 +- .../internal.tsx | 28 ++- .../styles.scss | 0 src/table-header-cell/internal.tsx | 2 +- src/table-header-row/index.tsx | 19 -- src/table-header-row/interfaces.ts | 11 -- src/table-header-row/internal.tsx | 34 ---- src/table-header-row/styles.scss | 16 -- .../__tests__/basic-table-aria-label.test.tsx | 11 +- .../__tests__/basic-table-roles.test.tsx | 11 +- .../basic-table-styling-props.test.tsx | 79 ++++----- src/table-root/__tests__/basic-table.test.tsx | 94 ++++++---- src/table-root/internal.tsx | 2 +- src/table-root/use-table-root.ts | 9 +- src/table-row/context.ts | 2 +- src/table-row/interfaces.ts | 11 +- src/table-row/internal.tsx | 16 +- src/table-row/styles.scss | 13 +- src/table/body-cell/td-element.tsx | 6 +- .../{table-cell => table-body-cell}/index.ts | 4 +- src/test-utils/dom/table-header-row/index.ts | 9 - 33 files changed, 258 insertions(+), 485 deletions(-) rename src/{table-cell => table-body-cell}/index.tsx (61%) rename src/{table-cell => table-body-cell}/interfaces.ts (64%) rename src/{table-cell => table-body-cell}/internal.tsx (76%) rename src/{table-cell => table-body-cell}/styles.scss (100%) delete mode 100644 src/table-header-row/index.tsx delete mode 100644 src/table-header-row/interfaces.ts delete mode 100644 src/table-header-row/internal.tsx delete mode 100644 src/table-header-row/styles.scss rename src/test-utils/dom/{table-cell => table-body-cell}/index.ts (63%) delete mode 100644 src/test-utils/dom/table-header-row/index.ts diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 306086ba03..6f21e5c142 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -81,10 +81,9 @@ const pluralizationMap = { Steps: 'Steps', Table: 'Tables', TableBody: 'TableBodies', - TableCell: 'TableCells', + TableBodyCell: 'TableBodyCells', TableHead: 'TableHeads', TableHeaderCell: 'TableHeaderCells', - TableHeaderRow: 'TableHeaderRows', TableRoot: 'TableRoots', TableRow: 'TableRows', Tabs: 'Tabs', diff --git a/pages/table-root/column-sizing.page.tsx b/pages/table-root/column-sizing.page.tsx index 9cfb2756f0..4f98a829ce 100644 --- a/pages/table-root/column-sizing.page.tsx +++ b/pages/table-root/column-sizing.page.tsx @@ -10,10 +10,9 @@ import Input from '~components/input'; import Select, { SelectProps } from '~components/select'; import SpaceBetween from '~components/space-between'; import TableBody from '~components/table-body'; -import TableCell from '~components/table-cell'; +import TableBodyCell from '~components/table-body-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'; @@ -155,17 +154,17 @@ export default function TableColumnSizingPlaygroundPage() {
Resources
- + {configs.map(config => ( {config.label} ))} - + {items.map((item: Item) => ( {configs.map(config => ( - {item[config.field]} + {item[config.field]} ))} ))} diff --git a/pages/table-root/common.tsx b/pages/table-root/common.tsx index 78e8dbf9ed..ae6f88a6a4 100644 --- a/pages/table-root/common.tsx +++ b/pages/table-root/common.tsx @@ -2,15 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableHeaderRow, - TableRootProps, - TableRow, -} from '~components'; +import { TableBody, TableBodyCell, TableHead, TableHeaderCell, TableRootProps, TableRow } from '~components'; export interface Item { id: string; @@ -41,12 +33,12 @@ export const DATA_COLUMNS: ReadonlyArray = [ export function DataHeader() { return ( - + Name Type Size Status - + ); } @@ -56,10 +48,10 @@ export function DataBody({ items }: { items: Item[] }) { {items.map(item => ( - {item.name} - {item.type} - {item.size} - {item.status} + {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 index 9f0c4ff861..f6886f3e45 100644 --- a/pages/table-root/loading-and-empty.page.tsx +++ b/pages/table-root/loading-and-empty.page.tsx @@ -22,7 +22,7 @@ const COLUMN_COUNT = 4; // Loading and empty states are composed by the consumer. In auto layout the table is a native // ``, so a single full-width status row is a plain ` - {children} - - ); -} diff --git a/src/table-header-row/styles.scss b/src/table-header-row/styles.scss deleted file mode 100644 index 66ce26719e..0000000000 --- a/src/table-header-row/styles.scss +++ /dev/null @@ -1,16 +0,0 @@ -/* - 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 index 2c652933f8..2c194f35ab 100644 --- a/src/table-root/__tests__/basic-table-aria-label.test.tsx +++ b/src/table-root/__tests__/basic-table-aria-label.test.tsx @@ -4,10 +4,9 @@ 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 TableBodyCell from '../../../lib/components/table-body-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'; @@ -30,16 +29,16 @@ function buildTree(labelProps: Pick - + Name Status - + {items.map(item => ( - {item.name} - {item.status} + {item.name} + {item.status} ))} diff --git a/src/table-root/__tests__/basic-table-roles.test.tsx b/src/table-root/__tests__/basic-table-roles.test.tsx index 76979bf70b..d8b461e7d1 100644 --- a/src/table-root/__tests__/basic-table-roles.test.tsx +++ b/src/table-root/__tests__/basic-table-roles.test.tsx @@ -4,10 +4,9 @@ import React from 'react'; import { act, render } from '@testing-library/react'; import TableBody from '../../../lib/components/table-body'; -import TableCell from '../../../lib/components/table-cell'; +import TableBodyCell from '../../../lib/components/table-body-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'; @@ -35,16 +34,16 @@ function LogTable({ items, grid }: { items: Item[]; grid?: boolean }) { return ( - + Name Status - + {items.map(item => ( - {item.name} - {item.status} + {item.name} + {item.status} ))} 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 0e7ee859b8..77ee152e19 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -5,17 +5,16 @@ import { render } from '@testing-library/react'; import Table from '../../../lib/components/table'; import TableBody from '../../../lib/components/table-body'; -import TableCell from '../../../lib/components/table-cell'; +import TableBodyCell from '../../../lib/components/table-body-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 legacyHeaderCellStyles from '../../../lib/components/table/header-cell/styles.css.js'; -import cellStyles from '../../../lib/components/table-cell/styles.css.js'; +import cellStyles 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 through context, sets no @@ -34,15 +33,15 @@ function Harness({ variant }: { variant?: TableRowProps.Variant }) { return ( - + Name Status - + - Resource 0 - Available + Resource 0 + Available @@ -55,13 +54,13 @@ function renderHarness(variant?: TableRowProps.Variant) { } function cellClassLists(wrapper: ReturnType) { - return wrapper.findAllTableCells().map(cell => cell.getElement().classList); + return wrapper.findAllTableBodyCells().map(cell => cell.getElement().classList); } describe('TableRow variant is visual-only and paints through the cell', () => { test("variant='selected' paints every cell selected, emits the data-awsui-variant-selected adjacency hook, and sets no aria-selected", () => { const { wrapper } = renderHarness('selected'); - const row = wrapper.findAllTableRows()[0].getElement(); + const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); // Visual state must NOT leak into ARIA; selection is conveyed by the selection control. expect(row).not.toHaveAttribute('aria-selected'); // The one sanctioned styling hook: data-awsui-variant-selected drives the consecutive-selected outline merge. @@ -78,7 +77,7 @@ describe('TableRow variant is visual-only and paints through the cell', () => { test("variant='shaded' paints every cell shaded and never selected", () => { const { wrapper } = renderHarness('shaded'); - const row = wrapper.findAllTableRows()[0].getElement(); + const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); expect(row).not.toHaveAttribute('data-awsui-variant-selected'); // data-awsui-variant-shaded drives the striped-row divider darkening (sibling adjacency), mirroring data-awsui-variant-selected. @@ -92,7 +91,7 @@ describe('TableRow variant is visual-only and paints through the cell', () => { test('the default variant paints neither and sets no aria-selected or data-awsui-variant-selected', () => { const { wrapper } = renderHarness(); - const row = wrapper.findAllTableRows()[0].getElement(); + const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); expect(row).not.toHaveAttribute('data-awsui-variant-selected'); for (const classList of cellClassLists(wrapper)) { @@ -101,34 +100,18 @@ describe('TableRow variant is visual-only and paints through the cell', () => { } }); - test('a consumer-passed data-awsui-variant-* cannot spoof the selection/shading hooks; variant is authoritative', () => { - const { container } = render( - - - - Spoof - - - - ); - const row = createWrapper(container).findAllTableRows()[0].getElement(); - // variant defaults to 'default', so both reserved hooks must be absent despite the consumer values. - expect(row).not.toHaveAttribute('data-awsui-variant-selected'); - expect(row).not.toHaveAttribute('data-awsui-variant-shaded'); - }); - - test('a TableCell rendered outside any TableRow falls back to the default (unpainted) variant', () => { + test('a TableBodyCell 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 + Loose ); - const classList = createWrapper(container).findAllTableCells()[0].getElement().classList; + const classList = createWrapper(container).findAllTableBodyCells()[0].getElement().classList; expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); }); @@ -141,13 +124,13 @@ describe('inline style props (virtualization)', () => { const { container } = render( - + Name - + - Row + Row @@ -157,7 +140,7 @@ describe('inline style props (virtualization)', () => { expect(body.style.position).toBe('relative'); expect(body.style.height).toBe('400px'); - const row = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + const row = createWrapper(wrapper.findTableBody()!.getElement()).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. @@ -166,18 +149,18 @@ describe('inline style props (virtualization)', () => { }); describe('disablePaddings', () => { - test('TableCell content opts into padding unless disablePaddings is set (mutually exclusive)', () => { + test('TableBodyCell content opts into padding unless disablePaddings is set (mutually exclusive)', () => { const { container } = render( - Control - Resource 0 + Control + Resource 0 ); - const cells = createWrapper(container).findAllTableCells(); + const cells = createWrapper(container).findAllTableBodyCells(); // Padding is opt-in on the inner `.body-cell-content` wrapper: `with-paddings` normally, the // `disable-paddings` overflow opt-out when disablePaddings is set — never both. const contentOf = (index: number) => @@ -192,10 +175,10 @@ describe('disablePaddings', () => { const { container } = render( - + Name - + ); @@ -214,13 +197,13 @@ describe('isRowHeader', () => { - Name - Value + Name + Value ); - const cell = createWrapper(container).findAllTableCells()[0].getElement(); + const cell = createWrapper(container).findAllTableBodyCells()[0].getElement(); expect(cell.tagName).toBe('TH'); expect(cell).toHaveAttribute('scope', 'row'); expect(cell).toHaveAttribute('role', 'rowheader'); @@ -231,13 +214,13 @@ describe('isRowHeader', () => { - Name - Value + Name + Value ); - const cell = createWrapper(container).findAllTableCells()[0].getElement(); + const cell = createWrapper(container).findAllTableBodyCells()[0].getElement(); expect(cell.tagName).toBe('TH'); expect(cell).toHaveAttribute('scope', 'row'); expect(cell).not.toHaveAttribute('role'); @@ -250,9 +233,9 @@ describe('nested content is insulated from the table/row context', () => { - +
` 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`. +// so its centered padding comes from spacing design tokens, not a standard data `TableBodyCell`. export default function TableLoadingEmptyPage() { const { urlParams, setUrlParams } = useAppContext<'dataState'>(); const state: State = diff --git a/pages/table-root/selection-edge-cases.page.tsx b/pages/table-root/selection-edge-cases.page.tsx index 5f3044c21c..9002fa17b7 100644 --- a/pages/table-root/selection-edge-cases.page.tsx +++ b/pages/table-root/selection-edge-cases.page.tsx @@ -4,10 +4,9 @@ import React from 'react'; import Box from '~components/box'; import TableBody from '~components/table-body'; -import TableCell from '~components/table-cell'; +import TableBodyCell from '~components/table-body-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, { TableRowProps } from '~components/table-row'; @@ -61,18 +60,18 @@ function Grid({
- + Name Type Status - + {ROWS.map((row, i) => ( - {longFirstCell && i === 1 ? LONG : row.name} - {row.type} - {row.status} + {longFirstCell && i === 1 ? LONG : row.name} + {row.type} + {row.status} ))} diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx index 20f26b653d..4964f20762 100644 --- a/pages/table-root/selection.page.tsx +++ b/pages/table-root/selection.page.tsx @@ -8,10 +8,9 @@ import RadioButton from '~components/radio-button'; import SegmentedControl from '~components/segmented-control'; import SpaceBetween from '~components/space-between'; import TableBody from '~components/table-body'; -import TableCell from '~components/table-cell'; +import TableBodyCell from '~components/table-body-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'; @@ -84,7 +83,7 @@ export default function TableSelectionPage() {
Resources
- + {mode === 'multi' ? (
@@ -99,12 +98,12 @@ export default function TableSelectionPage() { Name Status - + {items.map((item: Item) => ( - +
{mode === 'multi' ? ( )}
-
- {item.name} - {item.status} + + {item.name} + {item.status}
))}
diff --git a/pages/table-root/simple.page.tsx b/pages/table-root/simple.page.tsx index b60924339e..0f10b77ee4 100644 --- a/pages/table-root/simple.page.tsx +++ b/pages/table-root/simple.page.tsx @@ -5,7 +5,7 @@ import React from 'react'; import Header from '~components/header'; import SpaceBetween from '~components/space-between'; import TableBody from '~components/table-body'; -import TableCell from '~components/table-cell'; +import TableBodyCell from '~components/table-body-cell'; import TableRoot from '~components/table-root'; import TableRow from '~components/table-row'; import Toggle from '~components/toggle'; @@ -39,10 +39,10 @@ export default function TableSimplePage() { {items.map((item, index) => ( - {item.name} - {item.type} - {item.size} - {item.status} + {item.name} + {item.type} + {item.size} + {item.status} ))} diff --git a/pages/table-root/sorting.page.tsx b/pages/table-root/sorting.page.tsx index 982047aa6f..72d3df8b45 100644 --- a/pages/table-root/sorting.page.tsx +++ b/pages/table-root/sorting.page.tsx @@ -6,10 +6,9 @@ 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 TableBodyCell from '~components/table-body-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'; @@ -65,7 +64,7 @@ export default function TableSortingPage() {
Resources
- + {COLUMNS.map(({ key, label }) => { const active = key === sortKey; return ( @@ -87,15 +86,15 @@ export default function TableSortingPage() { ); })} - + {rows.map(item => ( - {item.name} - {item.type} - {item.size} - {item.status} + {item.name} + {item.type} + {item.size} + {item.status} ))} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 72756a6098..eb703ca000 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29888,12 +29888,12 @@ virtualization or draggable rows. It is not supported to use this for general st } `; -exports[`Components definition for table-cell matches the snapshot: table-cell 1`] = ` +exports[`Components definition for table-body-cell matches the snapshot: table-body-cell 1`] = ` { - "dashCaseName": "table-cell", + "dashCaseName": "table-body-cell", "events": [], "functions": [], - "name": "TableCell", + "name": "TableBodyCell", "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).", @@ -29918,7 +29918,8 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "type": "string", }, { - "description": "Renders the cell as a row header (\`
\`) instead of a data cell. Defaults to \`false\`.", + "description": "Renders the cell as a row header (\`\`) instead of a data cell (\`\`). A row header +keeps the data-cell styling; use it for the cell that names its row. Defaults to \`false\`.", "name": "isRowHeader", "optional": true, "type": "boolean", @@ -30045,41 +30046,6 @@ use the \`id\` attribute, consider setting it on a parent element instead.", } `; -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", @@ -30286,16 +30252,18 @@ rows. Not intended for general styling.", "type": "TableRowProps.PositionStyle", }, { - "description": "The row's visual state. Visual only — it does not set \`aria-selected\`; convey selection to -assistive technologies via the selection control in a leading cell. -* \`default\` - A standard row. -* \`selected\` - Applies selected-row styling. -* \`shaded\` - Applies a shaded background for alternating row colors.", + "description": "The row's variant. +* \`default\` - A standard body row. +* \`selected\` - Applies selected-row styling. Visual only — it does not set \`aria-selected\`; +convey selection to assistive technologies via the selection control in a leading cell. +* \`shaded\` - Applies a shaded background for alternating row colors. +* \`header\` - Marks the column-header row. Use inside \`TableHead\`.", "inlineType": { "name": "TableRowProps.Variant", "type": "union", "values": [ "default", + "header", "selected", "shaded", ], @@ -46736,7 +46704,7 @@ Returns the current value of the input.", }, { "methods": [], - "name": "TableCellWrapper", + "name": "TableBodyCellWrapper", }, { "methods": [], @@ -46746,10 +46714,6 @@ Returns the current value of the input.", "methods": [], "name": "TableHeaderCellWrapper", }, - { - "methods": [], - "name": "TableHeaderRowWrapper", - }, { "methods": [], "name": "TableRootWrapper", @@ -56602,7 +56566,7 @@ Supported options: }, { "methods": [], - "name": "TableCellWrapper", + "name": "TableBodyCellWrapper", }, { "methods": [], @@ -56612,10 +56576,6 @@ Supported options: "methods": [], "name": "TableHeaderCellWrapper", }, - { - "methods": [], - "name": "TableHeaderRowWrapper", - }, { "methods": [], "name": "TableRootWrapper", 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 3aa76bd7a8..e301c3b8b9 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 @@ -691,8 +691,8 @@ exports[`test-utils selectors 1`] = ` "table-body": [ "awsui_body_1i6l7", ], - "table-cell": [ - "awsui_cell_1reth", + "table-body-cell": [ + "awsui_cell_2seex", ], "table-head": [ "awsui_head_1otu2", @@ -700,9 +700,6 @@ exports[`test-utils selectors 1`] = ` "table-header-cell": [ "awsui_header-cell_uzgsh", ], - "table-header-row": [ - "awsui_header-row_1wc8l", - ], "table-root": [ "awsui_root_1pkvc", ], 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 f58c5328cd..41b1607f33 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 @@ -88,10 +88,9 @@ import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; import TableBodyWrapper from './table-body'; -import TableCellWrapper from './table-cell'; +import TableBodyCellWrapper from './table-body-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'; @@ -192,10 +191,9 @@ export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; export { TableBodyWrapper }; -export { TableCellWrapper }; +export { TableBodyCellWrapper }; export { TableHeadWrapper }; export { TableHeaderCellWrapper }; -export { TableHeaderRowWrapper }; export { TableRootWrapper }; export { TableRowWrapper }; export { TabsWrapper }; @@ -2432,33 +2430,33 @@ findAllTableBodies(selector?: string): Array; */ 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\`. + * Returns the wrapper of the first TableBodyCell that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableBodyCell. + * If no matching TableBodyCell is found, returns \`null\`. * * @param {string} [selector] CSS Selector - * @returns {TableCellWrapper | null} + * @returns {TableBodyCellWrapper | null} */ -findTableCell(selector?: string): TableCellWrapper | null; +findTableBodyCell(selector?: string): TableBodyCellWrapper | 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. + * Returns an array of TableBodyCell wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableBodyCells inside the current wrapper. + * If no matching TableBodyCell is found, returns an empty array. * * @param {string} [selector] CSS Selector - * @returns {Array} + * @returns {Array} */ -findAllTableCells(selector?: string): Array; +findAllTableBodyCells(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 the wrapper of the closest parent TableBodyCell for the current element, + * or the element itself if it is an instance of TableBodyCell. + * If no TableBodyCell is found, returns \`null\`. * - * @returns {TableCellWrapper | null} + * @returns {TableBodyCellWrapper | null} */ -findClosestTableCell(): TableCellWrapper | null; +findClosestTableBodyCell(): TableBodyCellWrapper | 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. @@ -2515,34 +2513,6 @@ findAllTableHeaderCells(selector?: string): Array; * @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. @@ -4106,18 +4076,18 @@ ElementWrapper.prototype.findTableBody = function(selector) { 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})\`; +ElementWrapper.prototype.findTableBodyCell = function(selector) { + let rootSelector = \`.\${TableBodyCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyCellWrapper && TableBodyCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyCellWrapper.rootSelector}, .\${TableBodyCellWrapper.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); + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyCellWrapper); }; -ElementWrapper.prototype.findAllTableCells = function(selector) { - return this.findAllComponents(TableCellWrapper, selector); +ElementWrapper.prototype.findAllTableBodyCells = function(selector) { + return this.findAllComponents(TableBodyCellWrapper, selector); }; ElementWrapper.prototype.findTableHead = function(selector) { let rootSelector = \`.\${TableHeadWrapper.rootSelector}\`; @@ -4145,19 +4115,6 @@ ElementWrapper.prototype.findTableHeaderCell = function(selector) { 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){ @@ -4801,10 +4758,10 @@ ElementWrapper.prototype.findClosestTableBody = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(TableBodyWrapper); }; -ElementWrapper.prototype.findClosestTableCell = function() { +ElementWrapper.prototype.findClosestTableBodyCell = 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); + return (this as any).findClosestComponent(TableBodyCellWrapper); }; ElementWrapper.prototype.findClosestTableHead = function() { // casting to 'any' is needed to avoid this issue with generics @@ -4816,11 +4773,6 @@ ElementWrapper.prototype.findClosestTableHeaderCell = function() { // 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 @@ -5014,10 +4966,9 @@ import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; import TableBodyWrapper from './table-body'; -import TableCellWrapper from './table-cell'; +import TableBodyCellWrapper from './table-body-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'; @@ -5118,10 +5069,9 @@ export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; export { TableBodyWrapper }; -export { TableCellWrapper }; +export { TableBodyCellWrapper }; export { TableHeadWrapper }; export { TableHeaderCellWrapper }; -export { TableHeaderRowWrapper }; export { TableRootWrapper }; export { TableRowWrapper }; export { TabsWrapper }; @@ -6489,22 +6439,22 @@ findTableBody(selector?: string): TableBodyWrapper; */ 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. + * Returns a wrapper that matches the TableBodyCells with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableBodyCells. * * @param {string} [selector] CSS Selector - * @returns {TableCellWrapper} + * @returns {TableBodyCellWrapper} */ -findTableCell(selector?: string): TableCellWrapper; +findTableBodyCell(selector?: string): TableBodyCellWrapper; /** - * 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. + * Returns a multi-element wrapper that matches TableBodyCells with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableBodyCells. * * @param {string} [selector] CSS Selector - * @returns {MultiElementWrapper} + * @returns {MultiElementWrapper} */ -findAllTableCells(selector?: string): MultiElementWrapper; +findAllTableBodyCells(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. @@ -6539,23 +6489,6 @@ findTableHeaderCell(selector?: string): TableHeaderCellWrapper; * @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. @@ -7910,18 +7843,18 @@ ElementWrapper.prototype.findTableBody = function(selector) { 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})\`; +ElementWrapper.prototype.findTableBodyCell = function(selector) { + let rootSelector = \`.\${TableBodyCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyCellWrapper && TableBodyCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyCellWrapper.rootSelector}, .\${TableBodyCellWrapper.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); + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyCellWrapper); }; -ElementWrapper.prototype.findAllTableCells = function(selector) { - return this.findAllComponents(TableCellWrapper, selector); +ElementWrapper.prototype.findAllTableBodyCells = function(selector) { + return this.findAllComponents(TableBodyCellWrapper, selector); }; ElementWrapper.prototype.findTableHead = function(selector) { let rootSelector = \`.\${TableHeadWrapper.rootSelector}\`; @@ -7949,19 +7882,6 @@ ElementWrapper.prototype.findTableHeaderCell = function(selector) { 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){ diff --git a/src/table-cell/index.tsx b/src/table-body-cell/index.tsx similarity index 61% rename from src/table-cell/index.tsx rename to src/table-body-cell/index.tsx index 86093636eb..815957e293 100644 --- a/src/table-cell/index.tsx +++ b/src/table-body-cell/index.tsx @@ -6,31 +6,30 @@ 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 { InternalTableCell } from './internal'; +import { TableBodyCellProps } from './interfaces'; +import { InternalTableBodyCell } from './internal'; -export { TableCellProps }; +export { TableBodyCellProps }; -function TableCell(props: TableCellProps) { - const baseComponentProps = useBaseComponent('TableCell', { +function TableBodyCell(props: TableBodyCellProps) { + const baseComponentProps = useBaseComponent('TableBodyCell', { props: { disablePaddings: props.disablePaddings, isRowHeader: props.isRowHeader }, }); const mergedProps = { ...props, ...baseComponentProps }; - const { children, disablePaddings, isRowHeader, __internalRootRef } = mergedProps; + const { children, isRowHeader, disablePaddings, __internalRootRef } = mergedProps; const { className, ...restBaseProps } = getBaseProps(mergedProps); return ( - {children} - + ); } -applyDisplayName(TableCell, 'TableCell'); -export default TableCell; +applyDisplayName(TableBodyCell, 'TableBodyCell'); +export default TableBodyCell; diff --git a/src/table-cell/interfaces.ts b/src/table-body-cell/interfaces.ts similarity index 64% rename from src/table-cell/interfaces.ts rename to src/table-body-cell/interfaces.ts index 49f7ca56e2..225ec156d4 100644 --- a/src/table-cell/interfaces.ts +++ b/src/table-body-cell/interfaces.ts @@ -4,16 +4,17 @@ import React from 'react'; import { BaseComponentProps } from '../types/base-component'; -/** Renders a single data cell. */ -export interface TableCellProps extends BaseComponentProps { +/** Renders a single data cell, or a row header when `isRowHeader` is set. */ +export interface TableBodyCellProps extends BaseComponentProps { /** - * Removes the cell's built-in padding so you can compose your own spacing. Defaults to `false`. + * Renders the cell as a row header (``) instead of a data cell (``). A row header + * keeps the data-cell styling; use it for the cell that names its row. Defaults to `false`. */ - disablePaddings?: boolean; + isRowHeader?: boolean; /** - * Renders the cell as a row header (``) instead of a data cell. Defaults to `false`. + * Removes the cell's built-in padding so you can compose your own spacing. Defaults to `false`. */ - isRowHeader?: boolean; + disablePaddings?: boolean; /** The cell content. */ children?: React.ReactNode; } diff --git a/src/table-cell/internal.tsx b/src/table-body-cell/internal.tsx similarity index 76% rename from src/table-cell/internal.tsx rename to src/table-body-cell/internal.tsx index 37c727b060..ec88392455 100644 --- a/src/table-cell/internal.tsx +++ b/src/table-body-cell/internal.tsx @@ -10,13 +10,12 @@ import { useRowVariant } from '../table-row/context'; import bodyCellStyles from '../table/body-cell/styles.css.js'; import styles from './styles.css.js'; -export interface InternalTableCellProps { +export interface InternalTableBodyCellProps { tag: 'td' | 'th'; className?: string; style?: React.CSSProperties; wrapLines?: boolean; disablePaddings?: boolean; - isRowHeader?: boolean; nativeAttributes?: Omit< React.TdHTMLAttributes | React.ThHTMLAttributes, 'style' | 'className' | 'onClick' @@ -29,7 +28,7 @@ export interface InternalTableCellProps { children?: React.ReactNode; } -export const InternalTableCell = React.forwardRef( +export const InternalTableBodyCell = React.forwardRef( ( { tag, @@ -37,7 +36,6 @@ export const InternalTableCell = React.forwardRef`. Grid mode drops the implicit cell role, so set it - // explicitly (rowheader for a row header, otherwise cell); a role supplied via nativeAttributes wins. - const mergedNativeAttributes = isGrid - ? { - ...nativeAttributes, - ...(isRowHeader ? { scope: 'row' as const } : {}), - role: nativeAttributes?.role ?? (isRowHeader ? 'rowheader' : 'cell'), - } - : isRowHeader - ? { ...nativeAttributes, scope: 'row' as const } - : nativeAttributes; + // Within a body cell a `` is always a row header (column headers use InternalTableHeaderCell). + const isRowHeader = tag === 'th'; + const mergedNativeAttributes = { + ...nativeAttributes, + // A row header is a `` in either layout mode. + ...(isRowHeader ? { scope: 'row' as const } : undefined), + // Grid mode drops the implicit cell role, so set it explicitly; a role from nativeAttributes wins. + ...(isGrid ? { role: nativeAttributes?.role ?? (isRowHeader ? 'rowheader' : 'cell') } : undefined), + }; + const Element = tag; return ( ; -} - -applyDisplayName(TableHeaderRow, 'TableHeaderRow'); -export default TableHeaderRow; diff --git a/src/table-header-row/interfaces.ts b/src/table-header-row/interfaces.ts deleted file mode 100644 index 7c159f0d20..0000000000 --- a/src/table-header-row/interfaces.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 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 deleted file mode 100644 index 79f9423114..0000000000 --- a/src/table-header-row/internal.tsx +++ /dev/null @@ -1,34 +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 { 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 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(rest); - return ( -
item.v }]} items={[{ v: 'nested' }]} /> - + diff --git a/src/table-root/__tests__/basic-table.test.tsx b/src/table-root/__tests__/basic-table.test.tsx index be85491234..4bdcf6b6eb 100644 --- a/src/table-root/__tests__/basic-table.test.tsx +++ b/src/table-root/__tests__/basic-table.test.tsx @@ -4,15 +4,14 @@ 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 TableBodyCell from '../../../lib/components/table-body-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) +// Tests for the atomic table parts (TableRoot/TableHead/TableHeaderCell/TableBody/TableRow/TableBodyCell) // 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 @@ -50,16 +49,16 @@ function TableHarness({ options }: { options: RenderOptions }) { return ( - + Name Status - + {items.map(item => ( - {item.name} - {item.status} + {item.name} + {item.status} ))} @@ -86,17 +85,17 @@ describe('Table atomic parts', () => { 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 rows = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows(); + expect(rows).toHaveLength(5); // scoped to TableBody; the header row is also a `.row`, but lives in TableHead - const firstRowCells = createWrapper(rows[0].getElement()).findAllTableCells(); + const firstRowCells = createWrapper(rows[0].getElement()).findAllTableBodyCells(); 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); + expect(wrapper.findAllTableBodyCells()).toHaveLength(10); }); test('ariaLabel is applied to the table element', () => { @@ -129,7 +128,9 @@ describe('Table atomic parts', () => { test('does not emit an inline grid-template-columns on rows', () => { const { wrapper } = renderTable(); - const row = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + const row = createWrapper(wrapper.findTableBody()!.getElement()) + .findAllTableRows()[0] + .getElement() as HTMLElement; expect(row.style.gridTemplateColumns).toBe(''); }); }); @@ -144,7 +145,7 @@ describe('Table atomic parts', () => { expect(headerCell.getAttribute('role')).toBe('columnheader'); expect(headerCell.getAttribute('scope')).toBe('col'); - const dataRow = wrapper.findAllTableRows()[0].getElement(); + const dataRow = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); expect(dataRow.getAttribute('role')).toBe('row'); expect(dataRow.querySelectorAll('[role="cell"]')).toHaveLength(2); }); @@ -155,17 +156,27 @@ describe('Table atomic parts', () => { const template = '200px minmax(0px, 1fr)'; expect(headerRow.style.gridTemplateColumns).toBe(template); - const dataRow = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + const dataRow = createWrapper(wrapper.findTableBody()!.getElement()) + .findAllTableRows()[0] + .getElement() as HTMLElement; expect(dataRow.style.gridTemplateColumns).toBe(template); }); - test('the header row is aria-rowindex 1 only when ariaRowcount is provided (explicit row numbering)', () => { - const withRowcount = renderTable({ grid: true, ariaRowcount: 500 }); - const withRowcountHeaderRow = withRowcount.wrapper.findTableHead()!.find('[role="row"]')!.getElement(); - expect(withRowcountHeaderRow.getAttribute('aria-rowindex')).toBe('1'); + test('the header row emits aria-rowindex only when set explicitly (consumer-managed numbering)', () => { + const withIndex = render( + + + + Name + + + + ); + const withIndexHeaderRow = createWrapper(withIndex.container).findTableHead()!.find('[role="row"]')!.getElement(); + expect(withIndexHeaderRow.getAttribute('aria-rowindex')).toBe('1'); - // Without ariaRowcount the consumer isn't managing row numbering, so positions derive from the DOM and - // no aria-rowindex is set. + // Without an explicit ariaRowindex the consumer isn't managing row numbering, so positions derive + // from the DOM and 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); @@ -177,21 +188,21 @@ describe('Table atomic parts', () => { const { container } = render( - + Name - + - Selected visual only + Selected visual only - Default + Default ); - const rows = createWrapper(container).findAllTableRows(); + const rows = createWrapper(createWrapper(container).findTableBody()!.getElement()).findAllTableRows(); expect(rows[0].getElement().hasAttribute('aria-selected')).toBe(false); expect(rows[1].getElement().hasAttribute('aria-selected')).toBe(false); }); @@ -202,15 +213,15 @@ describe('Table atomic parts', () => { const { container } = render( - + Name Status - + - Resource 0 - Available + Resource 0 + Available @@ -224,18 +235,20 @@ describe('Table atomic parts', () => { const { container } = render( - + Name - + - Resource 200 + Resource 200 ); - const row = createWrapper(container).findAllTableRows()[0].getElement(); + const row = createWrapper(createWrapper(container).findTableBody()!.getElement()) + .findAllTableRows()[0] + .getElement(); expect(row.getAttribute('aria-rowindex')).toBe('202'); }); }); @@ -245,20 +258,25 @@ describe('Table atomic parts', () => { const { container } = render( - + Name - + - Resource 7 + 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'); + expect( + createWrapper(wrapper.findTableBody()!.getElement()) + .findAllTableRows()[0] + .getElement() + .getAttribute('data-index') + ).toBe('7'); + expect(wrapper.findAllTableBodyCells()[0].getElement().getAttribute('data-column')).toBe('name'); }); }); }); diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx index 5bf27317c4..9179f55e26 100644 --- a/src/table-root/internal.tsx +++ b/src/table-root/internal.tsx @@ -25,7 +25,7 @@ export default function InternalTableRoot({ ...rest }: InternalTableRootProps) { const isGrid = columnLayout.type === 'grid'; - const table = useTableRoot(columnLayout, ariaRowcount); + const table = useTableRoot(columnLayout); const baseProps = getBaseProps(rest); // A wide table's horizontal scroller isn't keyboard-reachable on its own, so a read-only table with no diff --git a/src/table-root/use-table-root.ts b/src/table-root/use-table-root.ts index 8e2394a34b..bb42c8c27f 100644 --- a/src/table-root/use-table-root.ts +++ b/src/table-root/use-table-root.ts @@ -8,8 +8,6 @@ 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; } // Clamp negatives to 0 so one malformed dimension can't invalidate the whole @@ -19,7 +17,7 @@ function clamp(value: number | undefined): number | undefined { return value === undefined || !Number.isFinite(value) ? undefined : Math.max(0, value); } -export function useTableRoot(columnLayout: TableRootProps.ColumnLayout, ariaRowcount?: number): UseTableRootResult { +export function useTableRoot(columnLayout: TableRootProps.ColumnLayout): UseTableRootResult { const gridTemplateColumns = useMemo(() => { if (columnLayout.type !== 'grid') { return undefined; @@ -46,8 +44,5 @@ export function useTableRoot(columnLayout: TableRootProps.ColumnLayout, ariaRowc .join(' '); }, [columnLayout]); - return useMemo( - () => ({ columnLayout, gridTemplateColumns, ariaRowcount }), - [columnLayout, gridTemplateColumns, ariaRowcount] - ); + return useMemo(() => ({ columnLayout, gridTemplateColumns }), [columnLayout, gridTemplateColumns]); } diff --git a/src/table-row/context.ts b/src/table-row/context.ts index 1a1d166586..3ab0c0414d 100644 --- a/src/table-row/context.ts +++ b/src/table-row/context.ts @@ -4,7 +4,7 @@ import { createContext, useContext } from 'react'; import { TableRowProps } from './interfaces'; -// A row→cell channel so a `TableCell` learns its row's visual state and paints selection via its own +// A row→cell channel so a `TableBodyCell` 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'); diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts index e26a758eb5..d90ff6e9d3 100644 --- a/src/table-row/interfaces.ts +++ b/src/table-row/interfaces.ts @@ -6,11 +6,12 @@ import { BaseComponentProps } from '../types/base-component'; export interface TableRowProps extends BaseComponentProps { /** - * The row's visual state. Visual only — it does not set `aria-selected`; convey selection to - * assistive technologies via the selection control in a leading cell. - * * `default` - A standard row. - * * `selected` - Applies selected-row styling. + * The row's variant. + * * `default` - A standard body row. + * * `selected` - Applies selected-row styling. Visual only — it does not set `aria-selected`; + * convey selection to assistive technologies via the selection control in a leading cell. * * `shaded` - Applies a shaded background for alternating row colors. + * * `header` - Marks the column-header row. Use inside `TableHead`. */ variant?: TableRowProps.Variant; /** Provides an accessible name for the row. Use this or `ariaLabelledby`. */ @@ -34,7 +35,7 @@ export interface TableRowProps extends BaseComponentProps { } export namespace TableRowProps { - export type Variant = 'default' | 'selected' | 'shaded'; + export type Variant = 'default' | 'selected' | 'shaded' | 'header'; export interface PositionStyle { position?: React.CSSProperties['position']; transform?: React.CSSProperties['transform']; diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index 6230339aef..f14dd0b0f6 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -11,8 +11,9 @@ import { TableRowProps } from './interfaces'; import styles from './styles.css.js'; -// Sanctioned data-* hooks: `data-awsui-variant-selected` / `data-awsui-variant-shaded` on the let sibling-adjacency -// CSS (consecutive-selected merge, striped divider) work, which a cell can't do from context. Inert for the Table. +// Sanctioned data-* hooks: `data-awsui-variant-*` on the carry the row's variant so CSS can key off it — +// sibling-adjacency (consecutive-selected merge, striped divider) and the selection ring, which a cell can't +// express from context, plus the header-row background. Inert for the existing Table. export interface InternalTableRowProps extends TableRowProps, InternalBaseComponentProps {} export default function InternalTableRow({ @@ -29,18 +30,15 @@ export default function InternalTableRow({ const { columnLayout, gridTemplateColumns } = useTableContext(); const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(rest); - // `variant` is the sole source of truth for these hooks: emit both unconditionally after the base-prop - // spread (true|undefined) so a consumer-passed data-awsui-variant-* can't spoof the selection/shading paint. - const reservedVariantAttributes = { - 'data-awsui-variant-selected': variant === 'selected' ? 'true' : undefined, - 'data-awsui-variant-shaded': variant === 'shaded' ? 'true' : undefined, - }; + + // The active variant is stamped as its data-awsui-variant-* hook (dynamic — a new variant needs no change + // here); the `default` variant carries none. return ( {counter} ) : null} - + ); } ); diff --git a/src/test-utils/dom/table-cell/index.ts b/src/test-utils/dom/table-body-cell/index.ts similarity index 63% rename from src/test-utils/dom/table-cell/index.ts rename to src/test-utils/dom/table-body-cell/index.ts index 868c00b2ac..8eb3a8eaf1 100644 --- a/src/test-utils/dom/table-cell/index.ts +++ b/src/test-utils/dom/table-body-cell/index.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; -import styles from '../../../table-cell/styles.selectors.js'; +import styles from '../../../table-body-cell/styles.selectors.js'; -export default class TableCellWrapper extends ComponentWrapper { +export default class TableBodyCellWrapper extends ComponentWrapper { static rootSelector: string = styles.cell; } diff --git a/src/test-utils/dom/table-header-row/index.ts b/src/test-utils/dom/table-header-row/index.ts deleted file mode 100644 index 2db852ed2e..0000000000 --- a/src/test-utils/dom/table-header-row/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// 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']; -} From 7f621573c3d64479bbc141e7deb877471089c53a Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 21 Sep 2026 08:36:09 +0000 Subject: [PATCH 12/23] refactor: Remove TableRow header variant and grid-auto-rows floor Header rows no longer need a distinct row variant. A header row's visual identity (the shaded background) lives on TableHeaderCell, and its structural role is conveyed by placement inside TableHead, so TableRow variant narrows to default | selected | shaded. This lets two more things go: - The row-level header background only backed the grid underfill strip (header cells paint themselves); with it removed, an underfill header shows cells shaded to their tracks, consistent with body rows. - grid-auto-rows only imposed a minimum row height on all-short rows; dropping it makes grid rows content-sized, matching auto mode and the existing Table, neither of which has a row-height floor. --- pages/table-root/column-sizing.page.tsx | 2 +- pages/table-root/common.tsx | 2 +- .../table-root/selection-edge-cases.page.tsx | 2 +- pages/table-root/selection.page.tsx | 2 +- pages/table-root/sorting.page.tsx | 2 +- .../__snapshots__/documenter.test.ts.snap | 4 +--- .../__tests__/basic-table-aria-label.test.tsx | 2 +- .../__tests__/basic-table-roles.test.tsx | 2 +- .../basic-table-styling-props.test.tsx | 6 +++--- src/table-root/__tests__/basic-table.test.tsx | 12 ++++++------ src/table-row/interfaces.ts | 3 +-- src/table-row/internal.tsx | 2 +- src/table-row/styles.scss | 19 ------------------- 13 files changed, 19 insertions(+), 41 deletions(-) diff --git a/pages/table-root/column-sizing.page.tsx b/pages/table-root/column-sizing.page.tsx index 4f98a829ce..8ce3166642 100644 --- a/pages/table-root/column-sizing.page.tsx +++ b/pages/table-root/column-sizing.page.tsx @@ -154,7 +154,7 @@ export default function TableColumnSizingPlaygroundPage() {
Resources
- + {configs.map(config => ( {config.label} ))} diff --git a/pages/table-root/common.tsx b/pages/table-root/common.tsx index ae6f88a6a4..9e89047f77 100644 --- a/pages/table-root/common.tsx +++ b/pages/table-root/common.tsx @@ -33,7 +33,7 @@ export const DATA_COLUMNS: ReadonlyArray = [ export function DataHeader() { return ( - + Name Type Size diff --git a/pages/table-root/selection-edge-cases.page.tsx b/pages/table-root/selection-edge-cases.page.tsx index 9002fa17b7..d64464ac4b 100644 --- a/pages/table-root/selection-edge-cases.page.tsx +++ b/pages/table-root/selection-edge-cases.page.tsx @@ -60,7 +60,7 @@ function Grid({
- + Name Type Status diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx index 4964f20762..4aff090a13 100644 --- a/pages/table-root/selection.page.tsx +++ b/pages/table-root/selection.page.tsx @@ -83,7 +83,7 @@ export default function TableSelectionPage() {
Resources
- + {mode === 'multi' ? (
diff --git a/pages/table-root/sorting.page.tsx b/pages/table-root/sorting.page.tsx index 72d3df8b45..51c8adf368 100644 --- a/pages/table-root/sorting.page.tsx +++ b/pages/table-root/sorting.page.tsx @@ -64,7 +64,7 @@ export default function TableSortingPage() {
Resources
- + {COLUMNS.map(({ key, label }) => { const active = key === sortKey; return ( diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index eb703ca000..9da493394c 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -30256,14 +30256,12 @@ rows. Not intended for general styling.", * \`default\` - A standard body row. * \`selected\` - Applies selected-row styling. Visual only — it does not set \`aria-selected\`; convey selection to assistive technologies via the selection control in a leading cell. -* \`shaded\` - Applies a shaded background for alternating row colors. -* \`header\` - Marks the column-header row. Use inside \`TableHead\`.", +* \`shaded\` - Applies a shaded background for alternating row colors.", "inlineType": { "name": "TableRowProps.Variant", "type": "union", "values": [ "default", - "header", "selected", "shaded", ], diff --git a/src/table-root/__tests__/basic-table-aria-label.test.tsx b/src/table-root/__tests__/basic-table-aria-label.test.tsx index 2c194f35ab..8b90dee4a2 100644 --- a/src/table-root/__tests__/basic-table-aria-label.test.tsx +++ b/src/table-root/__tests__/basic-table-aria-label.test.tsx @@ -29,7 +29,7 @@ function buildTree(labelProps: Pick - + Name Status diff --git a/src/table-root/__tests__/basic-table-roles.test.tsx b/src/table-root/__tests__/basic-table-roles.test.tsx index d8b461e7d1..3e78c8cab7 100644 --- a/src/table-root/__tests__/basic-table-roles.test.tsx +++ b/src/table-root/__tests__/basic-table-roles.test.tsx @@ -34,7 +34,7 @@ function LogTable({ items, grid }: { items: Item[]; grid?: boolean }) { return ( - + Name Status 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 77ee152e19..a5f16482c8 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -33,7 +33,7 @@ function Harness({ variant }: { variant?: TableRowProps.Variant }) { return ( - + Name Status @@ -124,7 +124,7 @@ describe('inline style props (virtualization)', () => { const { container } = render( - + Name @@ -175,7 +175,7 @@ describe('disablePaddings', () => { const { container } = render( - + Name diff --git a/src/table-root/__tests__/basic-table.test.tsx b/src/table-root/__tests__/basic-table.test.tsx index 4bdcf6b6eb..9aad9b3788 100644 --- a/src/table-root/__tests__/basic-table.test.tsx +++ b/src/table-root/__tests__/basic-table.test.tsx @@ -49,7 +49,7 @@ function TableHarness({ options }: { options: RenderOptions }) { return ( - + Name Status @@ -166,7 +166,7 @@ describe('Table atomic parts', () => { const withIndex = render( - + Name @@ -188,7 +188,7 @@ describe('Table atomic parts', () => { const { container } = render( - + Name @@ -213,7 +213,7 @@ describe('Table atomic parts', () => { const { container } = render( - + Name Status @@ -235,7 +235,7 @@ describe('Table atomic parts', () => { const { container } = render( - + Name @@ -258,7 +258,7 @@ describe('Table atomic parts', () => { const { container } = render( - + Name diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts index d90ff6e9d3..f539f92902 100644 --- a/src/table-row/interfaces.ts +++ b/src/table-row/interfaces.ts @@ -11,7 +11,6 @@ export interface TableRowProps extends BaseComponentProps { * * `selected` - Applies selected-row styling. Visual only — it does not set `aria-selected`; * convey selection to assistive technologies via the selection control in a leading cell. * * `shaded` - Applies a shaded background for alternating row colors. - * * `header` - Marks the column-header row. Use inside `TableHead`. */ variant?: TableRowProps.Variant; /** Provides an accessible name for the row. Use this or `ariaLabelledby`. */ @@ -35,7 +34,7 @@ export interface TableRowProps extends BaseComponentProps { } export namespace TableRowProps { - export type Variant = 'default' | 'selected' | 'shaded' | 'header'; + export type Variant = 'default' | 'selected' | 'shaded'; export interface PositionStyle { position?: React.CSSProperties['position']; transform?: React.CSSProperties['transform']; diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index f14dd0b0f6..e923fc4831 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -13,7 +13,7 @@ import styles from './styles.css.js'; // Sanctioned data-* hooks: `data-awsui-variant-*` on the
carry the row's variant so CSS can key off it — // sibling-adjacency (consecutive-selected merge, striped divider) and the selection ring, which a cell can't -// express from context, plus the header-row background. Inert for the existing Table. +// express from context. Inert for the existing Table. export interface InternalTableRowProps extends TableRowProps, InternalBaseComponentProps {} export default function InternalTableRow({ diff --git a/src/table-row/styles.scss b/src/table-row/styles.scss index d3798419fd..6d10f6f276 100644 --- a/src/table-row/styles.scss +++ b/src/table-row/styles.scss @@ -4,7 +4,6 @@ */ @use '../internal/styles/tokens' as awsui; -@use '../table/cell-base/cell-box' as cell-base; .row { position: relative; @@ -17,18 +16,6 @@ align-items: center; } -// Body rows get a minimum row track derived from the same cell-base tokens as the cell padding, so the two -// can't drift. Header rows size to their content instead. -.row-grid:not([data-awsui-variant-header]) { - grid-auto-rows: minmax( - calc( - #{awsui.$line-height-body-m} + 2 * #{cell-base.$cell-vertical-padding} + 2 * - #{cell-base.$cell-negative-space-vertical} - #{awsui.$border-divider-list-width} - ), - auto - ); -} - // Selection outline drawn as a layout-neutral `::after` on the row. Scoped to the hashed `.row` (not the // bare `data-awsui-variant-selected` attribute) so it can't leak onto unrelated consumer elements. In grid mode // the abs-pos `::after` is a grid item — its containing block is the column tracks, so `inset:0` hugs the @@ -58,9 +45,3 @@ border-start-start-radius: 0; border-start-end-radius: 0; } - -// Header rows (TableRow variant="header", rendered inside TableHead). Shares the base .row / .row-grid box; -// the variant only adds the header background. -.row[data-awsui-variant-header] { - background: awsui.$color-background-table-header; -} From be58dfde7a41977963aa4db644e20f3eb7789e7e Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 21 Sep 2026 08:55:14 +0000 Subject: [PATCH 13/23] refactor: Reuse useResizeObserver in TableRoot and add overflow-region tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled ResizeObserver in TableRoot with two useResizeObserver calls (the scroller and the table it wraps), matching how the rest of the codebase observes elements and getting a synchronous initial measure for free. The re-measure effect narrows to gridTemplateColumns — the one overflow transition an observer misses (grid tracks overflow without either observed box resizing); auto-layout content growth resizes the table box and is caught by the child observer. The unit overflow-region test is rewritten to drive the transition through a column-template change (no ResizeObserver mock, which the shared hook's entry conversion would otherwise require). A new integ suite covers the two paths jsdom can't: keyboard-scrollability of the focusable region, a viewport resize starting/stopping the overflow (scroller observer), and auto-layout content growth starting it (table-box observer). --- pages/table-root/scroll-region.page.tsx | 71 ++++++++++++++++ .../__integ__/scroll-region.test.ts | 81 +++++++++++++++++++ .../__tests__/basic-table-roles.test.tsx | 46 +++++------ src/table-root/internal.tsx | 25 +++--- 4 files changed, 184 insertions(+), 39 deletions(-) create mode 100644 pages/table-root/scroll-region.page.tsx create mode 100644 src/table-root/__integ__/scroll-region.test.ts diff --git a/pages/table-root/scroll-region.page.tsx b/pages/table-root/scroll-region.page.tsx new file mode 100644 index 0000000000..a3a666194c --- /dev/null +++ b/pages/table-root/scroll-region.page.tsx @@ -0,0 +1,71 @@ +// 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 Button from '~components/button'; +import TableBody from '~components/table-body'; +import TableBodyCell from '~components/table-body-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +// Four fixed 400px tracks (1600px total): overflows a normal-width viewport and fits a very wide one, so a +// viewport resize flips the horizontal-overflow scroll region on and off. +const WIDE_COLUMNS: ReadonlyArray = [ + { size: 400 }, + { size: 400 }, + { size: 400 }, + { size: 400 }, +]; + +export default function TableScrollRegionPage() { + const [grown, setGrown] = useState(false); + return ( + +

Table atomics — scroll region

+ +

Overflowing grid

+ + + + Name + Type + Size + Status + + + + + Resource 0 + Compute + 1 GiB + Available + + + + +

Auto content growth

+ + + + + Name + + + + + {grown ? 'x'.repeat(4000) : 'short'} + + + +
+ ); +} diff --git a/src/table-root/__integ__/scroll-region.test.ts b/src/table-root/__integ__/scroll-region.test.ts new file mode 100644 index 0000000000..c0d0c25317 --- /dev/null +++ b/src/table-root/__integ__/scroll-region.test.ts @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import styles from '../../../lib/components/table-root/styles.selectors.js'; + +const pageUrl = '#/light/table-root/scroll-region'; +const scroller = (testid: string) => `[data-testid="${testid}"] .${styles['body-scroller']}`; +const scrollTable = scroller('scroll-table'); +const growTable = scroller('grow-table'); + +test( + 'exposes the overflowing scroller as a focusable region that scrolls with the arrow keys', + useBrowser({ width: 900, height: 800 }, async browser => { + await browser.url(pageUrl); + const page = new BasePageObject(browser); + await page.waitForVisible(scrollTable); + + // The 1600px grid overflows a 900px viewport, so the scroller is a focusable region. + await expect(page.getElementAttribute(scrollTable, 'role')).resolves.toEqual('region'); + await page.click('h1'); + await page.keys('Tab'); + await expect(page.isFocused(scrollTable)).resolves.toBe(true); + + let leftBefore = 0; + await page.keys('ArrowRight'); + await page.waitForAssertion(async () => { + leftBefore = (await page.getElementScroll(scrollTable)).left; + expect(leftBefore).toBeGreaterThan(0); + }); + + await page.keys('ArrowLeft'); + await page.waitForAssertion(async () => { + expect((await page.getElementScroll(scrollTable)).left).toBeLessThan(leftBefore); + }); + }) +); + +test( + 'adds and removes the region as a viewport resize starts and stops the overflow', + useBrowser({ width: 600, height: 800 }, async browser => { + await browser.url(pageUrl); + const page = new BasePageObject(browser); + await page.waitForVisible(scrollTable); + + // Narrow viewport: the grid overflows, so the region and tab stop are present. + await page.waitForAssertion(async () => { + await expect(page.getElementAttribute(scrollTable, 'role')).resolves.toEqual('region'); + await expect(page.getElementAttribute(scrollTable, 'tabindex')).resolves.toEqual('0'); + }); + + // Widen past the 1600px grid so it fits: the ResizeObserver on the scroller re-measures and the region clears. + await page.setWindowSize({ width: 2000, height: 800 }); + await page.waitForAssertion(async () => { + await expect(page.getElementAttribute(scrollTable, 'role')).resolves.toBeNull(); + await expect(page.getElementAttribute(scrollTable, 'tabindex')).resolves.toBeNull(); + }); + }) +); + +test( + 'adds the region when auto-layout content growth starts the overflow', + useBrowser({ width: 1200, height: 800 }, async browser => { + await browser.url(pageUrl); + const page = new BasePageObject(browser); + await page.waitForVisible(growTable); + + // Short content fits the viewport: no region. + await page.waitForAssertion(async () => { + await expect(page.getElementAttribute(growTable, 'role')).resolves.toBeNull(); + }); + + // Growing a cell widens the table's own box; the ResizeObserver on that box re-measures and the region appears. + await page.click('[data-testid="grow"]'); + await page.waitForAssertion(async () => { + await expect(page.getElementAttribute(growTable, 'role')).resolves.toEqual('region'); + await expect(page.getElementAttribute(growTable, 'tabindex')).resolves.toEqual('0'); + }); + }) +); diff --git a/src/table-root/__tests__/basic-table-roles.test.tsx b/src/table-root/__tests__/basic-table-roles.test.tsx index 3e78c8cab7..a2179adce9 100644 --- a/src/table-root/__tests__/basic-table-roles.test.tsx +++ b/src/table-root/__tests__/basic-table-roles.test.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { act, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import TableBody from '../../../lib/components/table-body'; import TableBodyCell from '../../../lib/components/table-body-cell'; @@ -103,46 +103,46 @@ describe('Table role semantics', () => { }); describe('horizontal-overflow scroll region', () => { - let resizeCallback: ResizeObserverCallback; - const originalResizeObserver = global.ResizeObserver; - - beforeEach(() => { - global.ResizeObserver = class { - constructor(cb: ResizeObserverCallback) { - resizeCallback = cb; - } - observe() {} - unobserve() {} - disconnect() {} - }; - }); - afterEach(() => { - global.ResizeObserver = originalResizeObserver; - }); - const setScrollerGeometry = (scroller: Element, scrollWidth: number, clientWidth: number) => { Object.defineProperty(scroller, 'scrollWidth', { configurable: true, value: scrollWidth }); Object.defineProperty(scroller, 'clientWidth', { configurable: true, value: clientWidth }); }; + const gridTable = (columns: ReadonlyArray) => ( + + + + Name + + + + + Resource 0 + + + + ); + test('exposes a focusable labeled region only while the content overflows', () => { - const { table } = renderTable(makeItems(5), true); - const scroller = table().parentElement!; // root > scroll-container > body-scroller > table + const { container, rerender } = render(gridTable([{ size: 200 }])); + const scroller = createWrapper(container).find('table')!.getElement().parentElement!; // body-scroller // Not overflowing (jsdom default 0/0): no region, not a tab stop. expect(scroller.hasAttribute('role')).toBe(false); expect(scroller.hasAttribute('tabindex')).toBe(false); - // Overflows -> focusable labeled region for keyboard scrolling. + // The grid tracks now overflow the viewport. jsdom has no layout, so simulate the geometry; the + // column-template change re-measures — the overflow case a ResizeObserver misses (the table's box is + // unchanged, only the grid tracks overflow). setScrollerGeometry(scroller, 1200, 400); - act(() => resizeCallback([], {} as ResizeObserver)); + rerender(gridTable([{ size: 1200 }])); expect(scroller.getAttribute('role')).toBe('region'); expect(scroller.getAttribute('tabindex')).toBe('0'); expect(scroller.getAttribute('aria-label')).toBe('Log events'); // Back within bounds -> the region and tab stop are removed. setScrollerGeometry(scroller, 400, 400); - act(() => resizeCallback([], {} as ResizeObserver)); + rerender(gridTable([{ size: 200 }])); expect(scroller.hasAttribute('role')).toBe(false); expect(scroller.hasAttribute('tabindex')).toBe(false); }); diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx index 9179f55e26..6585e81e5c 100644 --- a/src/table-root/internal.tsx +++ b/src/table-root/internal.tsx @@ -3,6 +3,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; +import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; + import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { RowVariantContextProvider } from '../table-row/context'; @@ -40,24 +42,15 @@ export default function InternalTableRoot({ setIsScrollable(node.scrollWidth - node.clientWidth > 1); } }, []); - // Observer stays tied to the stable scroller node (and its child) so it isn't reallocated on every render. - useEffect(() => { - const node = scrollerRef.current; - if (!node || typeof ResizeObserver === 'undefined') { - return; - } - const observer = new ResizeObserver(measureScrollable); - observer.observe(node); - if (node.firstElementChild) { - observer.observe(node.firstElementChild); - } - return () => observer.disconnect(); - }, [measureScrollable]); - // Re-measure on layout template and content changes: overflow can start/stop without a box-size change - // (dynamic grid content), which the ResizeObserver alone would miss. Cheap read, no observer churn. + // Observe the scroller and the table it wraps: a viewport resize changes the scroller box, and + // auto-layout content growth changes the table box — either can start or stop the horizontal overflow. + useResizeObserver(scrollerRef, measureScrollable); + useResizeObserver(() => scrollerRef.current?.firstElementChild ?? null, measureScrollable); + // Grid column templates change the tracks' overflow without resizing either observed box, so no observer + // fires — re-measure when the template changes. useEffect(() => { measureScrollable(); - }, [table.gridTemplateColumns, children, measureScrollable]); + }, [table.gridTemplateColumns, measureScrollable]); // Set role="region" whenever scrollable (label passes through even if undefined), matching the // existing Table's getTableWrapperRoleProps rather than gating the role on a label. From 96fb243bb48a7f6c022099bbace11210c148064f Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 21 Sep 2026 09:36:30 +0000 Subject: [PATCH 14/23] refactor: Remove RowVariantContext, painting shaded rows from the row data-attr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RowVariantContext's only runtime effect was the atomic body cell's shaded-background class. Selection already paints from the row's data-awsui-variant-selected attribute via a `> .cell` rule, and the shaded *border* already did too — only the shaded *background* went through the context. Move it to a matching `[data-awsui-variant-shaded] > .cell` rule and the context has no consumers, so delete it along with the per-row provider and both `value="default"` resets (TableRoot and the existing Table). The existing Table keeps its TableContextProvider reset (defaultTableContext): its cells are the shared substrate and read the ambient column layout, so without it a Table nested in a grid-layout atomic cell would render grid roles/classes. That reset is load-bearing; the RowVariant one was not. --- src/table-body-cell/internal.tsx | 5 +-- src/table-body-cell/styles.scss | 3 ++ .../basic-table-styling-props.test.tsx | 1 - src/table-root/internal.tsx | 41 +++++++++---------- src/table-row/context.ts | 15 ------- src/table-row/internal.tsx | 3 +- src/table/internal.tsx | 5 +-- 7 files changed, 25 insertions(+), 48 deletions(-) delete mode 100644 src/table-row/context.ts diff --git a/src/table-body-cell/internal.tsx b/src/table-body-cell/internal.tsx index ec88392455..733f5c2938 100644 --- a/src/table-body-cell/internal.tsx +++ b/src/table-body-cell/internal.tsx @@ -5,7 +5,6 @@ import clsx from 'clsx'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; -import { useRowVariant } from '../table-row/context'; import bodyCellStyles from '../table/body-cell/styles.css.js'; import styles from './styles.css.js'; @@ -47,7 +46,6 @@ export const InternalTableBodyCell = React.forwardRef { const { columnLayout } = useTableContext(); - const variant = useRowVariant(); const isVisualRefresh = useVisualRefresh(); const isGrid = columnLayout.type === 'grid'; // Within a body cell a `
); } diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 2688acb83a..e3ffe48945 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -36,7 +36,6 @@ import { isDevelopment } from '../internal/is-development'; import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; import { defaultTableContext, TableContextProvider } from '../table-root/context'; -import { RowVariantContextProvider } from '../table-row/context'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; import { ClearSortButton } from './clear-sort'; @@ -917,9 +916,7 @@ const InternalTable = React.forwardRef( // Reset the shared cell contexts to known defaults: the extracted cell substrate reads column // layout and row variant from context, so the existing Table pins them here (it drives its own // selection/striping paint directly, not via the atomic row-variant context). - - {tableContent} - + {tableContent} ); } ) as TableForwardRefType; From a164105f7a906c937935b1904fd5fc586e89f8e4 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 21 Sep 2026 09:37:20 +0000 Subject: [PATCH 15/23] test: Drop vestigial assertions for the removed class-based row painting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variant tests asserted that atomic cells do NOT carry the existing Table's per-cell selection/shading classes — testing the absence of a class-based mechanism the atomic never used now that selection and shading both paint from the row's data-awsui-variant-* attribute. Drop those per-cell class-absence loops and the unused helper; each variant test now asserts the real contract (the correct data-awsui-variant-* hook on the , mutual exclusivity, and no aria-selected). Also delete two tests that guarded removed/never-built behavior: the stray-cell test that existed only to check the deleted RowVariantContext default, and the grid roving-tabindex negative (a deferred feature the atomic never implemented). --- .../__tests__/basic-table-roles.test.tsx | 9 --- .../basic-table-styling-props.test.tsx | 63 ++++--------------- 2 files changed, 12 insertions(+), 60 deletions(-) diff --git a/src/table-root/__tests__/basic-table-roles.test.tsx b/src/table-root/__tests__/basic-table-roles.test.tsx index a2179adce9..a9fdcefaa5 100644 --- a/src/table-root/__tests__/basic-table-roles.test.tsx +++ b/src/table-root/__tests__/basic-table-roles.test.tsx @@ -90,15 +90,6 @@ describe('Table role semantics', () => { 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-styling-props.test.tsx b/src/table-root/__tests__/basic-table-styling-props.test.tsx index 2be47b0701..4c1193dcec 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -17,17 +17,12 @@ import legacyHeaderCellStyles from '../../../lib/components/table/header-cell/st import cellStyles 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 through context, sets no -// `aria-selected` (selection is conveyed by the selection control), that the narrowed inline `positionStyle` -// 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 a selected row emits `data-awsui-variant-selected` on the (a shaded row emits -// `data-awsui-variant-shaded`) — the one sanctioned styling hook — and the cell stylesheet reads it to paint the -// background and draw the selection outline (a layout-neutral `::after` ring) and to merge consecutive -// selected rows via sibling adjacency. It is driven by `variant`, never a public prop. A shaded row still -// reuses the existing Table's `.body-cell-shaded` background class. Selection and shading are mutually -// exclusive by type. +// Proves the row `variant` is purely visual: it emits a `data-awsui-variant-*` hook on the that the cell +// stylesheet paints from (selection background + a layout-neutral `::after` ring, shaded background, and +// consecutive-row adjacency), never sets `aria-selected`, and is driven by `variant` rather than a public prop +// — selection and shading being mutually exclusive by type. Also covers the narrowed inline `positionStyle` +// (virtualization) reaching the body/row roots and `disablePaddings` reaching the padding opt-out on the cell +// content and header-cell root. function Harness({ variant }: { variant?: TableRowProps.Variant }) { return ( @@ -53,66 +48,32 @@ function renderHarness(variant?: TableRowProps.Variant) { return { wrapper: createWrapper(container) }; } -function cellClassLists(wrapper: ReturnType) { - return wrapper.findAllTableBodyCells().map(cell => cell.getElement().classList); -} - describe('TableRow variant is visual-only and paints through the cell', () => { - test("variant='selected' paints every cell selected, emits the data-awsui-variant-selected adjacency hook, and sets no aria-selected", () => { + test("variant='selected' emits the data-awsui-variant-selected hook and sets no aria-selected", () => { const { wrapper } = renderHarness('selected'); const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); // Visual state must NOT leak into ARIA; selection is conveyed by the selection control. expect(row).not.toHaveAttribute('aria-selected'); - // The one sanctioned styling hook: data-awsui-variant-selected drives the consecutive-selected outline merge. + // The sanctioned styling hook the cell stylesheet paints from; selected and shaded are mutually exclusive. expect(row).toHaveAttribute('data-awsui-variant-selected', 'true'); expect(row).not.toHaveAttribute('data-awsui-variant-shaded'); - // Selection paints via the row's data-awsui-variant-selected hook (background + ::after ring), not by reusing - // the existing Table's body-cell-selected — so no per-cell selection/has-selection class is emitted. - for (const classList of cellClassLists(wrapper)) { - expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); - expect(classList.contains(bodyCellStyles['has-selection'])).toBe(false); - expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); - } }); - test("variant='shaded' paints every cell shaded and never selected", () => { + test("variant='shaded' emits the data-awsui-variant-shaded hook and never selected", () => { const { wrapper } = renderHarness('shaded'); const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); expect(row).not.toHaveAttribute('data-awsui-variant-selected'); - // data-awsui-variant-shaded drives the striped-row divider darkening (sibling adjacency), mirroring data-awsui-variant-selected. + // data-awsui-variant-shaded is the hook the cell stylesheet paints the shaded background + adjacency from. expect(row).toHaveAttribute('data-awsui-variant-shaded', 'true'); - for (const classList of cellClassLists(wrapper)) { - 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 or data-awsui-variant-selected', () => { + test('the default variant emits no data-awsui-variant-* hook and no aria-selected', () => { const { wrapper } = renderHarness(); const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); expect(row).not.toHaveAttribute('aria-selected'); expect(row).not.toHaveAttribute('data-awsui-variant-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 TableBodyCell 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).findAllTableBodyCells()[0].getElement().classList; - expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); - expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); + expect(row).not.toHaveAttribute('data-awsui-variant-shaded'); }); }); From 2d99de893f30f17938f2f88f0afa854d231ce038 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 21 Sep 2026 12:06:06 +0000 Subject: [PATCH 16/23] refactor: Make nativeAttributes passing more consistent with other components --- src/table-body-cell/index.tsx | 19 ++--------- src/table-body-cell/internal.tsx | 28 ++++++++-------- src/table-header-cell/index.tsx | 25 ++------------- src/table-header-cell/internal.tsx | 48 ++++++++++++++++++++-------- src/table/body-cell/td-element.tsx | 2 +- src/table/header-cell/th-element.tsx | 10 +++--- 6 files changed, 59 insertions(+), 73 deletions(-) diff --git a/src/table-body-cell/index.tsx b/src/table-body-cell/index.tsx index 815957e293..1b5f1a0573 100644 --- a/src/table-body-cell/index.tsx +++ b/src/table-body-cell/index.tsx @@ -3,7 +3,6 @@ '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 { TableBodyCellProps } from './interfaces'; @@ -12,23 +11,11 @@ import { InternalTableBodyCell } from './internal'; export { TableBodyCellProps }; function TableBodyCell(props: TableBodyCellProps) { - const baseComponentProps = useBaseComponent('TableBodyCell', { + const { __internalRootRef } = useBaseComponent('TableBodyCell', { props: { disablePaddings: props.disablePaddings, isRowHeader: props.isRowHeader }, }); - const mergedProps = { ...props, ...baseComponentProps }; - const { children, isRowHeader, disablePaddings, __internalRootRef } = mergedProps; - const { className, ...restBaseProps } = getBaseProps(mergedProps); - return ( - - {children} - - ); + const { isRowHeader, ...rest } = props; + return ; } applyDisplayName(TableBodyCell, 'TableBodyCell'); diff --git a/src/table-body-cell/internal.tsx b/src/table-body-cell/internal.tsx index 733f5c2938..931d0725c5 100644 --- a/src/table-body-cell/internal.tsx +++ b/src/table-body-cell/internal.tsx @@ -3,35 +3,33 @@ import React from 'react'; import clsx from 'clsx'; +import { getBaseProps } from '../internal/base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; +import { NativeAttributes } from '../types/native-attributes'; +import { TableBodyCellProps } from './interfaces'; import bodyCellStyles from '../table/body-cell/styles.css.js'; import styles from './styles.css.js'; -export interface InternalTableBodyCellProps { +export type InternalTableBodyCellProps = Omit & { tag: 'td' | 'th'; - className?: string; style?: React.CSSProperties; wrapLines?: boolean; - disablePaddings?: boolean; - nativeAttributes?: Omit< - React.TdHTMLAttributes | React.ThHTMLAttributes, - 'style' | 'className' | 'onClick' - >; + // Non-base native attributes injected by internal callers (the existing Table's td-element): role and + // sizing. Base props (className/id/data-*) flow directly and are read via getBaseProps. + nativeAttributes?: NativeAttributes>; tabIndex?: number; onClick?: React.MouseEventHandler; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; beforeContent?: React.ReactNode; - children?: React.ReactNode; -} +}; export const InternalTableBodyCell = React.forwardRef( - ( - { + (props, ref) => { + const { tag, - className, style, wrapLines, disablePaddings, @@ -42,9 +40,8 @@ export const InternalTableBodyCell = React.forwardRef { + } = props; + const { className, ...restBaseProps } = getBaseProps(props); const { columnLayout } = useTableContext(); const isVisualRefresh = useVisualRefresh(); const isGrid = columnLayout.type === 'grid'; @@ -61,6 +58,7 @@ export const InternalTableBodyCell = React.forwardRef - {children} - - ); + return ; } applyDisplayName(TableHeaderCell, 'TableHeaderCell'); diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx index 5802f78295..3aa4a753b4 100644 --- a/src/table-header-cell/internal.tsx +++ b/src/table-header-cell/internal.tsx @@ -3,37 +3,59 @@ import React from 'react'; import clsx from 'clsx'; +import { getBaseProps } from '../internal/base-component'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { useTableContext } from '../table-root/context'; +import { NativeAttributes } from '../types/native-attributes'; +import { TableHeaderCellProps } from './interfaces'; import headerCellStyles from '../table/header-cell/styles.css.js'; import styles from './styles.css.js'; -export interface InternalTableHeaderCellProps { - className?: string; +export type InternalTableHeaderCellProps = TableHeaderCellProps & { style?: React.CSSProperties; - nativeAttributes?: React.ThHTMLAttributes & { - [key: `data-${string}`]: string | number | boolean | undefined; - }; tabIndex?: number; - disablePaddings?: boolean; + // Non-base native attributes injected by internal callers (the existing Table's th-element): colSpan, + // scope, role, aria-sort. Base props (className/id/data-*) flow directly and are read via getBaseProps. + nativeAttributes?: NativeAttributes>; disableContentWrapper?: boolean; disableDivider?: boolean; - children?: React.ReactNode; -} +}; export const InternalTableHeaderCell = React.forwardRef( - ( - { className, style, nativeAttributes, tabIndex, disablePaddings, disableContentWrapper, disableDivider, children }, - ref - ) => { + (props, ref) => { + const { + ariaLabel, + ariaLabelledby, + ariaDescribedby, + ariaSort, + disablePaddings, + style, + tabIndex, + nativeAttributes, + disableContentWrapper, + disableDivider, + children, + } = props; + const { className, ...restBaseProps } = getBaseProps(props); const { columnLayout } = useTableContext(); const isVisualRefresh = useVisualRefresh(); const isGrid = columnLayout.type === 'grid'; - const mergedNativeAttributes = { ...nativeAttributes, ...(isGrid ? { role: 'columnheader' as const } : undefined) }; + // `scope='col'` is the default for a public header cell; an internal caller's nativeAttributes (e.g. the + // existing Table's `scope='colgroup'` and computed role/aria-sort) override it. Grid mode adds the role. + const mergedNativeAttributes = { + scope: 'col' as const, + ...nativeAttributes, + ...(ariaLabel !== undefined ? { 'aria-label': ariaLabel } : undefined), + ...(ariaLabelledby !== undefined ? { 'aria-labelledby': ariaLabelledby } : undefined), + ...(ariaDescribedby !== undefined ? { 'aria-describedby': ariaDescribedby } : undefined), + ...(ariaSort !== undefined ? { 'aria-sort': ariaSort } : undefined), + ...(isGrid ? { role: 'columnheader' as const } : undefined), + }; return ( that the cell -// stylesheet paints from (selection background + a layout-neutral `::after` ring, shaded background, and -// consecutive-row adjacency), never sets `aria-selected`, and is driven by `variant` rather than a public prop -// — selection and shading being mutually exclusive by type. Also covers the narrowed inline `positionStyle` +// Proves the row `selected`/`shaded` props are purely visual: each emits an independent `data-awsui-*` hook +// on the that the cell stylesheet paints from (selection background + a layout-neutral `::after` ring, +// shaded background, and consecutive-row adjacency), and never sets `aria-selected`. Selection wins over +// shading — a selected row omits the shaded hook. Also covers the narrowed inline `positionStyle` // (virtualization) reaching the body/row roots and `disablePaddings` reaching the padding opt-out on the cell // content and header-cell root. -function Harness({ variant }: { variant?: TableRowProps.Variant }) { +function Harness({ selected, shaded }: { selected?: boolean; shaded?: boolean }) { return ( @@ -34,7 +34,7 @@ function Harness({ variant }: { variant?: TableRowProps.Variant }) { - + Resource 0 Available @@ -43,37 +43,43 @@ function Harness({ variant }: { variant?: TableRowProps.Variant }) { ); } -function renderHarness(variant?: TableRowProps.Variant) { - const { container } = render(); +function renderHarness(props: { selected?: boolean; shaded?: boolean } = {}) { + const { container } = render(); return { wrapper: createWrapper(container) }; } -describe('TableRow variant is visual-only and paints through the cell', () => { - test("variant='selected' emits the data-awsui-variant-selected hook and sets no aria-selected", () => { - const { wrapper } = renderHarness('selected'); - const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); +describe('TableRow selection/shading is visual-only and paints through the cell', () => { + const bodyRow = (wrapper: ReturnType) => + createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); + + test('selected emits the data-awsui-selected hook and sets no aria-selected', () => { + const row = bodyRow(renderHarness({ selected: true }).wrapper); // Visual state must NOT leak into ARIA; selection is conveyed by the selection control. expect(row).not.toHaveAttribute('aria-selected'); - // The sanctioned styling hook the cell stylesheet paints from; selected and shaded are mutually exclusive. - expect(row).toHaveAttribute('data-awsui-variant-selected', 'true'); - expect(row).not.toHaveAttribute('data-awsui-variant-shaded'); + // The sanctioned styling hook the cell stylesheet paints from. + expect(row).toHaveAttribute('data-awsui-selected', 'true'); + expect(row).not.toHaveAttribute('data-awsui-shaded'); }); - test("variant='shaded' emits the data-awsui-variant-shaded hook and never selected", () => { - const { wrapper } = renderHarness('shaded'); - const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); + test('shaded emits the data-awsui-shaded hook and never aria-selected', () => { + const row = bodyRow(renderHarness({ shaded: true }).wrapper); expect(row).not.toHaveAttribute('aria-selected'); - expect(row).not.toHaveAttribute('data-awsui-variant-selected'); - // data-awsui-variant-shaded is the hook the cell stylesheet paints the shaded background + adjacency from. - expect(row).toHaveAttribute('data-awsui-variant-shaded', 'true'); + expect(row).not.toHaveAttribute('data-awsui-selected'); + // data-awsui-shaded is the hook the cell stylesheet paints the shaded background + adjacency from. + expect(row).toHaveAttribute('data-awsui-shaded', 'true'); + }); + + test('selection wins over shading — a selected+shaded row emits only the selected hook', () => { + const row = bodyRow(renderHarness({ selected: true, shaded: true }).wrapper); + expect(row).toHaveAttribute('data-awsui-selected', 'true'); + expect(row).not.toHaveAttribute('data-awsui-shaded'); }); - test('the default variant emits no data-awsui-variant-* hook and no aria-selected', () => { - const { wrapper } = renderHarness(); - const row = createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows()[0].getElement(); + test('an unstyled row emits neither hook and no aria-selected', () => { + const row = bodyRow(renderHarness().wrapper); expect(row).not.toHaveAttribute('aria-selected'); - expect(row).not.toHaveAttribute('data-awsui-variant-selected'); - expect(row).not.toHaveAttribute('data-awsui-variant-shaded'); + expect(row).not.toHaveAttribute('data-awsui-selected'); + expect(row).not.toHaveAttribute('data-awsui-shaded'); }); }); @@ -188,11 +194,11 @@ describe('isRowHeader', () => { }); describe('nested content is insulated from the table/row context', () => { - test('a classic Table nested in a selected grid cell inherits neither the outer grid layout nor the selected variant', () => { + test('a classic Table nested in a selected grid cell inherits neither the outer grid layout nor the selected styling', () => { const { container } = render( - +
` is always a row header (column headers use InternalTableHeaderCell). @@ -71,8 +69,7 @@ export const InternalTableBodyCell = React.forwardRef .cell { background-color: awsui.$color-background-item-selected; } +[data-awsui-variant-shaded] > .cell { + background-color: awsui.$color-background-cell-shaded; +} tr:has(+ [data-awsui-variant-shaded]) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; 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 a5f16482c8..2be47b0701 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -83,7 +83,6 @@ describe('TableRow variant is visual-only and paints through the cell', () => { // data-awsui-variant-shaded drives the striped-row divider darkening (sibling adjacency), mirroring data-awsui-variant-selected. expect(row).toHaveAttribute('data-awsui-variant-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); expect(classList.contains(bodyCellStyles['has-selection'])).toBe(false); } diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx index 6585e81e5c..8d02fd5ee4 100644 --- a/src/table-root/internal.tsx +++ b/src/table-root/internal.tsx @@ -7,7 +7,6 @@ import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal import { getBaseProps } from '../internal/base-component'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; -import { RowVariantContextProvider } from '../table-row/context'; import { TableContextProvider } from './context'; import { TableRootProps } from './interfaces'; import { useTableRoot } from './use-table-root'; @@ -65,29 +64,27 @@ export default function InternalTableRoot({ return (
- {/* Reset the shared cell contexts at each table boundary: the cell substrate reads column layout - and row variant from context, so a table nested inside another table's cell must start from - this table's own layout and a `default` row variant rather than inheriting the outer table's. */} + {/* TableContext supplies this table's column layout to every part. It also resets the layout at the + table boundary, so a table nested inside another table's cell renders from its own layout rather + than inheriting the outer table's. */} - - {/* The page owns vertical scroll; this wrapper reintroduces an inline scroll viewport so a wide table scrolls horizontally instead of spilling out. */} -
-
- - {children} -
-
+ {/* The page owns vertical scroll; this wrapper reintroduces an inline scroll viewport so a wide table scrolls horizontally instead of spilling out. */} +
+
+ + {children} +
- +
); diff --git a/src/table-row/context.ts b/src/table-row/context.ts deleted file mode 100644 index 3ab0c0414d..0000000000 --- a/src/table-row/context.ts +++ /dev/null @@ -1,15 +0,0 @@ -// 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 so a `TableBodyCell` 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; - -export function useRowVariant(): TableRowProps.Variant { - return useContext(RowVariantContext); -} diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index e923fc4831..75f30a55c2 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -6,7 +6,6 @@ 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 { RowVariantContextProvider } from './context'; import { TableRowProps } from './interfaces'; import styles from './styles.css.js'; @@ -46,7 +45,7 @@ export default function InternalTableRow({ aria-rowindex={ariaRowindex} style={(isGrid ? { gridTemplateColumns, ...positionStyle } : positionStyle) as React.CSSProperties} > - {children} + {children}
` 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({ @@ -111,11 +110,7 @@ export function TableThElement({ 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 ( @@ -145,6 +140,11 @@ export function TableThElement({ style={{ ...resizableStyle, ...stickyStyles.style }} ref={mergedRef} tabIndex={cellTabIndex === -1 ? undefined : cellTabIndex} + data-focus-id={`header-${String(columnId)}`} + data-rightmost={isLast || undefined} + data-column-index={scope !== 'colgroup' ? colIndex + 1 : undefined} + data-column-group-id={columnGroupId || undefined} + {...copyAnalyticsMetadataAttribute(props)} nativeAttributes={nativeAttributes} disableContentWrapper={true} disableDivider={true} From e1e203078c177d9ff150beaefd94d6bf42f0c8db Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 21 Sep 2026 12:31:37 +0000 Subject: [PATCH 17/23] =?UTF-8?q?refactor:=20Address=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20narrow=20flex-weight=20check=20and=20drop=20unused?= =?UTF-8?q?=20interface=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/table-body/internal.tsx | 2 +- src/table-root/use-table-root.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx index 8969293d08..eef817b748 100644 --- a/src/table-body/internal.tsx +++ b/src/table-body/internal.tsx @@ -10,7 +10,7 @@ import { TableBodyProps } from './interfaces'; import styles from './styles.css.js'; -export interface InternalTableBodyProps extends TableBodyProps, InternalBaseComponentProps {} +interface InternalTableBodyProps extends TableBodyProps, InternalBaseComponentProps {} export default function InternalTableBody({ children, diff --git a/src/table-root/use-table-root.ts b/src/table-root/use-table-root.ts index bb42c8c27f..b45dc329b4 100644 --- a/src/table-root/use-table-root.ts +++ b/src/table-root/use-table-root.ts @@ -29,7 +29,7 @@ export function useTableRoot(columnLayout: TableRootProps.ColumnLayout): UseTabl return `${size}px`; } const min = `${clamp(column.minWidth) ?? 0}px`; - const flex = typeof column.size === 'object' ? clamp(column.size.flex) : undefined; + const flex = typeof column.size === 'object' && column.size.flex ? clamp(column.size.flex) : undefined; if (flex !== undefined) { // Weighted track — `{ flex: number }`. The type forbids a maxWidth here (can't cap an fr track). return `minmax(${min}, ${flex}fr)`; From ffb5ac35cb1c681523fd56f755d3dfea4e042097 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 21 Sep 2026 14:33:43 +0000 Subject: [PATCH 18/23] docs: Recommend setting minWidth on flexible grid columns --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 3 ++- src/table-root/interfaces.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 9da493394c..ef5c1416b4 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -30096,7 +30096,8 @@ subset of rows, such as with virtualization; otherwise it is derived from the DO * \`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. + * \`minWidth\` (number) - The minimum width in pixels for a flexible column. We recommend setting one; + without it the column can shrink until its content clips or overlaps at narrow widths. * \`maxWidth\` (number) - Caps a flexible column's width in pixels (it grows up to the cap). A capped column can't also carry a proportional \`flex\` weight, so weighting applies to the uncapped columns. diff --git a/src/table-root/interfaces.ts b/src/table-root/interfaces.ts index 91930f55dd..ccf4980a3f 100644 --- a/src/table-root/interfaces.ts +++ b/src/table-root/interfaces.ts @@ -24,7 +24,8 @@ export interface TableRootProps extends BaseComponentProps { * * `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. + * * `minWidth` (number) - The minimum width in pixels for a flexible column. We recommend setting one; + * without it the column can shrink until its content clips or overlaps at narrow widths. * * `maxWidth` (number) - Caps a flexible column's width in pixels (it grows up to the cap). A capped * column can't also carry a proportional `flex` weight, so weighting applies to the uncapped columns. * From b66e2b87838f740fce75fb292cea78124a433bcf Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 21 Sep 2026 15:21:01 +0000 Subject: [PATCH 19/23] Update table-root columnLayout docs --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 5 ++--- src/table-root/interfaces.ts | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index ef5c1416b4..9c2440a4f3 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -30090,9 +30090,8 @@ subset of rows, such as with virtualization; otherwise it is derived from the DO "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. +* \`{ type: 'grid'; columns: ColumnDefinition[] }\` - Renders a CSS grid. The columns are then provided as +an array of objects with the following properties: * \`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. diff --git a/src/table-root/interfaces.ts b/src/table-root/interfaces.ts index ccf4980a3f..0b20d05561 100644 --- a/src/table-root/interfaces.ts +++ b/src/table-root/interfaces.ts @@ -18,9 +18,8 @@ export interface TableRootProps extends BaseComponentProps { * 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. + * * `{ type: 'grid'; columns: ColumnDefinition[] }` - Renders a CSS grid. The columns are then provided as + * an array of objects with the following properties: * * `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. From eca27b9ac57e684958763d752fd5334879fa0522 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 22 Sep 2026 14:29:55 +0000 Subject: [PATCH 20/23] refactor: Replace TableRow variant union with selected/shaded boolean props; selection wins over shading --- .../table-root/selection-edge-cases.page.tsx | 6 +- pages/table-root/selection.page.tsx | 4 +- pages/table-root/simple.page.tsx | 4 +- .../__snapshots__/documenter.test.ts.snap | 26 +++----- src/table-body-cell/styles.scss | 12 ++-- .../basic-table-styling-props.test.tsx | 66 ++++++++++--------- src/table-root/__tests__/basic-table.test.tsx | 6 +- src/table-row/index.tsx | 4 +- src/table-row/interfaces.ts | 14 ++-- src/table-row/internal.tsx | 15 +++-- src/table-row/styles.scss | 8 +-- 11 files changed, 83 insertions(+), 82 deletions(-) diff --git a/pages/table-root/selection-edge-cases.page.tsx b/pages/table-root/selection-edge-cases.page.tsx index d64464ac4b..857d9fda17 100644 --- a/pages/table-root/selection-edge-cases.page.tsx +++ b/pages/table-root/selection-edge-cases.page.tsx @@ -8,7 +8,7 @@ import TableBodyCell from '~components/table-body-cell'; import TableHead from '~components/table-head'; import TableHeaderCell from '~components/table-header-cell'; import TableRoot, { TableRootProps } from '~components/table-root'; -import TableRow, { TableRowProps } from '~components/table-row'; +import TableRow from '~components/table-row'; import { SimplePage } from '../app/templates'; @@ -52,8 +52,6 @@ function Grid({ longFirstCell?: boolean; width?: number; }) { - const variantOf = (i: number): TableRowProps.Variant => - selected?.includes(i) ? 'selected' : shaded?.includes(i) ? 'shaded' : 'default'; return ( {label} @@ -68,7 +66,7 @@ function Grid({ {ROWS.map((row, i) => ( - + {longFirstCell && i === 1 ? LONG : row.name} {row.type} {row.status} diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx index 4aff090a13..00ca0b1f39 100644 --- a/pages/table-root/selection.page.tsx +++ b/pages/table-root/selection.page.tsx @@ -21,7 +21,7 @@ import { Item, makeItems } from './common'; import styles from './styles.scss'; // Selection (grid layout), multi and single, composed by the consumer — the atomic parts contribute -// only `variant='selected'` (the visual row surface; the checkbox/radio conveys selection to +// only `selected` (the visual row surface; the checkbox/radio conveys selection to // assistive technologies). The control column uses `disablePaddings` cells with a centred control to // match the existing Table's selection column. // Control column is fixed; Name and Status share the remaining width via flex weights (rather than @@ -102,7 +102,7 @@ export default function TableSelectionPage() { {items.map((item: Item) => ( - +
{mode === 'multi' ? ( diff --git a/pages/table-root/simple.page.tsx b/pages/table-root/simple.page.tsx index 0f10b77ee4..439bc597d1 100644 --- a/pages/table-root/simple.page.tsx +++ b/pages/table-root/simple.page.tsx @@ -15,7 +15,7 @@ import { SimplePage } from '../app/templates'; import { DataHeader, makeItems } from './common'; // A minimal read-only table in auto layout (`columnLayout` omitted, so it defaults to `{ type: 'auto' }`). -// Striping is composed by the consumer via the row `variant`: with the toggle on, alternating rows are +// Striping is composed by the consumer via the row `shaded` prop: with the toggle on, alternating rows are // marked `shaded` — the atomic table owns no row-parity computation. export default function TableSimplePage() { const items = makeItems(10); @@ -38,7 +38,7 @@ export default function TableSimplePage() { {items.map((item, index) => ( - + {item.name} {item.type} {item.size} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 9c2440a4f3..1504ff7830 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -30252,23 +30252,17 @@ rows. Not intended for general styling.", "type": "TableRowProps.PositionStyle", }, { - "description": "The row's variant. -* \`default\` - A standard body row. -* \`selected\` - Applies selected-row styling. Visual only — it does not set \`aria-selected\`; -convey selection to assistive technologies via the selection control in a leading cell. -* \`shaded\` - Applies a shaded background for alternating row colors.", - "inlineType": { - "name": "TableRowProps.Variant", - "type": "union", - "values": [ - "default", - "selected", - "shaded", - ], - }, - "name": "variant", + "description": "Applies selected-row styling. Visual only — it does not set \`aria-selected\`; convey selection to +assistive technologies via the selection control in a leading cell.", + "name": "selected", "optional": true, - "type": "string", + "type": "boolean", + }, + { + "description": "Applies a shaded background, for alternating row colors.", + "name": "shaded", + "optional": true, + "type": "boolean", }, ], "regions": [ diff --git a/src/table-body-cell/styles.scss b/src/table-body-cell/styles.scss index 261c3cf6bc..608bcea711 100644 --- a/src/table-body-cell/styles.scss +++ b/src/table-body-cell/styles.scss @@ -11,25 +11,25 @@ // Selection is a background fill plus a layout-neutral `::after` ring: the cell keeps the base // `.body-cell` borders/padding, so the row never grows and content never shifts on toggle. -[data-awsui-variant-selected] > .cell { +[data-awsui-selected] > .cell { background-color: awsui.$color-background-item-selected; } -[data-awsui-variant-shaded] > .cell { +[data-awsui-shaded] > .cell { background-color: awsui.$color-background-cell-shaded; } -tr:has(+ [data-awsui-variant-shaded]) > .cell { +tr:has(+ [data-awsui-shaded]) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; } -[data-awsui-variant-shaded]:not(:last-child) > .cell { +[data-awsui-shaded]:not(:last-child) > .cell { border-block-end-color: awsui.$color-border-cell-shaded; } -[data-awsui-variant-selected]:has(+ [data-awsui-variant-selected]) > .cell { +[data-awsui-selected]:has(+ [data-awsui-selected]) > .cell { border-block-end-color: transparent; } -tr:not([data-awsui-variant-selected]):has(+ [data-awsui-variant-selected]) > .cell { +tr:not([data-awsui-selected]):has(+ [data-awsui-selected]) > .cell { border-block-end-color: transparent; } 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 4c1193dcec..44ee82a150 100644 --- a/src/table-root/__tests__/basic-table-styling-props.test.tsx +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -9,7 +9,7 @@ import TableBodyCell from '../../../lib/components/table-body-cell'; import TableHead from '../../../lib/components/table-head'; import TableHeaderCell from '../../../lib/components/table-header-cell'; import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; -import TableRow, { TableRowProps } from '../../../lib/components/table-row'; +import TableRow from '../../../lib/components/table-row'; import createWrapper from '../../../lib/components/test-utils/dom'; import bodyCellStyles from '../../../lib/components/table/body-cell/styles.css.js'; @@ -17,14 +17,14 @@ import legacyHeaderCellStyles from '../../../lib/components/table/header-cell/st import cellStyles 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: it emits a `data-awsui-variant-*` hook on the
item.v }]} items={[{ v: 'nested' }]} /> @@ -201,7 +207,7 @@ describe('nested content is insulated from the table/row context', () => { ); // The existing Table resets both atomic contexts at its root, so its own cells read auto layout and - // default variant — the outer grid class and selected paint do not leak into the nested table. + // no selection paint — the outer grid class and selected paint do not leak into the nested table. const nestedCell = createWrapper(container).findTable()!.findBodyCell(1, 1)!.getElement(); expect(nestedCell.classList.contains(cellStyles['cell-grid'])).toBe(false); expect(nestedCell.classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); diff --git a/src/table-root/__tests__/basic-table.test.tsx b/src/table-root/__tests__/basic-table.test.tsx index 9aad9b3788..46411e5a3e 100644 --- a/src/table-root/__tests__/basic-table.test.tsx +++ b/src/table-root/__tests__/basic-table.test.tsx @@ -183,8 +183,8 @@ describe('Table atomic parts', () => { }); }); - describe('row variant is visual-only', () => { - test('variant="selected" applies no aria-selected (selection is conveyed by the control)', () => { + describe('row selection is visual-only', () => { + test('selected applies no aria-selected (selection is conveyed by the control)', () => { const { container } = render( @@ -193,7 +193,7 @@ describe('Table atomic parts', () => { - + Selected visual only diff --git a/src/table-row/index.tsx b/src/table-row/index.tsx index 8eda6b30f7..099355f62c 100644 --- a/src/table-row/index.tsx +++ b/src/table-row/index.tsx @@ -11,7 +11,9 @@ import InternalTableRow from './internal'; export { TableRowProps }; function TableRow(props: TableRowProps) { - const baseComponentProps = useBaseComponent('TableRow', { props: { variant: props.variant } }); + const baseComponentProps = useBaseComponent('TableRow', { + props: { selected: props.selected, shaded: props.shaded }, + }); return ; } diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts index f539f92902..01e5053a6e 100644 --- a/src/table-row/interfaces.ts +++ b/src/table-row/interfaces.ts @@ -6,13 +6,14 @@ import { BaseComponentProps } from '../types/base-component'; export interface TableRowProps extends BaseComponentProps { /** - * The row's variant. - * * `default` - A standard body row. - * * `selected` - Applies selected-row styling. Visual only — it does not set `aria-selected`; - * convey selection to assistive technologies via the selection control in a leading cell. - * * `shaded` - Applies a shaded background for alternating row colors. + * Applies selected-row styling. Visual only — it does not set `aria-selected`; convey selection to + * assistive technologies via the selection control in a leading cell. */ - variant?: TableRowProps.Variant; + selected?: boolean; + /** + * Applies a shaded background, for alternating row colors. + */ + shaded?: boolean; /** 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. */ @@ -34,7 +35,6 @@ export interface TableRowProps extends BaseComponentProps { } export namespace TableRowProps { - export type Variant = 'default' | 'selected' | 'shaded'; export interface PositionStyle { position?: React.CSSProperties['position']; transform?: React.CSSProperties['transform']; diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx index 75f30a55c2..d1dd5cd08c 100644 --- a/src/table-row/internal.tsx +++ b/src/table-row/internal.tsx @@ -10,13 +10,15 @@ import { TableRowProps } from './interfaces'; import styles from './styles.css.js'; -// Sanctioned data-* hooks: `data-awsui-variant-*` on the carry the row's variant so CSS can key off it — -// sibling-adjacency (consecutive-selected merge, striped divider) and the selection ring, which a cell can't -// express from context. Inert for the existing Table. +// Sanctioned data-* hooks: `data-awsui-selected` / `data-awsui-shaded` on the carry the row's visual +// state so CSS can key off it — sibling-adjacency (consecutive-selected merge, striped divider) and the +// selection ring, which a cell can't express from its own context. Inert for the existing Table. A selected +// row omits the shaded hook so selection styling wins outright (no shaded paint on a selected row). export interface InternalTableRowProps extends TableRowProps, InternalBaseComponentProps {} export default function InternalTableRow({ - variant = 'default', + selected, + shaded, ariaLabel, ariaLabelledby, ariaDescribedby, @@ -30,14 +32,13 @@ export default function InternalTableRow({ const isGrid = columnLayout.type === 'grid'; const { className, ...restBaseProps } = getBaseProps(rest); - // The active variant is stamped as its data-awsui-variant-* hook (dynamic — a new variant needs no change - // here); the `default` variant carries none. return ( Date: Tue, 22 Sep 2026 16:13:17 +0000 Subject: [PATCH 21/23] fix: Honor explicit flex:0 as a 0fr track; correct stale TableHeaderRow doc references --- .../snapshot-tests/__snapshots__/documenter.test.ts.snap | 2 +- src/table-head/interfaces.ts | 4 ++-- src/table-root/__tests__/use-table-root.test.tsx | 4 ++++ src/table-root/use-table-root.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 1504ff7830..f523865fad 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29962,7 +29962,7 @@ use the \`id\` attribute, consider setting it on a parent element instead.", ], "regions": [ { - "description": "The header row: a \`TableHeaderRow\` whose cells are \`TableHeaderCell\` components.", + "description": "The header row: a \`TableRow\` whose cells are \`TableHeaderCell\` components.", "isDefault": true, "name": "children", }, diff --git a/src/table-head/interfaces.ts b/src/table-head/interfaces.ts index 6ff1f61998..618af959c9 100644 --- a/src/table-head/interfaces.ts +++ b/src/table-head/interfaces.ts @@ -4,8 +4,8 @@ import React from 'react'; import { BaseComponentProps } from '../types/base-component'; -/** Renders the table head. Its child is a single `TableHeaderRow` of `TableHeaderCell`s. */ +/** Renders the table head. Its child is a single `TableRow` of `TableHeaderCell`s. */ export interface TableHeadProps extends BaseComponentProps { - /** The header row: a `TableHeaderRow` whose cells are `TableHeaderCell` components. */ + /** The header row: a `TableRow` whose cells are `TableHeaderCell` components. */ children?: React.ReactNode; } diff --git a/src/table-root/__tests__/use-table-root.test.tsx b/src/table-root/__tests__/use-table-root.test.tsx index a947d8cf25..91dd4a8d7f 100644 --- a/src/table-root/__tests__/use-table-root.test.tsx +++ b/src/table-root/__tests__/use-table-root.test.tsx @@ -39,6 +39,10 @@ describe('useTableRoot', () => { expect(gridTemplate([{ size: { flex: 2 } }])).toBe('minmax(0px, 2fr)'); }); + test('an explicit zero flex weight becomes a 0fr track (not the default 1fr)', () => { + expect(gridTemplate([{ size: { flex: 0 } }])).toBe('minmax(0px, 0fr)'); + }); + test('minWidth floors a flexible track', () => { expect(gridTemplate([{ minWidth: 150 }])).toBe('minmax(150px, 1fr)'); }); diff --git a/src/table-root/use-table-root.ts b/src/table-root/use-table-root.ts index b45dc329b4..bb42c8c27f 100644 --- a/src/table-root/use-table-root.ts +++ b/src/table-root/use-table-root.ts @@ -29,7 +29,7 @@ export function useTableRoot(columnLayout: TableRootProps.ColumnLayout): UseTabl return `${size}px`; } const min = `${clamp(column.minWidth) ?? 0}px`; - const flex = typeof column.size === 'object' && column.size.flex ? clamp(column.size.flex) : undefined; + const flex = typeof column.size === 'object' ? clamp(column.size.flex) : undefined; if (flex !== undefined) { // Weighted track — `{ flex: number }`. The type forbids a maxWidth here (can't cap an fr track). return `minmax(${min}, ${flex}fr)`; From 681b0a68c34d5b9002e63562b0920ba4f39c49f9 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 22 Sep 2026 16:27:21 +0000 Subject: [PATCH 22/23] chore: Use SimplePage helper in the scroll-region dev page --- pages/table-root/scroll-region.page.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pages/table-root/scroll-region.page.tsx b/pages/table-root/scroll-region.page.tsx index a3a666194c..9676e78494 100644 --- a/pages/table-root/scroll-region.page.tsx +++ b/pages/table-root/scroll-region.page.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useState } from 'react'; -import Box from '~components/box'; import Button from '~components/button'; import TableBody from '~components/table-body'; import TableBodyCell from '~components/table-body-cell'; @@ -11,6 +10,8 @@ import TableHeaderCell from '~components/table-header-cell'; import TableRoot, { TableRootProps } from '~components/table-root'; import TableRow from '~components/table-row'; +import { SimplePage } from '../app/templates'; + // Four fixed 400px tracks (1600px total): overflows a normal-width viewport and fits a very wide one, so a // viewport resize flips the horizontal-overflow scroll region on and off. const WIDE_COLUMNS: ReadonlyArray = [ @@ -23,9 +24,7 @@ const WIDE_COLUMNS: ReadonlyArray = [ export default function TableScrollRegionPage() { const [grown, setGrown] = useState(false); return ( - -

Table atomics — scroll region

- +

Overflowing grid

-
+ ); } From 8fbab042c2bd87a5e0c2ea8f03fe4b758dccf1fc Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 22 Sep 2026 16:53:06 +0000 Subject: [PATCH 23/23] Clean up dev pages --- pages/table-root/column-sizing.page.tsx | 39 +++++++++---------- .../table-root/selection-edge-cases.page.tsx | 33 +++++++++------- pages/table-root/selection.page.tsx | 6 --- pages/table-root/simple.page.tsx | 3 -- pages/table-root/sorting.page.tsx | 5 --- 5 files changed, 36 insertions(+), 50 deletions(-) diff --git a/pages/table-root/column-sizing.page.tsx b/pages/table-root/column-sizing.page.tsx index 8ce3166642..4a3fbe5e5f 100644 --- a/pages/table-root/column-sizing.page.tsx +++ b/pages/table-root/column-sizing.page.tsx @@ -20,11 +20,6 @@ import { useAppContext } from '../app/app-context'; import { SimplePage } from '../app/templates'; import { Item, makeItems } from './common'; -// Column-sizing playground (grid layout). Adjust each column's sizing mode and widths to explore how -// `columnLayout: 'grid'` compiles `ColumnDefinition`s into a grid-template-columns track list. A CSS grid -// track can't be both fr-weighted and px-capped, so `flex` and `maxWidth` are mutually exclusive in the -// type: use `flex` (weighted, shares free space) or `capped` (grows only up to maxWidth), not both. - type Mode = 'fixed' | 'flex' | 'capped'; interface ColConfig { label: string; @@ -126,22 +121,24 @@ export default function TableColumnSizingPlaygroundPage() { /> )} - - update(index, { minWidth: detail.value })} - /> - - - update(index, { maxWidth: detail.value })} - /> - + {config.mode !== 'fixed' && ( + + update(index, { minWidth: detail.value })} + /> + + )} + {config.mode === 'capped' && ( + + update(index, { maxWidth: detail.value })} + /> + + )} ))} diff --git a/pages/table-root/selection-edge-cases.page.tsx b/pages/table-root/selection-edge-cases.page.tsx index 857d9fda17..34ca815ee2 100644 --- a/pages/table-root/selection-edge-cases.page.tsx +++ b/pages/table-root/selection-edge-cases.page.tsx @@ -12,17 +12,6 @@ import TableRow from '~components/table-row'; import { SimplePage } from '../app/templates'; -// Visual coverage for the grid-layout selection-outline edge cases: the selected-row outline is an -// abspos `::after` placed into the row's grid area (`grid-column: 1 / -1`), so it hugs the column extent -// regardless of how the tracks relate to the row box. These permutations pin the states that regressed -// during development: -// - FILL: flex tracks fill the viewport — outline ends at the last column (== viewport). -// - UNDERFILL: capped tracks are narrower than the row — outline stops at the last column, not the row edge. -// - OVERFLOW: fixed tracks exceed the scroll viewport — outline follows the tracks past the fold. -// - MERGE: two consecutive selected rows render as one continuous rounded outline. -// - SHADED: striped-row divider darkening via adjacency. -// - AUTO: in auto layout the row is not a grid, so the outline falls back to the row box. - interface Row { name: string; type: string; @@ -95,9 +84,18 @@ const fixedWide: TableRootProps.ColumnLayout = { export default function TableSelectionEdgeCasesPage() { return ( - - - + + + - + ); diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx index 00ca0b1f39..e30e13e5e1 100644 --- a/pages/table-root/selection.page.tsx +++ b/pages/table-root/selection.page.tsx @@ -20,12 +20,6 @@ import { Item, makeItems } from './common'; import styles from './styles.scss'; -// Selection (grid layout), multi and single, composed by the consumer — the atomic parts contribute -// only `selected` (the visual row surface; the checkbox/radio conveys selection to -// assistive technologies). The control column uses `disablePaddings` cells with a centred control to -// match the existing Table's selection column. -// Control column is fixed; Name and Status share the remaining width via flex weights (rather than -// flexing Name alone) so Status stays adjacent instead of being pushed to the far edge. const COLUMNS: ReadonlyArray = [ { size: 40 }, { size: { flex: 53 } }, diff --git a/pages/table-root/simple.page.tsx b/pages/table-root/simple.page.tsx index 439bc597d1..b45a16e5bd 100644 --- a/pages/table-root/simple.page.tsx +++ b/pages/table-root/simple.page.tsx @@ -14,9 +14,6 @@ import { useAppContext } from '../app/app-context'; import { SimplePage } from '../app/templates'; import { DataHeader, makeItems } from './common'; -// A minimal read-only table in auto layout (`columnLayout` omitted, so it defaults to `{ type: 'auto' }`). -// Striping is composed by the consumer via the row `shaded` prop: with the toggle on, alternating rows are -// marked `shaded` — the atomic table owns no row-parity computation. export default function TableSimplePage() { const items = makeItems(10); const { urlParams, setUrlParams } = useAppContext<'stripedRows'>(); diff --git a/pages/table-root/sorting.page.tsx b/pages/table-root/sorting.page.tsx index 51c8adf368..599996acbd 100644 --- a/pages/table-root/sorting.page.tsx +++ b/pages/table-root/sorting.page.tsx @@ -17,11 +17,6 @@ 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. Clicking a column sorts by it, toggling direction when it is -// already the active column. Caret icons match the existing Table: `caret-down` (sortable, inactive), -// `caret-up-filled` (ascending), `caret-down-filled` (descending). - type SortKey = 'name' | 'type' | 'size' | 'status'; type SortDirection = 'ascending' | 'descending';