diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 0a6439d9c0..c48b07cbbb 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -79,6 +79,13 @@ const pluralizationMap = { StatusIndicator: 'StatusIndicators', Steps: 'Steps', Table: 'Tables', + TableBody: 'TableBodies', + TableCell: 'TableCells', + TableHead: 'TableHeads', + TableHeaderCell: 'TableHeaderCells', + TableHeaderRow: 'TableHeaderRows', + TableRoot: 'TableRoots', + TableRow: 'TableRows', Tabs: 'Tabs', TagEditor: 'TagEditors', TextContent: 'TextContents', diff --git a/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..b8a70127a7 --- /dev/null +++ b/pages/table-root/selection.page.tsx @@ -0,0 +1,119 @@ +// 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. +// Selection control column is a fixed 40px; the Name and Status columns share the remaining width +// with proportional flex weights (~53:47), reproducing classic Table's balanced auto-layout split at +// the demo viewport. (Flexing Name to fill and pinning Status to a fixed width would shove Status to +// the far right with a large gap, unlike classic.) +const COLUMNS: ReadonlyArray = [ + { size: 40 }, + { size: { flex: 53 } }, + { size: { flex: 47 } }, +]; +const ITEM_COUNT = 10; + +type SortDirection = 'ascending' | 'descending'; + +export default function TableSelectionPage() { + const allItems = makeItems(ITEM_COUNT); + const [selectedIds, setSelectedIds] = useState>(new Set([allItems[1].id, allItems[2].id])); + const [direction, setDirection] = useState('ascending'); + + const items = useMemo(() => { + const sorted = [...allItems].sort((a, b) => a.name.localeCompare(b.name)); + return direction === 'ascending' ? sorted : sorted.reverse(); + }, [allItems, direction]); + + const allSelected = items.length > 0 && items.every(item => selectedIds.has(item.id)); + const someSelected = items.some(item => selectedIds.has(item.id)); + const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(items.map(item => item.id))); + const toggleRow = (id: string) => + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + const toggleSort = () => setDirection(prev => (prev === 'ascending' ? 'descending' : 'ascending')); + + return ( + + + Table atomics — selectable + sortable (grid layout) + + +
Resources
+ + + + +
+ +
+
+ + + + Status +
+
+ + {items.map((item: Item) => ( + + +
+ toggleRow(item.id)} + ariaLabel={`Select ${item.name}`} + /> +
+
+ {item.name} + {item.status} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/pages/table-root/simple.page.tsx b/pages/table-root/simple.page.tsx new file mode 100644 index 0000000000..b110d8d678 --- /dev/null +++ b/pages/table-root/simple.page.tsx @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import TableRoot from '~components/table-root'; + +import { DataBody, DataHeader, makeItems } from './common'; + +// A minimal read-only table in auto layout. `columnLayout` is omitted, so it +// defaults to `{ type: 'auto' }` — columns size to their content and the count comes from the cells. +export default function TableSimplePage() { + const items = makeItems(8); + return ( + + + Table atomics — simple (auto layout) + + +
Resources
+ + + + +
+
+
+ ); +} diff --git a/pages/table-root/single-selection.page.tsx b/pages/table-root/single-selection.page.tsx new file mode 100644 index 0000000000..027671490f --- /dev/null +++ b/pages/table-root/single-selection.page.tsx @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import Header from '~components/header'; +import RadioButton from '~components/radio-button'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableCell from '~components/table-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableHeaderRow from '~components/table-header-row'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +// Single selection (grid layout). It composes exactly like multi selection, but the control is a +// radio and only one row is selected at a time — the consumer tracks a single selected id. The rows +// share a radio `name`, so the browser's native radio group gives up/down arrow-key navigation +// between rows for free (matching classic Table). Each radio's accessible name comes from a +// visually-hidden label (RadioButton has no `ariaLabel` prop). The control column matches classic +// Table via `disablePaddings` cells and a centered control; the header has no select-all control. +// Selection control column is a fixed 40px; the Name and Status columns share the remaining width +// with proportional flex weights (~53:47), reproducing classic Table's balanced auto-layout split at +// the demo viewport. (Flexing Name to fill and pinning Status to a fixed width would shove Status to +// the far right with a large gap, unlike classic.) +const COLUMNS: ReadonlyArray = [ + { size: 40 }, + { size: { flex: 53 } }, + { size: { flex: 47 } }, +]; +const ITEM_COUNT = 10; + +export default function TableSingleSelectionPage() { + const items = makeItems(ITEM_COUNT); + const [selectedId, setSelectedId] = useState(items[1].id); + + return ( + + + Table atomics — single selection (grid layout) + + +
Resources
+ + + + + Name + Status + + + + {items.map((item: Item) => ( + + +
+ {/* The accessible name is supplied by an associated `
+
+ {item.name} + {item.status} +
+ ))} +
+
+
+
+
+ ); +} diff --git a/pages/table-root/sorting.page.tsx b/pages/table-root/sorting.page.tsx new file mode 100644 index 0000000000..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..5468fe95d0 --- /dev/null +++ b/pages/table-root/styles.scss @@ -0,0 +1,66 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +@use '~design-tokens' as tokens; + +// A minimal sort control for the demo: a native button that inherits the header cell's colour and +// typography (so the label stays the column-header colour, not link blue). It fills the cell and +// pushes the caret to the end, so the label stays left-aligned with the column while the sort +// indicator sits at the right. +.sort-button { + display: flex; + align-items: center; + justify-content: space-between; + inline-size: 100%; + gap: tokens.$space-static-xxs; + padding-block: 0; + padding-inline: 0; + border-block: none; + border-inline: none; + background: none; + color: inherit; + font: inherit; + cursor: pointer; +} + +// Centres the selection control within a disablePaddings control cell, matching classic Table. +// The Cloudscape checkbox/radio control carries an intrinsic 2px top margin (it aligns the box with +// the first line of a label); with no visible label in a centred control column that margin biases +// the control ~1px below centre. Classic's own SelectionControl absorbs it with a compensating +// block-end padding on the control label; mirror that here so the control lands on the row centre. +.selection-cell { + display: flex; + justify-content: center; + align-items: center; + padding-block-end: 2px; +} + +// Screen-reader-only label text (gives a bare control an accessible name without visible text). +.visually-hidden { + position: absolute; + inline-size: 1px; + block-size: 1px; + padding-block: 0; + padding-inline: 0; + margin-block: -1px; + margin-inline: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border-block: none; + border-inline: none; +} + +// Sort affordance at the right of a sortable header (caret + optional multi-sort priority badge). +.sort-indicator { + display: inline-flex; + align-items: center; + gap: tokens.$space-static-xxs; +} + +// Priority number shown next to each caret when more than one column is sorted (multi-column sort). +.sort-order { + font-size: 0.75em; + font-weight: 700; +} diff --git a/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/src/__tests__/functional-tests/test-utils.test.tsx b/src/__tests__/functional-tests/test-utils.test.tsx index 2eeca1dd8f..1fe0dca326 100644 --- a/src/__tests__/functional-tests/test-utils.test.tsx +++ b/src/__tests__/functional-tests/test-utils.test.tsx @@ -13,11 +13,16 @@ import { clearVisualRefreshState } from '@cloudscape-design/component-toolkit/in import { Modal } from '../../../lib/components'; import Button from '../../../lib/components/button'; -import createWrapperDom, { ElementWrapper as DomElementWrapper } from '../../../lib/components/test-utils/dom'; +import createWrapperDom from '../../../lib/components/test-utils/dom'; import createWrapperSelectors from '../../../lib/components/test-utils/selectors'; import { getRequiredPropsForComponent } from '../required-props-for-components'; import { getAllComponents, requireComponent } from '../utils'; +// Authoritative pluralization used by the test-utils generator (build-tools/tasks/test-utils.js), +// so the finder-name derivation here can never drift from the generated finder names. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { pluralizeComponentName } = require('../../../build-tools/utils/pluralize'); + const globalWithFlags = globalThis as any; beforeEach(() => { @@ -82,16 +87,14 @@ function renderComponents(componentName: string, props = RENDER_COMPONENTS_DEFAU function getComponentSelectors(componentName: string) { const componentNamePascalCase = pascalCase(componentName); - const findAllRegex = new RegExp(`findAll${componentNamePascalCase}.*`); - - // The same set of selector functions are present in both dom and selectors. - // For this reason, looking into DOM is representative of both groups. - const wrapperPropsList = Object.keys(DomElementWrapper.prototype); - // Every component has the same set of selector functions. - // For this reason, casting the function names into the Alert component. + // The findAll finder uses the pluralized component name, which is not always the + // singular name plus a suffix (e.g. TableBody -> TableBodies). Derive it from the + // same pluralization map the test-utils generator uses so the two never diverge. + // Every component has the same set of selector functions, so casting to the Alert + // component's finder names is representative. const findName = `find${componentNamePascalCase}` as 'findAlert'; - const findAllName = wrapperPropsList.find(selector => findAllRegex.test(selector)) as 'findAllAlerts'; + const findAllName = `findAll${pluralizeComponentName(componentNamePascalCase)}` as 'findAllAlerts'; return { findName, findAllName }; } diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index bd4c9dd0b3..9750702beb 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29578,6 +29578,528 @@ multiple lines instead of being truncated with an ellipsis.", } `; +exports[`Components definition for table-body matches the snapshot: table-body 1`] = ` +{ + "dashCaseName": "table-body", + "events": [], + "functions": [], + "name": "TableBody", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Applies inline styles to the body element. Use this to enable row positioning, for example for +virtualization or draggable rows. It is not supported to use this for general styling purposes.", + "inlineType": { + "name": "TableBodyProps.Style", + "properties": [ + { + "inlineType": { + "name": "Property.Height", + "type": "union", + "values": [ + "string", + "number", + "string & {}", + ], + }, + "name": "height", + "optional": true, + "type": "Property.Height", + }, + { + "inlineType": { + "name": "Property.Position", + "type": "union", + "values": [ + "fixed", + "absolute", + "inherit", + "-moz-initial", + "initial", + "revert", + "revert-layer", + "unset", + "-webkit-sticky", + "relative", + "static", + "sticky", + ], + }, + "name": "position", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "style", + "optional": true, + "type": "TableBodyProps.Style", + }, + ], + "regions": [ + { + "description": "The body rows.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-cell matches the snapshot: table-cell 1`] = ` +{ + "dashCaseName": "table-cell", + "events": [], + "functions": [], + "name": "TableCell", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Removes the cell's built-in padding so you can compose your own spacing, 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": "Property.Height", + "type": "union", + "values": [ + "string", + "number", + "string & {}", + ], + }, + "name": "height", + "optional": true, + "type": "Property.Height", + }, + { + "inlineType": { + "name": "Property.Position", + "type": "union", + "values": [ + "fixed", + "absolute", + "inherit", + "-moz-initial", + "initial", + "revert", + "revert-layer", + "unset", + "-webkit-sticky", + "relative", + "static", + "sticky", + ], + }, + "name": "position", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "Property.Transform", + "type": "union", + "values": [ + ""none"", + ""inherit"", + "string & {}", + ""-moz-initial"", + ""initial"", + ""revert"", + ""revert-layer"", + ""unset"", + ], + }, + "name": "transform", + "optional": true, + "type": "Property.Transform", + }, + ], + "type": "object", + }, + "name": "style", + "optional": true, + "type": "TableRowProps.Style", + }, + { + "description": "The row's visual state. 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", @@ -45920,6 +46442,34 @@ Returns the current value of the input.", ], "name": "StepWrapper", }, + { + "methods": [], + "name": "TableBodyWrapper", + }, + { + "methods": [], + "name": "TableCellWrapper", + }, + { + "methods": [], + "name": "TableHeadWrapper", + }, + { + "methods": [], + "name": "TableHeaderCellWrapper", + }, + { + "methods": [], + "name": "TableHeaderRowWrapper", + }, + { + "methods": [], + "name": "TableRootWrapper", + }, + { + "methods": [], + "name": "TableRowWrapper", + }, { "methods": [ { @@ -55708,6 +56258,34 @@ Supported options: ], "name": "StepWrapper", }, + { + "methods": [], + "name": "TableBodyWrapper", + }, + { + "methods": [], + "name": "TableCellWrapper", + }, + { + "methods": [], + "name": "TableHeadWrapper", + }, + { + "methods": [], + "name": "TableHeaderCellWrapper", + }, + { + "methods": [], + "name": "TableHeaderRowWrapper", + }, + { + "methods": [], + "name": "TableRootWrapper", + }, + { + "methods": [], + "name": "TableRowWrapper", + }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 0828da7738..dbe864243d 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -680,6 +680,27 @@ exports[`test-utils selectors 1`] = ` "awsui_tools-preferences_wih1l", "awsui_wrapper_wih1l", ], + "table-body": [ + "awsui_body_1i6l7", + ], + "table-cell": [ + "awsui_cell_1reth", + ], + "table-head": [ + "awsui_head_1otu2", + ], + "table-header-cell": [ + "awsui_header-cell_uzgsh", + ], + "table-header-row": [ + "awsui_header-row_1wc8l", + ], + "table-root": [ + "awsui_root_1pkvc", + ], + "table-row": [ + "awsui_row_3yyds", + ], "tabs": [ "awsui_actions-container_14rmt", "awsui_disabled-reason-tooltip_14rmt", diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 118ac8d1b2..72816e0809 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -86,6 +86,13 @@ import SplitPanelWrapper from './split-panel'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; +import TableBodyWrapper from './table-body'; +import TableCellWrapper from './table-cell'; +import TableHeadWrapper from './table-head'; +import TableHeaderCellWrapper from './table-header-cell'; +import TableHeaderRowWrapper from './table-header-row'; +import TableRootWrapper from './table-root'; +import TableRowWrapper from './table-row'; import TabsWrapper from './tabs'; import TagEditorWrapper from './tag-editor'; import TextContentWrapper from './text-content'; @@ -182,6 +189,13 @@ export { SplitPanelWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; +export { TableBodyWrapper }; +export { TableCellWrapper }; +export { TableHeadWrapper }; +export { TableHeaderCellWrapper }; +export { TableHeaderRowWrapper }; +export { TableRootWrapper }; +export { TableRowWrapper }; export { TabsWrapper }; export { TagEditorWrapper }; export { TextContentWrapper }; @@ -2359,6 +2373,202 @@ findAllTables(selector?: string): Array; * @returns {TableWrapper | null} */ findClosestTable(): TableWrapper | null; +/** + * Returns the wrapper of the first TableBody that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableBody. + * If no matching TableBody is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableBodyWrapper | null} + */ +findTableBody(selector?: string): TableBodyWrapper | null; + +/** + * Returns an array of TableBody wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableBodies inside the current wrapper. + * If no matching TableBody is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableBodies(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableBody for the current element, + * or the element itself if it is an instance of TableBody. + * If no TableBody is found, returns \`null\`. + * + * @returns {TableBodyWrapper | null} + */ +findClosestTableBody(): TableBodyWrapper | null; +/** + * Returns the wrapper of the first TableCell that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableCell. + * If no matching TableCell is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableCellWrapper | null} + */ +findTableCell(selector?: string): TableCellWrapper | null; + +/** + * Returns an array of TableCell wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableCells inside the current wrapper. + * If no matching TableCell is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableCells(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableCell for the current element, + * or the element itself if it is an instance of TableCell. + * If no TableCell is found, returns \`null\`. + * + * @returns {TableCellWrapper | null} + */ +findClosestTableCell(): TableCellWrapper | null; +/** + * Returns the wrapper of the first TableHead that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHead. + * If no matching TableHead is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeadWrapper | null} + */ +findTableHead(selector?: string): TableHeadWrapper | null; + +/** + * Returns an array of TableHead wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeads inside the current wrapper. + * If no matching TableHead is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeads(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHead for the current element, + * or the element itself if it is an instance of TableHead. + * If no TableHead is found, returns \`null\`. + * + * @returns {TableHeadWrapper | null} + */ +findClosestTableHead(): TableHeadWrapper | null; +/** + * Returns the wrapper of the first TableHeaderCell that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHeaderCell. + * If no matching TableHeaderCell is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderCellWrapper | null} + */ +findTableHeaderCell(selector?: string): TableHeaderCellWrapper | null; + +/** + * Returns an array of TableHeaderCell wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeaderCells inside the current wrapper. + * If no matching TableHeaderCell is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeaderCells(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHeaderCell for the current element, + * or the element itself if it is an instance of TableHeaderCell. + * If no TableHeaderCell is found, returns \`null\`. + * + * @returns {TableHeaderCellWrapper | null} + */ +findClosestTableHeaderCell(): TableHeaderCellWrapper | null; +/** + * Returns the wrapper of the first TableHeaderRow that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHeaderRow. + * If no matching TableHeaderRow is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderRowWrapper | null} + */ +findTableHeaderRow(selector?: string): TableHeaderRowWrapper | null; + +/** + * Returns an array of TableHeaderRow wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableHeaderRows inside the current wrapper. + * If no matching TableHeaderRow is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableHeaderRows(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableHeaderRow for the current element, + * or the element itself if it is an instance of TableHeaderRow. + * If no TableHeaderRow is found, returns \`null\`. + * + * @returns {TableHeaderRowWrapper | null} + */ +findClosestTableHeaderRow(): TableHeaderRowWrapper | null; +/** + * Returns the wrapper of the first TableRoot that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableRoot. + * If no matching TableRoot is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableRootWrapper | null} + */ +findTableRoot(selector?: string): TableRootWrapper | null; + +/** + * Returns an array of TableRoot wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableRoots inside the current wrapper. + * If no matching TableRoot is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableRoots(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableRoot for the current element, + * or the element itself if it is an instance of TableRoot. + * If no TableRoot is found, returns \`null\`. + * + * @returns {TableRootWrapper | null} + */ +findClosestTableRoot(): TableRootWrapper | null; +/** + * Returns the wrapper of the first TableRow that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableRow. + * If no matching TableRow is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableRowWrapper | null} + */ +findTableRow(selector?: string): TableRowWrapper | null; + +/** + * Returns an array of TableRow wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableRows inside the current wrapper. + * If no matching TableRow is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableRows(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableRow for the current element, + * or the element itself if it is an instance of TableRow. + * If no TableRow is found, returns \`null\`. + * + * @returns {TableRowWrapper | null} + */ +findClosestTableRow(): TableRowWrapper | null; /** * Returns the wrapper of the first Tabs that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first Tabs. @@ -3840,6 +4050,97 @@ ElementWrapper.prototype.findTable = function(selector) { ElementWrapper.prototype.findAllTables = function(selector) { return this.findAllComponents(TableWrapper, selector); }; +ElementWrapper.prototype.findTableBody = function(selector) { + let rootSelector = \`.\${TableBodyWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyWrapper && TableBodyWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyWrapper.rootSelector}, .\${TableBodyWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyWrapper); +}; + +ElementWrapper.prototype.findAllTableBodies = function(selector) { + return this.findAllComponents(TableBodyWrapper, selector); +}; +ElementWrapper.prototype.findTableCell = function(selector) { + let rootSelector = \`.\${TableCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableCellWrapper && TableCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableCellWrapper.rootSelector}, .\${TableCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableCellWrapper); +}; + +ElementWrapper.prototype.findAllTableCells = function(selector) { + return this.findAllComponents(TableCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHead = function(selector) { + let rootSelector = \`.\${TableHeadWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeadWrapper && TableHeadWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeadWrapper.rootSelector}, .\${TableHeadWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeadWrapper); +}; + +ElementWrapper.prototype.findAllTableHeads = function(selector) { + return this.findAllComponents(TableHeadWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderCell = function(selector) { + let rootSelector = \`.\${TableHeaderCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderCellWrapper && TableHeaderCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderCellWrapper.rootSelector}, .\${TableHeaderCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderCellWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderCells = function(selector) { + return this.findAllComponents(TableHeaderCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderRow = function(selector) { + let rootSelector = \`.\${TableHeaderRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderRowWrapper && TableHeaderRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderRowWrapper.rootSelector}, .\${TableHeaderRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderRowWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderRows = function(selector) { + return this.findAllComponents(TableHeaderRowWrapper, selector); +}; +ElementWrapper.prototype.findTableRoot = function(selector) { + let rootSelector = \`.\${TableRootWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRootWrapper && TableRootWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRootWrapper.rootSelector}, .\${TableRootWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRootWrapper); +}; + +ElementWrapper.prototype.findAllTableRoots = function(selector) { + return this.findAllComponents(TableRootWrapper, selector); +}; +ElementWrapper.prototype.findTableRow = function(selector) { + let rootSelector = \`.\${TableRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRowWrapper && TableRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRowWrapper.rootSelector}, .\${TableRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRowWrapper); +}; + +ElementWrapper.prototype.findAllTableRows = function(selector) { + return this.findAllComponents(TableRowWrapper, selector); +}; ElementWrapper.prototype.findTabs = function(selector) { let rootSelector = \`.\${TabsWrapper.rootSelector}\`; if("legacyRootSelector" in TabsWrapper && TabsWrapper.legacyRootSelector){ @@ -4447,6 +4748,41 @@ ElementWrapper.prototype.findClosestTable = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(TableWrapper); }; +ElementWrapper.prototype.findClosestTableBody = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableBodyWrapper); +}; +ElementWrapper.prototype.findClosestTableCell = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableCellWrapper); +}; +ElementWrapper.prototype.findClosestTableHead = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeadWrapper); +}; +ElementWrapper.prototype.findClosestTableHeaderCell = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeaderCellWrapper); +}; +ElementWrapper.prototype.findClosestTableHeaderRow = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableHeaderRowWrapper); +}; +ElementWrapper.prototype.findClosestTableRoot = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableRootWrapper); +}; +ElementWrapper.prototype.findClosestTableRow = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableRowWrapper); +}; ElementWrapper.prototype.findClosestTabs = function() { // casting to 'any' is needed to avoid this issue with generics // https://github.com/microsoft/TypeScript/issues/29132 @@ -4628,6 +4964,13 @@ import SplitPanelWrapper from './split-panel'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; +import TableBodyWrapper from './table-body'; +import TableCellWrapper from './table-cell'; +import TableHeadWrapper from './table-head'; +import TableHeaderCellWrapper from './table-header-cell'; +import TableHeaderRowWrapper from './table-header-row'; +import TableRootWrapper from './table-root'; +import TableRowWrapper from './table-row'; import TabsWrapper from './tabs'; import TagEditorWrapper from './tag-editor'; import TextContentWrapper from './text-content'; @@ -4724,6 +5067,13 @@ export { SplitPanelWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; +export { TableBodyWrapper }; +export { TableCellWrapper }; +export { TableHeadWrapper }; +export { TableHeaderCellWrapper }; +export { TableHeaderRowWrapper }; +export { TableRootWrapper }; +export { TableRowWrapper }; export { TabsWrapper }; export { TagEditorWrapper }; export { TextContentWrapper }; @@ -6054,6 +6404,125 @@ findTable(selector?: string): TableWrapper; * @returns {MultiElementWrapper} */ findAllTables(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableBodies with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableBodies. + * + * @param {string} [selector] CSS Selector + * @returns {TableBodyWrapper} + */ +findTableBody(selector?: string): TableBodyWrapper; + +/** + * Returns a multi-element wrapper that matches TableBodies with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableBodies. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableBodies(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableCells with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableCells. + * + * @param {string} [selector] CSS Selector + * @returns {TableCellWrapper} + */ +findTableCell(selector?: string): TableCellWrapper; + +/** + * Returns a multi-element wrapper that matches TableCells with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableCells. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableCells(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeads with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeads. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeadWrapper} + */ +findTableHead(selector?: string): TableHeadWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeads with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeads. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeads(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeaderCells with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeaderCells. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderCellWrapper} + */ +findTableHeaderCell(selector?: string): TableHeaderCellWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeaderCells with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeaderCells. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeaderCells(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeaderRows with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeaderRows. + * + * @param {string} [selector] CSS Selector + * @returns {TableHeaderRowWrapper} + */ +findTableHeaderRow(selector?: string): TableHeaderRowWrapper; + +/** + * Returns a multi-element wrapper that matches TableHeaderRows with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableHeaderRows. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableHeaderRows(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableRoots with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableRoots. + * + * @param {string} [selector] CSS Selector + * @returns {TableRootWrapper} + */ +findTableRoot(selector?: string): TableRootWrapper; + +/** + * Returns a multi-element wrapper that matches TableRoots with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableRoots. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableRoots(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableRows with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableRows. + * + * @param {string} [selector] CSS Selector + * @returns {TableRowWrapper} + */ +findTableRow(selector?: string): TableRowWrapper; + +/** + * Returns a multi-element wrapper that matches TableRows with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableRows. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableRows(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the Tabs with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches Tabs. @@ -7348,6 +7817,97 @@ ElementWrapper.prototype.findTable = function(selector) { ElementWrapper.prototype.findAllTables = function(selector) { return this.findAllComponents(TableWrapper, selector); }; +ElementWrapper.prototype.findTableBody = function(selector) { + let rootSelector = \`.\${TableBodyWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyWrapper && TableBodyWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyWrapper.rootSelector}, .\${TableBodyWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyWrapper); +}; + +ElementWrapper.prototype.findAllTableBodies = function(selector) { + return this.findAllComponents(TableBodyWrapper, selector); +}; +ElementWrapper.prototype.findTableCell = function(selector) { + let rootSelector = \`.\${TableCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableCellWrapper && TableCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableCellWrapper.rootSelector}, .\${TableCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableCellWrapper); +}; + +ElementWrapper.prototype.findAllTableCells = function(selector) { + return this.findAllComponents(TableCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHead = function(selector) { + let rootSelector = \`.\${TableHeadWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeadWrapper && TableHeadWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeadWrapper.rootSelector}, .\${TableHeadWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeadWrapper); +}; + +ElementWrapper.prototype.findAllTableHeads = function(selector) { + return this.findAllComponents(TableHeadWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderCell = function(selector) { + let rootSelector = \`.\${TableHeaderCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderCellWrapper && TableHeaderCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderCellWrapper.rootSelector}, .\${TableHeaderCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderCellWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderCells = function(selector) { + return this.findAllComponents(TableHeaderCellWrapper, selector); +}; +ElementWrapper.prototype.findTableHeaderRow = function(selector) { + let rootSelector = \`.\${TableHeaderRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableHeaderRowWrapper && TableHeaderRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableHeaderRowWrapper.rootSelector}, .\${TableHeaderRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableHeaderRowWrapper); +}; + +ElementWrapper.prototype.findAllTableHeaderRows = function(selector) { + return this.findAllComponents(TableHeaderRowWrapper, selector); +}; +ElementWrapper.prototype.findTableRoot = function(selector) { + let rootSelector = \`.\${TableRootWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRootWrapper && TableRootWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRootWrapper.rootSelector}, .\${TableRootWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRootWrapper); +}; + +ElementWrapper.prototype.findAllTableRoots = function(selector) { + return this.findAllComponents(TableRootWrapper, selector); +}; +ElementWrapper.prototype.findTableRow = function(selector) { + let rootSelector = \`.\${TableRowWrapper.rootSelector}\`; + if("legacyRootSelector" in TableRowWrapper && TableRowWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableRowWrapper.rootSelector}, .\${TableRowWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableRowWrapper); +}; + +ElementWrapper.prototype.findAllTableRows = function(selector) { + return this.findAllComponents(TableRowWrapper, selector); +}; ElementWrapper.prototype.findTabs = function(selector) { let rootSelector = \`.\${TabsWrapper.rootSelector}\`; if("legacyRootSelector" in TabsWrapper && TabsWrapper.legacyRootSelector){ diff --git a/src/table-body/index.tsx b/src/table-body/index.tsx new file mode 100644 index 0000000000..b38b4f3ac2 --- /dev/null +++ b/src/table-body/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableBodyProps } from './interfaces'; +import InternalTableBody from './internal'; + +export { TableBodyProps }; + +function TableBody(props: TableBodyProps) { + const baseComponentProps = useBaseComponent('TableBody'); + return ; +} + +applyDisplayName(TableBody, 'TableBody'); +export default TableBody; diff --git a/src/table-body/interfaces.ts b/src/table-body/interfaces.ts new file mode 100644 index 0000000000..b6b0effebe --- /dev/null +++ b/src/table-body/interfaces.ts @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders the table body that contains the rows. Its children are `TableRow` components. */ +export interface TableBodyProps extends BaseComponentProps { + /** + * Applies inline styles to the body element. Use this to enable row positioning, for example for + * virtualization or draggable rows. It is not supported to use this for general styling purposes. + */ + style?: TableBodyProps.Style; + /** The body rows. */ + children?: React.ReactNode; +} + +export namespace TableBodyProps { + /** Inline styles supported on the body element, for row positioning (for example, virtualization). */ + export interface Style { + position?: React.CSSProperties['position']; + height?: React.CSSProperties['height']; + } +} diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx new file mode 100644 index 0000000000..d081843eb4 --- /dev/null +++ b/src/table-body/internal.tsx @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { TableBodyProps } from './interfaces'; + +import styles from './styles.css.js'; + +export interface InternalTableBodyProps extends TableBodyProps, InternalBaseComponentProps {} + +export default function InternalTableBody({ children, style, __internalRootRef, ...rest }: InternalTableBodyProps) { + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(rest); + return ( +
+ {children} + + ); +} diff --git a/src/table-body/styles.scss b/src/table-body/styles.scss new file mode 100644 index 0000000000..cf15c7d64b --- /dev/null +++ b/src/table-body/styles.scss @@ -0,0 +1,12 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.body { + position: relative; +} + +.body-grid { + display: block; +} diff --git a/src/table-cell/index.tsx b/src/table-cell/index.tsx new file mode 100644 index 0000000000..f53d36ea8a --- /dev/null +++ b/src/table-cell/index.tsx @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import { getBaseProps } from '../internal/base-component'; +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableCellProps } from './interfaces'; +import { InternalTableCell } from './internal'; + +export { TableCellProps }; + +function TableCell(props: TableCellProps) { + const baseComponentProps = useBaseComponent('TableCell'); + const mergedProps = { ...props, ...baseComponentProps }; + const { children, disablePaddings, __internalRootRef } = mergedProps; + const { className, ...restBaseProps } = getBaseProps(mergedProps); + return ( + + {children} + + ); +} + +applyDisplayName(TableCell, 'TableCell'); +export default TableCell; diff --git a/src/table-cell/interfaces.ts b/src/table-cell/interfaces.ts new file mode 100644 index 0000000000..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..e902a0e7a9 --- /dev/null +++ b/src/table-cell/internal.tsx @@ -0,0 +1,110 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useTableContext } from '../table-root/context'; +import { useRowVariant } from '../table-row/context'; + +// Reuses classic's proven box model: the base `.body-cell` padding and `.body-cell-content` truncation +// wrapper live in the shared body-cell stylesheet, so this is pixel-identical to the element +// TableTdElement composes. Accepted one-way SCSS reuse. +import bodyCellStyles from '../table/body-cell/styles.css.js'; +import styles from './styles.css.js'; + +// Dual-consumer substrate interface: consumed by both the public `TableCell` (index.tsx) and the +// existing Table's `td-element`, so it retains the low-level fields that `td-element` threads through. +export interface InternalTableCellProps { + // The rendered element: 'td' for a data cell, 'th' for a row-header cell. + tag: 'td' | 'th'; + // Feature classes layered on top of the base `.body-cell` by the composing component. + className?: string; + style?: React.CSSProperties; + // When true the cell content wraps; otherwise it is truncated with an ellipsis. + wrapLines?: boolean; + // Removes the cell's built-in block/inline padding and defeats the first-column padding reduction. + disablePaddings?: boolean; + nativeAttributes?: Omit< + React.TdHTMLAttributes | React.ThHTMLAttributes, + 'style' | 'className' | 'onClick' + >; + tabIndex?: number; + onClick?: () => void; + onFocus?: () => void; + onBlur?: () => void; + // Rendered inside the cell before the content wrapper (e.g. an expand toggle). + beforeContent?: React.ReactNode; + children?: React.ReactNode; + // Set by the public `TableCell`. When true the cell reads the atomic row/table contexts to self-paint + // the test-utils marker, grid role/layout, selection ring, and shading. With `__atomic` off it is + // byte-identical to the bare `.body-cell` substrate the Table composes. + __atomic?: boolean; +} + +// The atomic-only classes and grid role are gated on `__atomic`; the selection/shading/edge CSS is keyed +// on the `.cell` marker + the row's `data-selected` / `data-shaded` hooks, none of which the existing +// Table emits — so the Table path is unaffected. +export const InternalTableCell = React.forwardRef( + ( + { + tag, + className, + style, + wrapLines, + disablePaddings, + nativeAttributes, + tabIndex, + onClick, + onFocus, + onBlur, + beforeContent, + children, + __atomic, + }, + ref + ) => { + const { columnLayout } = useTableContext(); + const variant = useRowVariant(); + const isVisualRefresh = useVisualRefresh(); + const isGrid = columnLayout.type === 'grid'; + const Element = tag; + // The atomic grid role rides in the native attributes so it never clobbers the Table's own role. + const mergedNativeAttributes = + __atomic && isGrid ? { ...nativeAttributes, role: 'cell' as const } : nativeAttributes; + return ( + + {beforeContent} +
+ {children} +
+
+ ); + } +); diff --git a/src/table-cell/styles.scss b/src/table-cell/styles.scss new file mode 100644 index 0000000000..a4eef98040 --- /dev/null +++ b/src/table-cell/styles.scss @@ -0,0 +1,147 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +// The base cell box model (padding, borders, divider, selection/shading) comes from the substrate's +// `.body-cell` + `.body-cell-selected` / `.body-cell-shaded` classes (src/table/table-cell, +// src/table/body-cell). This module must NOT re-declare that geometry — it only adds the test-utils +// marker and the grid-mode equal-height treatment. +.cell { + box-sizing: border-box; +} + +// Grid-mode only. Auto mode uses native display:table-cell (already equal-height). In grid mode the +// row's `align-items: center` leaves each item content-height, so a stretched control cell and a +// content-height data cell would step ~0.5px at the seam; stretching every cell and centring its own +// content keeps the column boxes colinear without changing the row track. `min-inline-size: 0` lets +// columns shrink below content size. No overflow/clip here — it would crop an interactive control's +// focus ring. +.cell-grid { + min-inline-size: 0; + align-self: stretch; + display: grid; + align-items: center; +} + +// Selection is painted as a LAYOUT-NEUTRAL overlay ring, not real cell borders. In grid mode each row is +// an independent `grid-auto-rows` track sized by its cells' border-box, so a real 2px border would grow +// the track and shift content on toggle. The selected cell keeps the unselected geometry (1px dividers, +// no border/radius) and the 2px ring is an absolutely-positioned `::after` on the row (below). +// `body-cell-selected` is still applied for its background; the rules here override only its +// border/radius, keyed on the `data-selected` / `.cell` hooks the substrate never emits (Table stays +// inert). `.cell`-target rules are ordered by ascending specificity (stylelint no-descending-specificity). + +// Neutralize the selection border back to the unselected 1px block dividers (top transparent, bottom +// the list divider), so a selected cell's block border-box equals the unselected cell's. +[data-selected] > .cell { + border-block-start: awsui.$border-divider-list-width solid transparent; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-secondary; +} + +// Undo the existing Table's selected-state padding reclaim on the reused content wrapper (grid mode). +// `body-cell-selected` shrinks the wrapper's block-end padding by ~1px to offset the real 2px border it +// paints; the atomic keeps the 1px dividers and paints the ring as `::after`, so that reclaim is +// spurious and drops centred content ~0.5px on select. Restore the wrapper's UNSELECTED block-end +// padding (the trailing `2px` mirrors `$cell-negative-space-vertical`, pairing with the unchanged +// `margin-block-end: -2px`). The `[class*='body-cell-content']` selector matches the cross-module hashed +// wrapper by fragment (its hashed name isn't selectable here, and stylelint bans a bare type selector). +// The disablePaddings control cell keeps its zeroed padding via a higher-specificity substrate rule. +[data-selected] > .cell-grid > [class*='body-cell-content'] { + padding-block-end: calc( + #{awsui.$space-scaled-xs} + (#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}) - + #{awsui.$border-divider-list-width} + 2px + ); +} + +// Striped-row divider darkening. The existing Table darkens every divider to `$color-border-cell-shaded` +// via a table-level `has-striped-rows` flag; the atomic has no table-level signal, only per-row +// `variant='shaded'` via context, so it derives striping from adjacency — the divider above a shaded row +// and a shaded row's own bottom divider — which darkens every internal boundary in alternating striping. +// The last row is excluded (its bottom border is the transparent edge placeholder). Keyed on +// `data-shaded`, which the existing Table never emits, so it stays inert there. +tr:has(+ [data-shaded]) > .cell { + border-block-end-color: awsui.$color-border-cell-shaded; +} +[data-shaded]:not(:last-child) > .cell { + border-block-end-color: awsui.$color-border-cell-shaded; +} + +// Inside a consecutive-selected run, hide the first-of-pair cell's own grey divider (transparent, width +// unchanged): the seam is drawn by the first-of-pair's 2px `::after` block-end edge (merge rules below), +// so the underlying divider must not show a sliver under it. +[data-selected]:has(+ [data-selected]) > .cell { + border-block-end-color: transparent; +} + +// Drop the inline selection border + item radius that `body-cell-selected` adds to the first/last cell, +// so inline geometry equals the unselected row (the first column's 2px border would otherwise shove +// content 2px on select). The ring draws these edges; the last column keeps its 2px transparent +// placeholder. +[data-selected] > .cell:first-child { + border-inline-start: none; + border-start-start-radius: 0; + border-end-start-radius: 0; +} +[data-selected] > .cell:last-child { + border-inline-end: awsui.$border-item-width solid transparent; + border-start-end-radius: 0; + border-end-end-radius: 0; +} + +// Suppress the grey divider on the UNSELECTED row directly above a selection (colour-only, width +// unchanged): it would otherwise abut the ring's top edge as a grey line. Keyed on `data-selected`, so +// the existing Table stays inert. +tr:not([data-selected]):has(+ [data-selected]) > .cell { + border-block-end-color: transparent; +} + +// Auto-layout edge-row placeholder borders. The extract reuses classic's `.body-cell` box model but not +// its `body-cell-first-row` / `body-cell-last-row` edge classes (which required `TableBody` to inspect +// its children to learn each row's position). In auto layout the row height is intrinsic, so classic +// renders the first/last rows 1px taller via a 2px transparent placeholder on the outer block edge, and +// the last row drops its divider (the table container draws that edge). Reproduce that structurally off +// the row's DOM position. Grid layout sizes rows from `grid-auto-rows`, where the placeholder would +// double-count, so `.cell-grid` is excluded (matching classic's `!isGrid` gating). Keyed on a bare `tr` +// plus the atomic `.cell` hash, so the existing Table (body cells carry `.body-cell`, never `.cell`) and +// the header row (cells carry `.header-cell`) both stay inert. +tr:first-child > .cell:not(.cell-grid) { + border-block-start: awsui.$border-item-width solid transparent; +} +tr:last-child > .cell:not(.cell-grid) { + border-block-end: awsui.$border-item-width solid transparent; +} + +// The overlay ring. `.row` is position:relative and this `::after` is position:absolute, so it is not a +// grid item. inset:0 traces the row's padding box (the cells' outer edge) at the 2px selection width, +// item radius, and colour. +[data-selected]::after { + content: ''; + position: absolute; + inset: 0; + border-block-start: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-block-end: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-inline-start: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-inline-end: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-start-start-radius: awsui.$border-radius-item; + border-start-end-radius: awsui.$border-radius-item; + border-end-start-radius: awsui.$border-radius-item; + border-end-end-radius: awsui.$border-radius-item; + pointer-events: none; +} + +// Merge a run of selected rows into one continuous rounded outline. The first-of-pair keeps its 2px +// `::after` bottom edge (that edge is the seam) and squares its bottom corners; a following row zeroes +// its top edge (so the seam is never doubled) and squares its top corners. Ordered after the base ring +// so specificity ascends. +[data-selected]:has(+ [data-selected])::after { + border-end-start-radius: 0; + border-end-end-radius: 0; +} +[data-selected] + [data-selected]::after { + border-block-start-width: 0; + border-start-start-radius: 0; + border-start-end-radius: 0; +} diff --git a/src/table-head/index.tsx b/src/table-head/index.tsx new file mode 100644 index 0000000000..fe30c688ca --- /dev/null +++ b/src/table-head/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeadProps } from './interfaces'; +import InternalTableHead from './internal'; + +export { TableHeadProps }; + +function TableHead(props: TableHeadProps) { + const baseComponentProps = useBaseComponent('TableHead'); + return ; +} + +applyDisplayName(TableHead, 'TableHead'); +export default TableHead; diff --git a/src/table-head/interfaces.ts b/src/table-head/interfaces.ts new file mode 100644 index 0000000000..6ff1f61998 --- /dev/null +++ b/src/table-head/interfaces.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders the table head. Its child is a single `TableHeaderRow` of `TableHeaderCell`s. */ +export interface TableHeadProps extends BaseComponentProps { + /** The header row: a `TableHeaderRow` whose cells are `TableHeaderCell` components. */ + children?: React.ReactNode; +} diff --git a/src/table-head/internal.tsx b/src/table-head/internal.tsx new file mode 100644 index 0000000000..a9cd26dac2 --- /dev/null +++ b/src/table-head/internal.tsx @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { TableHeadProps } from './interfaces'; + +import styles from './styles.css.js'; + +export interface InternalTableHeadProps extends TableHeadProps, InternalBaseComponentProps {} + +export default function InternalTableHead({ children, __internalRootRef, ...rest }: InternalTableHeadProps) { + const { columnLayout } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(rest); + return ( +
+ {children} + + ); +} diff --git a/src/table-head/styles.scss b/src/table-head/styles.scss new file mode 100644 index 0000000000..2d1806dcf5 --- /dev/null +++ b/src/table-head/styles.scss @@ -0,0 +1,12 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.head { + position: relative; +} + +.head-grid { + display: block; +} diff --git a/src/table-header-cell/index.tsx b/src/table-header-cell/index.tsx new file mode 100644 index 0000000000..4f4ae1745b --- /dev/null +++ b/src/table-header-cell/index.tsx @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import { getBaseProps } from '../internal/base-component'; +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeaderCellProps } from './interfaces'; +import { InternalTableHeaderCell } from './internal'; + +export { TableHeaderCellProps }; + +function TableHeaderCell(props: TableHeaderCellProps) { + const baseComponentProps = useBaseComponent('TableHeaderCell'); + const mergedProps = { ...props, ...baseComponentProps }; + const { children, ariaLabel, ariaLabelledby, ariaDescribedby, ariaSort, disablePaddings, __internalRootRef } = + mergedProps; + const { className, ...restBaseProps } = getBaseProps(mergedProps); + return ( + + {children} + + ); +} + +applyDisplayName(TableHeaderCell, 'TableHeaderCell'); +export default TableHeaderCell; diff --git a/src/table-header-cell/interfaces.ts b/src/table-header-cell/interfaces.ts new file mode 100644 index 0000000000..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..c242a42c18 --- /dev/null +++ b/src/table-header-cell/internal.tsx @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useTableContext } from '../table-root/context'; + +// Reuses classic's proven box model: the base `.header-cell` padding lives in the shared header-cell +// stylesheet, so this is pixel-identical to the + ); + } +); diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss new file mode 100644 index 0000000000..d29aff6e19 --- /dev/null +++ b/src/table-header-cell/styles.scss @@ -0,0 +1,72 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +// The base header-cell box model (padding, divider, background) comes from the substrate's +// `.header-cell` (src/table/table-header-cell). This module must NOT re-declare that geometry — it only +// adds the test-utils marker, grid-mode sizing, and the padding opt-out. +.header-cell { + box-sizing: border-box; +} + +// Vertical divider between adjacent header cells. The existing Table draws it with a composed +// Resizer/Divider element; the substrate renders no such child, so we reproduce the centered, +// gutter-inset divider as a presentation-only `::after` (no DOM node, no prop). The substrate's +// `.header-cell` is position:relative, so the pseudo-element anchors to the cell. +.header-cell:not(:last-child)::after { + content: ''; + position: absolute; + inset-inline-end: 0; + inset-block-start: 0; + inset-block-end: 0; + min-block-size: awsui.$line-height-heading-xs; + max-block-size: calc(100% - (2 * #{awsui.$space-xs} + #{awsui.$space-xxxs})); + margin-block: auto; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + pointer-events: none; +} + +// Grid-mode only: allow columns to shrink below content size and stretch every header cell to fill the +// row track (mirroring body `.cell-grid`). Without it an empty control header collapses to ~1px, leaving +// the divider a stray stub. Auto mode uses native display:table-cell and is untouched. +.header-cell-grid { + min-inline-size: 0; + align-self: stretch; + display: grid; + align-items: center; +} + +// Opt out of the built-in padding so the consumer can compose exact spacing (e.g. a selection +// control). Base padding lives on the substrate's `.header-cell`; this zeroes it on the same element. +.header-cell.disable-paddings { + padding-block: 0; + padding-inline: 0; +} + +// Header content box. The substrate renders header children with no wrapper, so we reproduce the +// existing Table's `.header-cell-content` block padding (row height) and inline offset (aligns header +// text to the body content's inline start). See src/table/header-cell `.header-cell-content`. +.header-cell-content { + padding-block: awsui.$space-scaled-xxs; + padding-inline-start: awsui.$space-s; + padding-inline-end: awsui.$space-s; + line-height: awsui.$line-height-body-m; +} + +// disablePaddings zeroes the cell's built-in padding; carry that through to the content box so a +// composed control cell has no residual block or inline padding. +.header-cell.disable-paddings > .header-cell-content { + padding-block: 0; + padding-inline: 0; +} + +// Visual refresh: the first column hugs the table's inline-start edge. Zero the content's inline-start +// offset so the substrate's reduced first-column inset is the only one (matching the existing Table), +// instead of adding the +12px body offset. +.header-cell.is-visual-refresh:first-child > .header-cell-content { + padding-inline-start: 0; +} diff --git a/src/table-header-row/index.tsx b/src/table-header-row/index.tsx new file mode 100644 index 0000000000..539da44381 --- /dev/null +++ b/src/table-header-row/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeaderRowProps } from './interfaces'; +import InternalTableHeaderRow from './internal'; + +export { TableHeaderRowProps }; + +function TableHeaderRow(props: TableHeaderRowProps) { + const baseComponentProps = useBaseComponent('TableHeaderRow'); + return ; +} + +applyDisplayName(TableHeaderRow, 'TableHeaderRow'); +export default TableHeaderRow; diff --git a/src/table-header-row/interfaces.ts b/src/table-header-row/interfaces.ts new file mode 100644 index 0000000000..7c159f0d20 --- /dev/null +++ b/src/table-header-row/interfaces.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders the header row, inside `TableHead`. Its children are `TableHeaderCell` components. */ +export interface TableHeaderRowProps extends BaseComponentProps { + /** The header cells, one per column, in order. */ + children?: React.ReactNode; +} diff --git a/src/table-header-row/internal.tsx b/src/table-header-row/internal.tsx new file mode 100644 index 0000000000..3a8641acd7 --- /dev/null +++ b/src/table-header-row/internal.tsx @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { TableHeaderRowProps } from './interfaces'; + +import styles from './styles.css.js'; + +export interface InternalTableHeaderRowProps extends TableHeaderRowProps, InternalBaseComponentProps {} + +export default function InternalTableHeaderRow({ children, __internalRootRef, ...rest }: InternalTableHeaderRowProps) { + const { columnLayout, gridTemplateColumns, ariaRowcount } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const baseProps = getBaseProps(rest); + return ( + + {children} + + ); +} diff --git a/src/table-header-row/styles.scss b/src/table-header-row/styles.scss new file mode 100644 index 0000000000..66ce26719e --- /dev/null +++ b/src/table-header-row/styles.scss @@ -0,0 +1,16 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +.header-row { + background: awsui.$color-background-table-header; +} + +.header-row-grid { + display: grid; + inline-size: 100%; + align-items: center; +} diff --git a/src/table-root/__tests__/basic-table-aria-label.test.tsx b/src/table-root/__tests__/basic-table-aria-label.test.tsx new file mode 100644 index 0000000000..2c652933f8 --- /dev/null +++ b/src/table-root/__tests__/basic-table-aria-label.test.tsx @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; + +// The accessible name is set through the top-level `ariaLabel` / `ariaLabelledby` props, which the +// component applies to the table's `aria-label` / `aria-labelledby`. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, name: `Resource ${i}`, status: i % 2 === 0 ? 'Up' : 'Down' })); + +const COLUMNS: ReadonlyArray = [{ minWidth: 120 }, {}]; + +function buildTree(labelProps: Pick) { + const items = makeItems(10); + return ( + + + + Name + Status + + + + {items.map(item => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +const getTable = (container: HTMLElement) => container.querySelector('table')!; + +describe('Table labelling', () => { + test('ariaLabel passes through to the table aria-label', () => { + const { container } = render(buildTree({ ariaLabel: 'Resources' })); + expect(getTable(container).getAttribute('aria-label')).toBe('Resources'); + }); + + test('ariaLabelledby passes through to the table aria-labelledby', () => { + const { container } = render(buildTree({ ariaLabelledby: 'heading-id' })); + expect(getTable(container).getAttribute('aria-labelledby')).toBe('heading-id'); + }); +}); diff --git a/src/table-root/__tests__/basic-table-roles.test.tsx b/src/table-root/__tests__/basic-table-roles.test.tsx new file mode 100644 index 0000000000..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. + + + )} + +
TableThElement composes. Accepted one-way SCSS reuse. +import headerCellStyles from '../table/header-cell/styles.css.js'; +import styles from './styles.css.js'; + +// Dual-consumer substrate interface: consumed by both the public `TableHeaderCell` (index.tsx) and the +// existing Table's `th-element`, so it retains the low-level fields that `th-element` threads through. +export interface InternalTableHeaderCellProps { + // Feature classes layered on top of the base `.header-cell` by the composing component. + className?: string; + style?: React.CSSProperties; + // Native attributes (role/scope/aria-sort/analytics/data-*) computed by the composing component + // and spread verbatim onto the element. + nativeAttributes?: React.ThHTMLAttributes & { + [key: `data-${string}`]: string | number | boolean | undefined; + }; + tabIndex?: number; + // Removes the cell's built-in block/inline padding (atomic control-column composition). + disablePaddings?: boolean; + children?: React.ReactNode; + // Set by the public `TableHeaderCell`. When true the cell reads the atomic table context to add the + // test-utils marker, grid role/layout, visual-refresh first-column reset, padding opt-out, and content + // wrapper. With `__atomic` off it is byte-identical to the bare `.header-cell` substrate the Table composes. + __atomic?: boolean; +} + +export const InternalTableHeaderCell = React.forwardRef( + ({ className, style, nativeAttributes, tabIndex, disablePaddings, __atomic, children }, ref) => { + const { columnLayout } = useTableContext(); + const isVisualRefresh = useVisualRefresh(); + const isGrid = columnLayout.type === 'grid'; + // The atomic grid role rides in the native attributes so it never clobbers the Table's own role. + const mergedNativeAttributes = + __atomic && isGrid ? { ...nativeAttributes, role: 'columnheader' as const } : nativeAttributes; + return ( + .header-cell-content`). + __atomic && isVisualRefresh && styles['is-visual-refresh'], + __atomic && disablePaddings && styles['disable-paddings'] + )} + style={style} + tabIndex={tabIndex} + {...mergedNativeAttributes} + > + {__atomic ?
{children}
: children} +
//// — the one sanctioned styling hook — +// which the cell stylesheet reads for the consecutive-selected outline merge (sibling adjacency a +// cell can't get from context). It is driven by `variant`, never a public prop. Selection and shading +// are mutually exclusive by type. + +function Harness({ variant, ariaSelected }: { variant?: TableRowProps.Variant; ariaSelected?: boolean }) { + return ( + + + + Name + Status + + + + + Resource 0 + Available + + + + ); +} + +function renderHarness(variant?: TableRowProps.Variant, ariaSelected?: boolean) { + const { container } = render(); + return { wrapper: createWrapper(container) }; +} + +function cellClassLists(wrapper: ReturnType) { + return wrapper.findAllTableCells().map(cell => cell.getElement().classList); +} + +describe('TableRow variant is visual-only and paints through the cell', () => { + test("variant='selected' paints every cell selected, emits the data-selected adjacency hook, and sets no aria-selected", () => { + const { wrapper } = renderHarness('selected'); + const row = wrapper.findAllTableRows()[0].getElement(); + // Visual state must NOT leak into ARIA; it is conveyed only by the explicit ariaSelected prop. + expect(row).not.toHaveAttribute('aria-selected'); + // The one sanctioned styling hook: data-selected drives the consecutive-selected outline merge. + expect(row).toHaveAttribute('data-selected', 'true'); + expect(row).not.toHaveAttribute('data-shaded'); + // The paint arrives on the cells via context, reusing the existing Table's selection + has-selection classes. + for (const classList of cellClassLists(wrapper)) { + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(true); + expect(classList.contains(bodyCellStyles['has-selection'])).toBe(true); + expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); + } + }); + + test("variant='shaded' paints every cell shaded and never selected", () => { + const { wrapper } = renderHarness('shaded'); + const row = wrapper.findAllTableRows()[0].getElement(); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-selected'); + // data-shaded drives the striped-row divider darkening (sibling adjacency), mirroring data-selected. + expect(row).toHaveAttribute('data-shaded', 'true'); + for (const classList of cellClassLists(wrapper)) { + expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(true); + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + expect(classList.contains(bodyCellStyles['has-selection'])).toBe(false); + } + }); + + test('the default variant paints neither and sets no aria-selected or data-selected', () => { + const { wrapper } = renderHarness(); + const row = wrapper.findAllTableRows()[0].getElement(); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-selected'); + for (const classList of cellClassLists(wrapper)) { + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); + } + }); + + test('a TableCell rendered outside any TableRow falls back to the default (unpainted) variant', () => { + // Guards the RowVariantContext default so a stray cell never paints itself selected/shaded. + const { container } = render( + + + + Loose + + + + ); + const classList = createWrapper(container).findAllTableCells()[0].getElement().classList; + expect(classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + expect(classList.contains(bodyCellStyles['body-cell-shaded'])).toBe(false); + }); + + test('ariaSelected drives aria-selected independently of variant', () => { + const selected = renderHarness('selected', true).wrapper.findAllTableRows()[0].getElement(); + expect(selected).toHaveAttribute('aria-selected', 'true'); + + const notSelected = renderHarness('default', false).wrapper.findAllTableRows()[0].getElement(); + expect(notSelected).toHaveAttribute('aria-selected', 'false'); + }); +}); + +describe('inline style props (virtualization)', () => { + const COLUMNS: ReadonlyArray = [{ size: 100 }]; + + test('TableBody and TableRow apply their narrowed inline style to their roots', () => { + const { container } = render( + + + + Name + + + + + Row + + + + ); + const wrapper = createWrapper(container); + const body = wrapper.findTableBody()!.getElement() as HTMLElement; + expect(body.style.position).toBe('relative'); + expect(body.style.height).toBe('400px'); + + const row = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(row.style.position).toBe('absolute'); + expect(row.style.transform).toBe('translateY(40px)'); + // The row keeps its shared grid template alongside the consumer's positioning style. + expect(row.style.gridTemplateColumns).toBe('100px'); + }); +}); + +describe('disablePaddings', () => { + test('TableCell applies the no-padding hook on the cell content only when disablePaddings is set', () => { + const { container } = render( + + + + Control + Resource 0 + + + + ); + const cells = createWrapper(container).findAllTableCells(); + // The opt-out lands on the inner `.body-cell-content` wrapper carved from the existing Table's box model. + const contentOf = (index: number) => + cells[index].getElement().getElementsByClassName(bodyCellStyles['body-cell-content'])[0]; + expect(contentOf(0).classList.contains(bodyCellStyles['disable-paddings'])).toBe(true); + expect(contentOf(1).classList.contains(bodyCellStyles['disable-paddings'])).toBe(false); + }); + + test('TableHeaderCell applies the no-padding hook on its root only when disablePaddings is set', () => { + const { container } = render( + + + + + Name + + + + ); + const headerCells = createWrapper(container).findAllTableHeaderCells(); + expect(headerCells[0].getElement().classList.contains(headerCellStyles['disable-paddings'])).toBe(true); + expect(headerCells[1].getElement().classList.contains(headerCellStyles['disable-paddings'])).toBe(false); + }); +}); 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..90345f3f8a --- /dev/null +++ b/src/table-root/__tests__/basic-table.test.tsx @@ -0,0 +1,275 @@ +// 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
/
, 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..db2e2c6c7e --- /dev/null +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -0,0 +1,191 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import TableBody from '../../../lib/components/table-body'; +import TableCell from '../../../lib/components/table-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableHeaderRow from '../../../lib/components/table-header-row'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow, { TableRowProps } from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import bodyCellStyles from '../../../lib/components/table/body-cell/styles.css.js'; +import headerCellStyles from '../../../lib/components/table-header-cell/styles.css.js'; + +// Proves the row `variant` is purely visual and reaches the cell paint through context, that +// `ariaSelected` drives `aria-selected` independently of `variant`, that the narrowed inline `style` +// props (for virtualization) reach the body and row roots, and that `disablePaddings` reaches the +// padding opt-out on the cell content and header-cell root. +// +// On this fork each TableCell self-paints by reusing the existing Table's `.body-cell-selected` / +// `.body-cell-shaded` classes (via RowVariantContext), so the per-cell paint needs no data-* hook. A +// selected row additionally emits `data-selected` on the
with no +// explicit ARIA roles; `{ type: 'grid' }` renders a display:grid table that restores the table roles +// and applies the shared column template. Sorting/selection are composed by the consumer. + +interface Item { + id: string; + name: string; + status: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, index) => ({ + id: `row-${index}`, + name: `Resource ${index}`, + status: index % 2 === 0 ? 'Available' : 'Pending', + })); + +// Grid layout needs one column entry per column (Name fixed 200px, Status flexible). +const GRID_COLUMNS: ReadonlyArray = [{ size: 200 }, {}]; + +interface RenderOptions { + grid?: boolean; + count?: number; + items?: Item[]; + ariaRowcount?: number; +} + +function TableHarness({ options }: { options: RenderOptions }) { + const items = options.items ?? makeItems(options.count ?? 5); + const columnLayout: TableRootProps.ColumnLayout = options.grid + ? { type: 'grid', columns: GRID_COLUMNS } + : { type: 'auto' }; + return ( + + + + Name + Status + + + + {items.map(item => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +function renderTable(options: RenderOptions = {}) { + const utils = render(); + const wrapper = createWrapper(utils.container); + const table = () => wrapper.find('table')!.getElement(); + return { wrapper, table, ...utils }; +} + +describe('Table atomic parts', () => { + test('renders the declarative header cells, discoverable via the generated finder', () => { + const { wrapper } = renderTable(); + expect(wrapper.findTableRoot()).not.toBeNull(); + const headerCells = wrapper.findAllTableHeaderCells(); + expect(headerCells).toHaveLength(2); + expect(headerCells[0].getElement().textContent).toContain('Name'); + expect(headerCells[1].getElement().textContent).toContain('Status'); + }); + + test('renders the mapped rows and cells, discoverable via the generated finders', () => { + const { wrapper } = renderTable({ count: 5 }); + const rows = wrapper.findAllTableRows(); + expect(rows).toHaveLength(5); // body rows only; the header row uses a different root class + + const firstRowCells = createWrapper(rows[0].getElement()).findAllTableCells(); + expect(firstRowCells).toHaveLength(2); + expect(firstRowCells[0].getElement().textContent).toBe('Resource 0'); + expect(firstRowCells[1].getElement().textContent).toBe('Available'); + + expect(wrapper.findTableBody()).not.toBeNull(); + expect(wrapper.findTableHead()).not.toBeNull(); + expect(wrapper.findAllTableCells()).toHaveLength(10); + }); + + test('ariaLabel is applied to the table element', () => { + const { table } = renderTable(); + expect(table().getAttribute('aria-label')).toBe('Resources'); + }); + + test('ariaRowcount is applied to aria-rowcount as-is', () => { + const { table } = renderTable({ count: 5, ariaRowcount: 40 }); + expect(table().getAttribute('aria-rowcount')).toBe('40'); + }); + + test('omits aria-rowcount when ariaRowcount is not provided (count derives from the DOM)', () => { + const { table } = renderTable({ count: 5 }); + expect(table().hasAttribute('aria-rowcount')).toBe(false); + }); + + describe('auto column layout (default)', () => { + test('renders a native
with no explicit table/row/cell ARIA roles', () => { + const { table, wrapper } = renderTable(); + expect(table().tagName).toBe('TABLE'); + expect(table().hasAttribute('role')).toBe(false); + expect(table().querySelectorAll('[role="row"]')).toHaveLength(0); + expect(table().querySelectorAll('[role="columnheader"]')).toHaveLength(0); + expect(table().querySelectorAll('[role="cell"], [role="gridcell"]')).toHaveLength(0); + const th = wrapper.findAllTableHeaderCells()[0].getElement(); + expect(th.tagName).toBe('TH'); + expect(th.getAttribute('scope')).toBe('col'); + }); + + test('does not emit an inline grid-template-columns on rows', () => { + const { wrapper } = renderTable(); + const row = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(row.style.gridTemplateColumns).toBe(''); + }); + }); + + describe('grid column layout', () => { + test('restores the table ARIA roles that display:grid strips', () => { + const { table, wrapper } = renderTable({ grid: true }); + expect(table().getAttribute('role')).toBe('table'); + expect(table().querySelectorAll('[role="rowgroup"]').length).toBeGreaterThanOrEqual(2); + + const headerCell = wrapper.findAllTableHeaderCells()[0].getElement(); + expect(headerCell.getAttribute('role')).toBe('columnheader'); + expect(headerCell.getAttribute('scope')).toBe('col'); + + const dataRow = wrapper.findAllTableRows()[0].getElement(); + expect(dataRow.getAttribute('role')).toBe('row'); + expect(dataRow.querySelectorAll('[role="cell"]')).toHaveLength(2); + }); + + test('the header row shares the column template with the data rows', () => { + const { wrapper } = renderTable({ grid: true }); + const headerRow = wrapper.findTableHead()!.find('[role="row"]')!.getElement() as HTMLElement; + const template = '200px minmax(0px, 1fr)'; + expect(headerRow.style.gridTemplateColumns).toBe(template); + + const dataRow = wrapper.findAllTableRows()[0].getElement() as HTMLElement; + expect(dataRow.style.gridTemplateColumns).toBe(template); + }); + + test('the header row is aria-rowindex 1 only when the grid declares an aria-rowcount (virtualized)', () => { + const virtualized = renderTable({ grid: true, ariaRowcount: 500 }); + const virtualizedHeaderRow = virtualized.wrapper.findTableHead()!.find('[role="row"]')!.getElement(); + expect(virtualizedHeaderRow.getAttribute('aria-rowindex')).toBe('1'); + + // In a non-virtualized grid the row positions derive from the DOM, so no aria-rowindex is set. + const plain = renderTable({ grid: true }); + const plainHeaderRow = plain.wrapper.findTableHead()!.find('[role="row"]')!.getElement(); + expect(plainHeaderRow.hasAttribute('aria-rowindex')).toBe(false); + }); + }); + + describe('row aria-selected is driven by ariaSelected, not variant', () => { + 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..c0a3412500 --- /dev/null +++ b/src/table-root/index.tsx @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableRootProps } from './interfaces'; +import InternalTableRoot from './internal'; + +// Each part is its own top-level component (one default export + props type) so the documenter documents it separately. +export { TableRootProps }; + +function TableRoot({ columnLayout = { type: 'auto' }, ...props }: TableRootProps) { + const baseComponentProps = useBaseComponent('TableRoot', { + props: {}, + metadata: { columnLayoutType: columnLayout.type }, + }); + return ; +} + +applyDisplayName(TableRoot, 'TableRoot'); + +export default TableRoot; diff --git a/src/table-root/interfaces.ts b/src/table-root/interfaces.ts new file mode 100644 index 0000000000..2a3a05dfb8 --- /dev/null +++ b/src/table-root/interfaces.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { 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 { + size?: number | { flex: number }; + minWidth?: number; + maxWidth?: number; + } +} diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx new file mode 100644 index 0000000000..58a913d2bf --- /dev/null +++ b/src/table-root/internal.tsx @@ -0,0 +1,53 @@ +// 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'; + +export interface InternalTableRootProps extends TableRootProps, InternalBaseComponentProps {} + +export default function InternalTableRoot({ + columnLayout = { type: 'auto' }, + ariaRowcount, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + children, + __internalRootRef, + ...rest +}: InternalTableRootProps) { + const isGrid = columnLayout.type === 'grid'; + const table = useTableRoot(columnLayout, ariaRowcount); + const baseProps = getBaseProps(rest); + + return ( +
+ + {/* The page owns vertical scroll; this wrapper reintroduces an inline scroll viewport so a wide table scrolls horizontally instead of spilling out. */} +
+
+
+ {children} +
+ + + + + ); +} diff --git a/src/table-root/styles.scss b/src/table-root/styles.scss new file mode 100644 index 0000000000..67a72d6503 --- /dev/null +++ b/src/table-root/styles.scss @@ -0,0 +1,46 @@ +/* + 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 existing Table) so each cell paints its own divider: the collapsed + // model breaks sticky columns and blurs the selected-row outline corners. Auto layout only; grid mode + // is display:block via `.table-grid`. + border-collapse: separate; + border-spacing: 0; +} + +.table-auto { + table-layout: auto; +} + +// Grid mode lays the table out as blocks so the inline grid-template-columns govern widths. +.table-grid { + display: block; +} diff --git a/src/table-root/use-table-root.ts b/src/table-root/use-table-root.ts new file mode 100644 index 0000000000..8759fd1d1d --- /dev/null +++ b/src/table-root/use-table-root.ts @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { useMemo } from 'react'; + +import { TableRootProps } from './interfaces'; + +export interface UseTableRootResult { + columnLayout: TableRootProps.ColumnLayout; + /** The `grid-template-columns` value for `grid` layout, compiled from each column's `size` union; `undefined` in `auto` layout. */ + gridTemplateColumns?: string; + /** The consumer-supplied `aria-rowcount`, present only when the table is virtualized (a grid rendering a subset of rows). */ + ariaRowcount?: number; +} + +export function useTableRoot(columnLayout: TableRootProps.ColumnLayout, ariaRowcount?: number): UseTableRootResult { + const gridTemplateColumns = useMemo(() => { + if (columnLayout.type !== 'grid') { + return undefined; + } + return columnLayout.columns + .map(column => { + if (typeof column.size === 'number') { + return `${column.size}px`; + } + const min = `${column.minWidth ?? 0}px`; + if (column.maxWidth !== undefined) { + return `minmax(${min}, ${column.maxWidth}px)`; + } + // `size` is narrowed to `{ flex: number } | undefined` here (the numeric case returned above). + const flex = column.size?.flex ?? 1; + return `minmax(${min}, ${flex}fr)`; + }) + .join(' '); + }, [columnLayout]); + + return useMemo( + () => ({ columnLayout, gridTemplateColumns, ariaRowcount }), + [columnLayout, gridTemplateColumns, ariaRowcount] + ); +} diff --git a/src/table-row/context.ts b/src/table-row/context.ts new file mode 100644 index 0000000000..1a1d166586 --- /dev/null +++ b/src/table-row/context.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createContext, useContext } from 'react'; + +import { TableRowProps } from './interfaces'; + +// A row→cell channel so a `TableCell` learns its row's visual state and paints selection via its own +// module class, avoiding a `data-*` styling hook. A cell rendered outside a `TableRow` reads `'default'`. +const RowVariantContext = createContext('default'); + +export const RowVariantContextProvider = RowVariantContext.Provider; + +export function useRowVariant(): TableRowProps.Variant { + return useContext(RowVariantContext); +} diff --git a/src/table-row/index.tsx b/src/table-row/index.tsx new file mode 100644 index 0000000000..8eda6b30f7 --- /dev/null +++ b/src/table-row/index.tsx @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableRowProps } from './interfaces'; +import InternalTableRow from './internal'; + +export { TableRowProps }; + +function TableRow(props: TableRowProps) { + const baseComponentProps = useBaseComponent('TableRow', { props: { variant: props.variant } }); + return ; +} + +applyDisplayName(TableRow, 'TableRow'); +export default TableRow; diff --git a/src/table-row/interfaces.ts b/src/table-row/interfaces.ts new file mode 100644 index 0000000000..56af6176cf --- /dev/null +++ b/src/table-row/interfaces.ts @@ -0,0 +1,53 @@ +// 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'; + /** Inline styles supported on a row element, for row positioning (for example, virtualization). */ + export interface Style { + position?: React.CSSProperties['position']; + transform?: React.CSSProperties['transform']; + height?: React.CSSProperties['height']; + } +} diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx new file mode 100644 index 0000000000..0b52e15b30 --- /dev/null +++ b/src/table-row/internal.tsx @@ -0,0 +1,58 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { RowVariantContextProvider } from './context'; +import { TableRowProps } from './interfaces'; + +import styles from './styles.css.js'; + +// The row publishes its `variant` via RowVariantContext so each cell self-paints its selection/shading. +// A selected row also emits `data-selected` (a shaded row `data-shaded`) on the : the +// consecutive-selected outline merge and striped-divider darkening need sibling adjacency, which a cell +// can only read from the DOM, not context. These are sanctioned data-* hooks — driven by the internal +// `variant` (never a public prop) and keyed on selectors the existing Table never emits, so they stay +// inert for it. +export interface InternalTableRowProps extends TableRowProps, InternalBaseComponentProps {} + +export default function InternalTableRow({ + variant = 'default', + ariaLabel, + ariaLabelledby, + ariaDescribedby, + ariaSelected, + ariaRowindex, + children, + style, + __internalRootRef, + ...rest +}: InternalTableRowProps) { + const { columnLayout, gridTemplateColumns } = useTableContext(); + const isGrid = columnLayout.type === 'grid'; + const { className, ...restBaseProps } = getBaseProps(rest); + // Spread (not literal keys) so these adjacency hooks are exempt from excess-property checking. + const selectedDataAttribute = variant === 'selected' ? { 'data-selected': 'true' } : undefined; + const shadedDataAttribute = variant === 'shaded' ? { 'data-shaded': 'true' } : undefined; + return ( + + {children} + + ); +} diff --git a/src/table-row/styles.scss b/src/table-row/styles.scss new file mode 100644 index 0000000000..2930ed3ce9 --- /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; + // The divider and selection/shading paint live on the cell (see table-cell/styles.scss); the row only + // carries the grid layout. Cells read the row's `variant` via RowVariantContext. +} + +.row-grid { + 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/body-cell/styles.scss b/src/table/body-cell/styles.scss index a25f7f3f55..0321a78df9 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -557,3 +557,31 @@ $cell-negative-space-vertical: 2px; @include cell-focus-outline; } } + +// Atomic-only substrate hook consumed by the extracted InternalTableCell. Lets a +// consumer opt out of the built-in cell padding (e.g. a selection-control column). +// Keyed on the content wrapper itself (where the padding lives); the classic table +// never applies `disable-paddings`, so this block is inert for it. +.body-cell-content.disable-paddings { + padding-block: 0; + padding-inline: 0; + margin-block: 0; + margin-inline: 0; + // A disable-paddings cell holds a control/custom node, not truncatable text, and + // collapses to the control's height. The `:not(.body-cell-wrap)` truncation clip + // above would then crop the focus ring of an interactive control (checkbox/radio), + // so opt this cell back out of clipping — same rationale as the edit-active case. + overflow: visible; +} + +// The selected-state block-start rules (`.body-cell-selected[…] > .body-cell-content`, +// specificity (0,3,0)) inject a lopsided top pad + negative margin as a border +// placeholder that the (0,2,0) reset above can't neutralise — leaving a centred control +// shoved down in a grid row (`align-items: center`, cell not stretched to the row). +// Match (0,3,0) here (and win on source order) to hold the block reset in the selected +// state. Block axis only: the first-child inline-start padding is (0,3,0) too and must +// stay identical to the unselected state, so this must not touch the inline axis. +.body-cell-selected > .body-cell-content.disable-paddings { + padding-block: 0; + margin-block: 0; +} diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index 49c45e45ef..50aaac296b 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -9,6 +9,7 @@ import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-too import { ExpandToggleButton } from '../../internal/components/expand-toggle-button'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; +import { InternalTableCell } from '../../table-cell/internal'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces.js'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; @@ -104,12 +105,16 @@ export const TableTdElement = React.forwardRef { - const Element = isRowHeader ? 'th' : 'td'; + const tag = isRowHeader ? 'th' : 'td'; const isVisualRefresh = useVisualRefresh(); resizableStyle = resizableColumns ? {} : resizableStyle; - nativeAttributes = { ...nativeAttributes, ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }) }; + const cellNativeAttributes = { + ...nativeAttributes, + ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }), + ...copyAnalyticsMetadataAttribute(rest), + }; const stickyStyles = useStickyCellStyles({ stickyColumns: stickyState, @@ -122,11 +127,16 @@ export const TableTdElement = React.forwardRef` CSS continues to match unchanged. return ( - + + + ) : null + } > - {level !== undefined && isExpandable && !isEditingActive && ( -
- + {children} + {counter ? ( +
+ + {counter}
- )} - -
- {children} - {counter ? ( -
- - {counter} -
- ) : null} -
- + ) : null} + ); } ); diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 68dd326b54..21828ad3fc 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -8,6 +8,7 @@ import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; +import { InternalTableHeaderCell } from '../../table-header-cell/internal'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; @@ -99,11 +100,30 @@ export function TableThElement({ const mergedRef = useMergeRefs(stickyStyles.ref, cellRef, cellRefObject); const { tabIndex: cellTabIndex } = useSingleTabStopNavigation(cellRefObject); + // The bare `.header-cell` substrate (the element, base padding, ref) is provided by + // the extracted InternalTableHeaderCell. All feature layering stays here, keyed on the same + // `.header-cell` class so the compound `.header-cell.` CSS continues to match + // unchanged, and every computed native attribute is threaded through verbatim. + const nativeAttributes = { + 'data-focus-id': `header-${String(columnId)}`, + colSpan, + rowSpan, + ...getTableColHeaderRoleProps({ + tableRole, + sortingStatus: suppressAriaSort ? undefined : sortingStatus, + colIndex, + }), + scope: scope ?? 'col', + ...copyAnalyticsMetadataAttribute(props), + ...(ariaLabel ? { 'aria-label': ariaLabel } : {}), + ...(isLast ? { 'data-rightmost': true } : {}), + ...(scope !== 'colgroup' ? { 'data-column-index': colIndex + 1 } : {}), + ...(columnGroupId ? { 'data-column-group-id': columnGroupId } : {}), + }; + return ( - {children} - + ); } diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 8877eb304b..870b4d8bb2 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -731,6 +731,8 @@ const InternalTable = React.forwardRef( { // When an element inside table row receives focus we want to adjust the scroll. // However, that behavior is unwanted when the focus is received as result of a click @@ -739,12 +741,10 @@ const InternalTable = React.forwardRef( stickyHeaderRef.current?.scrollToRow(currentTarget); } }} - {...focusMarkers.item} onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)} onContextMenu={ onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item) } - {...rowRoleProps} > {selection.getItemSelectionProps && (