diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 0a6439d9c0..8ef92ed2f7 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -11,6 +11,13 @@ const pluralizationMap = { Autosuggest: 'Autosuggests', Badge: 'Badges', BarChart: 'BarCharts', + TableRoot: 'TableRoots', + TableHead: 'TableHeads', + TableHeaderCell: 'TableHeaderCells', + TableHeaderRow: 'TableHeaderRows', + TableBody: 'TableBodies', + TableRow: 'TableRows', + TableCell: 'TableCells', Box: 'Boxes', BreadcrumbGroup: 'BreadcrumbGroups', Button: 'Buttons', 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.page.tsx b/pages/table-root/selection.page.tsx new file mode 100644 index 0000000000..cffa24cff3 --- /dev/null +++ b/pages/table-root/selection.page.tsx @@ -0,0 +1,111 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Box from '~components/box'; +import Checkbox from '~components/checkbox'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +// A selectable + sortable table (grid layout). Selection and sorting are composed +// by the consumer — the atomic parts contribute `variant='selected'` (row surface + aria-selected) +// and `ariaSort` (the header semantic). The control column uses `disablePaddings` cells and a +// centered checkbox to match the classic Table selection column; the name column flexes. +const COLUMNS: ReadonlyArray = [{ size: 40 }, { minWidth: 160 }, { size: 140 }]; +const ITEM_COUNT = 10; + +type SortDirection = 'ascending' | 'descending'; + +export default function TableSelectionPage() { + const allItems = makeItems(ITEM_COUNT); + const [selectedIds, setSelectedIds] = useState>(new Set([allItems[1].id, allItems[2].id])); + const [direction, setDirection] = useState('ascending'); + + const items = useMemo(() => { + const sorted = [...allItems].sort((a, b) => a.name.localeCompare(b.name)); + return direction === 'ascending' ? sorted : sorted.reverse(); + }, [allItems, direction]); + + const allSelected = items.length > 0 && items.every(item => selectedIds.has(item.id)); + const someSelected = items.some(item => selectedIds.has(item.id)); + const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(items.map(item => item.id))); + const toggleRow = (id: string) => + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + const toggleSort = () => setDirection(prev => (prev === 'ascending' ? 'descending' : 'ascending')); + + return ( + + + Table atomics — selectable + sortable (grid layout) + + +
Resources
+ + + + +
+ +
+
+ + + + Status +
+
+ + {items.map((item: Item) => ( + + +
+ toggleRow(item.id)} + ariaLabel={`Select ${item.name}`} + /> +
+
+ {item.name} + {item.status} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/pages/table-root/simple.page.tsx b/pages/table-root/simple.page.tsx new file mode 100644 index 0000000000..b110d8d678 --- /dev/null +++ b/pages/table-root/simple.page.tsx @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import TableRoot from '~components/table-root'; + +import { DataBody, DataHeader, makeItems } from './common'; + +// A minimal read-only table in auto layout. `columnLayout` is omitted, so it +// defaults to `{ type: 'auto' }` — columns size to their content and the count comes from the cells. +export default function TableSimplePage() { + const items = makeItems(8); + return ( + + + Table atomics — simple (auto layout) + + +
Resources
+ + + + +
+
+
+ ); +} diff --git a/pages/table-root/single-selection.page.tsx b/pages/table-root/single-selection.page.tsx new file mode 100644 index 0000000000..59302c18ef --- /dev/null +++ b/pages/table-root/single-selection.page.tsx @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import RadioButton from '~components/radio-button'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +// Single selection (grid layout). It composes exactly like multi selection, but the control is a +// radio and only one row is selected at a time — the consumer tracks a single selected id. The rows +// share a radio `name`, so the browser's native radio group gives up/down arrow-key navigation +// between rows for free (matching classic Table). Each radio's accessible name comes from a +// visually-hidden label (RadioButton has no `ariaLabel` prop). The control column matches classic +// Table via `disablePaddings` cells and a centered control; the header has no select-all control. +const COLUMNS: ReadonlyArray = [{ size: 40 }, { minWidth: 160 }, { size: 140 }]; +const ITEM_COUNT = 10; + +export default function TableSingleSelectionPage() { + const items = makeItems(ITEM_COUNT); + const [selectedId, setSelectedId] = useState(items[1].id); + + return ( + + + Table atomics — single selection (grid layout) + + +
Resources
+ + + + + Name + Status + + + + {items.map((item: Item) => ( + + +
+ setSelectedId(item.id)} + > + {`Select ${item.name}`} + +
+
+ {item.name} + {item.status} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/pages/table-root/sorting.page.tsx b/pages/table-root/sorting.page.tsx new file mode 100644 index 0000000000..ee6d90f8ad --- /dev/null +++ b/pages/table-root/sorting.page.tsx @@ -0,0 +1,136 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import Icon from '~components/icon'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +// Sorting (auto layout). Sorting is fully composed by the consumer — the atomic components contribute +// only `ariaSort` on each header cell. This page shows three things: sortable columns that are not +// currently sorted (a non-filled caret + `ariaSort='none'`), several independently sortable columns, +// and multi-column sort opted into from the header (shift-click adds a column to the sort chain, with +// a priority number next to each caret). Caret icons match classic Table: `caret-down` (sortable), +// `caret-up-filled` (ascending), `caret-down-filled` (descending). + +type SortKey = 'name' | 'type' | 'size' | 'status'; +type SortDirection = 'ascending' | 'descending'; +interface SortColumn { + key: SortKey; + direction: SortDirection; +} + +const COLUMNS: ReadonlyArray<{ key: SortKey; label: string }> = [ + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'size', label: 'Size' }, + { key: 'status', label: 'Status' }, +]; + +function compare(key: SortKey, a: Item, b: Item): number { + if (key === 'size') { + return parseInt(a.size, 10) - parseInt(b.size, 10); + } + return a[key].localeCompare(b[key]); +} + +export default function TableSortingPage() { + const items = makeItems(12); + const [sort, setSort] = useState>([{ key: 'name', direction: 'ascending' }]); + + const rows = useMemo(() => { + return [...items].sort((a, b) => { + for (const { key, direction } of sort) { + const result = compare(key, a, b); + if (result !== 0) { + return direction === 'ascending' ? result : -result; + } + } + return 0; + }); + }, [items, sort]); + + // Plain click sorts by this column alone (toggling direction when it is already the sole sort). + // Shift-click opts the column into a multi-column sort: it is appended to the chain, or its + // direction toggled if already present. + const handleSort = (key: SortKey, additive: boolean) => { + setSort(prev => { + const existing = prev.find(column => column.key === key); + const toggled: SortDirection = existing?.direction === 'ascending' ? 'descending' : 'ascending'; + if (additive) { + return existing + ? prev.map(column => (column.key === key ? { key, direction: toggled } : column)) + : [...prev, { key, direction: 'ascending' }]; + } + return [{ key, direction: prev.length === 1 && existing ? toggled : 'ascending' }]; + }); + }; + + const multiColumn = sort.length > 1; + + return ( + + + Table atomics — sorting (auto layout) + + Click a column to sort by it. Shift-click a column to add it to a multi-column sort. + + + +
Resources
+ + + + {COLUMNS.map(({ key, label }) => { + const index = sort.findIndex(column => column.key === key); + const active = index >= 0 ? sort[index] : undefined; + return ( + + + + ); + })} + + + + {rows.map(item => ( + + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + + +
+
+
+ ); +} diff --git a/pages/table-root/striped-rows.page.tsx b/pages/table-root/striped-rows.page.tsx new file mode 100644 index 0000000000..fb1ac4b740 --- /dev/null +++ b/pages/table-root/striped-rows.page.tsx @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableRoot from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { DataHeader, makeItems } from './common'; + +// Striped rows are composed via the row `variant`: the consumer renders the rows and knows each +// index, so it marks alternating rows `shaded`. The atomic table owns no row-parity computation. +export default function TableStripedRowsPage() { + const items = makeItems(12); + return ( + + + Table atomics — striped rows (variant='shaded') + + +
Resources
+ + + + {items.map((item, index) => ( + + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + + +
+
+
+ ); +} diff --git a/pages/table-root/styles.scss b/pages/table-root/styles.scss new file mode 100644 index 0000000000..0483400dc9 --- /dev/null +++ b/pages/table-root/styles.scss @@ -0,0 +1,62 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +@use '~design-tokens' as tokens; + +// Demo sort control: a native button inheriting the header cell's colour/typography (label stays +// column-header colour, not link blue), filling the cell with the caret pushed to the end. +.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 the Table. +.selection-cell { + display: flex; + justify-content: center; + align-items: center; + block-size: 100%; + // The Cloudscape control sits ~1px below its wrapper's centre; nudge up to land on the text line. + transform: translateY(-1px); +} + +// Screen-reader-only label (accessible name for a bare control). +.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: 0; + border-inline: 0; +} + +// 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 next to each caret in multi-column sort. +.sort-order { + font-size: 0.75em; + font-weight: 700; +} diff --git a/pages/table-root/virtualization.page.tsx b/pages/table-root/virtualization.page.tsx new file mode 100644 index 0000000000..4f6d1be8b2 --- /dev/null +++ b/pages/table-root/virtualization.page.tsx @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useRef, useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +// Bring-your-own fixed-height virtualization (grid layout). A tiny hand-rolled +// windowing calc (no external dependency) stands in for a library like @tanstack/react-virtual: the +// consumer owns the scroll container and computes which rows are in view, then positions them via the +// narrowed `style` props — `height`/`position` on TableBody (reserve the total scroll height) and +// `transform`/`position`/`height` on each TableRow (place it at its offset). `ariaRowcount` + +// `ariaRowindex` keep assistive technologies aware of the full dataset while only a window renders. +const ROW_HEIGHT = 40; +const VIEWPORT_HEIGHT = 400; +const OVERSCAN = 4; +const TOTAL = 10000; +const COLUMNS: ReadonlyArray = [{ size: 120 }, {}]; + +interface LogLine { + id: string; + timestamp: string; + message: string; +} + +const makeLine = (index: number): LogLine => ({ + id: `line-${index}`, + timestamp: new Date(1_700_000_000_000 + index * 1000).toISOString().slice(11, 19), + message: `Log message ${index} — event processed`, +}); + +export default function TableVirtualizationPage() { + const scrollRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + + const first = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const visibleCount = Math.ceil(VIEWPORT_HEIGHT / ROW_HEIGHT) + OVERSCAN * 2; + const last = Math.min(TOTAL, first + visibleCount); + const indexes: number[] = []; + for (let i = first; i < last; i++) { + indexes.push(i); + } + + return ( + + + Table atomics — bring-your-own virtualization (grid layout) + + +
Log lines
+
setScrollTop(event.currentTarget.scrollTop)} + style={{ overflowY: 'auto', height: VIEWPORT_HEIGHT }} + > + + + + Time + Message + + + + {indexes.map(index => { + const line = makeLine(index); + return ( + + {line.timestamp} + {line.message} + + ); + })} + + +
+
+
+
+ ); +} diff --git a/pages/table/inline-editor.permutations.page.tsx b/pages/table/inline-editor.permutations.page.tsx index b01336aa22..d5cad0dce0 100644 --- a/pages/table/inline-editor.permutations.page.tsx +++ b/pages/table/inline-editor.permutations.page.tsx @@ -116,8 +116,6 @@ export default function InlineEditorPermutations() { isEditable={true} isFirstRow={false} isLastRow={false} - isNextSelected={false} - isPrevSelected={false} isSelected={false} onEditStart={() => {}} onEditEnd={() => {}} diff --git a/src/__tests__/functional-tests/test-utils.test.tsx b/src/__tests__/functional-tests/test-utils.test.tsx index 2eeca1dd8f..0c703bf196 100644 --- a/src/__tests__/functional-tests/test-utils.test.tsx +++ b/src/__tests__/functional-tests/test-utils.test.tsx @@ -34,6 +34,10 @@ const componentsWithExceptions = [ 'annotation-context', 'icon-provider', 'error-boundary', + // table-body pluralizes to "TableBodies" (y->ies), which this test's `findAll${Pascal}.*` name + // derivation can't reconstruct (same as error-boundary -> ErrorBoundaries). The finder itself is + // generated and used elsewhere; only this generic finder-iteration test can't address it. + 'table-body', 'tooltip', ...componentWithMultipleRootElements, ]; diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 8f45830239..b6263eaa25 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29569,6 +29569,552 @@ multiple lines instead of being truncated with an ellipsis.", } `; +exports[`Components definition for table-body matches the snapshot: table-body 1`] = ` +{ + "dashCaseName": "table-body", + "events": [], + "functions": [], + "name": "TableBody", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Applies inline styles to the body element. Use this to enable row positioning, for example for +virtualization or draggable rows. It is not supported to use this for general styling purposes.", + "inlineType": { + "name": "TableBodyProps.Style", + "properties": [ + { + "inlineType": { + "name": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + "type": "union", + "values": [ + ""auto"", + ""inherit"", + "string & {}", + ""-moz-initial"", + ""initial"", + ""revert"", + ""revert-layer"", + ""unset"", + ""-moz-max-content"", + ""-moz-min-content"", + ""fit-content"", + ""max-content"", + ""min-content"", + ""-webkit-fit-content"", + "NonNullable", + ], + }, + "name": "height", + "optional": true, + "type": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + }, + { + "inlineType": { + "name": "Property.Position", + "type": "union", + "values": [ + "fixed", + "absolute", + "inherit", + "-moz-initial", + "initial", + "revert", + "revert-layer", + "unset", + "-webkit-sticky", + "relative", + "static", + "sticky", + ], + }, + "name": "position", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "style", + "optional": true, + "type": "TableBodyProps.Style", + }, + ], + "regions": [ + { + "description": "The body rows.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-cell matches the snapshot: table-cell 1`] = ` +{ + "dashCaseName": "table-cell", + "events": [], + "functions": [], + "name": "TableCell", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Removes the cell's built-in padding so you can compose your own spacing, for example to match a +selection-control column. Defaults to \`false\`.", + "name": "disablePaddings", + "optional": true, + "type": "boolean", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The cell content.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-head matches the snapshot: table-head 1`] = ` +{ + "dashCaseName": "table-head", + "events": [], + "functions": [], + "name": "TableHead", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The header row: a \`TableHeaderRow\` whose cells are \`TableHeaderCell\` components.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-header-cell matches the snapshot: table-header-cell 1`] = ` +{ + "dashCaseName": "table-header-cell", + "events": [], + "functions": [], + "name": "TableHeaderCell", + "properties": [ + { + "description": "Sets \`aria-describedby\`. Use the ID(s) of visible element(s) that describe the header cell.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the header cell. Use this or \`ariaLabelledby\`.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets \`aria-labelledby\`. Use the ID(s) of visible element(s) that label the header cell.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "Sets the column's sort direction on the cell's \`aria-sort\` attribute. Use it on a sortable +column and render your own sort control in \`children\`; the table does not manage sort state.", + "inlineType": { + "name": ""none" | "other" | "ascending" | "descending"", + "type": "union", + "values": [ + "none", + "other", + "ascending", + "descending", + ], + }, + "name": "ariaSort", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Removes the cell's built-in padding so you can compose your own spacing, for example to match a +selection-control column. Defaults to \`false\`.", + "name": "disablePaddings", + "optional": true, + "type": "boolean", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The header content, such as a column label or a sort control.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-header-row matches the snapshot: table-header-row 1`] = ` +{ + "dashCaseName": "table-header-row", + "events": [], + "functions": [], + "name": "TableHeaderRow", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The header cells, one per column, in order.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-root matches the snapshot: table-root 1`] = ` +{ + "dashCaseName": "table-root", + "events": [], + "functions": [], + "name": "TableRoot", + "properties": [ + { + "description": "Sets the \`aria-describedby\` attribute. Use the ID of a visible element that describes the table.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the table. Use this or \`ariaLabelledby\` to label the table.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets the \`aria-labelledby\` attribute. Use the ID of a visible element that labels the table.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "The total number of rows in the full dataset, set on the table's \`aria-rowcount\`. Provide it +only when you render a subset of rows, such as with virtualization, so assistive technologies +report the whole table. Omit it when you render every row, and the count is derived from the DOM.", + "name": "ariaRowcount", + "optional": true, + "type": "number", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "defaultValue": "{ type: 'auto' }", + "description": "Determines how column widths are calculated. +* \`{ type: 'auto' }\` - Renders a standard HTML table whose columns size to their content. No + column configuration is required. +* \`{ type: 'grid'; columns }\` - Renders a CSS grid and applies each column's \`size\`, \`minWidth\`, + and \`maxWidth\`. Provide one \`columns\` entry per column, in display order; cells bind to columns + by position. Virtualization requires this layout. + * \`size\` (number | { flex: number }) - A number sets a fixed pixel width; \`{ flex }\` gives the + column a weight that shares the remaining space in proportion. Omit it for a flexible column + with the default weight of 1. + * \`minWidth\` (number) - The minimum width in pixels, for a flexible column. + * \`maxWidth\` (number) - The maximum width in pixels. + +Defaults to \`{ type: 'auto' }\`.", + "inlineType": { + "name": "TableRootProps.ColumnLayout", + "type": "union", + "values": [ + "{ type: "auto"; }", + "{ type: "grid"; columns: ReadonlyArray; }", + ], + }, + "name": "columnLayout", + "optional": true, + "type": "TableRootProps.ColumnLayout", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The table's content. Provide a \`TableHead\` followed by a \`TableBody\` that contains the rows.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-row matches the snapshot: table-row 1`] = ` +{ + "dashCaseName": "table-row", + "events": [], + "functions": [], + "name": "TableRow", + "properties": [ + { + "description": "Sets \`aria-describedby\`. Use the ID(s) of visible element(s) that describe the row.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the row. Use this or \`ariaLabelledby\`.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets \`aria-labelledby\`. Use the ID(s) of visible element(s) that label the row.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "Sets the row's \`aria-rowindex\` — its 1-based position in the full dataset, counting the header +row (so a data row's value is its dataset index plus 2). Set this only when virtualizing, so +assistive technologies report the row's true position while you render a subset of rows; in a +standard table the position is derived from DOM order.", + "name": "ariaRowindex", + "optional": true, + "type": "number", + }, + { + "description": "Sets \`aria-selected\` to reflect the row's selection state.", + "name": "ariaSelected", + "optional": true, + "type": "boolean", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Applies inline styles to the row element. Use this for row positioning, for example for +virtualization or draggable rows. It is not supported to use this for general styling purposes.", + "inlineType": { + "name": "TableRowProps.Style", + "properties": [ + { + "inlineType": { + "name": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + "type": "union", + "values": [ + ""auto"", + ""inherit"", + "string & {}", + ""-moz-initial"", + ""initial"", + ""revert"", + ""revert-layer"", + ""unset"", + ""-moz-max-content"", + ""-moz-min-content"", + ""fit-content"", + ""max-content"", + ""min-content"", + ""-webkit-fit-content"", + "NonNullable", + ], + }, + "name": "height", + "optional": true, + "type": ""auto" | (string & {}) | Globals | "-moz-max-content" | "-moz-min-content" | "fit-content" | "max-content" | "min-content" | "-webkit-fit-content" | NonNullable", + }, + { + "inlineType": { + "name": "Property.Position", + "type": "union", + "values": [ + "fixed", + "absolute", + "inherit", + "-moz-initial", + "initial", + "revert", + "revert-layer", + "unset", + "-webkit-sticky", + "relative", + "static", + "sticky", + ], + }, + "name": "position", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "Property.Transform", + "type": "union", + "values": [ + ""none"", + ""inherit"", + "string & {}", + ""-moz-initial"", + ""initial"", + ""revert"", + ""revert-layer"", + ""unset"", + ], + }, + "name": "transform", + "optional": true, + "type": "Property.Transform", + }, + ], + "type": "object", + }, + "name": "style", + "optional": true, + "type": "TableRowProps.Style", + }, + { + "description": "The row's visual state. This is visual only — set \`ariaSelected\` to convey selection to +assistive technologies. +* \`default\` - A standard row. +* \`selected\` - Applies the selected-row styling. Pair it with \`ariaSelected\` and a selection + control, such as a checkbox, in a leading cell. +* \`shaded\` - Applies a shaded background, to create alternating row colors. Choose which rows + are shaded, typically with \`variant={index % 2 === 1 ? 'shaded' : 'default'}\`. + +Defaults to \`'default'\`.", + "inlineType": { + "name": "TableRowProps.Variant", + "type": "union", + "values": [ + "default", + "selected", + "shaded", + ], + }, + "name": "variant", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The row's cells, one per column, in order.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + exports[`Components definition for tabs matches the snapshot: tabs 1`] = ` { "dashCaseName": "tabs", @@ -45866,6 +46412,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": [ { @@ -55624,6 +56198,34 @@ Supported options: ], "name": "StepWrapper", }, + { + "methods": [], + "name": "TableBodyWrapper", + }, + { + "methods": [], + "name": "TableCellWrapper", + }, + { + "methods": [], + "name": "TableHeadWrapper", + }, + { + "methods": [], + "name": "TableHeaderCellWrapper", + }, + { + "methods": [], + "name": "TableHeaderRowWrapper", + }, + { + "methods": [], + "name": "TableRootWrapper", + }, + { + "methods": [], + "name": "TableRowWrapper", + }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 9dcc563904..8665059177 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -679,6 +679,27 @@ exports[`test-utils selectors 1`] = ` "awsui_tools-preferences_wih1l", "awsui_wrapper_wih1l", ], + "table-body": [ + "awsui_body_1i6l7", + ], + "table-cell": [ + "awsui_cell_1reth", + ], + "table-head": [ + "awsui_head_1otu2", + ], + "table-header-cell": [ + "awsui_header-cell_uzgsh", + ], + "table-header-row": [ + "awsui_header-row_1wc8l", + ], + "table-root": [ + "awsui_root_1pkvc", + ], + "table-row": [ + "awsui_row_3yyds", + ], "tabs": [ "awsui_actions-container_14rmt", "awsui_disabled-reason-tooltip_14rmt", diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 118ac8d1b2..72816e0809 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -86,6 +86,13 @@ import SplitPanelWrapper from './split-panel'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; +import TableBodyWrapper from './table-body'; +import TableCellWrapper from './table-cell'; +import TableHeadWrapper from './table-head'; +import TableHeaderCellWrapper from './table-header-cell'; +import TableHeaderRowWrapper from './table-header-row'; +import TableRootWrapper from './table-root'; +import TableRowWrapper from './table-row'; import TabsWrapper from './tabs'; import TagEditorWrapper from './tag-editor'; import TextContentWrapper from './text-content'; @@ -182,6 +189,13 @@ export { SplitPanelWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; +export { TableBodyWrapper }; +export { TableCellWrapper }; +export { TableHeadWrapper }; +export { TableHeaderCellWrapper }; +export { TableHeaderRowWrapper }; +export { TableRootWrapper }; +export { TableRowWrapper }; export { TabsWrapper }; export { TagEditorWrapper }; export { TextContentWrapper }; @@ -2359,6 +2373,202 @@ findAllTables(selector?: string): Array; * @returns {TableWrapper | null} */ findClosestTable(): TableWrapper | null; +/** + * Returns the wrapper of the first TableBody that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableBody. + * If no matching TableBody is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableBodyWrapper | null} + */ +findTableBody(selector?: string): TableBodyWrapper | null; + +/** + * Returns an array of TableBody wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableBodies inside the current wrapper. + * If no matching TableBody is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableBodies(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableBody for the current element, + * or the element itself if it is an instance of TableBody. + * If no TableBody is found, returns \`null\`. + * + * @returns {TableBodyWrapper | null} + */ +findClosestTableBody(): TableBodyWrapper | null; +/** + * Returns the wrapper of the first TableCell that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableCell. + * If no matching TableCell is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableCellWrapper | null} + */ +findTableCell(selector?: string): TableCellWrapper | null; + +/** + * Returns an array of TableCell wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableCells inside the current wrapper. + * If no matching TableCell is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableCells(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableCell for the current element, + * or the element itself if it is an instance of TableCell. + * If no TableCell is found, returns \`null\`. + * + * @returns {TableCellWrapper | null} + */ +findClosestTableCell(): TableCellWrapper | null; +/** + * Returns the wrapper of the first TableHead that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHead. + * If no matching TableHead is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeadWrapper | null} + */ +findTableHead(selector?: string): TableHeadWrapper | null; + +/** + * Returns an array of TableHead wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeads inside the current wrapper. + * If no matching TableHead is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeads(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHead for the current element, + * or the element itself if it is an instance of TableHead. + * If no TableHead is found, returns \`null\`. + * + * @returns {TableHeadWrapper | null} + */ +findClosestTableHead(): TableHeadWrapper | null; +/** + * Returns the wrapper of the first TableHeaderCell that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHeaderCell. + * If no matching TableHeaderCell is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderCellWrapper | null} + */ +findTableHeaderCell(selector?: string): TableHeaderCellWrapper | null; + +/** + * Returns an array of TableHeaderCell wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeaderCells inside the current wrapper. + * If no matching TableHeaderCell is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeaderCells(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHeaderCell for the current element, + * or the element itself if it is an instance of TableHeaderCell. + * If no TableHeaderCell is found, returns \`null\`. + * + * @returns {TableHeaderCellWrapper | null} + */ +findClosestTableHeaderCell(): TableHeaderCellWrapper | null; +/** + * Returns the wrapper of the first TableHeaderRow that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHeaderRow. + * If no matching TableHeaderRow is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderRowWrapper | null} + */ +findTableHeaderRow(selector?: string): TableHeaderRowWrapper | null; + +/** + * Returns an array of TableHeaderRow wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeaderRows inside the current wrapper. + * If no matching TableHeaderRow is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeaderRows(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHeaderRow for the current element, + * or the element itself if it is an instance of TableHeaderRow. + * If no TableHeaderRow is found, returns \`null\`. + * + * @returns {TableHeaderRowWrapper | null} + */ +findClosestTableHeaderRow(): TableHeaderRowWrapper | null; +/** + * Returns the wrapper of the first TableRoot that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableRoot. + * If no matching TableRoot is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableRootWrapper | null} + */ +findTableRoot(selector?: string): TableRootWrapper | null; + +/** + * Returns an array of TableRoot wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableRoots inside the current wrapper. + * If no matching TableRoot is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableRoots(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableRoot for the current element, + * or the element itself if it is an instance of TableRoot. + * If no TableRoot is found, returns \`null\`. + * + * @returns {TableRootWrapper | null} + */ +findClosestTableRoot(): TableRootWrapper | null; +/** + * Returns the wrapper of the first TableRow that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableRow. + * If no matching TableRow is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableRowWrapper | null} + */ +findTableRow(selector?: string): TableRowWrapper | null; + +/** + * Returns an array of TableRow wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableRows inside the current wrapper. + * If no matching TableRow is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableRows(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableRow for the current element, + * or the element itself if it is an instance of TableRow. + * If no TableRow is found, returns \`null\`. + * + * @returns {TableRowWrapper | null} + */ +findClosestTableRow(): TableRowWrapper | null; /** * Returns the wrapper of the first Tabs that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first Tabs. @@ -3840,6 +4050,97 @@ ElementWrapper.prototype.findTable = function(selector) { ElementWrapper.prototype.findAllTables = function(selector) { return this.findAllComponents(TableWrapper, selector); }; +ElementWrapper.prototype.findTableBody = function(selector) { + let rootSelector = \`.\${TableBodyWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyWrapper && TableBodyWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyWrapper.rootSelector}, .\${TableBodyWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyWrapper); +}; + +ElementWrapper.prototype.findAllTableBodies = function(selector) { + return this.findAllComponents(TableBodyWrapper, selector); +}; +ElementWrapper.prototype.findTableCell = function(selector) { + let rootSelector = \`.\${TableCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableCellWrapper && TableCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableCellWrapper.rootSelector}, .\${TableCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableCellWrapper); +}; + +ElementWrapper.prototype.findAllTableCells = function(selector) { + return this.findAllComponents(TableCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHead = function(selector) { + let rootSelector = \`.\${TableHeadWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeadWrapper && TableHeadWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeadWrapper.rootSelector}, .\${TableHeadWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeadWrapper); +}; + +ElementWrapper.prototype.findAllTableHeads = function(selector) { + return this.findAllComponents(TableHeadWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderCell = function(selector) { + let rootSelector = \`.\${TableHeaderCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderCellWrapper && TableHeaderCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderCellWrapper.rootSelector}, .\${TableHeaderCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderCellWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderCells = function(selector) { + return this.findAllComponents(TableHeaderCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderRow = function(selector) { + let rootSelector = \`.\${TableHeaderRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderRowWrapper && TableHeaderRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderRowWrapper.rootSelector}, .\${TableHeaderRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderRowWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderRows = function(selector) { + return this.findAllComponents(TableHeaderRowWrapper, selector); +}; +ElementWrapper.prototype.findTableRoot = function(selector) { + let rootSelector = \`.\${TableRootWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRootWrapper && TableRootWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRootWrapper.rootSelector}, .\${TableRootWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRootWrapper); +}; + +ElementWrapper.prototype.findAllTableRoots = function(selector) { + return this.findAllComponents(TableRootWrapper, selector); +}; +ElementWrapper.prototype.findTableRow = function(selector) { + let rootSelector = \`.\${TableRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRowWrapper && TableRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRowWrapper.rootSelector}, .\${TableRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRowWrapper); +}; + +ElementWrapper.prototype.findAllTableRows = function(selector) { + return this.findAllComponents(TableRowWrapper, selector); +}; ElementWrapper.prototype.findTabs = function(selector) { let rootSelector = \`.\${TabsWrapper.rootSelector}\`; if("legacyRootSelector" in TabsWrapper && TabsWrapper.legacyRootSelector){ @@ -4447,6 +4748,41 @@ ElementWrapper.prototype.findClosestTable = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(TableWrapper); }; +ElementWrapper.prototype.findClosestTableBody = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableBodyWrapper); +}; +ElementWrapper.prototype.findClosestTableCell = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableCellWrapper); +}; +ElementWrapper.prototype.findClosestTableHead = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeadWrapper); +}; +ElementWrapper.prototype.findClosestTableHeaderCell = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeaderCellWrapper); +}; +ElementWrapper.prototype.findClosestTableHeaderRow = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeaderRowWrapper); +}; +ElementWrapper.prototype.findClosestTableRoot = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableRootWrapper); +}; +ElementWrapper.prototype.findClosestTableRow = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableRowWrapper); +}; ElementWrapper.prototype.findClosestTabs = function() { // casting to 'any' is needed to avoid this issue with generics // https://github.com/microsoft/TypeScript/issues/29132 @@ -4628,6 +4964,13 @@ import SplitPanelWrapper from './split-panel'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; +import TableBodyWrapper from './table-body'; +import TableCellWrapper from './table-cell'; +import TableHeadWrapper from './table-head'; +import TableHeaderCellWrapper from './table-header-cell'; +import TableHeaderRowWrapper from './table-header-row'; +import TableRootWrapper from './table-root'; +import TableRowWrapper from './table-row'; import TabsWrapper from './tabs'; import TagEditorWrapper from './tag-editor'; import TextContentWrapper from './text-content'; @@ -4724,6 +5067,13 @@ export { SplitPanelWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; +export { TableBodyWrapper }; +export { TableCellWrapper }; +export { TableHeadWrapper }; +export { TableHeaderCellWrapper }; +export { TableHeaderRowWrapper }; +export { TableRootWrapper }; +export { TableRowWrapper }; export { TabsWrapper }; export { TagEditorWrapper }; export { TextContentWrapper }; @@ -6054,6 +6404,125 @@ findTable(selector?: string): TableWrapper; * @returns {MultiElementWrapper} */ findAllTables(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableBodies with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableBodies. + * + * @param {string} [selector] CSS Selector + * @returns {TableBodyWrapper} + */ +findTableBody(selector?: string): TableBodyWrapper; + +/** + * Returns a multi-element wrapper that matches TableBodies with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableBodies. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableBodies(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableCells with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableCells. + * + * @param {string} [selector] CSS Selector + * @returns {TableCellWrapper} + */ +findTableCell(selector?: string): TableCellWrapper; + +/** + * Returns a multi-element wrapper that matches TableCells with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableCells. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableCells(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeads with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeads. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeadWrapper} + */ +findTableHead(selector?: string): TableHeadWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeads with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeads. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeads(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeaderCells with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeaderCells. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderCellWrapper} + */ +findTableHeaderCell(selector?: string): TableHeaderCellWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeaderCells with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeaderCells. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeaderCells(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeaderRows with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeaderRows. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderRowWrapper} + */ +findTableHeaderRow(selector?: string): TableHeaderRowWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeaderRows with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeaderRows. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeaderRows(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableRoots with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableRoots. + * + * @param {string} [selector] CSS Selector + * @returns {TableRootWrapper} + */ +findTableRoot(selector?: string): TableRootWrapper; + +/** + * Returns a multi-element wrapper that matches TableRoots with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableRoots. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableRoots(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableRows with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableRows. + * + * @param {string} [selector] CSS Selector + * @returns {TableRowWrapper} + */ +findTableRow(selector?: string): TableRowWrapper; + +/** + * Returns a multi-element wrapper that matches TableRows with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableRows. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableRows(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the Tabs with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches Tabs. @@ -7348,6 +7817,97 @@ ElementWrapper.prototype.findTable = function(selector) { ElementWrapper.prototype.findAllTables = function(selector) { return this.findAllComponents(TableWrapper, selector); }; +ElementWrapper.prototype.findTableBody = function(selector) { + let rootSelector = \`.\${TableBodyWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyWrapper && TableBodyWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyWrapper.rootSelector}, .\${TableBodyWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyWrapper); +}; + +ElementWrapper.prototype.findAllTableBodies = function(selector) { + return this.findAllComponents(TableBodyWrapper, selector); +}; +ElementWrapper.prototype.findTableCell = function(selector) { + let rootSelector = \`.\${TableCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableCellWrapper && TableCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableCellWrapper.rootSelector}, .\${TableCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableCellWrapper); +}; + +ElementWrapper.prototype.findAllTableCells = function(selector) { + return this.findAllComponents(TableCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHead = function(selector) { + let rootSelector = \`.\${TableHeadWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeadWrapper && TableHeadWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeadWrapper.rootSelector}, .\${TableHeadWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeadWrapper); +}; + +ElementWrapper.prototype.findAllTableHeads = function(selector) { + return this.findAllComponents(TableHeadWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderCell = function(selector) { + let rootSelector = \`.\${TableHeaderCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderCellWrapper && TableHeaderCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderCellWrapper.rootSelector}, .\${TableHeaderCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderCellWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderCells = function(selector) { + return this.findAllComponents(TableHeaderCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderRow = function(selector) { + let rootSelector = \`.\${TableHeaderRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderRowWrapper && TableHeaderRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderRowWrapper.rootSelector}, .\${TableHeaderRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderRowWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderRows = function(selector) { + return this.findAllComponents(TableHeaderRowWrapper, selector); +}; +ElementWrapper.prototype.findTableRoot = function(selector) { + let rootSelector = \`.\${TableRootWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRootWrapper && TableRootWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRootWrapper.rootSelector}, .\${TableRootWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRootWrapper); +}; + +ElementWrapper.prototype.findAllTableRoots = function(selector) { + return this.findAllComponents(TableRootWrapper, selector); +}; +ElementWrapper.prototype.findTableRow = function(selector) { + let rootSelector = \`.\${TableRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRowWrapper && TableRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRowWrapper.rootSelector}, .\${TableRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRowWrapper); +}; + +ElementWrapper.prototype.findAllTableRows = function(selector) { + return this.findAllComponents(TableRowWrapper, selector); +}; ElementWrapper.prototype.findTabs = function(selector) { let rootSelector = \`.\${TabsWrapper.rootSelector}\`; if("legacyRootSelector" in TabsWrapper && TabsWrapper.legacyRootSelector){ diff --git a/src/table-body/index.tsx b/src/table-body/index.tsx new file mode 100644 index 0000000000..b5d956d043 --- /dev/null +++ b/src/table-body/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableBodyProps } from './interfaces'; +import { Body } from './internal'; + +export type { TableBodyProps }; + +function TableBody(props: TableBodyProps) { + const baseComponentProps = useBaseComponent('TableBody'); + return ; +} + +applyDisplayName(TableBody, 'TableBody'); +export default TableBody; diff --git a/src/table-body/interfaces.ts b/src/table-body/interfaces.ts new file mode 100644 index 0000000000..765f502e1c --- /dev/null +++ b/src/table-body/interfaces.ts @@ -0,0 +1,23 @@ +// 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 { + 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..670fd6c212 --- /dev/null +++ b/src/table-body/internal.tsx @@ -0,0 +1,39 @@ +// 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 function Body(props: TableBodyProps & InternalBaseComponentProps) { + const { children, style, __internalRootRef } = props; + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const baseProps = getBaseProps(props); + // Flag the last body row so its cells drop the divider (no row below). Rows are consumer-composed, + // so the last one is detected here and threaded via __lastRow (TableRow forwards it to each cell). + const childArray = React.Children.toArray(children); + const lastIndex = childArray.length - 1; + const rows = childArray.map((child, index) => + index === lastIndex && React.isValidElement(child) + ? React.cloneElement(child as React.ReactElement<{ __lastRow?: boolean }>, { __lastRow: true }) + : child + ); + return ( +
+ {rows} + + ); +} diff --git a/src/table-body/styles.scss b/src/table-body/styles.scss new file mode 100644 index 0000000000..cf15c7d64b --- /dev/null +++ b/src/table-body/styles.scss @@ -0,0 +1,12 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.body { + position: relative; +} + +.body-grid { + display: block; +} diff --git a/src/table-cell/index.tsx b/src/table-cell/index.tsx new file mode 100644 index 0000000000..e0d006fd39 --- /dev/null +++ b/src/table-cell/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableCellProps } from './interfaces'; +import { Cell } from './internal'; + +export type { TableCellProps }; + +function TableCell(props: TableCellProps) { + const baseComponentProps = useBaseComponent('TableCell'); + return ; +} + +applyDisplayName(TableCell, 'TableCell'); +export default TableCell; diff --git a/src/table-cell/interfaces.ts b/src/table-cell/interfaces.ts new file mode 100644 index 0000000000..57c87f85a2 --- /dev/null +++ b/src/table-cell/interfaces.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders a single data cell. */ +export interface TableCellProps extends BaseComponentProps { + /** + * Removes the cell's built-in padding so you can compose your own spacing, for example to match a + * selection-control column. Defaults to `false`. + */ + disablePaddings?: boolean; + /** The cell content. */ + children?: React.ReactNode; +} diff --git a/src/table-cell/internal.tsx b/src/table-cell/internal.tsx new file mode 100644 index 0000000000..c6b606a1ce --- /dev/null +++ b/src/table-cell/internal.tsx @@ -0,0 +1,105 @@ +// 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 { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useTableContext } from '../table-root/context'; +import { TableCellProps } from './interfaces'; + +import styles from './styles.css.js'; + +// Internal reuse surface: consumed only when the Table renders the atomic Cell as its body + ); +} diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss new file mode 100644 index 0000000000..f773c73920 --- /dev/null +++ b/src/table-cell/styles.scss @@ -0,0 +1,317 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; +@use '../internal/styles' as styles; +@use '../table/body-cell/selection-mixins' as sel; + +$cell-vertical-padding: awsui.$space-scaled-xs; +// Restores full first-column inline padding when the first column is sticky. +$cell-horizontal-padding: awsui.$space-scaled-l; +// Block-end carries the selected-border width delta so row height is stable on select (visual refresh). +$cell-vertical-padding-w-border: calc( + #{$cell-vertical-padding} + (#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}) +); +// Focus-ring room. The Table body-cell nets this out with a negative margin on its inner wrapper; the +// bare ; CSS adjacency derives prev/next. The mixin's inner- +// wrapper padding is inert here, so block padding is compensated on the + {children} + + ); +} diff --git a/src/table-head/styles.scss b/src/table-head/styles.scss new file mode 100644 index 0000000000..2d1806dcf5 --- /dev/null +++ b/src/table-head/styles.scss @@ -0,0 +1,12 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.head { + position: relative; +} + +.head-grid { + display: block; +} diff --git a/src/table-header-cell/index.tsx b/src/table-header-cell/index.tsx new file mode 100644 index 0000000000..577ea9097b --- /dev/null +++ b/src/table-header-cell/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeaderCellProps } from './interfaces'; +import { HeaderCell } from './internal'; + +export type { TableHeaderCellProps }; + +function TableHeaderCell(props: TableHeaderCellProps) { + const baseComponentProps = useBaseComponent('TableHeaderCell'); + return ; +} + +applyDisplayName(TableHeaderCell, 'TableHeaderCell'); +export default TableHeaderCell; diff --git a/src/table-header-cell/interfaces.ts b/src/table-header-cell/interfaces.ts new file mode 100644 index 0000000000..69324a4d30 --- /dev/null +++ b/src/table-header-cell/interfaces.ts @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders a single column header cell. Put the column label, or a composed sort control, in `children`. */ +export interface TableHeaderCellProps extends BaseComponentProps { + /** Provides an accessible name for the header cell. Use this or `ariaLabelledby`. */ + ariaLabel?: string; + /** Sets `aria-labelledby`. Use the ID(s) of visible element(s) that label the header cell. */ + ariaLabelledby?: string; + /** Sets `aria-describedby`. Use the ID(s) of visible element(s) that describe the header cell. */ + ariaDescribedby?: string; + /** + * Sets the column's sort direction on the cell's `aria-sort` attribute. Use it on a sortable + * column and render your own sort control in `children`; the table does not manage sort state. + */ + ariaSort?: React.AriaAttributes['aria-sort']; + /** + * Removes the cell's built-in padding so you can compose your own spacing, for example to match a + * selection-control column. Defaults to `false`. + */ + disablePaddings?: boolean; + /** The header content, such as a column label or a sort control. */ + children?: React.ReactNode; +} diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx new file mode 100644 index 0000000000..b7ee7705e7 --- /dev/null +++ b/src/table-header-cell/internal.tsx @@ -0,0 +1,41 @@ +// 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 { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useTableContext } from '../table-root/context'; +import { TableHeaderCellProps } from './interfaces'; + +import styles from './styles.css.js'; + +export function HeaderCell(props: TableHeaderCellProps & InternalBaseComponentProps) { + const { children, ariaLabel, ariaLabelledby, ariaDescribedby, ariaSort, disablePaddings, __internalRootRef } = props; + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const isVisualRefresh = useVisualRefresh(); + const baseProps = getBaseProps(props); + return ( + + ); +} diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss new file mode 100644 index 0000000000..aed9152a75 --- /dev/null +++ b/src/table-header-cell/styles.scss @@ -0,0 +1,67 @@ +/* + 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; + +.header-cell { + position: relative; + // Override the user-agent `th { text-align: center }` default. + text-align: start; + vertical-align: middle; + background: awsui.$color-background-table-header; + color: awsui.$color-text-column-header; + font-weight: awsui.$font-weight-heading-s; + @include styles.font-smoothing; + // Match the Table header height: body-m (20px), not heading-xs (18px, which is 2px shorter). + line-height: awsui.$line-height-body-m; + padding-block: calc(2 * #{awsui.$space-scaled-xxs}); + padding-inline: awsui.$space-scaled-l; + box-sizing: border-box; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; +} + +.header-cell-grid { + min-inline-size: 0; +} + +.header-cell::after { + content: ''; + position: absolute; + inset-inline-end: 0; + inset-block: 0; + margin-block: auto; + min-block-size: awsui.$line-height-heading-xs; + max-block-size: calc(100% - (2 * #{awsui.$space-xs} + #{awsui.$space-xxxs})); + inline-size: 0; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + pointer-events: none; +} + +.header-cell:last-child::after { + display: none; +} + +.header-cell:first-child { + padding-inline-start: awsui.$space-xxxs; + // Reserve the same transparent first-column placeholder the body cell keeps, so header/body text align. + border-inline-start: awsui.$border-item-width solid transparent; +} + +// Reclaim the placeholder in visual refresh for genuine data columns (a control column keeps it via +// disable-paddings), mirroring the body cell's reclaim rule. +.header-cell.is-visual-refresh:first-child:not(.disable-paddings) { + border-inline-start: none; +} + +.header-cell.disable-paddings, +.header-cell.disable-paddings:first-child { + padding-block: 0; + padding-inline: 0; + // Stretch to full header height so an empty grid-centred control cell doesn't collapse to a stray + // 1px divider line. Inert on the Table-reuse path. + align-self: stretch; +} diff --git a/src/table-header-row/index.tsx b/src/table-header-row/index.tsx new file mode 100644 index 0000000000..e3be64ae8f --- /dev/null +++ b/src/table-header-row/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeaderRowProps } from './interfaces'; +import { HeaderRow } from './internal'; + +export type { TableHeaderRowProps }; + +function TableHeaderRow(props: TableHeaderRowProps) { + const baseComponentProps = useBaseComponent('TableHeaderRow'); + return ; +} + +applyDisplayName(TableHeaderRow, 'TableHeaderRow'); +export default TableHeaderRow; diff --git a/src/table-header-row/interfaces.ts b/src/table-header-row/interfaces.ts new file mode 100644 index 0000000000..7c159f0d20 --- /dev/null +++ b/src/table-header-row/interfaces.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders the header row, inside `TableHead`. Its children are `TableHeaderCell` components. */ +export interface TableHeaderRowProps extends BaseComponentProps { + /** The header cells, one per column, in order. */ + children?: React.ReactNode; +} diff --git a/src/table-header-row/internal.tsx b/src/table-header-row/internal.tsx new file mode 100644 index 0000000000..2b2af77d34 --- /dev/null +++ b/src/table-header-row/internal.tsx @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { TableHeaderRowProps } from './interfaces'; + +import styles from './styles.css.js'; + +export function HeaderRow(props: TableHeaderRowProps & InternalBaseComponentProps) { + const { children, __internalRootRef } = props; + const { columnLayout, gridTemplateColumns } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const baseProps = getBaseProps(props); + return ( + + {children} + + ); +} diff --git a/src/table-header-row/styles.scss b/src/table-header-row/styles.scss new file mode 100644 index 0000000000..66ce26719e --- /dev/null +++ b/src/table-header-row/styles.scss @@ -0,0 +1,16 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +.header-row { + background: awsui.$color-background-table-header; +} + +.header-row-grid { + display: grid; + inline-size: 100%; + align-items: center; +} diff --git a/src/table-root/__tests__/basic-table-aria-label.test.tsx b/src/table-root/__tests__/basic-table-aria-label.test.tsx new file mode 100644 index 0000000000..2c652933f8 --- /dev/null +++ b/src/table-root/__tests__/basic-table-aria-label.test.tsx @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; + +// The accessible name is set through the top-level `ariaLabel` / `ariaLabelledby` props, which the +// component applies to the table's `aria-label` / `aria-labelledby`. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, name: `Resource ${i}`, status: i % 2 === 0 ? 'Up' : 'Down' })); + +const COLUMNS: ReadonlyArray = [{ minWidth: 120 }, {}]; + +function buildTree(labelProps: Pick) { + const items = makeItems(10); + return ( + + + + Name + Status + + + + {items.map(item => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +const getTable = (container: HTMLElement) => container.querySelector('table')!; + +describe('Table labelling', () => { + test('ariaLabel passes through to the table aria-label', () => { + const { container } = render(buildTree({ ariaLabel: 'Resources' })); + expect(getTable(container).getAttribute('aria-label')).toBe('Resources'); + }); + + test('ariaLabelledby passes through to the table aria-labelledby', () => { + const { container } = render(buildTree({ ariaLabelledby: 'heading-id' })); + expect(getTable(container).getAttribute('aria-labelledby')).toBe('heading-id'); + }); +}); diff --git a/src/table-root/__tests__/basic-table-roles.test.tsx b/src/table-root/__tests__/basic-table-roles.test.tsx new file mode 100644 index 0000000000..3090913404 --- /dev/null +++ b/src/table-root/__tests__/basic-table-roles.test.tsx @@ -0,0 +1,104 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +// Role semantics for the atomic table. In `auto` layout the parts are native +//
` 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. + + + )} + +
. +// The public surface stays TableCellProps = { disablePaddings, children }. +export interface TableCellInternalProps { + __ref?: React.Ref; + __style?: React.CSSProperties; + __tabIndex?: number; + __featureClassName?: string; + __nativeAttributes?: React.TdHTMLAttributes; + __onClick?: () => void; + __onFocus?: () => void; + __onBlur?: () => void; + __selected?: boolean; + __shaded?: boolean; + __prevSelected?: boolean; + __nextSelected?: boolean; + __notSelectedNext?: boolean; + // Sticky-only: the Table's first-row top placeholder only manifests as height under sticky positioning. + __firstRow?: boolean; + __lastRow?: boolean; + __hasFooter?: boolean; + __isVisualRefresh?: boolean; + __hasSelection?: boolean; + __hasStripedRows?: boolean; + __tableVariant?: string; +} + +export function Cell(props: TableCellProps & InternalBaseComponentProps & TableCellInternalProps) { + const { + children, + disablePaddings, + __internalRootRef, + __ref, + __style, + __tabIndex, + __featureClassName, + __nativeAttributes, + __onClick, + __onFocus, + __onBlur, + __selected, + __shaded, + __prevSelected, + __nextSelected, + __notSelectedNext, + __firstRow, + __lastRow, + __hasFooter, + __isVisualRefresh, + __hasSelection, + __hasStripedRows, + __tableVariant, + } = props; + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const runtimeVisualRefresh = useVisualRefresh(); + const isVisualRefresh = __isVisualRefresh ?? runtimeVisualRefresh; + const baseProps = getBaseProps(props); + return ( + + {children} + here can't, so it is folded into the block padding. 1px/side lands the row at 39px. +$cell-negative-space-vertical: 1px; + +.cell { + vertical-align: middle; + padding-block-start: calc( + #{$cell-vertical-padding} - #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical} + ); + padding-block-end: calc( + #{$cell-vertical-padding-w-border} - #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical} + ); + padding-inline: awsui.$space-scaled-l; + box-sizing: border-box; + // Cell-owned divider so the shared selection mechanism can grow it into the 2px selected border. + border-block-start: awsui.$border-divider-list-width solid transparent; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-secondary; +} + +// Lets grid columns shrink below content. No overflow clip (truncation deferred) — it would crop the +// focus ring of an interactive control inside the cell. +.cell-grid { + min-inline-size: 0; +} + +.cell:first-child { + padding-inline-start: awsui.$space-xxxs; + // Edge placeholder so the selected first-child border is a swap, not an added 2px that shifts + // content. Reclaimed for non-selectable tables below. + border-inline-start: awsui.$border-item-width solid transparent; +} +.cell:last-child { + border-inline-end: awsui.$border-item-width solid transparent; +} +// Absorb the last-column placeholder width so the unselected content position is unchanged. +.cell:last-child:not(.disable-paddings) { + padding-inline-end: calc(#{awsui.$space-scaled-l} - #{awsui.$border-item-width}); +} + +// Zeroes only inline padding so a consumer can compose an exact control column; block padding is kept +// so a composed control shares the data cells' content-box and stays centred on the text line. +.cell.disable-paddings, +.cell.disable-paddings:first-child { + padding-inline: 0; + // Fill the row height so a zero-padding control cell's selection border isn't a short detached pill. + align-self: stretch; +} + +// First-column inline padding in visual refresh: widened for striped rows, restored full for a sticky +// first column. +.cell.is-visual-refresh.has-striped-rows:first-child:not(.disable-paddings) { + padding-inline-start: awsui.$space-xxs; +} +// stylelint-disable-next-line no-descending-specificity +.cell.is-visual-refresh.sticky-cell-pad-inline-start:first-child:not(.has-selection):not(.disable-paddings), +.cell.is-visual-refresh.has-striped-rows.sticky-cell-pad-inline-start:first-child:not(.disable-paddings) { + padding-inline-start: $cell-horizontal-padding; +} + +// Reclaim the first-column placeholder for non-selectable tables; selection tables keep it so their +// side border swaps in with no shift. Gated on is-visual-refresh, so only the Table-reuse path reclaims. +// stylelint-disable-next-line no-descending-specificity +.cell.is-visual-refresh:first-child:not(.has-selection):not(.disable-paddings) { + border-inline-start: none; +} + +// First-row 2px top placeholder so a selected first row's top border is a swap. Applied only to sticky +// first-row cells (from td-element): height-neutral in non-sticky layout, +1px under sticky. Placed +// before the selection rules so a selected first row's colored border wins by source order. +.cell.body-cell-first-row { + border-block-start: awsui.$border-item-width solid transparent; +} + +// Row-gated per-cell selection paint, shared with the Table body-cell via _selection-mixins. The gate +// is data-selected / data-shaded on the
below. +[data-selected] > .cell { + @include sel.selected-cell; +} +[data-selected] + [data-selected] > .cell { + @include sel.prev-selected-cell; +} +[data-selected]:has(+ [data-selected]) > .cell { + @include sel.next-selected-cell; +} +// A not-selected row above a selected row drops its divider so the selected border reads as one block. +:not([data-selected]):has(+ [data-selected]) > .cell { + @include sel.not-selected-next-cell; + border-block-end: 0; +} +[data-shaded] > .cell { + @include sel.shaded-cell; +} + +// Block-padding compensation for the 1px divider -> 2px selected-border growth, on the directly. +// Each side shrinks by the full selected border, so a selected row keeps its height (no reflow). +// Grouped after the mixin includes to keep specificity monotonic (no-descending-specificity). +[data-selected] > .cell:not(.disable-paddings) { + padding-block-start: calc( + #{$cell-vertical-padding} + #{$cell-negative-space-vertical} - #{awsui.$border-width-item-selected} + ); + padding-block-end: calc( + #{$cell-vertical-padding-w-border} + #{$cell-negative-space-vertical} - #{awsui.$border-width-item-selected} + ); +} +// Lower row of a consecutive pair: top border is the 1px placeholder, so compensate only 1px. +[data-selected] + [data-selected] > .cell:not(.disable-paddings) { + padding-block-start: calc( + #{$cell-vertical-padding} + #{$cell-negative-space-vertical} - #{awsui.$border-divider-list-width} + ); +} +[data-selected]:has(+ [data-selected]) > .cell:not(.disable-paddings) { + padding-block-end: calc(#{$cell-vertical-padding} + #{$cell-negative-space-vertical}); +} +:not([data-selected]):has(+ [data-selected]) > .cell:not(.disable-paddings) { + padding-block-end: calc( + #{$cell-vertical-padding} + #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical} + ); +} + +// The control column zeroes only inline padding, so mirror the per-state block padding here to keep +// the centred control tracking the data cells in every selection state. +[data-selected] > .cell.disable-paddings { + padding-block-start: calc( + #{$cell-vertical-padding} + #{$cell-negative-space-vertical} - #{awsui.$border-width-item-selected} + ); + padding-block-end: calc( + #{$cell-vertical-padding-w-border} + #{$cell-negative-space-vertical} - #{awsui.$border-width-item-selected} + ); +} +[data-selected] + [data-selected] > .cell.disable-paddings { + padding-block-start: calc( + #{$cell-vertical-padding} + #{$cell-negative-space-vertical} - #{awsui.$border-divider-list-width} + ); +} +[data-selected]:has(+ [data-selected]) > .cell.disable-paddings { + padding-block-end: calc(#{$cell-vertical-padding} + #{$cell-negative-space-vertical}); +} +[data-selected] > .cell.body-cell-first-row.disable-paddings { + padding-block-start: calc( + #{$cell-vertical-padding} - #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical} + ); +} +:not([data-selected]):has(+ [data-selected]) > .cell.disable-paddings { + padding-block-end: calc( + #{$cell-vertical-padding} + #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical} + ); +} + +// Striped-table divider colour: darken the bottom divider of any shaded row and of any row above a +// shaded row (covers every divider in an alternating table). Selected rows and the last row are excluded. +[data-shaded]:not([data-selected]) > .cell:not(.body-cell-last-row), +:not([data-shaded]):not([data-selected]):has(+ [data-shaded]) > .cell:not(.body-cell-last-row) { + border-block-end-color: awsui.$color-border-cell-shaded; +} + +// Table-reuse path: when Table reuses this cell as its it sets these classes explicitly (see +// TableCellInternalProps). Same shared mixins as the row-gated rules above, so the geometry is identical. +// stylelint-disable no-descending-specificity +.cell.cell-selected { + @include sel.selected-cell; +} +.cell.cell-prev-selected { + @include sel.prev-selected-cell; +} +.cell.cell-next-selected { + @include sel.next-selected-cell; +} +.cell.cell-not-selected-next { + @include sel.not-selected-next-cell; + border-block-end: 0; +} +.cell.cell-shaded { + @include sel.shaded-cell; +} +.cell.cell-selected:not(.disable-paddings) { + padding-block-start: calc( + #{$cell-vertical-padding} + #{$cell-negative-space-vertical} - #{awsui.$border-width-item-selected} + ); + padding-block-end: calc( + #{$cell-vertical-padding-w-border} + #{$cell-negative-space-vertical} - #{awsui.$border-width-item-selected} + ); +} +.cell.cell-prev-selected:not(.disable-paddings) { + padding-block-start: calc( + #{$cell-vertical-padding} + #{$cell-negative-space-vertical} - #{awsui.$border-divider-list-width} + ); +} +.cell.cell-next-selected:not(.disable-paddings) { + padding-block-end: calc(#{$cell-vertical-padding} + #{$cell-negative-space-vertical}); +} +.cell.cell-not-selected-next:not(.disable-paddings) { + padding-block-end: calc( + #{$cell-vertical-padding} + #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical} + ); +} +// stylelint-enable no-descending-specificity + +// Selected first row: its top border is already the 2px placeholder, so it doesn't grow on select — +// restore the base block-start the general selected rule would otherwise shrink. +// stylelint-disable-next-line no-descending-specificity +.cell.body-cell-first-row.cell-selected:not(.disable-paddings), +[data-selected] > .cell.body-cell-first-row:not(.disable-paddings) { + padding-block-start: calc( + #{$cell-vertical-padding} - #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical} + ); +} + +// Selected last row: its bottom border is already 2px (the container draws the outer border), so it +// doesn't change on select — match the unselected last row's block-end (no container growth on select). +// stylelint-disable-next-line no-descending-specificity +.cell.cell-selected.body-cell-last-row:not(.disable-paddings), +[data-selected] > .cell.body-cell-last-row:not(.disable-paddings) { + padding-block-end: calc( + #{$cell-vertical-padding-w-border} + #{$cell-negative-space-vertical} - + (#{awsui.$border-width-item-selected} - #{awsui.$border-divider-list-width}) + ); +} +// Same last-row block-end on the control cell so the centred control stays put on select. +// stylelint-disable-next-line no-descending-specificity +.cell.cell-selected.body-cell-last-row.disable-paddings, +[data-selected] > .cell.body-cell-last-row.disable-paddings { + padding-block-end: calc( + #{$cell-vertical-padding-w-border} + #{$cell-negative-space-vertical} - + (#{awsui.$border-width-item-selected} - #{awsui.$border-divider-list-width}) + ); +} + +// Last-row bottom border: the container draws the outer border, so a non-selected last row drops its +// divider (or draws the footer separator). Scoped to :not([data-selected]) so a selected last row keeps +// its selection border. +:not([data-selected]) > .cell.body-cell-last-row:not(.cell-selected) { + border-block-end: awsui.$border-item-width solid transparent; +} +:not([data-selected]) > .cell.body-cell-last-row.body-cell-has-footer:not(.cell-selected) { + border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; +} + +// Sticky-column support for the Table-reuse path. Table's .body-cell.sticky-cell rules never matched +// the reused cell (no body-cell class), so it stayed static on horizontal scroll. Mirrored here re-keyed +// to `.cell`; the sticky-cell* classes come from td-element's getClassName and inset offsets from +// useStickyCellStyles. +.cell.sticky-cell { + position: sticky; + background: awsui.$color-background-container-content; + // Our sticky elements should have z-index in the range of 800-850, this value needs to be lower. + z-index: 798; + &.table-variant-full-page { + background: awsui.$color-background-layout-main; + } + @include styles.with-motion { + transition-property: padding; + transition-duration: awsui.$motion-duration-transition-show-quick; + transition-timing-function: awsui.$motion-easing-sticky; + } + [data-shaded] > & { + background: awsui.$color-background-cell-shaded; + } + // Sticky cells add an opaque background + shadow/clip to occlude cells scrolling underneath. + [data-selected] > & { + background-color: awsui.$color-background-item-selected; + + &:first-child { + box-shadow: 0 0 0 4px awsui.$color-background-container-content; + clip-path: inset(0 0 0 0); + } + &:last-child { + box-shadow: 4px 0 0 0 awsui.$color-background-container-content; + clip-path: inset(0 0 0 0); + &.sticky-cell-last-inline-end { + box-shadow: + awsui.$shadow-sticky-column-last, + 8px 0 0 0 awsui.$color-background-container-content; + clip-path: inset(0 0 0 -24px); + + @include styles.with-direction('rtl') { + box-shadow: awsui.$shadow-sticky-column-first; + clip-path: inset(0 -24px 0 0); + } + } + } + } +} +.cell.sticky-cell-last-inline-start { + box-shadow: awsui.$shadow-sticky-column-first; + clip-path: inset(0px -24px 0px 0px); + + @include styles.with-direction('rtl') { + box-shadow: awsui.$shadow-sticky-column-last; + clip-path: inset(0 0 0 -24px); + } +} +.cell.sticky-cell-last-inline-end { + box-shadow: awsui.$shadow-sticky-column-last; + clip-path: inset(0 0 0 -24px); + + @include styles.with-direction('rtl') { + box-shadow: awsui.$shadow-sticky-column-first; + clip-path: inset(0 -24px 0 0); + } +} diff --git a/src/table-head/index.tsx b/src/table-head/index.tsx new file mode 100644 index 0000000000..2a13944925 --- /dev/null +++ b/src/table-head/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeadProps } from './interfaces'; +import { Head } from './internal'; + +export type { TableHeadProps }; + +function TableHead(props: TableHeadProps) { + const baseComponentProps = useBaseComponent('TableHead'); + return ; +} + +applyDisplayName(TableHead, 'TableHead'); +export default TableHead; diff --git a/src/table-head/interfaces.ts b/src/table-head/interfaces.ts new file mode 100644 index 0000000000..6ff1f61998 --- /dev/null +++ b/src/table-head/interfaces.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders the table head. Its child is a single `TableHeaderRow` of `TableHeaderCell`s. */ +export interface TableHeadProps extends BaseComponentProps { + /** The header row: a `TableHeaderRow` whose cells are `TableHeaderCell` components. */ + children?: React.ReactNode; +} diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx new file mode 100644 index 0000000000..8d56403d87 --- /dev/null +++ b/src/table-head/internal.tsx @@ -0,0 +1,28 @@ +// 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 function Head(props: TableHeadProps & InternalBaseComponentProps) { + const { children, __internalRootRef } = props; + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const baseProps = getBaseProps(props); + return ( +
+ {children} +
//// + {cells} + + ); +} diff --git a/src/table-row/styles.scss b/src/table-row/styles.scss new file mode 100644 index 0000000000..ba9f11cb8a --- /dev/null +++ b/src/table-row/styles.scss @@ -0,0 +1,23 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +.row { + position: relative; + box-sizing: border-box; + // Divider and selection/shaded paint live on the cell (see table-cell/styles.scss); the row only + // carries grid layout and the data-selected / data-shaded gate. +} + +.row-grid { + display: grid; + inline-size: 100%; + align-items: center; + grid-auto-rows: minmax( + calc(#{awsui.$line-height-body-m} + 2 * #{awsui.$space-scaled-xs} + 4px - #{awsui.$border-divider-list-width}), + auto + ); +} diff --git a/src/table/__tests__/body-cell.test.tsx b/src/table/__tests__/body-cell.test.tsx index 59a3214759..9f4c139e9c 100644 --- a/src/table/__tests__/body-cell.test.tsx +++ b/src/table/__tests__/body-cell.test.tsx @@ -68,8 +68,6 @@ const commonProps: TestBodyCellProps = { onEditStart: onEditStart, onEditEnd: onEditEnd, isEditable: true, - isPrevSelected: false, - isNextSelected: false, isFirstRow: true, isLastRow: true, isSelected: false, diff --git a/src/table/__tests__/selection-atomic-row.characterization.test.tsx b/src/table/__tests__/selection-atomic-row.characterization.test.tsx new file mode 100644 index 0000000000..4300599e9c --- /dev/null +++ b/src/table/__tests__/selection-atomic-row.characterization.test.tsx @@ -0,0 +1,54 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Characterization test for the Step 3a atomic-row selection swap: a plain table (tableRole='table') +// renders data rows as the atomic . This locks in two contracts: +// 1. findSelectedRows() still resolves selected rows (finder reconciled via dual-class), and +// 2. selected rows expose aria-selected="true" (new in 3a; classic rows had no aria-selected). +// Run on clean HEAD this is RED (aria-selected is null); after 3a it is GREEN. +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Item { + id: number; + name: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { header: 'id', cell: item => item.id }, + { header: 'name', cell: item => item.name }, +]; + +const items: Item[] = [ + { id: 1, name: 'Apples' }, + { id: 2, name: 'Oranges' }, + { id: 3, name: 'Bananas' }, +]; + +function renderTable(tableProps: Partial) { + const props: TableProps = { items, totalItemsCount: items.length, columnDefinitions, ...tableProps }; + 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); + }); + }); +}); 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..04f0f5a565 --- /dev/null +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -0,0 +1,147 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow, { TableRowProps } from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import cellStyles from '../../../lib/components/table-cell/styles.css.js'; +import headerCellStyles from '../../../lib/components/table-header-cell/styles.css.js'; + +// Proves the row `variant` reaches its data-* style hooks and accessibility state, and that the +// narrowed inline `style` props (for virtualization) reach the body and row elements: +// variant='selected' -> data-selected; variant='shaded' -> data-shaded; default -> neither. +// aria-selected is set only via the separate ariaSelected prop. Selection and shading are mutually +// exclusive by type, so a row is never both. The data-* attributes gate the shared cell-layer +// selection/shaded paint (see table-cell/styles.scss). + +function Harness({ variant, ariaSelected }: { variant?: TableRowProps.Variant; ariaSelected?: boolean }) { + return ( + + + + Name + Status + + + + + Resource 0 + Available + + + + ); +} + +function renderHarness(variant?: TableRowProps.Variant, ariaSelected?: boolean) { + const { container } = render(); + return { wrapper: createWrapper(container) }; +} + +describe('TableRow variant (visual only) and ariaSelected', () => { + test("variant='selected' sets data-selected but does NOT set aria-selected on its own", () => { + const { wrapper } = renderHarness('selected'); + const row = wrapper.findAllTableRows()[0].getElement(); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).toHaveAttribute('data-selected', 'true'); + expect(row).not.toHaveAttribute('data-shaded'); + }); + + test("variant='shaded' sets data-shaded and no aria-selected", () => { + const { wrapper } = renderHarness('shaded'); + const row = wrapper.findAllTableRows()[0].getElement(); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).toHaveAttribute('data-shaded', 'true'); + expect(row).not.toHaveAttribute('data-selected'); + }); + + test('the default variant sets neither hook and no aria-selected', () => { + const { wrapper } = renderHarness(); + const row = wrapper.findAllTableRows()[0].getElement(); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-selected'); + expect(row).not.toHaveAttribute('data-shaded'); + }); + + test('ariaSelected drives aria-selected independently of variant', () => { + const selected = renderHarness('selected', true).wrapper.findAllTableRows()[0].getElement(); + expect(selected).toHaveAttribute('aria-selected', 'true'); + expect(selected).toHaveAttribute('data-selected', 'true'); + + const notSelected = renderHarness('default', false).wrapper.findAllTableRows()[0].getElement(); + expect(notSelected).toHaveAttribute('aria-selected', 'false'); + }); +}); + +describe('inline style props (virtualization)', () => { + const COLUMNS: ReadonlyArray = [{ size: 100 }]; + + test('TableBody and TableRow apply their narrowed inline style to their roots', () => { + const { container } = render( + + + + Name + + + + + Row + + + + ); + const wrapper = createWrapper(container); + const body = wrapper.findTableBody()!.getElement() as HTMLElement; + expect(body.style.position).toBe('relative'); + expect(body.style.height).toBe('400px'); + + const row = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(row.style.position).toBe('absolute'); + expect(row.style.transform).toBe('translateY(40px)'); + // The row keeps its shared grid template alongside the consumer's positioning style. + expect(row.style.gridTemplateColumns).toBe('100px'); + }); +}); + +describe('disablePaddings', () => { + test('TableCell applies the no-padding hook only when disablePaddings is set', () => { + const { container } = render( + + + + Control + Resource 0 + + + + ); + const cells = createWrapper(container).findAllTableCells(); + expect(cells[0].getElement().classList.contains(cellStyles['disable-paddings'])).toBe(true); + expect(cells[1].getElement().classList.contains(cellStyles['disable-paddings'])).toBe(false); + }); + + test('TableHeaderCell applies the no-padding hook only when disablePaddings is set', () => { + const { container } = render( + + + + + Name + + + + ); + const headerCells = createWrapper(container).findAllTableHeaderCells(); + expect(headerCells[0].getElement().classList.contains(headerCellStyles['disable-paddings'])).toBe(true); + expect(headerCells[1].getElement().classList.contains(headerCellStyles['disable-paddings'])).toBe(false); + }); +}); diff --git a/src/table-root/__tests__/basic-table.test.tsx b/src/table-root/__tests__/basic-table.test.tsx new file mode 100644 index 0000000000..dded87c857 --- /dev/null +++ b/src/table-root/__tests__/basic-table.test.tsx @@ -0,0 +1,265 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +// Tests for the atomic table parts (TableRoot/TableHead/TableHeaderCell/TableBody/TableRow/TableCell) +// over the headless useTableRoot hook, accessed through the generated per-part test-utils finders. +// The consumer declares the head as a TableRow of TableHeaderCells and maps the body Rows/Cells; +// TableRoot auto-renders neither. `{ type: 'auto' }` (default) renders a native with no +// explicit ARIA roles; `{ type: 'grid' }` renders a display:grid table that restores the table roles +// and applies the shared column template. Sorting/selection are composed by the consumer. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, index) => ({ + id: `row-${index}`, + name: `Resource ${index}`, + status: index % 2 === 0 ? 'Available' : 'Pending', + })); + +// Grid layout needs one column entry per column (Name fixed 200px, Status flexible). +const GRID_COLUMNS: ReadonlyArray = [{ size: 200 }, {}]; + +interface RenderOptions { + grid?: boolean; + count?: number; + items?: Item[]; + ariaRowcount?: number; +} + +function TableHarness({ options }: { options: RenderOptions }) { + const items = options.items ?? makeItems(options.count ?? 5); + const columnLayout: TableRootProps.ColumnLayout = options.grid + ? { type: 'grid', columns: GRID_COLUMNS } + : { type: 'auto' }; + return ( + + + + Name + Status + + + + {items.map(item => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +function renderTable(options: RenderOptions = {}) { + const utils = render(); + const wrapper = createWrapper(utils.container); + const table = () => wrapper.find('table')!.getElement(); + return { wrapper, table, ...utils }; +} + +describe('Table atomic parts', () => { + test('renders the declarative header cells, discoverable via the generated finder', () => { + const { wrapper } = renderTable(); + expect(wrapper.findTableRoot()).not.toBeNull(); + const headerCells = wrapper.findAllTableHeaderCells(); + expect(headerCells).toHaveLength(2); + expect(headerCells[0].getElement().textContent).toContain('Name'); + expect(headerCells[1].getElement().textContent).toContain('Status'); + }); + + test('renders the mapped rows and cells, discoverable via the generated finders', () => { + const { wrapper } = renderTable({ count: 5 }); + const rows = wrapper.findAllTableRows(); + expect(rows).toHaveLength(5); // body rows only; the header row uses a different root class + + const firstRowCells = createWrapper(rows[0].getElement()).findAllTableCells(); + expect(firstRowCells).toHaveLength(2); + expect(firstRowCells[0].getElement().textContent).toBe('Resource 0'); + expect(firstRowCells[1].getElement().textContent).toBe('Available'); + + expect(wrapper.findTableBody()).not.toBeNull(); + expect(wrapper.findTableHead()).not.toBeNull(); + expect(wrapper.findAllTableCells()).toHaveLength(10); + }); + + test('ariaLabel is applied to the table element', () => { + const { table } = renderTable(); + expect(table().getAttribute('aria-label')).toBe('Resources'); + }); + + test('ariaRowcount is applied to aria-rowcount as-is', () => { + const { table } = renderTable({ count: 5, ariaRowcount: 40 }); + expect(table().getAttribute('aria-rowcount')).toBe('40'); + }); + + test('omits aria-rowcount when ariaRowcount is not provided (count derives from the DOM)', () => { + const { table } = renderTable({ count: 5 }); + expect(table().hasAttribute('aria-rowcount')).toBe(false); + }); + + describe('auto column layout (default)', () => { + test('renders a native
with no explicit table/row/cell ARIA roles', () => { + const { table, wrapper } = renderTable(); + expect(table().tagName).toBe('TABLE'); + expect(table().hasAttribute('role')).toBe(false); + expect(table().querySelectorAll('[role="row"]')).toHaveLength(0); + expect(table().querySelectorAll('[role="columnheader"]')).toHaveLength(0); + expect(table().querySelectorAll('[role="cell"], [role="gridcell"]')).toHaveLength(0); + const th = wrapper.findAllTableHeaderCells()[0].getElement(); + expect(th.tagName).toBe('TH'); + expect(th.getAttribute('scope')).toBe('col'); + }); + + test('does not emit an inline grid-template-columns on rows', () => { + const { wrapper } = renderTable(); + const row = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(row.style.gridTemplateColumns).toBe(''); + }); + }); + + describe('grid column layout', () => { + test('restores the table ARIA roles that display:grid strips', () => { + const { table, wrapper } = renderTable({ grid: true }); + expect(table().getAttribute('role')).toBe('table'); + expect(table().querySelectorAll('[role="rowgroup"]').length).toBeGreaterThanOrEqual(2); + + const headerCell = wrapper.findAllTableHeaderCells()[0].getElement(); + expect(headerCell.getAttribute('role')).toBe('columnheader'); + expect(headerCell.getAttribute('scope')).toBe('col'); + + const dataRow = wrapper.findAllTableRows()[0].getElement(); + expect(dataRow.getAttribute('role')).toBe('row'); + expect(dataRow.querySelectorAll('[role="cell"]')).toHaveLength(2); + }); + + test('the header row is aria-rowindex 1 and shares the column template with the data rows', () => { + const { wrapper } = renderTable({ grid: true }); + const headerRow = wrapper.findTableHead()!.find('[role="row"]')!.getElement() as HTMLElement; + expect(headerRow.getAttribute('aria-rowindex')).toBe('1'); + const template = '200px minmax(0px, 1fr)'; + expect(headerRow.style.gridTemplateColumns).toBe(template); + + const dataRow = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(dataRow.style.gridTemplateColumns).toBe(template); + }); + }); + + describe('row aria-selected is driven by ariaSelected, not variant', () => { + test('variant is visual-only; ariaSelected sets aria-selected independently', () => { + const { container } = render( + + + + Name + + + + + Selected + announced + + + Selected visual only + + + Explicitly not selected + + + Default + + + + ); + const rows = createWrapper(container).findAllTableRows(); + // variant='selected' + ariaSelected -> aria-selected="true" + expect(rows[0].getElement().getAttribute('aria-selected')).toBe('true'); + // variant='selected' WITHOUT ariaSelected -> no aria-selected (variant is visual only) + expect(rows[1].getElement().hasAttribute('aria-selected')).toBe(false); + // ariaSelected={false} -> aria-selected="false" + expect(rows[2].getElement().getAttribute('aria-selected')).toBe('false'); + // default -> no aria-selected + expect(rows[3].getElement().hasAttribute('aria-selected')).toBe(false); + }); + }); + + describe('declared per-part ARIA props', () => { + test('HeaderCell ariaSort sets aria-sort on the column header', () => { + const { container } = render( + + + + Name + Status + + + + + Resource 0 + Available + + + + ); + const headerCells = createWrapper(container).findAllTableHeaderCells(); + expect(headerCells[0].getElement().getAttribute('aria-sort')).toBe('ascending'); + expect(headerCells[1].getElement().hasAttribute('aria-sort')).toBe(false); + }); + + test('Row ariaRowindex sets aria-rowindex for virtualization', () => { + const { container } = render( + + + + Name + + + + + Resource 200 + + + + ); + const row = createWrapper(container).findAllTableRows()[0].getElement(); + expect(row.getAttribute('aria-rowindex')).toBe('202'); + }); + }); + + describe('native data-* passthrough on parts (virtualization interop)', () => { + test('a row and cell forward data-* to their roots', () => { + const { container } = render( + + + + Name + + + + + Resource 7 + + + + ); + const wrapper = createWrapper(container); + expect(wrapper.findAllTableRows()[0].getElement().getAttribute('data-index')).toBe('7'); + expect(wrapper.findAllTableCells()[0].getElement().getAttribute('data-column')).toBe('name'); + }); + }); +}); diff --git a/src/table-root/__tests__/use-table-root.test.tsx b/src/table-root/__tests__/use-table-root.test.tsx new file mode 100644 index 0000000000..1dc475eb15 --- /dev/null +++ b/src/table-root/__tests__/use-table-root.test.tsx @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { renderHook } from '../../__tests__/render-hook'; +import { TableRootProps } from '../interfaces'; +import { useTableRoot } from '../use-table-root'; + +const COLUMNS: ReadonlyArray = [{ size: 200 }, {}, { size: 100 }]; + +function gridTemplate(columns: ReadonlyArray) { + return renderHook(() => useTableRoot({ type: 'grid', columns })).result.current.gridTemplateColumns; +} + +describe('useTableRoot', () => { + test('auto layout exposes the layout and no grid template', () => { + const { result } = renderHook(() => useTableRoot({ type: 'auto' })); + expect(result.current.columnLayout.type).toBe('auto'); + expect(result.current.gridTemplateColumns).toBeUndefined(); + }); + + test('grid layout exposes the layout', () => { + const { result } = renderHook(() => useTableRoot({ type: 'grid', columns: COLUMNS })); + expect(result.current.columnLayout.type).toBe('grid'); + }); + + describe('gridTemplateColumns compiled from the size union', () => { + test('multiple columns join into one template', () => { + expect(gridTemplate(COLUMNS)).toBe('200px minmax(0px, 1fr) 100px'); + }); + + test('a fixed pixel size becomes a px track', () => { + expect(gridTemplate([{ size: 200 }])).toBe('200px'); + }); + + test('an absent size becomes a flexible minmax(0px, 1fr) track', () => { + expect(gridTemplate([{}])).toBe('minmax(0px, 1fr)'); + }); + + test('a flex weight becomes minmax(0px, fr)', () => { + expect(gridTemplate([{ size: { flex: 2 } }])).toBe('minmax(0px, 2fr)'); + }); + + test('minWidth floors a flexible track', () => { + expect(gridTemplate([{ minWidth: 150 }])).toBe('minmax(150px, 1fr)'); + }); + + test('a fixed size ignores minWidth (redundant on a fixed track)', () => { + expect(gridTemplate([{ size: 200, minWidth: 150 }])).toBe('200px'); + }); + + test('maxWidth caps a flexible track at a px ceiling', () => { + expect(gridTemplate([{ maxWidth: 300 }])).toBe('minmax(0px, 300px)'); + expect(gridTemplate([{ minWidth: 100, maxWidth: 300 }])).toBe('minmax(100px, 300px)'); + }); + }); +}); diff --git a/src/table-root/context.ts b/src/table-root/context.ts new file mode 100644 index 0000000000..dd31fa5bb3 --- /dev/null +++ b/src/table-root/context.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createContext, useContext } from 'react'; + +import { UseTableRootResult } from './use-table-root'; + +// A part rendered outside `TableRoot` reads this default (auto layout) instead of throwing. +const defaultTableContext: UseTableRootResult = { columnLayout: { type: 'auto' }, gridTemplateColumns: undefined }; + +const TableContext = createContext(defaultTableContext); + +export const TableContextProvider = TableContext.Provider; + +export function useTableContext(): UseTableRootResult { + return useContext(TableContext); +} diff --git a/src/table-root/index.tsx b/src/table-root/index.tsx new file mode 100644 index 0000000000..b2fa1c4fb4 --- /dev/null +++ b/src/table-root/index.tsx @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableRootProps } from './interfaces'; +import { InternalRoot } from './internal'; + +// Root of the atomic table. The parts (TableHead, TableRow, …) are sibling top-level components; +// keeping each dir single-component (one default export + its props type) is what lets the +// documenter treat every part as its own documented component. +export type { TableRootProps }; + +function TableRoot({ columnLayout = { type: 'auto' }, ...props }: TableRootProps) { + const baseComponentProps = useBaseComponent('TableRoot', { + props: {}, + metadata: { columnLayoutType: columnLayout.type }, + }); + return ; +} + +applyDisplayName(TableRoot, 'TableRoot'); + +export default TableRoot; diff --git a/src/table-root/interfaces.ts b/src/table-root/interfaces.ts new file mode 100644 index 0000000000..faab94bfc4 --- /dev/null +++ b/src/table-root/interfaces.ts @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** + * A composable table. You render the head and rows as children, and TableRoot provides the column + * layout and accessibility semantics. + */ +export interface TableRootProps extends BaseComponentProps { + /** + * The table's content. Provide a `TableHead` followed by a `TableBody` that contains the rows. + */ + children: React.ReactNode; + + /** + * Determines how column widths are calculated. + * * `{ type: 'auto' }` - Renders a standard HTML table whose columns size to their content. No + * column configuration is required. + * * `{ type: 'grid'; columns }` - Renders a CSS grid and applies each column's `size`, `minWidth`, + * and `maxWidth`. Provide one `columns` entry per column, in display order; cells bind to columns + * by position. Virtualization requires this layout. + * * `size` (number | { flex: number }) - A number sets a fixed pixel width; `{ flex }` gives the + * column a weight that shares the remaining space in proportion. Omit it for a flexible column + * with the default weight of 1. + * * `minWidth` (number) - The minimum width in pixels, for a flexible column. + * * `maxWidth` (number) - The maximum width in pixels. + * + * Defaults to `{ type: 'auto' }`. + */ + columnLayout?: TableRootProps.ColumnLayout; + + /** Provides an accessible name for the table. Use this or `ariaLabelledby` to label the table. */ + ariaLabel?: string; + /** Sets the `aria-labelledby` attribute. Use the ID of a visible element that labels the table. */ + ariaLabelledby?: string; + /** Sets the `aria-describedby` attribute. Use the ID of a visible element that describes the table. */ + ariaDescribedby?: string; + + /** + * The total number of rows in the full dataset, set on the table's `aria-rowcount`. Provide it + * only when you render a subset of rows, such as with virtualization, so assistive technologies + * report the whole table. Omit it when you render every row, and the count is derived from the DOM. + */ + ariaRowcount?: number; +} + +export namespace TableRootProps { + export type ColumnLayout = { type: 'auto' } | { type: 'grid'; columns: ReadonlyArray }; + + export interface ColumnDefinition { + /** + * A number sets a fixed pixel width; `{ flex }` gives the column a weight that shares the + * remaining space in proportion. Omit it for a flexible column with the default weight of 1. + */ + size?: number | { flex: number }; + /** The minimum width in pixels, for a flexible column. */ + minWidth?: number; + /** The maximum width in pixels. */ + maxWidth?: number; + } +} diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx new file mode 100644 index 0000000000..959b227ca8 --- /dev/null +++ b/src/table-root/internal.tsx @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { TableContextProvider } from './context'; +import { TableRootProps } from './interfaces'; +import { useTableRoot } from './use-table-root'; + +import styles from './styles.css.js'; + +type InternalRootProps = TableRootProps & InternalBaseComponentProps; + +export function InternalRoot(props: InternalRootProps) { + const { + columnLayout = { type: 'auto' }, + ariaRowcount, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + children, + __internalRootRef, + } = props; + + const isGrid = columnLayout.type === 'grid'; + const table = useTableRoot(columnLayout); + const baseProps = getBaseProps(props); + + return ( +
+ + {/* The page owns vertical scroll; this wrapper reintroduces an inline scroll viewport so a wide table scrolls horizontally instead of spilling out. */} +
+
+
role is used (an explicit + // role="table" there is redundant and flagged by a11y validators). + role={isGrid ? 'table' : undefined} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledby} + aria-describedby={ariaDescribedby} + aria-rowcount={ariaRowcount} + className={clsx(styles.table, isGrid ? styles['table-grid'] : styles['table-auto'])} + > + {children} +
+ + + + + ); +} diff --git a/src/table-root/styles.scss b/src/table-root/styles.scss new file mode 100644 index 0000000000..a83dd633f7 --- /dev/null +++ b/src/table-root/styles.scss @@ -0,0 +1,44 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/index' as styles; +@use '../internal/styles/tokens' as awsui; + +.root { + @include styles.styles-reset; + position: relative; + display: flex; + flex-direction: column; + inline-size: 100%; + background: awsui.$color-background-container-content; +} + +.scroll-container { + position: relative; + flex: 1 1 auto; + min-block-size: 0; + overflow: auto; + inline-size: 100%; +} + +.body-scroller { + overflow-x: auto; +} + +.table { + inline-size: 100%; + // Separate borders (matching the Table): required for sticky columns and gives the per-cell + // selected-row outline defined corners. Only applies in auto layout; grid mode uses display: block. + 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..5db1f035ff --- /dev/null +++ b/src/table-root/use-table-root.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useMemo } from 'react'; + +import { TableRootProps } from './interfaces'; + +export interface UseTableRootResult { + columnLayout: TableRootProps.ColumnLayout; + /** The `grid-template-columns` value for `grid` layout, compiled from each column's `size` union; `undefined` in `auto` layout. */ + gridTemplateColumns?: string; +} + +export function useTableRoot(columnLayout: TableRootProps.ColumnLayout): UseTableRootResult { + const gridTemplateColumns = useMemo(() => { + if (columnLayout.type !== 'grid') { + return undefined; + } + return columnLayout.columns + .map(column => { + if (typeof column.size === 'number') { + return `${column.size}px`; + } + const min = `${column.minWidth ?? 0}px`; + if (column.maxWidth !== undefined) { + return `minmax(${min}, ${column.maxWidth}px)`; + } + const flex = column.size ? column.size.flex : 1; + return `minmax(${min}, ${flex}fr)`; + }) + .join(' '); + }, [columnLayout]); + + return { columnLayout, gridTemplateColumns }; +} diff --git a/src/table-row/index.tsx b/src/table-row/index.tsx new file mode 100644 index 0000000000..914efa08f2 --- /dev/null +++ b/src/table-row/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableRowProps } from './interfaces'; +import { Row } from './internal'; + +export type { TableRowProps }; + +function TableRow(props: TableRowProps) { + const baseComponentProps = useBaseComponent('TableRow', { props: { variant: props.variant } }); + return ; +} + +applyDisplayName(TableRow, 'TableRow'); +export default TableRow; diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts new file mode 100644 index 0000000000..0261304efa --- /dev/null +++ b/src/table-row/interfaces.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders a single data row, inside `TableBody`. */ +export interface TableRowProps extends BaseComponentProps { + /** + * The row's visual state. This is visual only — set `ariaSelected` to convey selection to + * assistive technologies. + * * `default` - A standard row. + * * `selected` - Applies the selected-row styling. Pair it with `ariaSelected` and a selection + * control, such as a checkbox, in a leading cell. + * * `shaded` - Applies a shaded background, to create alternating row colors. Choose which rows + * are shaded, typically with `variant={index % 2 === 1 ? 'shaded' : 'default'}`. + * + * Defaults to `'default'`. + */ + variant?: TableRowProps.Variant; + /** Provides an accessible name for the row. Use this or `ariaLabelledby`. */ + ariaLabel?: string; + /** Sets `aria-labelledby`. Use the ID(s) of visible element(s) that label the row. */ + ariaLabelledby?: string; + /** Sets `aria-describedby`. Use the ID(s) of visible element(s) that describe the row. */ + ariaDescribedby?: string; + /** Sets `aria-selected` to reflect the row's selection state. */ + ariaSelected?: boolean; + /** + * Sets the row's `aria-rowindex` — its 1-based position in the full dataset, counting the header + * row (so a data row's value is its dataset index plus 2). Set this only when virtualizing, so + * assistive technologies report the row's true position while you render a subset of rows; in a + * standard table the position is derived from DOM order. + */ + ariaRowindex?: number; + /** + * Applies inline styles to the row element. Use this for row positioning, for example for + * virtualization or draggable rows. It is not supported to use this for general styling purposes. + */ + style?: TableRowProps.Style; + /** The row's cells, one per column, in order. */ + children?: React.ReactNode; +} + +export namespace TableRowProps { + export type Variant = 'default' | 'selected' | 'shaded'; + export interface Style { + transform?: React.CSSProperties['transform']; + position?: React.CSSProperties['position']; + height?: React.CSSProperties['height']; + } +} diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx new file mode 100644 index 0000000000..b14479f97e --- /dev/null +++ b/src/table-row/internal.tsx @@ -0,0 +1,61 @@ +// 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 { TableRowProps } from './interfaces'; + +import styles from './styles.css.js'; + +export function Row( + props: TableRowProps & + InternalBaseComponentProps & { nativeAttributes?: React.HTMLAttributes; __lastRow?: boolean } +) { + const { + variant, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + ariaSelected, + ariaRowindex, + children, + style, + nativeAttributes, + __lastRow, + __internalRootRef, + } = props; + const { columnLayout, gridTemplateColumns } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const baseProps = getBaseProps(props); + // Forward TableBody's last-row flag on to each cell so it drops the divider. + const cells = __lastRow + ? React.Children.map(children, child => + React.isValidElement(child) + ? React.cloneElement(child as React.ReactElement<{ __lastRow?: boolean }>, { __lastRow: true }) + : child + ) + : children; + return ( +
); + return createWrapper(container).findTable()!; +} + +test('selected rows are resolved by findSelectedRows and expose aria-selected="true"', () => { + const wrapper = renderTable({ selectionType: 'multi', selectedItems: [items[0], items[2]] }); + + const selectedRows = wrapper.findSelectedRows(); + // Finder contract: reconciled via dual-class so the classic test-util marker survives the swap. + expect(selectedRows).toHaveLength(2); + + selectedRows.forEach(row => { + expect(row.getElement().tagName).toBe('TR'); + expect(row.getElement().getAttribute('aria-selected')).toBe('true'); + }); + + // Unselected rows must not falsely advertise selection. + const allRows = wrapper.findRows(); + expect(allRows).toHaveLength(3); + const unselected = allRows.filter(row => row.getElement().getAttribute('aria-selected') === 'true'); + expect(unselected).toHaveLength(2); // only the two selected rows carry aria-selected +}); diff --git a/src/table/__tests__/striping-atomic-row.characterization.test.tsx b/src/table/__tests__/striping-atomic-row.characterization.test.tsx new file mode 100644 index 0000000000..359df3569b --- /dev/null +++ b/src/table/__tests__/striping-atomic-row.characterization.test.tsx @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Characterization test for the striping ownership under Inc A (plain/selection cells reuse the +// atomic Cell). The row-level `data-shaded` marker is retained on the (it still gates the +// un-migrated th/editable/expandable cells and the sticky-only per-cell fill), while the migrated +// plain data cells SELF-PAINT their shaded fill via their own module class (`cell-shaded`) and do +// NOT carry a per-cell `data-shaded` attribute. This locks in: +// 1. odd (non-even) data rows carry the row-level data-shaded marker; even rows and non-striped +// tables do not, and +// 2. migrated cells in shaded rows expose the self-painted `cell-shaded` class, not a per-cell +// data-shaded attribute. +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Item { + id: number; + name: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { header: 'id', cell: item => item.id }, + { header: 'name', cell: item => item.name }, +]; + +const items: Item[] = [ + { id: 1, name: 'Apples' }, + { id: 2, name: 'Oranges' }, + { id: 3, name: 'Bananas' }, + { id: 4, name: 'Pears' }, +]; + +function renderTable(tableProps: Partial) { + const props: TableProps = { items, totalItemsCount: items.length, columnDefinitions, ...tableProps }; + const { container } = render(
); + return createWrapper(container).findTable()!; +} + +// The row-level marker lives on the itself; a migrated cell self-paints via its own cell-shaded +// class. querySelector on the row targets descendants, so cell-level data-shaded (the pre-migration +// per-cell marker) would show up here — it must NOT. +function rowIsShaded(row: ReturnType['findRows']>[number]) { + return row.getElement().getAttribute('data-shaded') === 'true'; +} +function rowHasPerCellDataShaded(row: ReturnType['findRows']>[number]) { + return !!row.getElement().querySelector('[data-shaded="true"]'); +} +function rowHasSelfPaintedShadedCell(row: ReturnType['findRows']>[number]) { + return !!row.getElement().querySelector('[class*="cell-shaded"]'); +} + +test('striped rows carry the row-level data-shaded marker on odd rows; cells self-paint cell-shaded', () => { + const wrapper = renderTable({ stripedRows: true }); + const rows = wrapper.findRows(); + expect(rows).toHaveLength(4); + + // Even rows (index 0, 2) are not shaded; odd rows (index 1, 3) are. + expect(rowIsShaded(rows[0])).toBe(false); + expect(rowIsShaded(rows[1])).toBe(true); + expect(rowIsShaded(rows[2])).toBe(false); + expect(rowIsShaded(rows[3])).toBe(true); + + // Migrated cells self-paint the shaded fill via their own class, without a per-cell data-shaded attr. + expect(rowHasSelfPaintedShadedCell(rows[1])).toBe(true); + expect(rowHasPerCellDataShaded(rows[1])).toBe(false); + expect(rowHasSelfPaintedShadedCell(rows[0])).toBe(false); +}); + +test('without stripedRows no row is marked shaded and no cell self-paints shaded', () => { + const wrapper = renderTable({}); + wrapper.findRows().forEach(row => { + expect(rowIsShaded(row)).toBe(false); + expect(rowHasSelfPaintedShadedCell(row)).toBe(false); + }); +}); diff --git a/src/table/body-cell/_selection-mixins.scss b/src/table/body-cell/_selection-mixins.scss new file mode 100644 index 0000000000..12ade67f2e --- /dev/null +++ b/src/table/body-cell/_selection-mixins.scss @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +@use '../../internal/styles/tokens' as awsui; + +// Shared per-cell selection/divider/stripe geometry @included by both the Table body-cell and the +// atomic table-cell. The padding-compensation vars and block-padding mixins below are duplicated from +// body-cell/styles.scss deliberately, so the Table's non-selection styling stays untouched. + +$cell-vertical-padding: awsui.$space-scaled-xs; +// Padding compensates for the selected-border vs row-divider width difference to avoid content shift (VR). +$cell-vertical-padding-w-border: calc( + #{$cell-vertical-padding} + (#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}) +); +// Ensuring enough space for absolute-positioned focus outlines of focus-able cell content elements. +$cell-negative-space-vertical: 2px; +$selected-border: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; +$selected-border-placeholder: awsui.$border-divider-list-width solid awsui.$color-border-item-placeholder; + +@mixin cell-padding-block-start($padding) { + > .body-cell-content { + 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 { + padding-block-end: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical}); + margin-block-end: calc(-1 * #{$cell-negative-space-vertical}); + } +} + +@mixin selected-cell-first { + border-inline-start: $selected-border; + border-start-start-radius: awsui.$border-radius-item; + border-start-end-radius: 0; + border-end-start-radius: awsui.$border-radius-item; + border-end-end-radius: 0; +} +@mixin selected-cell-last { + border-inline-end: $selected-border; + border-start-start-radius: 0; + border-start-end-radius: awsui.$border-radius-item; + border-end-start-radius: 0; + border-end-end-radius: awsui.$border-radius-item; +} +@mixin prev-selected-cell { + border-block-start: $selected-border-placeholder; + @include cell-padding-block-start($cell-vertical-padding-w-border); + // Remove the top corner radii so consecutive selected rows read as one block (visual refresh). + &:first-child { + border-start-start-radius: 0; + } + &:last-child { + border-start-end-radius: 0; + } +} +@mixin next-selected-cell { + border-block-end-width: awsui.$border-divider-list-width; + // Remove the bottom corner radii so consecutive selected rows read as one block (visual refresh). + &:first-child { + border-end-start-radius: 0; + } + &:last-child { + border-end-end-radius: 0; + } +} +@mixin not-selected-next-cell { + border-block-end: 0; + @include cell-padding-block-end(calc(#{$cell-vertical-padding} + #{awsui.$border-divider-list-width})); +} +@mixin shaded-cell { + background: awsui.$color-background-cell-shaded; +} +@mixin selected-cell { + background-color: awsui.$color-background-item-selected; + border-block-start: $selected-border; + border-block-end: $selected-border; + @include cell-padding-block-end($cell-vertical-padding); + // Base value; when the row above is also selected, prev-selected-cell overrides it at higher specificity. + @include cell-padding-block-start($cell-vertical-padding); + + // Last selected row has a fixed border-bottom width which does not change on selection (visual refresh). + &.body-cell-last-row.is-visual-refresh { + @include cell-padding-block-end(calc(#{$cell-vertical-padding} + #{awsui.$border-divider-list-width})); + } + + &:first-child { + @include selected-cell-first; + } + &:last-child { + @include selected-cell-last; + } +} diff --git a/src/table/body-cell/styles.scss b/src/table/body-cell/styles.scss index a25f7f3f55..17d96987d1 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -6,18 +6,16 @@ @use '../../internal/styles' as styles; @use '../../internal/styles/tokens' as awsui; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; +@use './selection-mixins' as sel; $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). +// Padding compensates for the selected-border vs row-divider width difference to avoid content shift (VR). $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}); $cell-horizontal-padding-w-border: calc(#{$cell-edge-horizontal-padding} + #{awsui.$border-item-width}); -$selected-border: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; -$selected-border-placeholder: awsui.$border-divider-list-width solid awsui.$color-border-item-placeholder; $border-placeholder: awsui.$border-item-width solid transparent; $icon-width-with-spacing: calc(#{awsui.$size-icon-normal} + #{awsui.$space-xs}); // Right paddings of the absolute positioned icons (success icon is shown next to the edit icon) @@ -34,8 +32,7 @@ $cell-negative-space-vertical: 2px; @mixin safe-focus-highlight($params) { @include styles.focus-highlight($params); - // @mixin focus-highlight sets cell's position to "relative". - // Reinforcing sticky position for it to take precedence. + // focus-highlight sets position:relative; reassert sticky so it wins. &.sticky-cell { position: sticky; } @@ -174,8 +171,7 @@ $cell-negative-space-vertical: 2px; @include cell-padding-inline-start(awsui.$space-xxxs); @include body-cell-active-hover-padding(awsui.$space-xxxs); - // Using slightly larger padding for tables with striped rows because the shaded background - // makes the child content appear too close to the table edge. + // Slightly larger padding for striped rows: the shaded background makes content look too close to the edge. &:first-child.has-striped-rows { @include cell-padding-inline-start(awsui.$space-xxs); @include body-cell-active-hover-padding(awsui.$space-xxs); @@ -186,17 +182,12 @@ $cell-negative-space-vertical: 2px; } } - // Using normal padding when 1st column is sticky. &.sticky-cell-pad-inline-start:not(.has-selection) { @include cell-padding-inline-start($cell-horizontal-padding); @include body-cell-active-hover-padding($cell-horizontal-padding); } - /* - Remove the placeholder border if the row is not selectable. - Rows that are not selectable will reserve the horizontal space - that the placeholder border would consume. - */ + // Non-selectable rows reserve the placeholder border's horizontal space, so drop the border itself. &:not(.has-selection):not(.body-cell-editable) { border-inline-start: none; } @@ -204,52 +195,36 @@ $cell-negative-space-vertical: 2px; &-first-row { border-block-start: $border-placeholder; } - &-last-row:not(.body-cell-selected) { + &-last-row:not([data-selected]) { &:not(.has-footer) { // skip the border for the last row because the container already has a border border-block-end: $border-placeholder; } &.has-footer { - /* - Add a bottom border to the body cells of the last row as a separator between the - table and the footer - */ + // Bottom border on the last row's cells separates the table body from the footer. border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; } } &-shaded { - background: awsui.$color-background-cell-shaded; + @include sel.shaded-cell; } - &.has-striped-rows:not(.body-cell-selected):not(.body-cell-last-row) { + &.has-striped-rows:not([data-selected]):not(.body-cell-last-row) { border-block-end-color: awsui.$color-border-cell-shaded; } - &-selected { - background-color: awsui.$color-background-item-selected; - border-block-start: $selected-border; - border-block-end: $selected-border; - @include cell-padding-block-end($cell-vertical-padding); - - // Last selected row has a fixed border-bottom width which do not change on selection in visual refresh. - // Adjust padding-bottom prevents a slight jump in the table height. - &.body-cell-last-row.is-visual-refresh { - @include cell-padding-block-end(calc(#{$cell-vertical-padding} + #{awsui.$border-divider-list-width})); - } - - &:first-child { - border-inline-start: $selected-border; - border-start-start-radius: awsui.$border-radius-item; - border-start-end-radius: 0; - border-end-start-radius: awsui.$border-radius-item; - border-end-end-radius: 0; - } - &:last-child { - border-inline-end: $selected-border; - border-start-start-radius: 0; - border-start-end-radius: awsui.$border-radius-item; - border-end-start-radius: 0; - border-end-end-radius: awsui.$border-radius-item; - } + // Selection state lives on the row (data-selected/data-shaded); cells paint via the row ancestor + + // CSS adjacency, uniformly for the Table's own path and the reused atomic row. + [data-selected] > & { + @include sel.selected-cell; + } + [data-selected] + [data-selected] > & { + @include sel.prev-selected-cell; + } + [data-selected]:has(+ [data-selected]) > & { + @include sel.next-selected-cell; + } + :not([data-selected]):has(+ [data-selected]) > & { + @include sel.not-selected-next-cell; } &.sticky-cell { @@ -264,13 +239,14 @@ $cell-negative-space-vertical: 2px; &.table-variant-full-page { background: awsui.$color-background-layout-main; } - &.body-cell-shaded { + // Sticky-only stripe fill, driven by the row's data-shaded so it also covers reused atomic rows. + [data-shaded] > & { background: awsui.$color-background-cell-shaded; } - &.body-cell-selected { + // Opaque background + box-shadow/clip occlude cells scrolling underneath the sticky column. + [data-selected] > & { background-color: awsui.$color-background-item-selected; - // Create a background using box-shadow and clip path to hide underlying elements &:first-child { box-shadow: 0 0 0 4px awsui.$color-background-container-content; clip-path: inset(0 0 0 0); @@ -311,40 +287,6 @@ $cell-negative-space-vertical: 2px; } } - // Use padding as a selected border placeholder to make sure rows don't change height on selection (visual refresh) - &-selected:not(:first-child) { - @include cell-padding-block-start($cell-vertical-padding-w-border); - } - &:not(.body-cell-selected).body-cell-next-selected { - border-block-end: 0; - @include cell-padding-block-end(calc(#{$cell-vertical-padding} + #{awsui.$border-divider-list-width})); - } - &-selected.body-cell-prev-selected { - border-block-start: $selected-border-placeholder; - @include cell-padding-block-start($cell-vertical-padding-w-border); - } - &-selected.body-cell-next-selected { - border-block-end-width: awsui.$border-divider-list-width; - } - // Remove border radii for consecutive selected rows (visual refresh) - &-selected.body-cell-next-selected:first-child { - border-end-start-radius: 0; - } - &-selected.body-cell-next-selected:last-child { - border-end-end-radius: 0; - } - &-selected.body-cell-prev-selected:first-child { - border-start-start-radius: 0; - } - &-selected.body-cell-prev-selected:last-child { - border-start-end-radius: 0; - } - // Reset padding for selected rows with no adjacent selected row above it, - // because rows reuse adjacent selected borders (visual refresh) - &-selected:not(.body-cell-prev-selected) { - @include cell-padding-block-start($cell-vertical-padding); - } - &-editor-wrapper { padding-block: 0; padding-inline-start: 0; @@ -368,7 +310,6 @@ $cell-negative-space-vertical: 2px; justify-content: flex-end; } &-editor { - // Reset some native . + const useAtomicRow = tableRole === 'table' && !isExpandable; + const onRowFocus = ({ currentTarget }: React.FocusEvent) => { + // Adjust scroll when something inside the row is focused, but not when the focus came + // from a click (that would swallow the click before it reaches the target). + if (!currentTarget.contains(getMouseDownTarget())) { + stickyHeaderRef.current?.scrollToRow(currentTarget); + } + }; + const onRowClick = onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item); + const onRowContextMenu = + onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item); + const rowAriaRowindex = (rowRoleProps as Record)['aria-rowindex']; + const dataRowContent = ( + <> {selection.getItemSelectionProps && ( ); })} + + ); + return useAtomicRow ? ( + + {dataRowContent} + + ) : ( + + {dataRowContent} ); } diff --git a/src/table/skeleton-rows.tsx b/src/table/skeleton-rows.tsx index 6d8f62be0b..dbb14c98c7 100644 --- a/src/table/skeleton-rows.tsx +++ b/src/table/skeleton-rows.tsx @@ -70,8 +70,6 @@ export function SkeletonRows({ isFirstRow={isFirstRow} isLastRow={isLastRow} isSelected={false} - isPrevSelected={false} - isNextSelected={false} hasSelection={hasSelection} hasFooter={hasFooter} stickyState={stickyState} 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; +} diff --git a/src/test-utils/dom/table/index.ts b/src/test-utils/dom/table/index.ts index 59f6c83d27..791d532b19 100644 --- a/src/test-utils/dom/table/index.ts +++ b/src/test-utils/dom/table/index.ts @@ -89,7 +89,11 @@ export default class TableWrapper extends ComponentWrapper { */ findBodyCell(rowIndex: number, columnIndex: number): ElementWrapper | null { return this.findNativeTable().find( - `tbody tr:nth-child(${rowIndex}) .${bodyCellStyles['body-cell']}:nth-child(${columnIndex})` + // Column cells are located by position, not by styling class: plain data and selection-control + // columns are rendered by the reused atomic Cell (class `cell`, not `body-cell`), while + // row-header / editable / expandable columns stay on the Table's own `body-cell`. Matching the + // row's Nth child covers both without coupling the locator to either styling module. + `tbody tr:nth-child(${rowIndex}) > :nth-child(${columnIndex})` ); }
path instead of the reused atomic Cell. +const MIGRATE_STICKY_CELLS = true; +// VR-oracle harness escape hatch: false renders every column on the Table's own path. +const MIGRATE_ATOMIC_CELLS = true; + export interface TableTdElementProps { wrapLines: boolean | undefined; isRowHeader?: boolean; isFirstRow: boolean; isLastRow: boolean; isSelected: boolean; - isNextSelected: boolean; - isPrevSelected: boolean; + isPrevSelected?: boolean; + isNextSelected?: boolean; nativeAttributes?: Omit< React.TdHTMLAttributes | React.ThHTMLAttributes, 'style' | 'className' | 'onClick' @@ -37,8 +44,10 @@ export interface TableTdElementProps { children?: React.ReactNode; isEvenRow?: boolean; stripedRows?: boolean; + isAtomicRow?: boolean; isSelection?: boolean; hasSelection?: boolean; + hasStickyColumns?: boolean; hasFooter?: boolean; columnId: PropertyKey; colIndex: number; @@ -70,16 +79,18 @@ export const TableTdElement = React.forwardRef getStickyClassNames(styles, props), + getClassName: props => ({ + ...getStickyClassNames(styles, props), + // Mirror only the sticky classes implemented in table-cell/styles.scss onto the reused cell. + ...(willUseAtomicCell + ? { + [cellStyles['sticky-cell']]: !!props, + [cellStyles['sticky-cell-last-inline-start']]: !!props?.lastInsetInlineStart, + [cellStyles['sticky-cell-last-inline-end']]: !!props?.lastInsetInlineEnd, + [cellStyles['sticky-cell-pad-inline-start']]: !!props?.padInlineStart, + } + : {}), + }), }); const cellRefObject = useRef(null); @@ -122,6 +149,67 @@ export const TableTdElement = React.forwardRef); the Table passes prev/next-selected explicitly since it knows the sibling rows. + const useAtomicCell = + MIGRATE_ATOMIC_CELLS && + !isRowHeader && + !isEditable && + !isEditing && + level === undefined && + (MIGRATE_STICKY_CELLS || !isStickyCell); + if (useAtomicCell) { + return ( + + // has no inner wrapper to absorb it, so gate on hasStickyColumns to keep the non-sticky row at 39px. + __firstRow={isFirstRow && hasStickyColumns} + __lastRow={isLastRow} + __hasFooter={hasFooter} + __isVisualRefresh={isVisualRefresh} + __hasSelection={hasSelection} + __hasStripedRows={stripedRows} + __tableVariant={tableVariant} + __nativeAttributes={ + { + ...nativeAttributes, + ...(isSelection ? {} : copyAnalyticsMetadataAttribute(rest)), + } as React.TdHTMLAttributes + } + __onClick={onClick} + __onFocus={onFocus} + __onBlur={onBlur} + // Only Table-owned feature classes layer on the reused cell (sticky pinning, selection-control + // width); selection/divider/stripe belong to the atomic cell's own module. + __featureClassName={ + clsx(isSelection && tableStyles['selection-control'], stickyStyles.className) || undefined + } + > + {/* Truncation lives on this wrapper, not the , so the cell stays overflow:visible and + interactive-control focus rings aren't clipped. */} +
+ {children} + {counter ? ( +
+ + {counter} +
+ ) : null} +
+ + ); + } + return ( , ref: React.Ref ) => { - // Keyboard navigation defaults to `true` for tables with expandable rows. if (externalExpandableRows && enableKeyboardNavigation === undefined) { enableKeyboardNavigation = true; } @@ -477,10 +477,8 @@ const InternalTable = React.forwardRef( const skeletonRowsCount = skeleton?.totalRows === 'auto' ? allItems.length + autoSkeletonRows : (skeleton?.totalRows ?? 0); - // When the clear-sort button is activated it unmounts (there is no longer a sort to clear), - // which would drop keyboard focus to the document body. Move focus to the first sortable - // column header instead. Focusing synchronously (before the re-render unmounts the button) - // keeps focus on the persistent header element. + // The clear-sort button unmounts on activation; move focus to the first sortable header + // synchronously (before the unmount) so it doesn't fall to document.body. const focusFirstSortableColumn = () => { tableRefObject.current?.querySelector('[data-focus-id^="sorting-control-"][role="button"]')?.focus(); }; @@ -502,7 +500,7 @@ const InternalTable = React.forwardRef( numRows: allRows?.length, }); const toolsHeaderPerformanceMarkRef = useRef(null); - // If is mobile, we take into consideration the AppLayout's mobile bar and we subtract the tools wrapper height so only the table header is sticky + // On mobile, subtract the tools wrapper height so only the table header stays sticky under AppLayout's bar. const [toolsHeaderHeight, toolsHeaderWrapperMeasureRef] = useContainerQuery(rect => rect.borderBoxHeight); const toolsHeaderWrapper = useMergeRefs(toolsHeaderPerformanceMarkRef, toolsHeaderWrapperMeasureRef); @@ -619,9 +617,7 @@ const InternalTable = React.forwardRef( )} - {/* Announce multi-column sort changes politely. The text is built internally from - per-column i18n fragments and only changes when the sort changes, so it isn't - announced on initial mount. */} + {/* Politely announce sort changes; text changes only on sort, so nothing is announced on mount. */} {multiColumnSort?.sortingColumns && ( { - // When an element inside table row receives focus we want to adjust the scroll. - // However, that behavior is unwanted when the focus is received as result of a click - // as it causes the click to never reach the target element. - if (!currentTarget.contains(getMouseDownTarget())) { - stickyHeaderRef.current?.scrollToRow(currentTarget); - } - }} - {...focusMarkers.item} - onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)} - onContextMenu={ - onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item) - } - {...rowRoleProps} - > + // Plain tables (role='table', non-expandable) use the atomic ; grid/treegrid + // and expandable rows keep the Table's own