diff --git a/build-tools/tasks/docs.js b/build-tools/tasks/docs.js index f777a43af0..1b9129573b 100644 --- a/build-tools/tasks/docs.js +++ b/build-tools/tasks/docs.js @@ -1,20 +1,65 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +const fs = require('fs'); const path = require('path'); const { writeComponentsDocumentation, writeTestUtilsDocumentation } = require('@cloudscape-design/documenter'); const workspace = require('../utils/workspace'); +// Dash-case names of the versioned beta components — the dirs matching the beta documenter glob +// (src/beta/-//index.tsx). Used to flag their generated definitions. +function getBetaComponentNames(srcDir = 'src') { + const betaRoot = path.join(srcDir, 'beta'); + if (!fs.existsSync(betaRoot)) { + return []; + } + const names = []; + for (const versionDir of fs.readdirSync(betaRoot)) { + const versionPath = path.join(betaRoot, versionDir); + if (!fs.statSync(versionPath).isDirectory()) { + continue; + } + for (const component of fs.readdirSync(versionPath)) { + if (fs.existsSync(path.join(versionPath, component, 'index.tsx'))) { + names.push(component); + } + } + } + return names; +} + module.exports = function docs() { + const componentsOutDir = path.join(workspace.apiDocsPath, 'components'); + + // Document the stable components AND the versioned beta components into the SINGLE `components` + // output (one combined glob → one index barrel; the documenter rewrites the index from its glob, + // so a second same-outDir pass would clobber it). Beta components are NOT shipped as a separate + // barrel; they live in `components` and are distinguished by the `releaseStatus: 'beta'` flag + // stamped below. writeComponentsDocumentation({ - outDir: path.join(workspace.apiDocsPath, 'components'), + outDir: componentsOutDir, tsconfigPath: require.resolve('../../tsconfig.json'), - publicFilesGlob: 'src/*/index.tsx', + publicFilesGlob: 'src/{*/index.tsx,beta/*/*/index.tsx}', extraExports: { FileDropzone: ['useFilesDragging'], IconProvider: ['defineIcons', 'IconRegistry', 'IconMap'], TagEditor: ['getTagsDiff'], }, }); + + // The documenter hard-codes `releaseStatus: 'stable'` with no tag override, so stamp the beta + // components' generated definitions as `beta`. The website derives `isBeta` from this (beta page + // header alert + nav badge), keeping the single components barrel with per-component flagging. + for (const name of getBetaComponentNames('src')) { + const definitionFile = path.join(componentsOutDir, `${name}.js`); + if (!fs.existsSync(definitionFile)) { + continue; + } + + const definition = require(path.resolve(definitionFile)); + definition.releaseStatus = 'beta'; + fs.writeFileSync(definitionFile, `module.exports = ${JSON.stringify(definition, null, 2)};`); + } + writeTestUtilsDocumentation({ outDir: path.join(workspace.apiDocsPath, 'test-utils-doc'), tsconfigPath: require.resolve('../../src/test-utils/tsconfig.json'), diff --git a/build-tools/tasks/package-json.js b/build-tools/tasks/package-json.js index 4cd2424ac2..11954b71ed 100644 --- a/build-tools/tasks/package-json.js +++ b/build-tools/tasks/package-json.js @@ -3,7 +3,7 @@ const { parallel } = require('gulp'); const path = require('path'); const fs = require('fs'); -const { writeFile, listPublicItems } = require('../utils/files'); +const { writeFile, listPublicItems, listBetaItems } = require('../utils/files'); const themes = require('../utils/themes'); const { task, copyTask } = require('../utils/gulp-utils'); const workspace = require('../utils/workspace'); @@ -51,6 +51,13 @@ function getComponentsExports() { result[`./${component}`] = `./${component}/index.js`; } + // Versioned beta components, published only at their explicit subpath (e.g. + // `@cloudscape-design/components/beta/basic-table-0.1`) — the versioning escape-hatch. Not added + // to the top-level barrel. + for (const betaItem of listBetaItems('src')) { + result[`./${betaItem}`] = `./${betaItem}/index.js`; + } + // Per-component test-utils DOM wrappers (e.g. `.../test-utils/dom/button`). for (const component of listPublicItems('src/test-utils/dom')) { result[`./test-utils/dom/${component}`] = `./test-utils/dom/${component}/index.js`; diff --git a/build-tools/utils/files.js b/build-tools/utils/files.js index 2bf5d0e366..39621a25da 100644 --- a/build-tools/utils/files.js +++ b/build-tools/utils/files.js @@ -24,8 +24,25 @@ function listPublicItems(baseDir) { elem !== 'i18n' && elem !== 'theming' && elem !== 'plugins' && - elem !== 'contexts' + elem !== 'contexts' && + // `beta` is not a component: it is a container for versioned, opt-in beta components + // (e.g. `beta/basic-table-0.1`) enumerated separately via `listBetaItems`. + elem !== 'beta' ); } -module.exports = { writeFile, listPublicItems }; +// Lists the versioned beta components as `beta/` (e.g. `beta/basic-table-0.1`). Beta components +// are opt-in and published only at their versioned export subpath — they are intentionally excluded +// from the top-level barrel and treated as their own kind of public item elsewhere in the build. +function listBetaItems(srcDir = 'src') { + const betaDir = path.join(srcDir, 'beta'); + if (!fs.existsSync(betaDir)) { + return []; + } + return fs + .readdirSync(betaDir) + .filter(elem => !elem.startsWith('__') && !elem.startsWith('.') && fs.statSync(path.join(betaDir, elem)).isDirectory()) + .map(elem => `beta/${elem}`); +} + +module.exports = { writeFile, listPublicItems, listBetaItems }; diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 0a6439d9c0..ce0c758267 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -11,6 +11,7 @@ const pluralizationMap = { Autosuggest: 'Autosuggests', Badge: 'Badges', BarChart: 'BarCharts', + BasicTable: 'BasicTables', Box: 'Boxes', BreadcrumbGroup: 'BreadcrumbGroups', Button: 'Buttons', diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index e872e9fbab..e781e0e093 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -37687,6 +37687,84 @@ Options get highlighted when they match the value of the input field.", ], "name": "BarChartWrapper", }, + { + "methods": [ + { + "name": "findColumnHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "name": "findHeaderRow", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "name": "findLoadingText", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "name": "findRowByIndex", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "BasicTableWrapper", + }, { "methods": [], "name": "BoxWrapper", @@ -49495,6 +49573,69 @@ Options get highlighted when they match the value of the input field.", ], "name": "BarChartWrapper", }, + { + "methods": [ + { + "name": "findColumnHeaders", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "name": "findHeaderRow", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findLoadingText", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findRowByIndex", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "BasicTableWrapper", + }, { "methods": [], "name": "BoxWrapper", 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 16e98a9c41..98028441ff 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 @@ -81,6 +81,14 @@ exports[`test-utils selectors 1`] = ` "bar-chart": [ "awsui_root_1gfe1", ], + "beta": [ + "awsui_body_e1x3z", + "awsui_header-cell_e1x3z", + "awsui_header-row_e1x3z", + "awsui_loading_e1x3z", + "awsui_root_e1x3z", + "awsui_row_e1x3z", + ], "box": [ "awsui_root_18wu0", ], 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..a1285ce3af 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 @@ -20,6 +20,7 @@ import AttributeEditorWrapper from './attribute-editor'; import AutosuggestWrapper from './autosuggest'; import BadgeWrapper from './badge'; import BarChartWrapper from './bar-chart'; +import BasicTableWrapper from './basic-table'; import BoxWrapper from './box'; import BreadcrumbGroupWrapper from './breadcrumb-group'; import ButtonWrapper from './button'; @@ -116,6 +117,7 @@ export { AttributeEditorWrapper }; export { AutosuggestWrapper }; export { BadgeWrapper }; export { BarChartWrapper }; +export { BasicTableWrapper }; export { BoxWrapper }; export { BreadcrumbGroupWrapper }; export { ButtonWrapper }; @@ -511,6 +513,34 @@ findAllBarCharts(selector?: string): Array; * @returns {BarChartWrapper | null} */ findClosestBarChart(): BarChartWrapper | null; +/** + * Returns the wrapper of the first BasicTable that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first BasicTable. + * If no matching BasicTable is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {BasicTableWrapper | null} + */ +findBasicTable(selector?: string): BasicTableWrapper | null; + +/** + * Returns an array of BasicTable wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the BasicTables inside the current wrapper. + * If no matching BasicTable is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllBasicTables(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent BasicTable for the current element, + * or the element itself if it is an instance of BasicTable. + * If no BasicTable is found, returns \`null\`. + * + * @returns {BasicTableWrapper | null} + */ +findClosestBasicTable(): BasicTableWrapper | null; /** * Returns the wrapper of the first Box that matches the specified CSS selector. * If no CSS selector is specified, returns the wrapper of the first Box. @@ -2982,6 +3012,19 @@ ElementWrapper.prototype.findBarChart = function(selector) { ElementWrapper.prototype.findAllBarCharts = function(selector) { return this.findAllComponents(BarChartWrapper, selector); }; +ElementWrapper.prototype.findBasicTable = function(selector) { + let rootSelector = \`.\${BasicTableWrapper.rootSelector}\`; + if("legacyRootSelector" in BasicTableWrapper && BasicTableWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${BasicTableWrapper.rootSelector}, .\${BasicTableWrapper.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, BasicTableWrapper); +}; + +ElementWrapper.prototype.findAllBasicTables = function(selector) { + return this.findAllComponents(BasicTableWrapper, selector); +}; ElementWrapper.prototype.findBox = function(selector) { let rootSelector = \`.\${BoxWrapper.rootSelector}\`; if("legacyRootSelector" in BoxWrapper && BoxWrapper.legacyRootSelector){ @@ -4117,6 +4160,11 @@ ElementWrapper.prototype.findClosestBarChart = function() { // https://github.com/microsoft/TypeScript/issues/29132 return (this as any).findClosestComponent(BarChartWrapper); }; +ElementWrapper.prototype.findClosestBasicTable = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(BasicTableWrapper); +}; ElementWrapper.prototype.findClosestBox = function() { // casting to 'any' is needed to avoid this issue with generics // https://github.com/microsoft/TypeScript/issues/29132 @@ -4562,6 +4610,7 @@ import AttributeEditorWrapper from './attribute-editor'; import AutosuggestWrapper from './autosuggest'; import BadgeWrapper from './badge'; import BarChartWrapper from './bar-chart'; +import BasicTableWrapper from './basic-table'; import BoxWrapper from './box'; import BreadcrumbGroupWrapper from './breadcrumb-group'; import ButtonWrapper from './button'; @@ -4658,6 +4707,7 @@ export { AttributeEditorWrapper }; export { AutosuggestWrapper }; export { BadgeWrapper }; export { BarChartWrapper }; +export { BasicTableWrapper }; export { BoxWrapper }; export { BreadcrumbGroupWrapper }; export { ButtonWrapper }; @@ -4932,6 +4982,23 @@ findBarChart(selector?: string): BarChartWrapper; * @returns {MultiElementWrapper} */ findAllBarCharts(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the BasicTables with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches BasicTables. + * + * @param {string} [selector] CSS Selector + * @returns {BasicTableWrapper} + */ +findBasicTable(selector?: string): BasicTableWrapper; + +/** + * Returns a multi-element wrapper that matches BasicTables with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches BasicTables. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllBasicTables(selector?: string): MultiElementWrapper; /** * Returns a wrapper that matches the Boxes with the specified CSS selector. * If no CSS selector is specified, returns a wrapper that matches Boxes. @@ -6490,6 +6557,19 @@ ElementWrapper.prototype.findBarChart = function(selector) { ElementWrapper.prototype.findAllBarCharts = function(selector) { return this.findAllComponents(BarChartWrapper, selector); }; +ElementWrapper.prototype.findBasicTable = function(selector) { + let rootSelector = \`.\${BasicTableWrapper.rootSelector}\`; + if("legacyRootSelector" in BasicTableWrapper && BasicTableWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${BasicTableWrapper.rootSelector}, .\${BasicTableWrapper.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, BasicTableWrapper); +}; + +ElementWrapper.prototype.findAllBasicTables = function(selector) { + return this.findAllComponents(BasicTableWrapper, selector); +}; ElementWrapper.prototype.findBox = function(selector) { let rootSelector = \`.\${BoxWrapper.rootSelector}\`; if("legacyRootSelector" in BoxWrapper && BoxWrapper.legacyRootSelector){ diff --git a/src/__tests__/utils.tsx b/src/__tests__/utils.tsx index 7b2ed47b49..3be1a9941e 100644 --- a/src/__tests__/utils.tsx +++ b/src/__tests__/utils.tsx @@ -22,6 +22,7 @@ export function getAllComponents(): string[] { name !== 'plugins' && name !== 'i18n' && name !== 'types' && + name !== 'beta' && !name.includes('.') && !name.includes('LICENSE') && !name.includes('NOTICE') diff --git a/src/beta/basic-table-0.1/__tests__/__stubs__/styles-stub.js b/src/beta/basic-table-0.1/__tests__/__stubs__/styles-stub.js new file mode 100644 index 0000000000..6b69858f44 --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/__stubs__/styles-stub.js @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +// Stub for generated styles.css.js / styles.selectors.js modules. +// These are normally produced by `gulp quick-build` but may not exist when running tests from source. +// Returns an empty object so CSS class lookups resolve to undefined (matching the jest css-transformer +// behaviour for built artifacts). +module.exports = new Proxy( + {}, + { + get(_target, prop) { + if (prop === '__esModule') { + return true; + } + if (prop === 'default') { + return module.exports; + } + // Return the class name as-is so toHaveClass assertions against selector keys still match. + return String(prop); + }, + } +); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-a11y.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-a11y.test.tsx new file mode 100644 index 0000000000..bdce9aec19 --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-a11y.test.tsx @@ -0,0 +1,378 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; + +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; +import createWrapper from '../../../../lib/components/test-utils/dom'; +import BasicTable, { + BasicTableHeader, + BasicTableHeaderCell, + BasicTableBody, + BasicTableRow, + BasicTableCell, + BasicTableExpandedContent, + BasicTableProps, +} from '../../../../lib/components/beta/basic-table-0.1'; + +import './setup'; + +// Accessibility tests for the compound BasicTable. The sub-components spread the useBasicTable hook's +// role/ARIA getters onto native ////`. */ + export interface RowProps extends StructuralPartProps { + /** Zero-based data index of this row. */ + index: number; + /** Stable identity for the row's expansion/region wiring. Required for accessible expansion: + * it links the disclosure toggle's `aria-controls` to the `ExpandedContent` region and lets + * Escape return focus to the toggle. Without it, expansion still renders but loses that wiring. */ + id?: string; + /** Whether the row's `ExpandedContent` region is shown (consumer-controlled). */ + expanded?: boolean; + /** Fired when the row's expansion is toggled from within — e.g. Escape pressed inside the + * region when there is no disclosure toggle to return focus to. Detail is empty. */ + onToggleExpand?: NonCancelableEventHandler; + /** Marks the row as selected: applies the tokenized selected-row surface (background + selected + * border) and sets `aria-selected`. Pair with a composed selection control (a checkbox/radio in + * a leading `Cell`). Avoids styling the selected state through the deprecated `className`. */ + selected?: boolean; + /** Absolute row position for `aria-rowindex`, forwarded so a consumer's own virtualization can + * report a windowed row's true position in the full dataset (overrides the computed value). */ + 'aria-rowindex'?: number; + children?: React.ReactNode; // Cells + optional ExpandedContent + } + + /** Props for `BasicTable.Cell`. Positional by default (Nth Cell = Nth column); pass `columnId` + * to bind by id. May carry a `style` (e.g. a `gridColumnStart` from a consumer's own column + * windowing) and standard cell HTML attributes. */ + export interface CellProps extends StructuralPartProps { + /** Bind to a column by id instead of by position. */ + columnId?: string; + /** Wraps the cell content onto multiple lines instead of truncating it with an ellipsis. + * @defaultValue false */ + wrapText?: boolean; + /** Renders a fixed-width, centered leading control cell (tokenized width + focus chrome) — host + * the control here — instead of a default data cell. `"selection"` for a row selection + * checkbox/radio; `"disclosure"` for the row's expand/collapse toggle. */ + variant?: 'selection' | 'disclosure'; + children?: React.ReactNode; + } + + /** Props for `BasicTable.ExpandedContent` — arbitrary non-tabular expanded detail nested inside + * its `Row`. Reads `expanded`/`id` from its row; renders the labeled region only when the row + * is expanded. */ + export interface ExpandedContentProps { + /** Accessible name for the expanded region, tying it to its row. */ + label?: string; + children: React.ReactNode; + } + + export interface ColumnWidthsDetail { + /** Per-column widths (px), keyed by column INDEX. */ + widths: Record; + } + + export interface StickyColumns { + /** Number of leading columns pinned to the inline-start edge. @defaultValue 0 */ + first?: number; + /** Number of trailing columns pinned to the inline-end edge. @defaultValue 0 */ + last?: number; + } + + export interface I18nStrings { + /** Accessible name for the grid, set as its `aria-label`. Recommended — a data grid should + * always have an accessible name. */ + tableLabel?: string; + /** Custom role description for a column's resize handle. @i18n @defaultValue 'resize handle' */ + resizerRoleDescription?: string; + } +} diff --git a/src/beta/basic-table-0.1/basic-table/internal.tsx b/src/beta/basic-table-0.1/basic-table/internal.tsx new file mode 100644 index 0000000000..3413b01a19 --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/internal.tsx @@ -0,0 +1,561 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useMergeRefs, useSingleTabStopNavigation, useUniqueId } from '@cloudscape-design/component-toolkit/internal'; + +import { getBaseProps } from '../../../internal/base-component'; +import DragHandleWrapper from '../../../internal/components/drag-handle-wrapper'; +import { fireNonCancelableEvent } from '../../../internal/events'; +import { InternalBaseComponentProps } from '../../../internal/hooks/use-base-component'; +import LiveRegion from '../../../live-region/internal'; +import StatusIndicator from '../../../status-indicator/internal'; +import { StickyColumnsCellState, useStickyCellStyles } from '../../../table/sticky-columns'; +import { GridNavigationProvider } from '../../../table/table-role'; +import { + BasicRowContextProvider, + BasicRowContextValue, + BasicTableContextProvider, + ColumnIndexProvider, + useBasicRowContext, + useBasicTableContext, + useColumnIndexContext, +} from './context'; +import { BasicTableProps } from './interfaces'; +import { useBasicTable } from './use-basic-table'; + +import styles from './styles.css.js'; + +// Maps a context name to its themable class so compact density picks up the compact-table visual +// context. +const getVisualContextClassname = (contextType: string) => `awsui-context-${contextType}`; + +// The compound components over the headless `useBasicTable` hook. Columns are a positional width +// config on `Root`; the header and rows are declarative children. Each part self-renders its DOM +// from the hook's prop-getters (read from context) and binds to the Nth column via a positional +// `ColumnIndexContext`, or by an explicit `columnId`. Per-element concerns that need a hook each — +// roving tabindex (`useSingleTabStopNavigation`) and per-cell sticky (`useStickyCellStyles`) — run +// inside Cell / HeaderCell. A virtualizing consumer spreads their own `rowProps` +// (absolute offset + measure ref) onto `Row`. + +// Maps the sticky store's per-cell state to this component's sticky style keys. +function stickyClassNames(state: null | StickyColumnsCellState): Record { + if (!state) { + return {}; + } + return { + [styles['sticky-cell']]: true, + [styles['sticky-cell-pad-inline-start']]: state.padInlineStart, + [styles['sticky-cell-last-inline-start']]: state.lastInsetInlineStart, + [styles['sticky-cell-last-inline-end']]: state.lastInsetInlineEnd, + }; +} + +// Wrap each child of Header / Row in a positional `ColumnIndexProvider` (emits no DOM, so the +// ` + ); +}; + +// Declarative header rowgroup — renders the HeaderCell children it is GIVEN (positional), never +// auto-generated from config. +export const Header = ({ sticky, children }: BasicTableProps.HeaderProps): React.ReactElement => { + const ctx = useBasicTableContext('Header'); + const groupProps = ctx.getHeaderGroupProps(); + return ( + + + {withColumnIndices(children)} + + + ); +}; + +// --- Body -------------------------------------------------------------------- + +// Renders the mapped Row children directly (no harvesting) and carries a consumer's own runway +// ref/style spread when windowing. +export const Body = React.forwardRef(function Body( + { children, className, ...rest }, + ref +) { + const ctx = useBasicTableContext('Body'); + const bodyProps = ctx.getBodyProps(); + return ( + + {children} + + ); +}); + +// --- Cell + ExpandedContent -------------------------------------------------- + +// Self-renders one body cell (` + ); +}; + +// The expanded region is NESTED inside the measured Row (same ``), on a second grid +// line spanning all columns, so windowing only sees "one taller auto row." It reads `expanded`/`id` +// from its row context and owns the region a11y (region role + label + Escape-to-return-focus). The +// disclosure toggle is placed by the consumer in a Cell with id `${Row.id}-toggle`. +export const ExpandedContent = ({ + label, + children, +}: BasicTableProps.ExpandedContentProps): React.ReactElement | null => { + const ctx = useBasicTableContext('ExpandedContent'); + const row = useBasicRowContext('ExpandedContent'); + if (!row.expanded) { + return null; + } + const regionId = row.id ? `${row.id}-region` : undefined; + return ( + + ); +}; + +// --- Row --------------------------------------------------------------------- + +// Renders its Cell / ExpandedContent children directly (no harvesting), wrapping each in a +// positional `ColumnIndexProvider`, and provides the row context so those children learn their +// index / expansion. Static grid props come from the hook; a virtual consumer's spread `rowProps` +// (absolute offset + aria-rowindex override + measure ref) wins. +export const Row = React.forwardRef(function Row( + { index, id, expanded, onToggleExpand, selected, children, className, style, ...rest }, + ref +) { + const ctx = useBasicTableContext('Row'); + const rowProps = ctx.getRowProps(index); + const rowContext = useMemo( + () => ({ + index, + id, + expanded, + onToggleExpand: onToggleExpand ? () => fireNonCancelableEvent(onToggleExpand) : undefined, + }), + [index, id, expanded, onToggleExpand] + ); + return ( + + + {withColumnIndices(children)} + + + ); +}); + +// --- Root -------------------------------------------------------------------- + +type InternalRootProps = BasicTableProps & InternalBaseComponentProps; + +export function InternalRoot(props: InternalRootProps) { + const { + columns, + role = 'grid', + resizableColumns = false, + columnWidths, + onColumnWidthsChange, + stickyColumns, + contentDensity = 'comfortable', + totalRowCount = 0, + height, + maxHeight, + header, + empty, + loading = false, + loadingText, + i18nStrings, + columnLayout = 'fixed', + children, + __internalRootRef, + } = props; + + // Auto column sizing: when `columnLayout="auto"`, measure each flexible column's content width + // from the rendered DOM and feed it back to the hook as fixed tracks (each is its own CSS + // grid sharing one template, so CSS `auto` tracks can't align across rows — content must be + // JS-measured, like Table's first-render width read). Fixed-width columns are left untouched. + const [autoColumnWidths, setAutoColumnWidths] = useState>({}); + + const table = useBasicTable({ + columns, + role, + resizableColumns, + columnWidths, + onColumnWidthsChange, + stickyColumns, + contentDensity, + totalRowCount, + i18nStrings, + columnLayout, + autoColumnWidths, + }); + + const columnCount = table.columnCount; + const showLoading = loading; + const showEmpty = !loading && totalRowCount === 0; + + const stickyFirst = stickyColumns?.first ?? 0; + const stickyLast = stickyColumns?.last ?? 0; + const pageSize = Math.max(1, Math.min(totalRowCount || 1, 100)); + + const tableRef = useRef(null); + const scrollContainerRef = useMergeRefs(table.stickyColumns.refs.wrapper); + const mergedTableRef = useMergeRefs(tableRef, table.stickyColumns.refs.table); + + const baseProps = getBaseProps(props); + const tableProps = table.getTableProps(); + + // Measure content widths for `columnLayout="auto"`. Runs after paint (a brief reflow is + // acceptable and SSR-safe); re-measures when the column config / children change and whenever the + // table box resizes. Single-column cells only (the full-width expanded / state cells carry + // `aria-colspan` and are excluded). Converges: a track set to a cell's ceil(scrollWidth) makes the + // cell fit its content, so the next measurement is stable and the equality guard stops re-renders. + useEffect(() => { + if (columnLayout !== 'auto') { + setAutoColumnWidths(prev => (Object.keys(prev).length === 0 ? prev : {})); + return; + } + const tableNode = tableRef.current; + if (!tableNode || typeof ResizeObserver === 'undefined') { + return; + } + const measure = () => { + const next: Record = {}; + for (let c = 0; c < columnCount; c++) { + if (columns[c]?.width !== undefined) { + continue; // fixed columns keep their configured width + } + const cells = tableNode.querySelectorAll(`[aria-colindex="${c + 1}"]:not([aria-colspan])`); + let max = 0; + cells.forEach(cell => { + max = Math.max(max, cell.scrollWidth); + }); + if (max > 0) { + next[c] = Math.ceil(max); + } + } + setAutoColumnWidths(prev => { + const keys = Object.keys(next); + if (keys.length === Object.keys(prev).length && keys.every(k => prev[Number(k)] === next[Number(k)])) { + return prev; + } + return next; + }); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(tableNode); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnLayout, columnCount, columns, children]); + + return ( +
+ {header &&
{header}
} + + tableRef.current} + > +
+
/
, so the whole compound +// collapses to one role="grid" tree: grid -> rowgroup -> row -> columnheader/gridcell, with +// full-dataset aria-rowcount/rowindex and aria-colcount/colindex coherence and roving-tabindex +// cell-by-cell keyboard navigation via the shared GridNavigationProvider. +// +// Columns are a positional width list; the header is declared with Header/HeaderCell children. +// +// Expansion is nested: there is no auto disclosure column; the consumer renders its own toggle +// button (id `${rowId}-toggle`) inside a Cell and an ExpandedContent region nested in the same Row. +// The region owns its a11y (role="region" + label, colspan, Escape returns focus to the toggle) and +// is marked data-awsui-table-suppress-navigation so the grid navigation leaves its content's arrow +// keys alone. + +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 i18nStrings: BasicTableProps.I18nStrings = { + tableLabel: 'Log events', +}; + +interface TreeOptions { + expandable?: boolean; + singleColumn?: boolean; + expandedItems?: ReadonlyArray; + loading?: boolean; + loadingText?: string; + empty?: React.ReactNode; +} + +const DATA_COLUMNS = 2; + +function detail(item: Item) { + return ( +
+

Log record {item.id}

+
+
Level
+
INFO
+
Message
+
{item.name}
+
+ +
+ ); +} + +// Stateful harness: expansion state lives in the consumer. The toggle and region are +// consumer-rendered; BasicTable only spreads the hook's getters and the row's expansion context. +function LogTable({ items, options }: { items: Item[]; options: TreeOptions }) { + const [expanded, setExpanded] = useState>(new Set(options.expandedItems ?? [])); + const toggle = (id: string) => + setExpanded(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const columns: BasicTableProps.ColumnDefinition[] = options.singleColumn ? [{}] : [{}, {}]; + + return ( + + + Name + {!options.singleColumn && Status} + + + {items.map((item, index) => { + const isExpanded = expanded.has(item.id); + return ( + toggle(item.id)} + > + + {options.expandable && ( + + )} + {item.name} + + {!options.singleColumn && {item.status}} + {options.expandable && ( + + {detail(item)} + + )} + + ); + })} + + + ); +} + +function renderTable(items: Item[], options: TreeOptions = {}) { + const { container, rerender } = render(); + const wrapper = createWrapper(container).findBasicTable()!; + const update = (nextItems: Item[], nextOptions: TreeOptions = options) => + rerender(); + return { container, wrapper, update }; +} + +function getGrid(container: HTMLElement): HTMLElement { + return container.querySelector('[role="grid"]') as HTMLElement; +} +// Helpers: the toggle and region are consumer DOM addressed by their stable ids. +const findToggle = (container: HTMLElement, id: string) => container.querySelector(`#${id}-toggle`) as HTMLElement | null; +const findRegion = (container: HTMLElement, id: string) => container.querySelector(`#${id}-region`) as HTMLElement | null; + +describe('BasicTable (compound components) a11y', () => { + describe('axe / HTML validity', () => { + test('validates a plain compound grid', async () => { + const { container } = renderTable(makeItems(20)); + await expect(container).toValidateA11y(); + }); + + test('validates a grid with collapsed expandable rows (consumer toggles)', async () => { + const { container } = renderTable(makeItems(20), { expandable: true }); + await expect(container).toValidateA11y(); + }); + + test('validates a grid with expanded, labeled nested regions', async () => { + const { container } = renderTable(makeItems(20), { expandable: true, expandedItems: ['row-0', 'row-3'] }); + await expect(container).toValidateA11y(); + }); + + test('validates the reduced single-column shape', async () => { + const { container } = renderTable(makeItems(30), { singleColumn: true }); + await expect(container).toValidateA11y(); + }); + + test('validates the empty and loading states', async () => { + const empty = renderTable([], { empty: No log events }); + await expect(empty.container).toValidateA11y(); + + const loading = renderTable([], { loading: true, loadingText: 'Loading log events' }); + await expect(loading.container).toValidateA11y(); + }); + }); + + describe('compound structure produces a single grid accessibility tree', () => { + test('the compound sub-components collapse to one grid -> rowgroup -> row tree', () => { + const { container } = renderTable(makeItems(20), { expandable: true, expandedItems: ['row-0'] }); + expect(container.querySelectorAll('[role="grid"]')).toHaveLength(1); + const grid = getGrid(container); + + const directChildren = Array.from(grid.children); + expect(directChildren.length).toBeGreaterThan(0); + directChildren.forEach(child => expect(child.getAttribute('role')).toBe('rowgroup')); + + grid.querySelectorAll('[role="row"]').forEach(row => { + expect(row.closest('[role="rowgroup"]')).not.toBeNull(); + }); + grid.querySelectorAll('[role="columnheader"], [role="gridcell"]').forEach(cell => { + expect(cell.closest('[role="row"]')).not.toBeNull(); + }); + }); + + test('the loading state renders as a valid full-width row inside the grid', () => { + const { wrapper, container } = renderTable([], { loading: true, loadingText: 'Loading' }); + const status = wrapper.findLoadingText()!.getElement(); + const cell = status.closest('[role="gridcell"]'); + expect(cell).not.toBeNull(); + expect(cell!.closest('[role="row"]')).not.toBeNull(); + expect(cell!.closest('[role="rowgroup"]')).not.toBeNull(); + expect(getGrid(container)).not.toBeNull(); + }); + }); + + describe('keyboard navigation (shared roving-tabindex grid model)', () => { + test('the grid container is programmatically focusable but NOT a tab stop, and has no active descendant', () => { + const { container } = renderTable(makeItems(20)); + const grid = getGrid(container); + expect(grid.getAttribute('role')).toBe('grid'); + expect(grid.getAttribute('tabindex')).toBe('-1'); + expect(grid.getAttribute('aria-activedescendant')).toBeNull(); + }); + + test('cells are native
/ carrying role + aria-colindex', () => { + const { wrapper } = renderTable(makeItems(20)); + expect(wrapper.findColumnHeaders()[0].getElement().tagName).toBe('TH'); + const row0 = wrapper.findRowByIndex(0)!.getElement(); + const cells = Array.from(row0.querySelectorAll('[role="gridcell"]')); + expect(cells[0].tagName).toBe('TD'); + expect(cells[0].getAttribute('aria-colindex')).toBe('1'); + expect(cells[1].getAttribute('aria-colindex')).toBe('2'); + }); + + test('exactly one cell is the roving tab stop (tabindex 0) after mount', async () => { + const { container } = renderTable(makeItems(20)); + const grid = getGrid(container); + await waitFor(() => expect(grid.querySelectorAll('[tabindex="0"]')).toHaveLength(1)); + const target = grid.querySelector('[tabindex="0"]')!; + expect(['TD', 'TH']).toContain(target.tagName); + expect(target.getAttribute('role')).toMatch(/columnheader|gridcell/); + }); + + test('Arrow keys move focus cell-by-cell (Left/Right within a row, Up/Down across rows); Home/End to row ends', async () => { + const { container, wrapper } = renderTable(makeItems(20)); + const grid = getGrid(container); + + await waitFor(() => expect(grid.querySelector('[tabindex="0"]')).not.toBeNull()); + const firstTarget = grid.querySelector('[tabindex="0"]') as HTMLElement; + firstTarget.focus(); + expect(document.activeElement).toBe(firstTarget); + const colOf = () => document.activeElement?.getAttribute('aria-colindex'); + const rowOf = () => document.activeElement?.closest('[role="row"]')?.getAttribute('aria-rowindex'); + expect(colOf()).toBe('1'); + expect(rowOf()).toBe('1'); // header row + + fireEvent.keyDown(grid, { keyCode: KeyCode.right }); + expect(colOf()).toBe('2'); + fireEvent.keyDown(grid, { keyCode: KeyCode.left }); + expect(colOf()).toBe('1'); + + fireEvent.keyDown(grid, { keyCode: KeyCode.down }); + expect(rowOf()).toBe('2'); // data row 0 (header is 1) + expect(colOf()).toBe('1'); + expect(document.activeElement).toBe(wrapper.findRowByIndex(0)!.getElement().querySelectorAll('[role="gridcell"]')[0]); + + fireEvent.keyDown(grid, { keyCode: KeyCode.end }); + expect(colOf()).toBe('2'); + fireEvent.keyDown(grid, { keyCode: KeyCode.home }); + expect(colOf()).toBe('1'); + + fireEvent.keyDown(grid, { keyCode: KeyCode.up }); + expect(rowOf()).toBe('1'); + expect(colOf()).toBe('1'); + }); + + test('does not hijack arrow keys originating inside the expanded region', () => { + const { container } = renderTable(makeItems(20), { expandable: true, expandedItems: ['row-0'] }); + const grid = getGrid(container); + + const innerButton = findRegion(container, 'row-0')!.querySelector('button')!; + innerButton.focus(); + expect(document.activeElement).toBe(innerButton); + + fireEvent.keyDown(grid, { keyCode: KeyCode.down }); + expect(document.activeElement).toBe(innerButton); + }); + }); + + describe('decision-2 nested expansion wiring', () => { + test('the consumer disclosure control reflects aria-expanded / aria-controls across a toggle', () => { + const { container } = renderTable(makeItems(10), { expandable: true }); + const toggle = findToggle(container, 'row-0')!; + + expect(toggle.tagName).toBe('BUTTON'); + expect(toggle.getAttribute('aria-label')).toBe('Expand details for Resource 0'); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + expect(toggle.getAttribute('aria-controls')).toBeNull(); + expect(findRegion(container, 'row-0')).toBeNull(); + + fireEvent.click(toggle); + + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + const region = findRegion(container, 'row-0')!; + expect(region).not.toBeNull(); + expect(toggle.getAttribute('aria-controls')).toBe(region.id); + expect(region.getAttribute('role')).toBe('region'); + }); + + test('the expanded region carries the consumer-supplied accessible name', () => { + const { container } = renderTable(makeItems(10), { expandable: true, expandedItems: ['row-2'] }); + const region = findRegion(container, 'row-2')!; + expect(region.getAttribute('aria-label')).toBe('Details for Resource 2'); + }); + + test('Escape inside the expanded region returns focus to its disclosure toggle (source-wired)', () => { + const { container } = renderTable(makeItems(10), { expandable: true, expandedItems: ['row-0'] }); + const toggle = findToggle(container, 'row-0')!; + const innerButton = findRegion(container, 'row-0')!.querySelector('button')!; + + innerButton.focus(); + expect(document.activeElement).toBe(innerButton); + + fireEvent.keyDown(innerButton, { key: 'Escape' }); + expect(document.activeElement).toBe(toggle); + }); + + test('the expanded region content is reachable (not aria-hidden / inert / disabled)', () => { + const { container } = renderTable(makeItems(10), { expandable: true, expandedItems: ['row-0'] }); + const innerButton = findRegion(container, 'row-0')!.querySelector('button') as HTMLButtonElement; + expect(innerButton.closest('[aria-hidden="true"]')).toBeNull(); + expect(innerButton.closest('[inert]')).toBeNull(); + expect(innerButton.hasAttribute('disabled')).toBe(false); + innerButton.focus(); + expect(document.activeElement).toBe(innerButton); + }); + }); + + describe('full-dataset ARIA coherence', () => { + test('aria-rowcount counts the header once; aria-colcount is the configured column count (no disclosure column)', () => { + const { container } = renderTable(makeItems(500), { expandable: true }); + const grid = getGrid(container); + expect(grid.getAttribute('aria-rowcount')).toBe('501'); + expect(grid.getAttribute('aria-colcount')).toBe(String(DATA_COLUMNS)); + }); + + test('the header row is aria-rowindex 1 with the first data column at aria-colindex 1', () => { + const { wrapper } = renderTable(makeItems(500), { expandable: true }); + const header = wrapper.findHeaderRow()!.getElement(); + expect(header.getAttribute('aria-rowindex')).toBe('1'); + const headers = header.querySelectorAll('[role="columnheader"]'); + expect(headers[0].getAttribute('aria-colindex')).toBe('1'); + expect(headers[1].getAttribute('aria-colindex')).toBe('2'); + }); + + test('data rows carry a full-dataset aria-rowindex and 1-based cell colindex', () => { + const { wrapper } = renderTable(makeItems(500)); + const row0 = wrapper.findRowByIndex(0)!.getElement(); + expect(row0.getAttribute('aria-rowindex')).toBe('2'); + const cells = row0.querySelectorAll('[role="gridcell"]'); + expect(cells[0].getAttribute('aria-colindex')).toBe('1'); + expect(cells[1].getAttribute('aria-colindex')).toBe('2'); + }); + + test('the nested expanded region shares its data row index and spans all columns without changing aria-rowcount', () => { + const { container, wrapper } = renderTable(makeItems(500), { expandable: true, expandedItems: ['row-0'] }); + // Expanding a row does not add to the row count (nested expansion is one taller row). + expect(getGrid(container).getAttribute('aria-rowcount')).toBe('501'); + + const region = findRegion(container, 'row-0')!; + const expandedRow = region.closest('[role="row"]')!; + expect(expandedRow.getAttribute('aria-rowindex')).toBe('2'); // shares its data row's index + expect(expandedRow).toBe(wrapper.findRowByIndex(0)!.getElement()); + + const expandedCell = region.closest('[role="gridcell"]')!; + expect(expandedCell.getAttribute('aria-colindex')).toBe('1'); + expect(expandedCell.getAttribute('aria-colspan')).toBe(String(DATA_COLUMNS)); + }); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-i18n.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-i18n.test.tsx new file mode 100644 index 0000000000..6a2b40c47a --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-i18n.test.tsx @@ -0,0 +1,76 @@ +// 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 BasicTable, { + BasicTableHeader, + BasicTableHeaderCell, + BasicTableBody, + BasicTableRow, + BasicTableCell, + BasicTableProps, +} from '../../../../lib/components/beta/basic-table-0.1'; + +// Localization is prop-driven: the consumer passes already-localized strings through the typed +// `i18nStrings` object and the component reads them directly. There is no I18nProvider runtime in +// this package. The resize-handle role description has a hardcoded English fallback for when no +// value is supplied. + +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' })); + +// resizableColumns renders the resize handle whose toggle carries aria-roledescription. +function buildTree(i18nStrings?: BasicTableProps.I18nStrings) { + const columns: BasicTableProps.ColumnDefinition[] = [ + { id: 'name', minWidth: 120 }, + { id: 'status' }, // flexible (no width → shares remaining space) + ]; + const items = makeItems(10); + return ( + + + Name + Status + + + {items.map((item, index) => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +// The resize toggle button owns aria-roledescription. +const getResizerRoleDescription = (container: HTMLElement) => + container.querySelector('[aria-roledescription]')!.getAttribute('aria-roledescription'); + +const getTableLabel = (container: HTMLElement) => + container.querySelector('[role="grid"]')!.getAttribute('aria-label'); + +describe('BasicTable i18nStrings passthrough (#20)', () => { + test('(a) resizerRoleDescription passes through to the resize handle', () => { + const { container } = render(buildTree({ resizerRoleDescription: 'width handle' })); + expect(getResizerRoleDescription(container)).toBe('width handle'); + }); + + test('(b) tableLabel passes through to the grid aria-label', () => { + const { container } = render(buildTree({ tableLabel: 'Resources' })); + expect(getTableLabel(container)).toBe('Resources'); + }); + + test('(c) no i18nStrings: the hardcoded English resize-handle fallback remains', () => { + const { container } = render(buildTree()); + expect(getResizerRoleDescription(container)).toBe('resize handle'); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-resize.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-resize.test.tsx new file mode 100644 index 0000000000..5af0323dea --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-resize.test.tsx @@ -0,0 +1,181 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; + +import BasicTable, { + BasicTableHeader, + BasicTableHeaderCell, + BasicTableBody, + BasicTableRow, + BasicTableCell, + BasicTableProps, +} from '../../../../lib/components/beta/basic-table-0.1'; + +// Keyboard and screen-reader tests for the column resize handle. Each HeaderCell renders its own +// resize handle when resizableColumns is set. The handle has a two-element model: a focusable toggle +// (a single roving tab stop in its header cell) plus a role="slider" separator that owns Left/Right +// while in keyboard-drag mode and is marked data-awsui-table-suppress-navigation so the grid +// navigation does not hijack those keys. +// +// jsdom has no layout (getBoundingClientRect() === 0), so width is driven off a controlled +// columnWidths map (fed back through onColumnWidthsChange), not a measured DOM width. + +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 i18nStrings: BasicTableProps.I18nStrings = { tableLabel: 'Resources' }; + +const NAME_START_WIDTH = 200; +const STATUS_WIDTH = 150; +const NAME_MIN_WIDTH = 120; + +// A controlled harness that feeds onColumnWidthsChange back into columnWidths, so aria-valuenow +// and the freeze-on-resize basis track the latest width across repeated keyboard steps. +function ResizableTable({ onWidths, nameMinWidth }: { onWidths: jest.Mock; nameMinWidth?: number }) { + const [widths, setWidths] = useState>({ 0: NAME_START_WIDTH, 1: STATUS_WIDTH }); + const columns: BasicTableProps.ColumnDefinition[] = [ + { id: 'name', minWidth: nameMinWidth }, + { id: 'status' }, // flexible (no width → shares remaining space) + ]; + return ( + { + onWidths(event.detail); + setWidths(event.detail.widths); + }} + i18nStrings={i18nStrings} + > + + Name + Status + + + {makeItems(20).map((item, index) => ( + + {item.name} + {item.status} + + ))} + + + ); +} + +function renderResizable(opts: { nameMinWidth?: number } = { nameMinWidth: NAME_MIN_WIDTH }) { + const onWidths = jest.fn(); + const { container } = render(); + const grid = container.querySelector('[role="grid"]') as HTMLElement; + // The declarative header renders one columnheader per column; the first is the "name" column. + const nameHeader = grid.querySelectorAll('[role="columnheader"]')[0] as HTMLElement; + const getToggle = () => nameHeader.querySelector('button') as HTMLButtonElement; + const getSeparator = () => nameHeader.querySelector('[role="slider"]') as HTMLElement; + const lastWidths = () => onWidths.mock.calls[onWidths.mock.calls.length - 1][0].widths as Record; + return { container, grid, nameHeader, getToggle, getSeparator, onWidths, lastWidths }; +} + +describe('BasicTable resize handle keyboard + SR a11y (#8)', () => { + test('the toggle has an accessible name (aria-labelledby -> header text) and resize roledescription', () => { + const { nameHeader, getToggle } = renderResizable(); + const toggle = getToggle(); + expect(toggle.tagName).toBe('BUTTON'); + expect(toggle.getAttribute('aria-roledescription')).toBe('resize handle'); + + const labelledby = toggle.getAttribute('aria-labelledby'); + expect(labelledby).toBeTruthy(); + const labelEl = document.getElementById(labelledby!); + expect(labelEl).toBe(nameHeader); + expect(labelEl!.textContent).toContain('Name'); + }); + + test('the toggle is the single roving tab stop in its header cell (tabIndex 0)', async () => { + const { grid, getToggle } = renderResizable(); + await waitFor(() => expect(getToggle().tabIndex).toBe(0)); + expect(grid.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + }); + + test('the separator is a slider, suppresses grid navigation, and exposes width via ARIA (hidden until dragging)', () => { + const { getSeparator } = renderResizable(); + const separator = getSeparator(); + expect(separator.getAttribute('role')).toBe('slider'); + expect(separator.hasAttribute('data-awsui-table-suppress-navigation')).toBe(true); + expect(separator.getAttribute('tabindex')).toBe('-1'); + expect(separator.getAttribute('aria-valuemin')).toBe(String(NAME_MIN_WIDTH)); + expect(separator.getAttribute('aria-valuenow')).toBe(String(NAME_START_WIDTH)); + // The slider is effectively unbounded above; instead of a nonsensical numeric max it exposes a + // human-readable width via aria-valuetext. + expect(separator.hasAttribute('aria-valuemax')).toBe(false); + expect(separator.getAttribute('aria-valuetext')).toBe(`${NAME_START_WIDTH} pixels`); + expect(separator.getAttribute('aria-hidden')).toBe('true'); + }); + + test.each(['Enter', ' '])('%s on the toggle enters keyboard-drag mode: separator shown + focused', key => { + const { getToggle, getSeparator } = renderResizable(); + fireEvent.keyDown(getToggle(), { key }); + expect(getSeparator().getAttribute('aria-hidden')).toBe('false'); + expect(document.activeElement).toBe(getSeparator()); + }); + + test('ArrowRight/ArrowLeft on the separator adjust the width by 10px and clamp at minWidth', () => { + const { getToggle, getSeparator, lastWidths } = renderResizable(); + + fireEvent.keyDown(getToggle(), { key: 'Enter' }); + + // ArrowRight: 200 -> 210. + fireEvent.keyDown(getSeparator(), { key: 'ArrowRight' }); + expect(lastWidths()[0]).toBe(NAME_START_WIDTH + 10); + expect(getSeparator().getAttribute('aria-valuenow')).toBe(String(NAME_START_WIDTH + 10)); + + // ArrowLeft: 210 -> 200. + fireEvent.keyDown(getSeparator(), { key: 'ArrowLeft' }); + expect(lastWidths()[0]).toBe(NAME_START_WIDTH); + + // Repeated ArrowLeft clamps at minWidth and never goes below it. + for (let i = 0; i < 30; i++) { + fireEvent.keyDown(getSeparator(), { key: 'ArrowLeft' }); + expect(lastWidths()[0]).toBeGreaterThanOrEqual(NAME_MIN_WIDTH); + } + expect(lastWidths()[0]).toBe(NAME_MIN_WIDTH); + // The other column is untouched by name's resize. + expect(lastWidths()[1]).toBe(STATUS_WIDTH); + }); + + test('Escape exits keyboard-drag mode: separator hidden again + focus returns to the toggle', () => { + const { getToggle, getSeparator } = renderResizable(); + const toggle = getToggle(); + + fireEvent.keyDown(toggle, { key: 'Enter' }); + expect(getSeparator().getAttribute('aria-hidden')).toBe('false'); + expect(document.activeElement).toBe(getSeparator()); + + fireEvent.keyDown(getSeparator(), { key: 'Escape' }); + expect(getSeparator().getAttribute('aria-hidden')).toBe('true'); + expect(document.activeElement).toBe(toggle); + }); + + // A column with no configured minWidth floors every resize at DEFAULT_COLUMN_WIDTH (120): the hook + // clamps at `col.minWidth ?? DEFAULT_COLUMN_WIDTH` (use-basic-table.ts resizeFloors / + // resizeMinWidthOf), so an un-configured column cannot collapse toward 0. + test('R1: a column with NO configured minWidth clamps at DEFAULT_COLUMN_WIDTH (120)', () => { + const { getToggle, getSeparator, lastWidths } = renderResizable({}); + // No configured minWidth -> the resize floor (and aria-valuemin) is the default 120, not 0. + expect(getSeparator().getAttribute('aria-valuemin')).toBe('120'); + + fireEvent.keyDown(getToggle(), { key: 'Enter' }); + // 30 ArrowLefts from 200px would reach -100 unclamped; the width clamps at the 120 default floor. + for (let i = 0; i < 30; i++) { + fireEvent.keyDown(getSeparator(), { key: 'ArrowLeft' }); + } + expect(lastWidths()[0]).toBe(120); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-sticky-columns.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-sticky-columns.test.tsx new file mode 100644 index 0000000000..e73a15818f --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-sticky-columns.test.tsx @@ -0,0 +1,159 @@ +// 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 BasicTable, { + BasicTableHeader, + BasicTableHeaderCell, + BasicTableBody, + BasicTableRow, + BasicTableCell, + BasicTableProps, +} from '../../../../lib/components/beta/basic-table-0.1'; + +import styles from '../../../../lib/components/beta/basic-table-0.1/basic-table/styles.css.js'; + +// Tests for sticky (pinned) columns. Sticky styling comes from the shared sticky-columns primitive: +// useStickyColumns measures cumulative offsets and disables itself when the table is not scrollable +// or too narrow, and useStickyCellStyles toggles the sticky classes on each header/body cell. +// +// jsdom has no layout — getBoundingClientRect() returns 0 — so the primitive's isEnabled check is +// always false and no sticky class would apply. getBoundingClientRect is mocked per tag (a wide +// table, a narrower scroll-container div, fixed-width cells) to drive isEnabled=true so the base +// sticky-cell class lands on the pinned cells. +// +// The boundary-shadow classes (sticky-cell-last-inline-start / -last-inline-end) depend on scroll +// "stuck" state (wrapper.scrollWidth/clientWidth/scrollLeft, all 0 in jsdom and not mockable via +// getBoundingClientRect), so only the base sticky-cell class (position and which columns are pinned) +// is asserted, never the stuck-state shadow variants. + +interface Item { + id: string; + a: string; + b: string; + c: string; + d: string; +} + +const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, i) => ({ id: `row-${i}`, a: `A${i}`, b: `B${i}`, c: `C${i}`, d: `D${i}` })); + +const i18nStrings: BasicTableProps.I18nStrings = { tableLabel: 'Resources' }; + +const COLUMNS = ['a', 'b', 'c', 'd'] as const; + +const WRAPPER_WIDTH = 800; +const TABLE_WIDTH = 2000; +const CELL_WIDTH = 100; + +function rect(width: number): DOMRect { + return { width, height: 20, top: 0, left: 0, right: width, bottom: 20, x: 0, y: 0, toJSON: () => ({}) } as DOMRect; +} + +function mockLayout() { + return jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.tagName === 'TABLE') { + return rect(TABLE_WIDTH); + } + if (this.tagName === 'TD' || this.tagName === 'TH') { + return rect(CELL_WIDTH); + } + return rect(WRAPPER_WIDTH); + }); +} + +function renderTable(stickyColumns?: BasicTableProps.StickyColumns) { + const columns: BasicTableProps.ColumnDefinition[] = COLUMNS.map(id => ({ + id, + width: 150, + })); + const items = makeItems(20); + const { container } = render( + + + {COLUMNS.map(id => ( + + {id.toUpperCase()} + + ))} + + + {items.map((item, index) => ( + + {COLUMNS.map(id => ( + + {item[id]} + + ))} + + ))} + + + ); + const grid = container.querySelector('[role="grid"]') as HTMLElement; + const headers = Array.from(grid.querySelectorAll('[role="columnheader"]')) as HTMLElement[]; + const firstRow = grid.querySelector('[role="row"][aria-rowindex="2"]') as HTMLElement; + const bodyCells = Array.from(firstRow.querySelectorAll('[role="gridcell"]')) as HTMLElement[]; + return { container, grid, headers, bodyCells }; +} + +const STICKY = styles['sticky-cell']; + +describe('BasicTable sticky columns (#6)', () => { + let layoutSpy: ReturnType; + beforeEach(() => { + layoutSpy = mockLayout(); + }); + afterEach(() => { + layoutSpy.mockRestore(); + }); + + test('stickyColumns={{ first: 1 }} pins the first column header and body cell (not the others)', () => { + const { headers, bodyCells } = renderTable({ first: 1 }); + expect(headers).toHaveLength(4); + + expect(headers[0]).toHaveClass(STICKY); + expect(bodyCells[0]).toHaveClass(STICKY); + + expect(headers[1]).not.toHaveClass(STICKY); + expect(bodyCells[1]).not.toHaveClass(STICKY); + expect(headers[3]).not.toHaveClass(STICKY); + }); + + test('stickyColumns={{ last: 1 }} pins the last column header and body cell (not the first)', () => { + const { headers, bodyCells } = renderTable({ last: 1 }); + + const lastIndex = headers.length - 1; + expect(headers[lastIndex]).toHaveClass(STICKY); + expect(bodyCells[bodyCells.length - 1]).toHaveClass(STICKY); + + expect(headers[0]).not.toHaveClass(STICKY); + expect(bodyCells[0]).not.toHaveClass(STICKY); + }); + + test('no stickyColumns prop: no header or body cell is sticky (feature inert)', () => { + const { headers, bodyCells } = renderTable(); + for (const cell of [...headers, ...bodyCells]) { + expect(cell).not.toHaveClass(STICKY); + } + }); + + test('smoke: setting stickyColumns does not change the rendered column/row structure', () => { + const withSticky = renderTable({ first: 1, last: 1 }); + const withoutSticky = renderTable(); + + expect(withSticky.headers).toHaveLength(4); + expect(withoutSticky.headers).toHaveLength(4); + + const rows = withSticky.grid.querySelectorAll('[role="row"]'); + expect(rows.length).toBeGreaterThan(1); // header row + data rows + expect(withSticky.bodyCells).toHaveLength(4); + }); +}); diff --git a/src/beta/basic-table-0.1/__tests__/basic-table-styling-props.test.tsx b/src/beta/basic-table-0.1/__tests__/basic-table-styling-props.test.tsx new file mode 100644 index 0000000000..d559db835c --- /dev/null +++ b/src/beta/basic-table-0.1/__tests__/basic-table-styling-props.test.tsx @@ -0,0 +1,177 @@ +// 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 { renderHook } from '../../../__tests__/render-hook'; +import BasicTable, { + BasicTableHeader, + BasicTableHeaderCell, + BasicTableBody, + BasicTableRow, + BasicTableCell, + BasicTableProps, +} from '../../../../lib/components/beta/basic-table-0.1'; +import { useBasicTable } from '../basic-table/use-basic-table'; + +import styles from '../../../../lib/components/beta/basic-table-0.1/basic-table/styles.css.js'; + +// Proves the semantic styling props that close the dead-hook gaps reach their (otherwise +// unreachable, CSS-module-hashed) style hooks — without the deprecated `className` lever: +// RowProps.selected -> .row-selected + aria-selected; CellProps/HeaderCellProps wrapText -> .cell-wrap; +// variant="selection"|"disclosure" -> the shared control-column chrome; and the headless +// columnLayout="auto" branch consuming measured content widths. + +const COLUMNS: ReadonlyArray = [{}, {}]; + +function Harness({ rowProps, cells }: { rowProps?: Partial; cells?: React.ReactNode }) { + return ( + + + Name + Status + + + + {cells ?? ( + <> + Resource 0 + Available + + )} + + + + ); +} + +describe('RowProps.selected', () => { + test('applies the tokenized selected-row hook and aria-selected when selected', () => { + const { container } = render(); + const row = container.querySelector('[aria-rowindex="2"]')!; + expect(row).toHaveAttribute('aria-selected', 'true'); + expect(row.classList.contains(styles['row-selected'])).toBe(true); + }); + + test('omits aria-selected and the selected hook when not provided', () => { + const { container } = render(); + const row = container.querySelector('[aria-rowindex="2"]')!; + expect(row).not.toHaveAttribute('aria-selected'); + expect(row.classList.contains(styles['row-selected'])).toBe(false); + }); +}); + +describe('wrapText', () => { + test('Cell wrapText applies the .cell-wrap hook', () => { + const { container } = render( + + Resource 0 + Available + + } + /> + ); + const cells = container.querySelectorAll('[role="gridcell"]'); + expect(cells[0].classList.contains(styles['cell-wrap'])).toBe(true); + expect(cells[1].classList.contains(styles['cell-wrap'])).toBe(false); + }); + + test('HeaderCell wrapText applies the .cell-wrap hook', () => { + const { container } = render( + + + A long header label + Status + + + + ); + const headers = container.querySelectorAll('[role="columnheader"]'); + expect(headers[0].classList.contains(styles['cell-wrap'])).toBe(true); + expect(headers[1].classList.contains(styles['cell-wrap'])).toBe(false); + }); +}); + +describe('variant control columns', () => { + test('Cell variant="selection" uses the selection-cell chrome (not the default cell)', () => { + const { container } = render( + + + + + Resource 0 + + } + /> + ); + const cells = container.querySelectorAll('[role="gridcell"]'); + expect(cells[0].classList.contains(styles['selection-cell'])).toBe(true); + expect(cells[0].classList.contains(styles.cell)).toBe(false); + }); + + test('Cell variant="disclosure" uses the disclosure-cell chrome', () => { + const { container } = render( + + + `/`` stays a direct +// grid child), giving the Nth `HeaderCell`/`Cell` its column index WITHOUT the parent probing +// `child.type`. A cell that sets an explicit `columnId` ignores this and resolves by id instead. +export const ColumnIndexContext = createContext(null); +export const ColumnIndexProvider = ColumnIndexContext.Provider; + +export function useColumnIndexContext(): number | null { + return useContext(ColumnIndexContext); +} diff --git a/src/beta/basic-table-0.1/basic-table/index.tsx b/src/beta/basic-table-0.1/basic-table/index.tsx new file mode 100644 index 0000000000..4c48f8eb91 --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/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 useBaseComponent from '../../../internal/hooks/use-base-component'; +import { applyDisplayName } from '../../../internal/utils/apply-display-name'; +import { BasicTableProps } from './interfaces'; +import { InternalRoot } from './internal'; + +// The root BasicTable component (the beta module's default export). Documented as `BasicTable` (this +// dir's basename). The parts (BasicTableRow, …) live in sibling dirs; the hooks + shared types are +// re-exported from the beta entry (../index), not here, so the documenter treats this dir as a single +// component with a single props type. +export type { BasicTableProps }; + +function BasicTable({ + columnLayout = 'fixed', + role = 'grid', + loading = false, + resizableColumns = false, + ...props +}: BasicTableProps) { + const baseComponentProps = useBaseComponent('BasicTable', { + props: { columnLayout, role, resizableColumns }, + }); + return ( + + ); +} + +applyDisplayName(BasicTable, 'BasicTable'); + +export default BasicTable; diff --git a/src/beta/basic-table-0.1/basic-table/interfaces.ts b/src/beta/basic-table-0.1/basic-table/interfaces.ts new file mode 100644 index 0000000000..407cc8b83f --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/interfaces.ts @@ -0,0 +1,264 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../../../internal/base-component'; +import { NonCancelableEventHandler } from '../../../internal/events'; + +// Public types for BasicTable: the headless `useBasicTable` hook config and the compound-component +// props. BasicTable renders the header and rows it is given (declarative `Header` / `HeaderCell` / +// `Body` / `Row` / `Cell` / `ExpandedContent` children; there is no `items` prop) and owns no data. +// It is windowing-free and owns no virtualization — to window a large dataset, a consumer brings +// their own virtualization and spreads the resulting offset/measure props onto `Body` / `Row`. +// +// Columns are a positional width list: `columns: [{ width?, minWidth?, id? }, ...]`, matched to +// cells by position (Nth HeaderCell/Cell = Nth column). A flexible column omits `width` +// (`minmax(minWidth, 1fr)`); a fixed column sets `width`. A stable `id` is only needed to bind a +// cell by id rather than by position. + +/** Config for the headless `useBasicTable` hook and, by extension, `BasicTable.Root`. */ +export interface UseBasicTableConfig { + /** Positional column layout list (the single source of column WIDTH authority). One entry per + * column, in order; the Nth HeaderCell/Cell binds to the Nth entry. */ + columns: ReadonlyArray; + + /** Semantic role of the grid. `"grid"` (default) for interactive keyboard nav; `"table"` for + * static data. @defaultValue "grid" */ + role?: BasicTableProps.Role; + + /** Enables column resize handles. Emits via `onColumnWidthsChange`. @defaultValue false */ + resizableColumns?: boolean; + /** Controlled per-column widths (px), keyed by column INDEX. Uncontrolled if omitted. */ + columnWidths?: Record; + onColumnWidthsChange?: NonCancelableEventHandler; + + /** Column layout. `"fixed"` (default) uses the positional width config (flexible columns share + * remaining space as `1fr`); `"auto"` sizes each flexible column to its measured content width. + * @defaultValue "fixed" */ + columnLayout?: BasicTableProps.ColumnLayout; + /** Measured content widths (px) keyed by column INDEX, applied only when `columnLayout="auto"`. + * `BasicTable.Root` measures these from the DOM; a headless consumer of the hook may supply its + * own measurements. */ + autoColumnWidths?: Record; + + /** Pins a number of leading (`first`) / trailing (`last`) columns during horizontal scroll. */ + stickyColumns?: BasicTableProps.StickyColumns; + + /** `"compact"` reduces cell padding via the shared Cloudscape compact-table context. + * @defaultValue "comfortable" */ + contentDensity?: 'comfortable' | 'compact'; + + /** True total dataset row count — authoritative source for the grid's `aria-rowcount` / SR count + * and empty detection (needed because the windowed consumer only renders a slice). */ + totalRowCount?: number; + + /** Localized accessibility strings and label functions. @i18n */ + i18nStrings?: BasicTableProps.I18nStrings; +} + +// NOTE: `UseBasicTableResult` (the hook's return type) lives in `./use-basic-table` because it +// references internal Cloudscape types (the sticky-columns model). + +/** Return shapes of the `useBasicTable` prop-getters (spreadable DOM props, no hooks). */ +export namespace BasicTableGetters { + export interface TableProps { + role: BasicTableProps.Role; + 'aria-rowcount': number; + 'aria-colcount': number; + 'aria-label'?: string; + tabIndex: number; + } + export interface HeaderGroupProps { + role: 'row'; + 'aria-rowindex': number; + style: React.CSSProperties; + } + export interface ColumnHeaderProps { + role: 'columnheader'; + 'aria-colindex': number; + scope: 'col'; + } + export interface ResizeHandleProps { + 'aria-roledescription': string; + onPointerDown: (event: React.PointerEvent) => void; + } + export interface BodyProps { + role: 'rowgroup'; + } + export interface RowProps { + role: 'row'; + 'aria-rowindex': number; + style: React.CSSProperties; + } + export interface CellProps { + role: 'gridcell' | 'cell'; + 'aria-colindex': number; + 'data-awsui-row-index'?: number; + } +} + +/** Props for `BasicTable.Root`. Extends the headless config with the presentational shell + * (viewport height, empty/loading) and the compound children (Header + Body). */ +export interface BasicTableProps extends BaseComponentProps, UseBasicTableConfig { + /** Compound children — a declarative `Header` plus a `Body` (the mapped `Row`s). */ + children: React.ReactNode; + + /** Column layout. `"fixed"` (default) applies fixed table layout; `"auto"` sizes to content. + * @defaultValue "fixed" */ + columnLayout?: BasicTableProps.ColumnLayout; + + /** Fixed height (px) of the scroll viewport. */ + height?: number; + /** Maximum height (px) of the scroll viewport. */ + maxHeight?: number; + + /** Header slot above the grid (title, counter, actions). */ + header?: React.ReactNode; + /** Rendered when there are no rows. */ + empty?: React.ReactNode; + /** Loading state for the whole grid. */ + loading?: boolean; + loadingText?: string; +} + +/** Arbitrary `data-*` attributes forwarded to a part's root element — e.g. the `data-index` + * measurement hook a 3rd-party virtualization library (react-window, TanStack Virtual, …) sets on + * each row. */ +interface DataAttributes { + [key: `data-${string}`]: string | number | boolean | undefined; +} + +/** Minimal DOM pass-through shared by BasicTable's structural parts. Deliberately NOT the full + * `HTMLAttributes` surface: it omits the event-handler grab-bag (which the documenter can't + * serialize and which over-exposes the DOM), keeping only what a consumer — or a 3rd-party + * virtualization library — actually forwards: styling hooks and `data-*` measurement attributes. */ +interface StructuralPartProps extends DataAttributes { + /** Extra CSS class applied to the part's root element. */ + className?: string; + /** Inline styles applied to the part's root element — e.g. the absolute-offset / `transform` + * a consumer's own virtualization spreads onto each row or onto the body runway. */ + style?: React.CSSProperties; +} + +export namespace BasicTableProps { + export type Role = 'grid' | 'table'; + export type ColumnLayout = 'fixed' | 'auto'; + + /** A column's layout authority — a positional entry (order is the identity). A flexible column + * omits `width` (`minmax(minWidth, 1fr)`); a fixed column sets `width`. A stable `id` is only + * needed to bind a cell by id rather than by position. */ + export interface ColumnDefinition { + /** Fixed track width (px). Omit for a flexible track that shares remaining space. */ + width?: number; + /** Minimum track width (px) — the `minmax` floor for a flexible track, and the resize floor. */ + minWidth?: number; + /** Stable column identifier — only needed to bind a `HeaderCell`/`Cell` by id + * rather than by position. */ + id?: string; + } + + /** Props for `BasicTable.Header` — a declarative header rowgroup. Renders the `HeaderCell` + * children it is GIVEN (positional); BasicTable never auto-generates the header from config. */ + export interface HeaderProps { + /** Renders a sticky header. @defaultValue false */ + sticky?: boolean; + children?: React.ReactNode; + } + + /** Props for `BasicTable.HeaderCell` — self-renders one column header (``). Positional by + * default (Nth HeaderCell = Nth column); pass `columnId` to bind by id. To make a column + * sortable, set `aria-sort` here (spread through) and render your own sort control in the + * children — the table holds no sort state. */ + export interface HeaderCellProps extends StructuralPartProps { + /** Bind to a column by id instead of by position. */ + columnId?: string; + /** Sort indicator for a sortable column, forwarded to the header cell's `aria-sort`. BasicTable + * holds no sort state — render your own sort control in `children` and manage sorting yourself. */ + 'aria-sort'?: React.AriaAttributes['aria-sort']; + /** Wraps the header content onto multiple lines instead of truncating it with an ellipsis. + * @defaultValue false */ + wrapText?: boolean; + /** Renders a fixed-width, centered leading control-column header (tokenized width + focus + * chrome) instead of a default data-column header. `"selection"` for a select-all checkbox + * column; `"disclosure"` for an expand/collapse column. */ + variant?: 'selection' | 'disclosure'; + children?: React.ReactNode; + } + + /** Props for `BasicTable.Body`. Renders ELEMENT children (Row elements) — not a function-child. + * Accepts a consumer's own virtualization runway props (style + ref) when windowing. */ + export interface BodyProps extends StructuralPartProps { + children?: React.ReactNode; + } + + /** Props for `BasicTable.Row`. Structural — carries NO `item`/data. `index` is the row's + * data index (drives `aria-rowindex` and cell wiring). Accepts standard row HTML attributes + * (including a `style`/`aria-rowindex` a consumer's own virtualization spreads); a `ref` (e.g. a + * virtualization measure ref) forwards to the underlying `
`/`` stays a direct grid child) so the Nth cell learns its column index without the +// parent probing `child.type`. +function withColumnIndices(children: React.ReactNode): React.ReactNode { + return React.Children.map(children, (child, index) => + child === null || child === undefined ? child : {child} + ); +} + +// --- Resize handle (element-level focusable) --------------------------------- + +function ResizeHandle({ columnIndex, headerId }: { columnIndex: number; headerId: string }): React.ReactElement { + const ctx = useBasicTableContext('ResizeHandle'); + const toggleRef = useRef(null); + const separatorRef = useRef(null); + const { tabIndex } = useSingleTabStopNavigation(toggleRef); + const [isKeyboardDragging, setIsKeyboardDragging] = useState(false); + const [showButtons, setShowButtons] = useState(false); + const currentWidth = ctx.currentColumnWidth(columnIndex); + const minWidth = ctx.resizeMinWidthOf(columnIndex); + const handleProps = ctx.getResizeHandleProps(columnIndex); + + const step = (delta: number) => ctx.adjustColumnWidth(columnIndex, delta); + const enterKeyboardDrag = () => { + setIsKeyboardDragging(true); + setShowButtons(true); + }; + // Focus the separator only after `isKeyboardDragging` commits and clears its `aria-hidden`, so + // focus never lands inside an aria-hidden subtree. + useEffect(() => { + if (isKeyboardDragging) { + separatorRef.current?.focus(); + } + }, [isKeyboardDragging]); + const exitKeyboardDrag = () => { + setIsKeyboardDragging(false); + setShowButtons(false); + toggleRef.current?.focus(); + }; + + // The resize handle uses the generic `DragHandleWrapper` (depends on component-toolkit only, not + // table-coupled) to provide the focus ring, the hover tooltip, and the direction-button + // keyboard-resize control. Pointer drag stays on the toggle (`handleProps.onPointerDown` → the + // index-based `startColumnResize`); the sibling role="slider" separator owns Arrow-key drag. The + // width model is index-keyed via `grid-template-columns` — it does NOT pull the table-DOM-coupled + // `resizer/resizer-lookup` or the id-keyed `ColumnWidthsProvider`. Direction buttons step ±20px. + return ( + + minWidth ? 'active' : 'disabled', + 'inline-end': 'active', + }} + triggerMode="controlled" + controlledShowButtons={showButtons} + clickDragThreshold={3} + hideButtonsOnDrag={false} + tooltipText={ctx.resizerRoleDescription} + wrapperClassName={styles['resize-handle-drag']} + onDirectionClick={direction => { + if (direction === 'inline-start') { + step(-20); + } else if (direction === 'inline-end') { + step(20); + } + }} + > + `). Positional by default (its column index comes from the +// `ColumnIndexContext` supplied by `Header`); an explicit `columnId` binds by id. Layers per-cell +// roving tabindex + sticky styles on the hook's static header props. To make a column sortable, set +// `aria-sort` here (spread through `rest`) and render your own sort control in `children`. +export const HeaderCell = ({ + columnId, + children, + className, + style, + wrapText, + variant, + ...rest +}: BasicTableProps.HeaderCellProps): React.ReactElement => { + const ctx = useBasicTableContext('HeaderCell'); + const positional = useColumnIndexContext(); + const columnIndex = ctx.resolveColumnIndex(columnId, positional); + const stickyId = ctx.stickyColumnId(columnIndex); + const ref = useRef(null); + const { tabIndex } = useSingleTabStopNavigation(ref); + const sticky = useStickyCellStyles({ + stickyColumns: ctx.stickyColumns, + columnId: stickyId, + getClassName: stickyClassNames, + }); + const headerId = useUniqueId('basic-table-header-'); + const registerRef = useMemo( + () => (node: HTMLElement | null) => ctx.registerHeaderCell(columnIndex, node), + [ctx, columnIndex] + ); + const mergedRef = useMergeRefs(ref, sticky.ref, registerRef); + const headerProps = ctx.getColumnHeaderProps(columnIndex); + const isControl = variant === 'selection' || variant === 'disclosure'; + const resizable = !isControl && ctx.resizableColumns; + return ( + + {children} + {resizable && } +
`). Positional by default (column index from +// `ColumnIndexContext` supplied by `Row`); an explicit `columnId` binds by id. Layers per-cell +// roving tabindex + sticky styles on the hook's static cell props; reads its row index from the row +// context. +export const Cell = ({ + columnId, + children, + className, + style, + wrapText, + variant, + ...rest +}: BasicTableProps.CellProps): React.ReactElement => { + const ctx = useBasicTableContext('Cell'); + const positional = useColumnIndexContext(); + const columnIndex = ctx.resolveColumnIndex(columnId, positional); + const stickyId = ctx.stickyColumnId(columnIndex); + const { index } = useBasicRowContext('Cell'); + const ref = useRef(null); + const { tabIndex } = useSingleTabStopNavigation(ref); + const sticky = useStickyCellStyles({ + stickyColumns: ctx.stickyColumns, + columnId: stickyId, + getClassName: stickyClassNames, + }); + const mergedRef = useMergeRefs(ref, sticky.ref); + const cellProps = ctx.getCellProps(columnIndex, index); + return ( + + {children} +
+
{ + if (event.key === 'Escape') { + event.stopPropagation(); + const toggle = row.id ? document.getElementById(`${row.id}-toggle`) : null; + if (toggle) { + toggle.focus(); + } else { + row.onToggleExpand?.(); + } + } + }} + > + {children} +
+
0 && styles['grid-table-sticky'], + contentDensity === 'compact' && getVisualContextClassname('compact-table') + )} + > + {children} + {(showLoading || showEmpty) && ( + + + + + + )} +
+ {showLoading ? ( + + + {loadingText} + + + ) : ( +
{empty}
+ )} +
+ + + + + ); +} diff --git a/src/beta/basic-table-0.1/basic-table/styles.scss b/src/beta/basic-table-0.1/basic-table/styles.scss new file mode 100644 index 0000000000..7383c2b43c --- /dev/null +++ b/src/beta/basic-table-0.1/basic-table/styles.scss @@ -0,0 +1,445 @@ +/* + 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; + +// Styles the BasicTable to visually match the Cloudscape `Table` by default. Every colour / +// border / spacing value below is a Cloudscape design token rather than a bespoke value, so the +// look tracks Table and the visual refresh automatically. The scroll viewport (.scroll-container) +// scrolls with the native browser scrollbar — there is no custom / synthetic / overlay scrollbar. + +.root { + @include styles.styles-reset; + position: relative; + // Height-owning flex column: when the component (or a parent) is given a bounded block-size, + // the scroll container flexes to fill it and windows. Combined with the height / maxHeight + // props (which bound .scroll-container directly), this guarantees a bounded viewport so the + // model windows instead of mounting every row. + display: flex; + flex-direction: column; + inline-size: 100%; + // Sit on the container content surface, like a table inside a Container. + background: awsui.$color-background-container-content; +} + +.header { + margin-block-end: awsui.$space-scaled-s; +} + +.scroll-container { + position: relative; + // Fill the height-owning root when it is bounded (min-block-size:0 lets a flex item shrink + // below its content so it can scroll); an explicit height / maxHeight prop overrides this + // with a fixed viewport. Either way the viewport is bounded so the model windows. + flex: 1 1 auto; + min-block-size: 0; + // Native scrollbar only — no ::-webkit-scrollbar / overlay / synthetic scrollbar. + overflow: auto; + inline-size: 100%; + + // This div is only the overflow viewport (windowing measures it). The grid semantics (role=grid, + // tabIndex=-1, aria-row/colcount) live on the inner .grid-table, and the single tab stop is the + // roving cell managed by the shared GridNavigationProvider, so the focus ring lives on the + // focused cell (see the cell :focus-visible rules below). +} + +// The grid is a real (so ///
/ are valid HTML and the shared +// grid-navigation processor gets a genuine HTMLTableElement to query), but laid out as a BLOCK +// so the tbody runway + absolutely-positioned windowed rows and the CSS-grid column tracks +// govern layout instead of native table formatting. +.grid-table { + display: block; + inline-size: 100%; + border-collapse: collapse; +} + +// When sticky (pinned) columns are active, grow the measured table box to its content width so the +// sticky-columns isEnabled gate (tableWidth > wrapperWidth) can pass. min-inline-size keeps the box +// filling the wrapper when content is narrower (so sticky correctly stays disabled and 1fr columns +// still stretch without spurious h-scroll). Scoped to sticky-opted-in tables only, so non-sticky +// text tables keep their clamped inline-size:100% (truncation, not h-scroll). +.grid-table-sticky { + inline-size: max-content; + min-inline-size: 100%; +} + +.header-rowgroup { + display: block; +} + +// Header treatment: table-header surface + a default divider under the header row. A grid (not +// flex) so the shared column template governs every column's width identically to the body rows +// (columns align across rows content-independently). +.header-row { + display: grid; + inline-size: 100%; + background: awsui.$color-background-table-header; + border-block-end: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; +} + +// Sticky header is opt-in; the header stays inside the single scroll container so it scrolls +// horizontally with the body while pinning vertically. +// +// The rowgroup itself is the sticky box (not the inner .header-row): its containing block is the +// whole .scroll-container content (header + body runway), so inset-block-start:0 has the travel +// room to pin. Sticking the inner row instead traps it in the short rowgroup box (no travel) and +// it scrolls away with content. +.sticky-header { + position: sticky; + inset-block-start: 0; + // Sticky-header stacking: 800 pins the header above sticky columns (798); a bare `1` risks + // under-layering other console stacking contexts. + z-index: 800; + background: awsui.$color-background-table-header; +} + +.header-cell { + position: relative; + min-inline-size: 0; + // Left-align header text. Without this a plain non-sortable inherits the UA + // `th { text-align: center }`; a composed sort trigger sets its own text-align:start, so only + // plain headers would otherwise be centered. + text-align: start; + // Opaque header surface per cell: when the header is sticky over the windowed (absolutely + // positioned) body rows, a transparent cell lets a body row composite behind the column label, + // dropping its contrast below WCAG AA in dark mode. Painting the table-header surface on the cell + // itself keeps the label on the header background. + background: awsui.$color-background-table-header; + color: awsui.$color-text-column-header; + font-weight: awsui.$font-weight-heading-s; + line-height: awsui.$line-height-heading-xs; + // The header cell has no inner content wrapper (a composed sort trigger, when present, brings its + // own), so it sums two vertical padding layers here to reach the standard header height at density + // parity. The token scales in compact mode, so 2x keeps that parity. + padding-block: calc(2 * #{awsui.$space-scaled-xxs}); + padding-inline: awsui.$space-scaled-l; + box-sizing: border-box; +} + +// Static inter-column divider on the trailing edge, so resizable and non-resizable headers read +// identically. Suppressed on the last column and when a resize handle is present (that draws its +// own divider on the same edge). +.header-cell::after { + content: ''; + position: absolute; + inset-inline-end: 0; + inset-block: awsui.$space-xxs; // same block gap as the resize-handle divider + inline-size: 0; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + pointer-events: none; +} +.header-cell:last-child::after { + display: none; +} +.header-cell-resizable::after { + // resize-handle draws the trailing divider in the resizable case + display: none; +} + +// Column resize affordance: pins the resize handle to the header cell's inline-end edge. The handle +// is a focusable, keyboard-operable toggle (native