diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 5c7813083f..6f21e5c142 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -80,6 +80,12 @@ const pluralizationMap = { StatusIndicator: 'StatusIndicators', Steps: 'Steps', Table: 'Tables', + TableBody: 'TableBodies', + TableBodyCell: 'TableBodyCells', + TableHead: 'TableHeads', + TableHeaderCell: 'TableHeaderCells', + TableRoot: 'TableRoots', + TableRow: 'TableRows', Tabs: 'Tabs', TagEditor: 'TagEditors', TextContent: 'TextContents', diff --git a/pages/table-root/col-span.page.tsx b/pages/table-root/col-span.page.tsx new file mode 100644 index 0000000000..f2a655cbee --- /dev/null +++ b/pages/table-root/col-span.page.tsx @@ -0,0 +1,166 @@ +// 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 Checkbox from '~components/checkbox'; +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 TableBodyCell from '~components/table-body-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; +import Toggle from '~components/toggle'; + +import { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; +import { DATA_COLUMNS, Item, makeItems } from './common'; + +import styles from './styles.scss'; + +type Layout = 'grid' | 'auto'; + +const CONTROL_COLUMN: TableRootProps.ColumnDefinition = { size: 40 }; +const DATA_COLUMN_COUNT = DATA_COLUMNS.length; +const ITEM_COUNT = 5; +const SPAN_ROW_IDS = ['span-leading', 'span-middle', 'span-trailing']; + +export default function TableColSpanPage() { + const items = makeItems(ITEM_COUNT); + const { urlParams, setUrlParams } = useAppContext<'layout' | 'stripedRows' | 'selectable'>(); + const layout: Layout = urlParams.layout === 'auto' ? 'auto' : 'grid'; + const striped = urlParams.stripedRows === true || urlParams.stripedRows === 'true'; + const selectable = urlParams.selectable === true || urlParams.selectable === 'true'; + + const selectableIds = [...items.map(item => item.id), ...SPAN_ROW_IDS]; + const [selectedIds, setSelectedIds] = useState>(new Set([items[1].id])); + const allSelected = selectableIds.every(id => selectedIds.has(id)); + const someSelected = selectableIds.some(id => selectedIds.has(id)); + const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(selectableIds)); + 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 columns = selectable ? [CONTROL_COLUMN, ...DATA_COLUMNS] : DATA_COLUMNS; + const totalColumns = columns.length; + const columnLayout: TableRootProps.ColumnLayout = layout === 'grid' ? { type: 'grid', columns } : { type: 'auto' }; + + const filler = (count: number, prefix: string) => + Array.from({ length: count }, (_, index) => Cell); + + const controlCell = (id: string, label: string) => + selectable ? ( + +
+ toggleRow(id)} ariaLabel={`Select ${label}`} /> +
+
+ ) : null; + + return ( + + setUrlParams({ layout: detail.selectedId })} + options={[ + { id: 'grid', text: 'Grid' }, + { id: 'auto', text: 'Auto' }, + ]} + /> + setUrlParams({ stripedRows: detail.checked })}> + Striped rows + + setUrlParams({ selectable: detail.checked })}> + Selection + + + } + screenshotArea={{}} + > + +
+ Resources +
+ + + + {selectable && ( + +
+ +
+
+ )} + Name + Type + Size + Status +
+
+ + {items.map((item: Item, index) => ( + + {controlCell(item.id, item.name)} + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + + + {controlCell('span-leading', 'leading span row')} + Leading colSpan = 2 + {filler(DATA_COLUMN_COUNT - 2, 'leading')} + + + + {controlCell('span-middle', 'middle span row')} + {filler(1, 'middle-start')} + Middle colSpan = 2 + {filler(DATA_COLUMN_COUNT - 3, 'middle-end')} + + + + {controlCell('span-trailing', 'trailing span row')} + {filler(DATA_COLUMN_COUNT - 2, 'trailing')} + Trailing colSpan = 2 + + + + + + Full-width status row (colSpan = {totalColumns}) + + + + +
+
+
+ ); +} diff --git a/pages/table-root/column-sizing.page.tsx b/pages/table-root/column-sizing.page.tsx new file mode 100644 index 0000000000..4a3fbe5e5f --- /dev/null +++ b/pages/table-root/column-sizing.page.tsx @@ -0,0 +1,174 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo } from 'react'; + +import Box from '~components/box'; +import ColumnLayout from '~components/column-layout'; +import FormField from '~components/form-field'; +import Header from '~components/header'; +import Input from '~components/input'; +import Select, { SelectProps } from '~components/select'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableBodyCell from '~components/table-body-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; +import { Item, makeItems } from './common'; + +type Mode = 'fixed' | 'flex' | 'capped'; +interface ColConfig { + label: string; + field: keyof Item; + mode: Mode; + value: number; // pixels when fixed, flex weight when flex; unused when capped + minWidth: string; // raw input text; '' means unset + maxWidth: string; +} + +const MODE_OPTIONS: ReadonlyArray = [ + { value: 'fixed', label: 'Fixed (px)' }, + { value: 'flex', label: 'Flex (weight)' }, + { value: 'capped', label: 'Capped (maxWidth)' }, +]; + +const INITIAL: ColConfig[] = [ + { label: 'Name', field: 'name', mode: 'flex', value: 2, minWidth: '160', maxWidth: '' }, + { label: 'Type', field: 'type', mode: 'flex', value: 1, minWidth: '', maxWidth: '' }, + { label: 'Size', field: 'size', mode: 'fixed', value: 120, minWidth: '', maxWidth: '' }, + { label: 'Status', field: 'status', mode: 'capped', value: 1, minWidth: '', maxWidth: '200' }, +]; + +const ITEM_COUNT = 8; + +// The per-column mutable fields persisted to the URL; label/field are fixed and restored from INITIAL. +type StoredCol = Pick; + +function serializeConfigs(configs: ColConfig[]): string { + return JSON.stringify(configs.map(({ mode, value, minWidth, maxWidth }) => ({ mode, value, minWidth, maxWidth }))); +} + +function parseConfigs(raw: string | boolean | undefined): ColConfig[] { + if (typeof raw === 'string') { + try { + const stored = JSON.parse(raw) as Partial[]; + if (Array.isArray(stored) && stored.length === INITIAL.length) { + return INITIAL.map((base, index) => ({ ...base, ...stored[index] })); + } + } catch { + // Malformed URL value — fall back to defaults. + } + } + return INITIAL; +} + +function toColumnDefinition(config: ColConfig): TableRootProps.ColumnDefinition { + const min = parseInt(config.minWidth, 10); + const max = parseInt(config.maxWidth, 10); + if (config.mode === 'fixed') { + return { size: config.value }; + } + if (config.mode === 'capped') { + // Non-weighted, hard-capped track (grows up to maxWidth). No flex weight. + return { ...(Number.isNaN(min) ? {} : { minWidth: min }), ...(Number.isNaN(max) ? {} : { maxWidth: max }) }; + } + // flex: weighted track, optional minWidth (a fr track can carry a min but not a hard cap). + return { size: { flex: config.value }, ...(Number.isNaN(min) ? {} : { minWidth: min }) }; +} + +export default function TableColumnSizingPlaygroundPage() { + const items = makeItems(ITEM_COUNT); + const { urlParams, setUrlParams } = useAppContext<'columns'>(); + const configs = useMemo(() => parseConfigs(urlParams.columns), [urlParams.columns]); + + const update = (index: number, patch: Partial) => + setUrlParams({ + columns: serializeConfigs(configs.map((config, i) => (i === index ? { ...config, ...patch } : config))), + }); + + const columns = useMemo(() => configs.map(toColumnDefinition), [configs]); + + return ( + + + + Adjust each column below and watch the table re-lay out. A CSS grid track can't be both weighted and + hard-capped, so a column is either flex (shares free space by weight) or capped (grows only + up to maxWidth) — never both. + + + + {configs.map((config, index) => ( + + {config.label} + + update(index, { value: Number(detail.value) || 0 })} + /> + + )} + {config.mode !== 'fixed' && ( + + update(index, { minWidth: detail.value })} + /> + + )} + {config.mode === 'capped' && ( + + update(index, { maxWidth: detail.value })} + /> + + )} + + ))} + + +
+          {`columnLayout = { type: 'grid', columns: ${JSON.stringify(columns)} }`}
+        
+ + +
Resources
+ + + + {configs.map(config => ( + {config.label} + ))} + + + + {items.map((item: Item) => ( + + {configs.map(config => ( + {item[config.field]} + ))} + + ))} + + +
+
+
+ ); +} diff --git a/pages/table-root/common.tsx b/pages/table-root/common.tsx new file mode 100644 index 0000000000..9e89047f77 --- /dev/null +++ b/pages/table-root/common.tsx @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { TableBody, TableBodyCell, TableHead, TableHeaderCell, 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..f6886f3e45 --- /dev/null +++ b/pages/table-root/loading-and-empty.page.tsx @@ -0,0 +1,79 @@ +// 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 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 { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; +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/scroll-region.page.tsx b/pages/table-root/scroll-region.page.tsx new file mode 100644 index 0000000000..6400230945 --- /dev/null +++ b/pages/table-root/scroll-region.page.tsx @@ -0,0 +1,73 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Button from '~components/button'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableBodyCell from '~components/table-body-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { SimplePage } from '../app/templates'; + +// Four fixed 400px tracks (1600px total): overflows a normal-width viewport and fits a very wide one, so a +// viewport resize flips the horizontal-overflow scroll region on and off. +const WIDE_COLUMNS: ReadonlyArray = [ + { size: 400 }, + { size: 400 }, + { size: 400 }, + { size: 400 }, +]; + +export default function TableScrollRegionPage() { + const [grown, setGrown] = useState(false); + return ( + + +

Overflowing grid

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

Auto content growth

+ + + + + Name + + + + + {grown ? 'x'.repeat(4000) : 'short'} + + + +
+
+ ); +} diff --git a/pages/table-root/selection-edge-cases.page.tsx b/pages/table-root/selection-edge-cases.page.tsx new file mode 100644 index 0000000000..34ca815ee2 --- /dev/null +++ b/pages/table-root/selection-edge-cases.page.tsx @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Box from '~components/box'; +import TableBody from '~components/table-body'; +import TableBodyCell from '~components/table-body-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { SimplePage } from '../app/templates'; + +interface Row { + name: string; + type: string; + status: string; +} + +const ROWS: Row[] = [ + { name: 'Resource 0', type: 'Compute', status: 'Available' }, + { name: 'Resource 1', type: 'Storage', status: 'Pending' }, + { name: 'Resource 2', type: 'Network', status: 'Available' }, +]; + +const LONG = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function Grid({ + label, + columnLayout, + selected, + shaded, + longFirstCell, + width, +}: { + label: string; + columnLayout?: TableRootProps.ColumnLayout; + selected?: number[]; + shaded?: number[]; + longFirstCell?: boolean; + width?: number; +}) { + return ( + + {label} +
+ + + + Name + Type + Status + + + + {ROWS.map((row, i) => ( + + {longFirstCell && i === 1 ? LONG : row.name} + {row.type} + {row.status} + + ))} + + +
+
+ ); +} + +const flex3: TableRootProps.ColumnLayout = { + type: 'grid', + columns: [{ size: { flex: 1 } }, { size: { flex: 1 } }, { size: { flex: 1 } }], +}; +const capped3: TableRootProps.ColumnLayout = { + type: 'grid', + columns: [{ maxWidth: 120 }, { maxWidth: 120 }, { maxWidth: 120 }], +}; +const fixedWide: TableRootProps.ColumnLayout = { + type: 'grid', + columns: [{ size: 260 }, { size: 260 }, { size: 260 }], +}; + +export default function TableSelectionEdgeCasesPage() { + return ( + + + + + + + + + + ); +} diff --git a/pages/table-root/selection.page.tsx b/pages/table-root/selection.page.tsx new file mode 100644 index 0000000000..52629ad73f --- /dev/null +++ b/pages/table-root/selection.page.tsx @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Checkbox from '~components/checkbox'; +import Header from '~components/header'; +import RadioButton from '~components/radio-button'; +import SegmentedControl from '~components/segmented-control'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableBodyCell from '~components/table-body-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableRoot, { TableRootProps } from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +const COLUMNS: ReadonlyArray = [ + { size: 40 }, + { size: { flex: 53 } }, + { size: { flex: 47 } }, +]; +const ITEM_COUNT = 10; + +type SelectionMode = 'multi' | 'single'; + +export default function TableSelectionPage() { + const items = makeItems(ITEM_COUNT); + const { urlParams, setUrlParams } = useAppContext<'selectionMode'>(); + const mode: SelectionMode = urlParams.selectionMode === 'single' ? 'single' : 'multi'; + const [selectedIds, setSelectedIds] = useState>(new Set([items[1].id])); + + const allSelected = items.length > 0 && items.every(item => selectedIds.has(item.id)); + const someSelected = items.some(item => selectedIds.has(item.id)); + + const toggleAll = () => setSelectedIds(allSelected ? new Set() : new Set(items.map(item => item.id))); + const toggleRow = (id: string) => + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + const selectSingle = (id: string) => setSelectedIds(new Set([id])); + + const changeMode = (next: SelectionMode) => { + setUrlParams({ selectionMode: next }); + // Single mode permits one row; an unconditional cap is safe since leaving single the selection is already ≤1. + setSelectedIds(prev => new Set([...prev].slice(0, 1))); + }; + + return ( + changeMode(detail.selectedId as SelectionMode)} + options={[ + { id: 'multi', text: 'Multi-select' }, + { id: 'single', text: 'Single-select' }, + ]} + /> + } + screenshotArea={{}} + > + +
Resources
+ + + + + {mode === 'multi' ? ( +
+ +
+ ) : null} +
+ Name + Status +
+
+ + {items.map((item: Item) => ( + + +
+ {mode === 'multi' ? ( + toggleRow(item.id)} + ariaLabel={`Select ${item.name}`} + /> + ) : ( + selectSingle(item.id)} + ariaLabel={`Select ${item.name}`} + /> + )} +
+
+ {item.name} + {item.status} +
+ ))} +
+
+
+
+ ); +} diff --git a/pages/table-root/simple.page.tsx b/pages/table-root/simple.page.tsx new file mode 100644 index 0000000000..b45a16e5bd --- /dev/null +++ b/pages/table-root/simple.page.tsx @@ -0,0 +1,50 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Header from '~components/header'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableBodyCell from '~components/table-body-cell'; +import TableRoot from '~components/table-root'; +import TableRow from '~components/table-row'; +import Toggle from '~components/toggle'; + +import { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; +import { DataHeader, makeItems } from './common'; + +export default function TableSimplePage() { + const items = makeItems(10); + const { urlParams, setUrlParams } = useAppContext<'stripedRows'>(); + const striped = urlParams.stripedRows === true || urlParams.stripedRows === 'true'; + + return ( + setUrlParams({ stripedRows: detail.checked })}> + Striped rows + + } + screenshotArea={{}} + > + +
Resources
+ + + + {items.map((item, index) => ( + + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + + +
+
+ ); +} diff --git a/pages/table-root/sorting.page.tsx b/pages/table-root/sorting.page.tsx new file mode 100644 index 0000000000..599996acbd --- /dev/null +++ b/pages/table-root/sorting.page.tsx @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useMemo, useState } from 'react'; + +import Header from '~components/header'; +import Icon from '~components/icon'; +import SpaceBetween from '~components/space-between'; +import TableBody from '~components/table-body'; +import TableBodyCell from '~components/table-body-cell'; +import TableHead from '~components/table-head'; +import TableHeaderCell from '~components/table-header-cell'; +import TableRoot from '~components/table-root'; +import TableRow from '~components/table-row'; + +import { SimplePage } from '../app/templates'; +import { Item, makeItems } from './common'; + +import styles from './styles.scss'; + +type SortKey = 'name' | 'type' | 'size' | 'status'; +type SortDirection = 'ascending' | 'descending'; + +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 [sortKey, setSortKey] = useState('name'); + const [direction, setDirection] = useState('ascending'); + + const rows = useMemo(() => { + const sorted = [...items].sort((a, b) => compare(sortKey, a, b)); + return direction === 'ascending' ? sorted : sorted.reverse(); + }, [items, sortKey, direction]); + + const handleSort = (key: SortKey) => { + if (key === sortKey) { + setDirection(prev => (prev === 'ascending' ? 'descending' : 'ascending')); + } else { + setSortKey(key); + setDirection('ascending'); + } + }; + + return ( + + +
Resources
+ + + + {COLUMNS.map(({ key, label }) => { + const active = key === sortKey; + return ( + + + + ); + })} + + + + {rows.map(item => ( + + {item.name} + {item.type} + {item.size} + {item.status} + + ))} + + +
+
+ ); +} diff --git a/pages/table-root/styles.scss b/pages/table-root/styles.scss new file mode 100644 index 0000000000..22258759a4 --- /dev/null +++ b/pages/table-root/styles.scss @@ -0,0 +1,44 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +@use '~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; +} + +// Sort affordance at the right of a sortable header (the caret icon). +.sort-indicator { + display: inline-flex; + align-items: center; + gap: tokens.$space-static-xxs; +} 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 2ff69da995..bf381c09bc 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -24200,6 +24200,26 @@ exports[`Components definition for radio-button matches the snapshot: radio-butt ], "name": "RadioButton", "properties": [ + { + "description": "Adds \`aria-describedby\` to the native control.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Adds an \`aria-label\` to the native control. + +Use this if you don't have a visible label for this control.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Adds \`aria-labelledby\` to the native control.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, { "description": "Specifies if the component is selected.", "name": "checked", @@ -29783,6 +29803,485 @@ 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.PositionStyle", + "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": "positionStyle", + "optional": true, + "type": "TableBodyProps.PositionStyle", + }, + ], + "regions": [ + { + "description": "The body rows.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-body-cell matches the snapshot: table-body-cell 1`] = ` +{ + "dashCaseName": "table-body-cell", + "events": [], + "functions": [], + "name": "TableBodyCell", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Makes the cell span the given number of columns.", + "name": "colSpan", + "optional": true, + "type": "number", + }, + { + "description": "Removes the cell's built-in padding so you can compose your own spacing. Defaults to \`false\`.", + "name": "disablePaddings", + "optional": true, + "type": "boolean", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Renders the cell as a row header (\`
+ {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-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..618af959c9 --- /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 `TableRow` of `TableHeaderCell`s. */ +export interface TableHeadProps extends BaseComponentProps { + /** The header row: a `TableRow` 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..2b1a82c967 --- /dev/null +++ b/src/table-head/internal.tsx @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { TableHeadProps } from './interfaces'; + +import styles from './styles.css.js'; + +export interface InternalTableHeadProps extends TableHeadProps, InternalBaseComponentProps {} + +export default function InternalTableHead({ children, __internalRootRef, ...rest }: InternalTableHeadProps) { + const { isGrid } = useTableContext(); + 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..3fac726236 --- /dev/null +++ b/src/table-header-cell/index.tsx @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { TableHeaderCellProps } from './interfaces'; +import { InternalTableHeaderCell } from './internal'; + +export { TableHeaderCellProps }; + +function TableHeaderCell(props: TableHeaderCellProps) { + const { __internalRootRef } = useBaseComponent('TableHeaderCell', { + props: { disablePaddings: props.disablePaddings }, + }); + return ; +} + +applyDisplayName(TableHeaderCell, 'TableHeaderCell'); +export default TableHeaderCell; diff --git a/src/table-header-cell/interfaces.ts b/src/table-header-cell/interfaces.ts new file mode 100644 index 0000000000..c5943cc790 --- /dev/null +++ b/src/table-header-cell/interfaces.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +/** Renders a single column header cell. Put the column label, or a composed sort control, in `children`. */ +export interface TableHeaderCellProps extends BaseComponentProps { + /** Provides an accessible name for the header cell. Use this or `ariaLabelledby`. */ + ariaLabel?: string; + /** Sets `aria-labelledby`. Use the ID(s) of visible element(s) that label the header cell. */ + ariaLabelledby?: string; + /** Sets `aria-describedby`. Use the ID(s) of visible element(s) that describe the header cell. */ + ariaDescribedby?: string; + /** + * Sets the column's sort direction on the cell's `aria-sort` attribute. + */ + ariaSort?: React.AriaAttributes['aria-sort']; + /** + * Removes the cell's built-in padding so you can compose your own spacing. Defaults to `false`. + */ + disablePaddings?: boolean; + /** The header content, such as a column label or a sort control. */ + children?: React.ReactNode; +} diff --git a/src/table-header-cell/internal.tsx b/src/table-header-cell/internal.tsx new file mode 100644 index 0000000000..08f2e21fce --- /dev/null +++ b/src/table-header-cell/internal.tsx @@ -0,0 +1,80 @@ +// 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 { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useTableContext } from '../table-root/context'; +import { NativeAttributes } from '../types/native-attributes'; +import { TableHeaderCellProps } from './interfaces'; + +import headerCellStyles from '../table/header-cell/styles.css.js'; +import styles from './styles.css.js'; + +export type InternalTableHeaderCellProps = TableHeaderCellProps & { + style?: React.CSSProperties; + tabIndex?: number; + // Non-base native attributes injected by internal callers (the existing Table's th-element): colSpan, + // scope, role, aria-sort. Base props (className/id/data-*) flow directly and are read via getBaseProps. + nativeAttributes?: NativeAttributes>; + disableContentWrapper?: boolean; + disableDivider?: boolean; +}; + +export const InternalTableHeaderCell = React.forwardRef( + (props, ref) => { + const { + ariaLabel, + ariaLabelledby, + ariaDescribedby, + ariaSort, + disablePaddings, + style, + tabIndex, + nativeAttributes, + disableContentWrapper, + disableDivider, + children, + } = props; + const { className, ...restBaseProps } = getBaseProps(props); + const { isGrid } = useTableContext(); + const isVisualRefresh = useVisualRefresh(); + // `scope='col'` is the default for a public header cell; an internal caller's nativeAttributes (e.g. the + // existing Table's `scope='colgroup'` and computed role/aria-sort) override it. Grid mode adds the role. + const mergedNativeAttributes = { + scope: 'col' as const, + ...nativeAttributes, + ...(ariaLabel !== undefined ? { 'aria-label': ariaLabel } : undefined), + ...(ariaLabelledby !== undefined ? { 'aria-labelledby': ariaLabelledby } : undefined), + ...(ariaDescribedby !== undefined ? { 'aria-describedby': ariaDescribedby } : undefined), + ...(ariaSort !== undefined ? { 'aria-sort': ariaSort } : undefined), + ...(isGrid ? { role: 'columnheader' as const } : undefined), + }; + return ( + + ); + } +); diff --git a/src/table-header-cell/styles.scss b/src/table-header-cell/styles.scss new file mode 100644 index 0000000000..dfae49be29 --- /dev/null +++ b/src/table-header-cell/styles.scss @@ -0,0 +1,57 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; +@use '../table/cell-base/cell-box' as cell-base; + +.header-cell { + box-sizing: border-box; +} + +// Presentation-only divider `::after` (the substrate has no Resizer child); geometry mirrors the existing +// Table's resizer `.divider` via the shared `$divider-block-gap`. +.header-cell:not(:last-child):not(.no-divider)::after { + content: ''; + position: absolute; + inset-inline-end: 0; + inset-block-start: 0; + inset-block-end: 0; + min-block-size: awsui.$line-height-heading-xs; + max-block-size: calc(100% - #{cell-base.$divider-block-gap}); + margin-block: auto; + border-inline-start: awsui.$border-divider-list-width solid awsui.$color-border-divider-default; + box-sizing: border-box; + pointer-events: none; +} + +.header-cell.disable-paddings { + padding-block: 0; + padding-inline: 0; +} + +.header-cell-content { + padding-block: awsui.$space-scaled-xxs; + padding-inline-start: awsui.$space-s; + padding-inline-end: awsui.$space-s; + line-height: awsui.$line-height-body-m; +} + +.header-cell.disable-paddings > .header-cell-content { + padding-block: 0; + padding-inline: 0; +} + +// VR-gated: the substrate is shared with the existing Table, which still supports non-VR mode. +.header-cell.is-visual-refresh:first-child > .header-cell-content { + padding-inline-start: 0; +} + +// Stretch to the row track, else an empty control header collapses to ~1px, leaving a stray divider stub. +.header-cell-grid { + min-inline-size: 0; + align-self: stretch; + display: grid; + align-items: center; +} diff --git a/src/table-root/__integ__/scroll-region.test.ts b/src/table-root/__integ__/scroll-region.test.ts new file mode 100644 index 0000000000..c0d0c25317 --- /dev/null +++ b/src/table-root/__integ__/scroll-region.test.ts @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import styles from '../../../lib/components/table-root/styles.selectors.js'; + +const pageUrl = '#/light/table-root/scroll-region'; +const scroller = (testid: string) => `[data-testid="${testid}"] .${styles['body-scroller']}`; +const scrollTable = scroller('scroll-table'); +const growTable = scroller('grow-table'); + +test( + 'exposes the overflowing scroller as a focusable region that scrolls with the arrow keys', + useBrowser({ width: 900, height: 800 }, async browser => { + await browser.url(pageUrl); + const page = new BasePageObject(browser); + await page.waitForVisible(scrollTable); + + // The 1600px grid overflows a 900px viewport, so the scroller is a focusable region. + await expect(page.getElementAttribute(scrollTable, 'role')).resolves.toEqual('region'); + await page.click('h1'); + await page.keys('Tab'); + await expect(page.isFocused(scrollTable)).resolves.toBe(true); + + let leftBefore = 0; + await page.keys('ArrowRight'); + await page.waitForAssertion(async () => { + leftBefore = (await page.getElementScroll(scrollTable)).left; + expect(leftBefore).toBeGreaterThan(0); + }); + + await page.keys('ArrowLeft'); + await page.waitForAssertion(async () => { + expect((await page.getElementScroll(scrollTable)).left).toBeLessThan(leftBefore); + }); + }) +); + +test( + 'adds and removes the region as a viewport resize starts and stops the overflow', + useBrowser({ width: 600, height: 800 }, async browser => { + await browser.url(pageUrl); + const page = new BasePageObject(browser); + await page.waitForVisible(scrollTable); + + // Narrow viewport: the grid overflows, so the region and tab stop are present. + await page.waitForAssertion(async () => { + await expect(page.getElementAttribute(scrollTable, 'role')).resolves.toEqual('region'); + await expect(page.getElementAttribute(scrollTable, 'tabindex')).resolves.toEqual('0'); + }); + + // Widen past the 1600px grid so it fits: the ResizeObserver on the scroller re-measures and the region clears. + await page.setWindowSize({ width: 2000, height: 800 }); + await page.waitForAssertion(async () => { + await expect(page.getElementAttribute(scrollTable, 'role')).resolves.toBeNull(); + await expect(page.getElementAttribute(scrollTable, 'tabindex')).resolves.toBeNull(); + }); + }) +); + +test( + 'adds the region when auto-layout content growth starts the overflow', + useBrowser({ width: 1200, height: 800 }, async browser => { + await browser.url(pageUrl); + const page = new BasePageObject(browser); + await page.waitForVisible(growTable); + + // Short content fits the viewport: no region. + await page.waitForAssertion(async () => { + await expect(page.getElementAttribute(growTable, 'role')).resolves.toBeNull(); + }); + + // Growing a cell widens the table's own box; the ResizeObserver on that box re-measures and the region appears. + await page.click('[data-testid="grow"]'); + await page.waitForAssertion(async () => { + await expect(page.getElementAttribute(growTable, 'role')).resolves.toEqual('region'); + await expect(page.getElementAttribute(growTable, 'tabindex')).resolves.toEqual('0'); + }); + }) +); diff --git a/src/table-root/__tests__/basic-table-aria-label.test.tsx b/src/table-root/__tests__/basic-table-aria-label.test.tsx new file mode 100644 index 0000000000..291e8f20a5 --- /dev/null +++ b/src/table-root/__tests__/basic-table-aria-label.test.tsx @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { findTable, renderResourcesTable } from './table-fixtures'; + +describe('Table labelling', () => { + test('ariaLabel passes through to the table aria-label', () => { + const { wrapper } = renderResourcesTable({ grid: true, ariaLabel: 'Resources' }); + expect(findTable(wrapper).getAttribute('aria-label')).toBe('Resources'); + }); + + test('ariaLabelledby passes through to the table aria-labelledby', () => { + const { wrapper } = renderResourcesTable({ grid: true, ariaLabelledby: 'heading-id' }); + expect(findTable(wrapper).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..b983ef7c7a --- /dev/null +++ b/src/table-root/__tests__/basic-table-roles.test.tsx @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { findTable, makeItems, renderResourcesTable, ResourcesTable } from './table-fixtures'; + +// 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 `TableBodyCell`. +export default function TableLoadingEmptyPage() { + const { urlParams, setUrlParams } = useAppContext<'dataState'>(); + const state: State = + urlParams.dataState === 'loading' || urlParams.dataState === 'empty' ? urlParams.dataState : 'loaded'; + const items = state === 'loaded' ? makeItems(20) : []; + + return ( + setUrlParams({ dataState: event.detail.selectedId as State })} + label="Data state" + options={[ + { id: 'loaded', text: 'Loaded' }, + { id: 'loading', text: 'Loading' }, + { id: 'empty', text: 'Empty' }, + ]} + /> + } + screenshotArea={{}} + > + +
Resources
+ + + {state === 'loaded' ? ( + + ) : ( + + +
+ + {state === 'loading' ? ( + Loading resources + ) : ( + + No resources + + No resources to display. + + + )} + + \`) instead of a data cell (\`\`). A row header +keeps the data-cell styling; use it for the cell that names its row. Defaults to \`false\`.", + "name": "isRowHeader", + "optional": true, + "type": "boolean", + }, + ], + "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 \`TableRow\` whose cells are \`TableHeaderCell\` components.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-header-cell matches the snapshot: table-header-cell 1`] = ` +{ + "dashCaseName": "table-header-cell", + "events": [], + "functions": [], + "name": "TableHeaderCell", + "properties": [ + { + "description": "Sets \`aria-describedby\`. Use the ID(s) of visible element(s) that describe the header cell.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the header cell. Use this or \`ariaLabelledby\`.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets \`aria-labelledby\`. Use the ID(s) of visible element(s) that label the header cell.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "Sets the column's sort direction on the cell's \`aria-sort\` attribute.", + "inlineType": { + "name": ""none" | "other" | "ascending" | "descending"", + "type": "union", + "values": [ + "none", + "other", + "ascending", + "descending", + ], + }, + "name": "ariaSort", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Removes the cell's built-in padding so you can compose your own spacing. Defaults to \`false\`.", + "name": "disablePaddings", + "optional": true, + "type": "boolean", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The header content, such as a column label or a sort control.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-root matches the snapshot: table-root 1`] = ` +{ + "dashCaseName": "table-root", + "events": [], + "functions": [], + "name": "TableRoot", + "properties": [ + { + "description": "Sets the \`aria-describedby\` attribute. Use the ID of a visible element that describes the table.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the table. Use this or \`ariaLabelledby\` to label the table.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets the \`aria-labelledby\` attribute. Use the ID of a visible element that labels the table.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "Sets the table's \`aria-rowcount\`, counting the header row. Provide it only when you render a +subset of rows, such as with virtualization; otherwise it is derived from the DOM.", + "name": "ariaRowcount", + "optional": true, + "type": "number", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "defaultValue": "{ type: 'auto' }", + "description": "Determines how column widths are calculated. +* \`{ type: 'auto' }\` - Renders a standard HTML table whose columns size to their content. No + column configuration is required. +* \`{ type: 'grid'; columns: ColumnDefinition[] }\` - Renders a CSS grid. The columns are then provided as +an array of objects with the following properties: + * \`size\` (number | { flex: number }) - A number sets a fixed pixel width; \`{ flex }\` gives the + column a weight that shares the remaining space in proportion. Omit it for a flexible column + with the default weight of 1. + * \`minWidth\` (number) - The minimum width in pixels for a flexible column. We recommend setting one; + without it the column can shrink until its content clips or overlaps at narrow widths. + * \`maxWidth\` (number) - Caps a flexible column's width in pixels (it grows up to the cap). A capped + column can't also carry a proportional \`flex\` weight, so weighting applies to the uncapped columns. + +Defaults to \`{ type: 'auto' }\`.", + "inlineType": { + "name": "TableRootProps.ColumnLayout", + "type": "union", + "values": [ + "{ type: "auto"; }", + "{ type: "grid"; columns: ReadonlyArray; }", + ], + }, + "name": "columnLayout", + "optional": true, + "type": "TableRootProps.ColumnLayout", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + ], + "regions": [ + { + "description": "The table's content. Provide a \`TableHead\` followed by a \`TableBody\` that contains the rows.", + "isDefault": true, + "name": "children", + }, + ], + "releaseStatus": "stable", +} +`; + +exports[`Components definition for table-row matches the snapshot: table-row 1`] = ` +{ + "dashCaseName": "table-row", + "events": [], + "functions": [], + "name": "TableRow", + "properties": [ + { + "description": "Sets \`aria-describedby\`. Use the ID(s) of visible element(s) that describe the row.", + "name": "ariaDescribedby", + "optional": true, + "type": "string", + }, + { + "description": "Provides an accessible name for the row. Use this or \`ariaLabelledby\`.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Sets \`aria-labelledby\`. Use the ID(s) of visible element(s) that label the row.", + "name": "ariaLabelledby", + "optional": true, + "type": "string", + }, + { + "description": "Sets the row's \`aria-rowindex\`, its position in the full dataset counting the header row. Set +this only when virtualizing; otherwise it is derived from DOM order.", + "name": "ariaRowindex", + "optional": true, + "type": "number", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "description": "Applies inline styles to the row element for positioning, such as virtualization or draggable +rows. Not intended for general styling.", + "inlineType": { + "name": "TableRowProps.PositionStyle", + "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": "positionStyle", + "optional": true, + "type": "TableRowProps.PositionStyle", + }, + { + "description": "Applies selected-row styling. Visual only — it does not set \`aria-selected\`; convey selection to +assistive technologies via the selection control in a leading cell.", + "name": "selected", + "optional": true, + "type": "boolean", + }, + { + "description": "Applies a shaded background, for alternating row colors.", + "name": "shaded", + "optional": true, + "type": "boolean", + }, + ], + "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", @@ -46197,6 +46696,30 @@ Returns the current value of the input.", ], "name": "StepWrapper", }, + { + "methods": [], + "name": "TableBodyWrapper", + }, + { + "methods": [], + "name": "TableBodyCellWrapper", + }, + { + "methods": [], + "name": "TableHeadWrapper", + }, + { + "methods": [], + "name": "TableHeaderCellWrapper", + }, + { + "methods": [], + "name": "TableRootWrapper", + }, + { + "methods": [], + "name": "TableRowWrapper", + }, { "methods": [ { @@ -56035,6 +56558,30 @@ Supported options: ], "name": "StepWrapper", }, + { + "methods": [], + "name": "TableBodyWrapper", + }, + { + "methods": [], + "name": "TableBodyCellWrapper", + }, + { + "methods": [], + "name": "TableHeadWrapper", + }, + { + "methods": [], + "name": "TableHeaderCellWrapper", + }, + { + "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 7d8198bd6a..e301c3b8b9 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -688,6 +688,24 @@ exports[`test-utils selectors 1`] = ` "awsui_tools-preferences_wih1l", "awsui_wrapper_wih1l", ], + "table-body": [ + "awsui_body_1i6l7", + ], + "table-body-cell": [ + "awsui_cell_2seex", + ], + "table-head": [ + "awsui_head_1otu2", + ], + "table-header-cell": [ + "awsui_header-cell_uzgsh", + ], + "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 7e3ec4b049..41b1607f33 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -87,6 +87,12 @@ import SplitPanelWrapper from './split-panel'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; +import TableBodyWrapper from './table-body'; +import TableBodyCellWrapper from './table-body-cell'; +import TableHeadWrapper from './table-head'; +import TableHeaderCellWrapper from './table-header-cell'; +import TableRootWrapper from './table-root'; +import TableRowWrapper from './table-row'; import TabsWrapper from './tabs'; import TagEditorWrapper from './tag-editor'; import TextContentWrapper from './text-content'; @@ -184,6 +190,12 @@ export { SplitPanelWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; +export { TableBodyWrapper }; +export { TableBodyCellWrapper }; +export { TableHeadWrapper }; +export { TableHeaderCellWrapper }; +export { TableRootWrapper }; +export { TableRowWrapper }; export { TabsWrapper }; export { TagEditorWrapper }; export { TextContentWrapper }; @@ -2389,6 +2401,174 @@ 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 TableBodyCell that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableBodyCell. + * If no matching TableBodyCell is found, returns \`null\`. + * + * @param {string} [selector] CSS Selector + * @returns {TableBodyCellWrapper | null} + */ +findTableBodyCell(selector?: string): TableBodyCellWrapper | null; + +/** + * Returns an array of TableBodyCell wrapper that matches the specified CSS selector. + * If no CSS selector is specified, returns all of the TableBodyCells inside the current wrapper. + * If no matching TableBodyCell is found, returns an empty array. + * + * @param {string} [selector] CSS Selector + * @returns {Array} + */ +findAllTableBodyCells(selector?: string): Array; + +/** + * Returns the wrapper of the closest parent TableBodyCell for the current element, + * or the element itself if it is an instance of TableBodyCell. + * If no TableBodyCell is found, returns \`null\`. + * + * @returns {TableBodyCellWrapper | null} + */ +findClosestTableBodyCell(): TableBodyCellWrapper | null; +/** + * Returns the wrapper of the first TableHead that matches the specified CSS selector. + * If no CSS selector is specified, returns the wrapper of the first TableHead. + * 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 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. @@ -3883,6 +4063,84 @@ 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.findTableBodyCell = function(selector) { + let rootSelector = \`.\${TableBodyCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyCellWrapper && TableBodyCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyCellWrapper.rootSelector}, .\${TableBodyCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyCellWrapper); +}; + +ElementWrapper.prototype.findAllTableBodyCells = function(selector) { + return this.findAllComponents(TableBodyCellWrapper, 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.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){ @@ -4495,6 +4753,36 @@ 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.findClosestTableBodyCell = function() { + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findClosestComponent(TableBodyCellWrapper); +}; +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.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 @@ -4677,6 +4965,12 @@ import SplitPanelWrapper from './split-panel'; import StatusIndicatorWrapper from './status-indicator'; import StepsWrapper from './steps'; import TableWrapper from './table'; +import TableBodyWrapper from './table-body'; +import TableBodyCellWrapper from './table-body-cell'; +import TableHeadWrapper from './table-head'; +import TableHeaderCellWrapper from './table-header-cell'; +import TableRootWrapper from './table-root'; +import TableRowWrapper from './table-row'; import TabsWrapper from './tabs'; import TagEditorWrapper from './tag-editor'; import TextContentWrapper from './text-content'; @@ -4774,6 +5068,12 @@ export { SplitPanelWrapper }; export { StatusIndicatorWrapper }; export { StepsWrapper }; export { TableWrapper }; +export { TableBodyWrapper }; +export { TableBodyCellWrapper }; +export { TableHeadWrapper }; +export { TableHeaderCellWrapper }; +export { TableRootWrapper }; +export { TableRowWrapper }; export { TabsWrapper }; export { TagEditorWrapper }; export { TextContentWrapper }; @@ -6121,6 +6421,108 @@ 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 TableBodyCells with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableBodyCells. + * + * @param {string} [selector] CSS Selector + * @returns {TableBodyCellWrapper} + */ +findTableBodyCell(selector?: string): TableBodyCellWrapper; + +/** + * Returns a multi-element wrapper that matches TableBodyCells with the specified CSS selector. + * If no CSS selector is specified, returns a multi-element wrapper that matches TableBodyCells. + * + * @param {string} [selector] CSS Selector + * @returns {MultiElementWrapper} + */ +findAllTableBodyCells(selector?: string): MultiElementWrapper; +/** + * Returns a wrapper that matches the TableHeads with the specified CSS selector. + * If no CSS selector is specified, returns a wrapper that matches TableHeads. + * + * @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 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. @@ -7428,6 +7830,84 @@ 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.findTableBodyCell = function(selector) { + let rootSelector = \`.\${TableBodyCellWrapper.rootSelector}\`; + if("legacyRootSelector" in TableBodyCellWrapper && TableBodyCellWrapper.legacyRootSelector){ + rootSelector = \`:is(.\${TableBodyCellWrapper.rootSelector}, .\${TableBodyCellWrapper.legacyRootSelector})\`; + } + // casting to 'any' is needed to avoid this issue with generics + // https://github.com/microsoft/TypeScript/issues/29132 + return (this as any).findComponent(selector ? appendSelector(selector, rootSelector) : rootSelector, TableBodyCellWrapper); +}; + +ElementWrapper.prototype.findAllTableBodyCells = function(selector) { + return this.findAllComponents(TableBodyCellWrapper, 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.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/internal/components/radio-button/index.tsx b/src/internal/components/radio-button/index.tsx index e5c4ac47b5..2eadde5901 100644 --- a/src/internal/components/radio-button/index.tsx +++ b/src/internal/components/radio-button/index.tsx @@ -28,6 +28,9 @@ export default React.forwardRef(function RadioButton( children, value, checked, + ariaLabel, + ariaLabelledby, + ariaDescribedby, description, disabled, controlId, @@ -54,6 +57,9 @@ export default React.forwardRef(function RadioButton( controlClassName={styles['radio-control']} outlineClassName={styles.outline} label={children} + ariaLabel={ariaLabel} + ariaLabelledby={ariaLabelledby} + ariaDescribedby={ariaDescribedby} description={description} disabled={disabled} readOnly={readOnly} diff --git a/src/radio-button/__tests__/radio-button.test.tsx b/src/radio-button/__tests__/radio-button.test.tsx index 8d153b167c..afd198a848 100644 --- a/src/radio-button/__tests__/radio-button.test.tsx +++ b/src/radio-button/__tests__/radio-button.test.tsx @@ -28,6 +28,21 @@ describe('Radio Button native attributes from props', () => { const radioButton = renderRadioButton(); expect(radioButton.findNativeInput()!.getElement().getAttribute('value')).toBe('my-radio-button-value'); }); + + test('applies the `ariaLabel` prop as `aria-label` on the native element', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement()).toHaveAttribute('aria-label', 'Select resource'); + }); + + test('applies the `ariaLabelledby` prop as `aria-labelledby` on the native element', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement()).toHaveAttribute('aria-labelledby', 'label-id'); + }); + + test('applies the `ariaDescribedby` prop as `aria-describedby` on the native element', () => { + const radioButton = renderRadioButton(); + expect(radioButton.findNativeInput()!.getElement()).toHaveAttribute('aria-describedby', 'desc-id'); + }); }); describe('Radio Button events', () => { diff --git a/src/radio-button/interfaces.ts b/src/radio-button/interfaces.ts index f23e7a8901..057db31817 100644 --- a/src/radio-button/interfaces.ts +++ b/src/radio-button/interfaces.ts @@ -15,6 +15,23 @@ export interface RadioButtonProps extends BaseComponentProps { */ checked: boolean; + /** + * Adds an `aria-label` to the native control. + * + * Use this if you don't have a visible label for this control. + */ + ariaLabel?: string; + + /** + * Adds `aria-labelledby` to the native control. + */ + ariaLabelledby?: string; + + /** + * Adds `aria-describedby` to the native control. + */ + ariaDescribedby?: string; + /** * Specifies the ID of the native form element. You can use it to relate * a label element's `for` attribute to this control. diff --git a/src/table-body-cell/index.tsx b/src/table-body-cell/index.tsx new file mode 100644 index 0000000000..3ba873f2ac --- /dev/null +++ b/src/table-body-cell/index.tsx @@ -0,0 +1,22 @@ +// 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 { TableBodyCellProps } from './interfaces'; +import { InternalTableBodyCell } from './internal'; + +export { TableBodyCellProps }; + +function TableBodyCell(props: TableBodyCellProps) { + const { __internalRootRef } = useBaseComponent('TableBodyCell', { + props: { disablePaddings: props.disablePaddings, isRowHeader: props.isRowHeader, colSpan: props.colSpan }, + }); + const { isRowHeader, ...rest } = props; + return ; +} + +applyDisplayName(TableBodyCell, 'TableBodyCell'); +export default TableBodyCell; diff --git a/src/table-body-cell/interfaces.ts b/src/table-body-cell/interfaces.ts new file mode 100644 index 0000000000..0585e092c1 --- /dev/null +++ b/src/table-body-cell/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 a single data cell, or a row header when `isRowHeader` is set. */ +export interface TableBodyCellProps extends BaseComponentProps { + /** + * Renders the cell as a row header (``) instead of a data cell (``). A row header + * keeps the data-cell styling; use it for the cell that names its row. Defaults to `false`. + */ + isRowHeader?: boolean; + /** + * Removes the cell's built-in padding so you can compose your own spacing. Defaults to `false`. + */ + disablePaddings?: boolean; + /** + * Makes the cell span the given number of columns. + */ + colSpan?: number; + /** The cell content. */ + children?: React.ReactNode; +} diff --git a/src/table-body-cell/internal.tsx b/src/table-body-cell/internal.tsx new file mode 100644 index 0000000000..3e6c55282a --- /dev/null +++ b/src/table-body-cell/internal.tsx @@ -0,0 +1,95 @@ +// 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 { useVisualRefresh } from '../internal/hooks/use-visual-mode'; +import { useTableContext } from '../table-root/context'; +import { NativeAttributes } from '../types/native-attributes'; +import { TableBodyCellProps } from './interfaces'; + +import bodyCellStyles from '../table/body-cell/styles.css.js'; +import styles from './styles.css.js'; + +export type InternalTableBodyCellProps = Omit & { + tag: 'td' | 'th'; + style?: React.CSSProperties; + wrapLines?: boolean; + // Non-base native attributes injected by internal callers (the existing Table's td-element): role and + // sizing. Base props (className/id/data-*) flow directly and are read via getBaseProps. + nativeAttributes?: NativeAttributes>; + tabIndex?: number; + onClick?: React.MouseEventHandler; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; + beforeContent?: React.ReactNode; +}; + +export const InternalTableBodyCell = React.forwardRef( + (props, ref) => { + const { + tag, + style, + wrapLines, + disablePaddings, + colSpan, + nativeAttributes, + tabIndex, + onClick, + onFocus, + onBlur, + beforeContent, + children, + } = props; + const { className, ...restBaseProps } = getBaseProps(props); + const { isGrid } = useTableContext(); + const isVisualRefresh = useVisualRefresh(); + // Within a body cell a `` is always a row header (column headers use InternalTableHeaderCell). + const isRowHeader = tag === 'th'; + // In grid mode the native colspan is inert, so span the tracks with grid-column and mark the span with aria-colspan. + const gridColumnStyle = isGrid && colSpan ? { gridColumn: `span ${colSpan}` } : undefined; + const mergedNativeAttributes = { + ...nativeAttributes, + // A row header is a `` in either layout mode. + ...(isRowHeader ? { scope: 'row' as const } : undefined), + // Grid mode drops the implicit cell role, so set it explicitly; a role from nativeAttributes wins. + ...(isGrid ? { role: nativeAttributes?.role ?? (isRowHeader ? 'rowheader' : 'cell') } : undefined), + ...(isGrid && colSpan ? { 'aria-colspan': colSpan } : undefined), + }; + const Element = tag; + return ( + + {beforeContent} +
+ {children} +
+
+ ); + } +); diff --git a/src/table-body-cell/styles.scss b/src/table-body-cell/styles.scss new file mode 100644 index 0000000000..608bcea711 --- /dev/null +++ b/src/table-body-cell/styles.scss @@ -0,0 +1,41 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles/tokens' as awsui; + +.cell { + box-sizing: border-box; +} + +// Selection is a background fill plus a layout-neutral `::after` ring: the cell keeps the base +// `.body-cell` borders/padding, so the row never grows and content never shifts on toggle. +[data-awsui-selected] > .cell { + background-color: awsui.$color-background-item-selected; +} +[data-awsui-shaded] > .cell { + background-color: awsui.$color-background-cell-shaded; +} + +tr:has(+ [data-awsui-shaded]) > .cell { + border-block-end-color: awsui.$color-border-cell-shaded; +} +[data-awsui-shaded]:not(:last-child) > .cell { + border-block-end-color: awsui.$color-border-cell-shaded; +} + +[data-awsui-selected]:has(+ [data-awsui-selected]) > .cell { + border-block-end-color: transparent; +} + +tr:not([data-awsui-selected]):has(+ [data-awsui-selected]) > .cell { + border-block-end-color: transparent; +} + +.cell-grid { + min-inline-size: 0; + align-self: stretch; + display: grid; + align-items: center; +} diff --git a/src/table-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..5315b34d98 --- /dev/null +++ b/src/table-body/interfaces.ts @@ -0,0 +1,22 @@ +// 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'; + +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. + */ + positionStyle?: TableBodyProps.PositionStyle; + /** The body rows. */ + children?: React.ReactNode; +} + +export namespace TableBodyProps { + export interface PositionStyle { + position?: React.CSSProperties['position']; + height?: React.CSSProperties['height']; + } +} diff --git a/src/table-body/internal.tsx b/src/table-body/internal.tsx new file mode 100644 index 0000000000..fcd7ac30ad --- /dev/null +++ b/src/table-body/internal.tsx @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { TableBodyProps } from './interfaces'; + +import styles from './styles.css.js'; + +interface InternalTableBodyProps extends TableBodyProps, InternalBaseComponentProps {} + +export default function InternalTableBody({ + children, + positionStyle, + __internalRootRef, + ...rest +}: InternalTableBodyProps) { + const { isGrid } = useTableContext(); + const { className, ...restBaseProps } = getBaseProps(rest); + return ( +
+ {disableContentWrapper ? children :
{children}
} +
//// and never sets `aria-selected`. Selection wins over shading by omitting the shaded hook. + +describe('TableRow selection/shading is visual-only and paints through the cell', () => { + test('selected emits the data-awsui-selected hook and sets no aria-selected', () => { + const row = findBodyRow(renderResourcesTable({ items: makeItems(1), rowProps: { selected: true } }).wrapper); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).toHaveAttribute('data-awsui-selected', 'true'); + }); + + test('shaded emits the data-awsui-shaded hook', () => { + const row = findBodyRow(renderResourcesTable({ items: makeItems(1), rowProps: { shaded: true } }).wrapper); + expect(row).toHaveAttribute('data-awsui-shaded', 'true'); + }); + + test('selection wins over shading — a selected+shaded row emits only the selected hook', () => { + const row = findBodyRow( + renderResourcesTable({ items: makeItems(1), rowProps: { selected: true, shaded: true } }).wrapper + ); + expect(row).toHaveAttribute('data-awsui-selected', 'true'); + expect(row).not.toHaveAttribute('data-awsui-shaded'); + }); + + test('an unstyled row emits neither hook and no aria-selected', () => { + const row = findBodyRow(renderResourcesTable({ items: makeItems(1) }).wrapper); + expect(row).not.toHaveAttribute('aria-selected'); + expect(row).not.toHaveAttribute('data-awsui-selected'); + expect(row).not.toHaveAttribute('data-awsui-shaded'); + }); +}); + +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 = findBodyRow(wrapper); + expect(row.style.position).toBe('absolute'); + expect(row.style.transform).toBe('translateY(40px)'); + expect(row.style.gridTemplateColumns).toBe('100px'); + }); +}); + +describe('disablePaddings', () => { + test('TableBodyCell content opts into padding unless disablePaddings is set (mutually exclusive)', () => { + const { container } = render( + + + + Control + Resource 0 + + + + ); + const cells = createWrapper(container).findAllTableBodyCells(); + const contentOf = (index: number) => + cells[index].getElement().getElementsByClassName(bodyCellStyles['body-cell-content'])[0]; + expect(contentOf(0).classList.contains(bodyCellStyles['disable-paddings'])).toBe(true); + expect(contentOf(0).classList.contains(bodyCellStyles['with-paddings'])).toBe(false); + expect(contentOf(1).classList.contains(bodyCellStyles['disable-paddings'])).toBe(false); + expect(contentOf(1).classList.contains(bodyCellStyles['with-paddings'])).toBe(true); + }); + + test('TableHeaderCell opts into padding unless disablePaddings is set (mutually exclusive)', () => { + const { container } = render( + + + + + Name + + + + ); + const headerCells = createWrapper(container).findAllTableHeaderCells(); + const rootOf = (index: number) => headerCells[index].getElement(); + expect(rootOf(0).classList.contains(headerCellStyles['disable-paddings'])).toBe(true); + expect(rootOf(0).classList.contains(legacyHeaderCellStyles['with-paddings'])).toBe(false); + expect(rootOf(1).classList.contains(headerCellStyles['disable-paddings'])).toBe(false); + expect(rootOf(1).classList.contains(legacyHeaderCellStyles['with-paddings'])).toBe(true); + }); +}); + +describe('isRowHeader', () => { + test('renders a row header as carry the row's visual +// state so CSS can key off it — sibling-adjacency (consecutive-selected merge, striped divider) and the +// selection ring, which a cell can't express from its own context. Inert for the existing Table. A selected +// row omits the shaded hook so selection styling wins outright (no shaded paint on a selected row). +export interface InternalTableRowProps extends TableRowProps, InternalBaseComponentProps {} + +export default function InternalTableRow({ + selected, + shaded, + ariaLabel, + ariaLabelledby, + ariaDescribedby, + ariaRowindex, + children, + positionStyle, + __internalRootRef, + ...rest +}: InternalTableRowProps) { + const { isGrid, gridTemplateColumns } = useTableContext(); + const { className, ...restBaseProps } = getBaseProps(rest); + + return ( + + {children} + + ); +} diff --git a/src/table-row/styles.scss b/src/table-row/styles.scss new file mode 100644 index 0000000000..37a6696db6 --- /dev/null +++ b/src/table-row/styles.scss @@ -0,0 +1,47 @@ +/* + 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; +} + +.row-grid { + display: grid; + inline-size: 100%; + align-items: center; +} + +// Selection outline drawn as a layout-neutral `::after` on the row. Scoped to the hashed `.row` (not the +// bare `data-awsui-selected` attribute) so it can't leak onto unrelated consumer elements. In grid mode +// the abs-pos `::after` is a grid item — its containing block is the column tracks, so `inset:0` hugs the +// columns (stops at underfill, follows overflow); auto mode falls back to the row box. +.row[data-awsui-selected]::after { + content: ''; + position: absolute; + inset: 0; + grid-column: 1 / -1; + grid-row: 1; + border-block-start: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-block-end: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-inline-start: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-inline-end: awsui.$border-width-item-selected solid awsui.$color-border-item-selected; + border-start-start-radius: awsui.$border-radius-item; + border-start-end-radius: awsui.$border-radius-item; + border-end-start-radius: awsui.$border-radius-item; + border-end-end-radius: awsui.$border-radius-item; + pointer-events: none; +} +.row[data-awsui-selected]:has(+ .row[data-awsui-selected])::after { + border-end-start-radius: 0; + border-end-end-radius: 0; +} +.row[data-awsui-selected] + .row[data-awsui-selected]::after { + border-block-start-width: 0; + border-start-start-radius: 0; + border-start-end-radius: 0; +} diff --git a/src/table/body-cell/styles.scss b/src/table/body-cell/styles.scss index 3e141b7ea9..b526aa6480 100644 --- a/src/table/body-cell/styles.scss +++ b/src/table/body-cell/styles.scss @@ -135,8 +135,10 @@ $editing-cell-padding-block: awsui.$space-scaled-xxxs; border-inline-start: none; } } - // Guard selected out: a selected first row must keep its (thicker, coloured) selected - // top border, which the transparent placeholder would otherwise cover. + // First/last-row transparent placeholder. `:not(.body-cell-selected)` keeps a selected row's own border. + // Keyed on `.body-cell` with no grid-mode guard, so it is the single source of the placeholder for + // every cell — the existing Table's rows and the atomic cells (grid and auto) alike; load-bearing for + // grid selection-control centering. tr:first-child > &:not(.body-cell-selected) { border-block-start: cell-base.$border-placeholder; } @@ -505,3 +507,10 @@ $editing-cell-padding-block: awsui.$space-scaled-xxxs; @include cell-focus-outline; } } + +// A paddingless control cell has no reserved negative space, so the base truncation `overflow: hidden` +// would crop the control's focus ring. (No padding/margin reset needed: a control cell omits the +// `with-paddings` opt-in, so the padding mixins never apply.) +.body-cell-content.disable-paddings { + overflow: visible; +} diff --git a/src/table/body-cell/td-element.tsx b/src/table/body-cell/td-element.tsx index d4a21492d1..f1f4527a34 100644 --- a/src/table/body-cell/td-element.tsx +++ b/src/table/body-cell/td-element.tsx @@ -9,7 +9,7 @@ import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-too import { useInternalComponentIcons } from '../../icon-provider/use-component-icons'; import { ExpandToggleButton } from '../../internal/components/expand-toggle-button'; -import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; +import { InternalTableBodyCell } from '../../table-body-cell/internal'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces.js'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; @@ -101,12 +101,14 @@ export const TableTdElement = React.forwardRef { - const Element = isRowHeader ? 'th' : 'td'; - const isVisualRefresh = useVisualRefresh(); + const tag = isRowHeader ? 'th' : 'td'; resizableStyle = resizableColumns ? {} : resizableStyle; - nativeAttributes = { ...nativeAttributes, ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }) }; + const cellNativeAttributes = { + ...nativeAttributes, + ...getTableCellRoleProps({ tableRole, isRowHeader, colIndex }), + }; const stickyStyles = useStickyCellStyles({ stickyColumns: stickyState, @@ -121,16 +123,16 @@ export const TableTdElement = React.forwardRef + + + ) : null + } > - {level !== undefined && isExpandable && !isEditingActive && ( -
- + {children} + {counter ? ( +
+ + {counter}
- )} - -
- {children} - {counter ? ( -
- - {counter} -
- ) : null} -
- + ) : null} + ); } ); diff --git a/src/table/cell-base/_cell-box.scss b/src/table/cell-base/_cell-box.scss index f3c2fe53fe..94ed2ac4ca 100644 --- a/src/table/cell-base/_cell-box.scss +++ b/src/table/cell-base/_cell-box.scss @@ -4,23 +4,27 @@ @use '../../internal/styles/tokens' as awsui; $cell-vertical-padding: awsui.$space-scaled-xs; -// Calculate padding to prevent a shift in content after selection due to the difference -// between selected border widths and normal row divider widths (visual refresh). +// Extra padding so content doesn't shift on selection (VR: the selected border is wider than the divider). $cell-vertical-padding-w-border: calc( #{$cell-vertical-padding} + (#{awsui.$border-item-width} - #{awsui.$border-divider-list-width}) ); $cell-horizontal-padding: awsui.$space-scaled-l; $cell-edge-horizontal-padding: calc(#{awsui.$space-l} - #{awsui.$border-item-width}); $border-placeholder: awsui.$border-item-width solid transparent; +// Divider inset from the cell's top/bottom edges. Shared with the existing Table's resizer so the two match. +$divider-block-gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); $cell-offset: calc(#{awsui.$space-m} + #{awsui.$space-xs}); -// Ensuring enough space for absolute-positioned focus outlines of focus-able cell content elements. +// Space reserved for focus outlines of focusable cell content. $cell-negative-space-vertical: 2px; +// Padding is opt-in: only content carrying `.with-paddings` gets it (stamped unless `disablePaddings`). +// A control cell omits the class, so no padding/negative-margin applies — nothing to override, at any +// specificity, even as new edge/state rules are added. @mixin cell-padding-inline-start($padding) { $max-nesting-levels: 9; $offset-padding: calc($padding - 1 * awsui.$border-divider-list-width); - > .body-cell-content { + > .body-cell-content.with-paddings { padding-inline-start: $offset-padding; } > .expandable-toggle-wrapper { @@ -29,7 +33,7 @@ $cell-negative-space-vertical: 2px; @for $i from 0 through $max-nesting-levels { &.expandable-level-#{$i} { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-inline-start: calc(#{$offset-padding} / 2); margin-inline-start: calc(#{$offset-padding} / 2 + #{$i} * #{$cell-offset}); } @@ -39,7 +43,7 @@ $cell-negative-space-vertical: 2px; } } &.expandable-level-next { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-inline-start: calc(#{$offset-padding} / 2); margin-inline-start: calc(#{$offset-padding} / 2 + #{$max-nesting-levels} * #{$cell-offset}); } @@ -49,24 +53,24 @@ $cell-negative-space-vertical: 2px; } } @mixin cell-padding-inline-end($padding) { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-inline-end: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width}); } } @mixin cell-padding-block($padding) { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-block: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical}); margin-block: calc(-1 * #{$cell-negative-space-vertical}); } } @mixin cell-padding-block-start($padding) { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-block-start: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical}); margin-block-start: calc(-1 * #{$cell-negative-space-vertical}); } } @mixin cell-padding-block-end($padding) { - > .body-cell-content { + > .body-cell-content.with-paddings { padding-block-end: calc(#{$padding} - 1 * #{awsui.$border-divider-list-width} + #{$cell-negative-space-vertical}); margin-block-end: calc(-1 * #{$cell-negative-space-vertical}); } diff --git a/src/table/header-cell/styles.scss b/src/table/header-cell/styles.scss index cb1852de61..6a10d749d8 100644 --- a/src/table/header-cell/styles.scss +++ b/src/table/header-cell/styles.scss @@ -283,7 +283,9 @@ settings icon in the pagination slot. @include header-cell-focus-outline-first(awsui.$space-table-header-focus-outline-gutter); } - &:first-child:not(.has-striped-rows):not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( + // Opt-in gate: a control header (`disablePaddings`) omits `.with-paddings`, so this high-specificity + //
+ ); } diff --git a/src/table/internal.tsx b/src/table/internal.tsx index b56bfd4762..e3ffe48945 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -35,6 +35,7 @@ import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import { isDevelopment } from '../internal/is-development'; import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; +import { defaultTableContext, TableContextProvider } from '../table-root/context'; import { GeneratedAnalyticsMetadataTableComponent } from './analytics-metadata/interfaces'; import { TableBodyCell } from './body-cell'; import { ClearSortButton } from './clear-sort'; @@ -510,7 +511,7 @@ const InternalTable = React.forwardRef( const totalColumnsCount = visibleColumnDefinitions.length + colIndexOffset; const headerRowCount = columnGroupsLayout?.rows.length || 1; - return ( + const tableContent = ( { // When an element inside table row receives focus we want to adjust the scroll. // However, that behavior is unwanted when the focus is received as result of a click @@ -732,12 +735,10 @@ const InternalTable = React.forwardRef( stickyHeaderRef.current?.scrollToRow(currentTarget); } }} - {...focusMarkers.item} onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, row.item)} onContextMenu={ onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, row.item) } - {...rowRoleProps} > {selection.getItemSelectionProps && ( ); + + return ( + // Reset the shared cell contexts to known defaults: the extracted cell substrate reads column + // layout and row variant from context, so the existing Table pins them here (it drives its own + // selection/striping paint directly, not via the atomic row-variant context). + {tableContent} + ); } ) as TableForwardRefType; diff --git a/src/table/resizer/styles.scss b/src/table/resizer/styles.scss index 5dcdb0ab46..4c5eda6d1d 100644 --- a/src/table/resizer/styles.scss +++ b/src/table/resizer/styles.scss @@ -5,6 +5,7 @@ @use '../../internal/styles/index' as styles; @use '../../internal/styles/tokens' as awsui; +@use '../cell-base/cell-box' as cell-base; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; //stylelint-disable-next-line selector-combinator-disallowed-list,selector-max-universal @@ -15,7 +16,7 @@ $handle-width: awsui.$space-xl; $active-separator-width: 2px; -$block-gap: calc(2 * #{awsui.$space-xs} + #{awsui.$space-xxxs}); +$block-gap: cell-base.$divider-block-gap; .resizer-wrapper { inset-block: 0; diff --git a/src/test-utils/dom/table-body-cell/index.ts b/src/test-utils/dom/table-body-cell/index.ts new file mode 100644 index 0000000000..8eb3a8eaf1 --- /dev/null +++ b/src/test-utils/dom/table-body-cell/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-body-cell/styles.selectors.js'; + +export default class TableBodyCellWrapper extends ComponentWrapper { + static rootSelector: string = styles.cell; +} diff --git a/src/test-utils/dom/table-body/index.ts b/src/test-utils/dom/table-body/index.ts new file mode 100644 index 0000000000..527bc8fce3 --- /dev/null +++ b/src/test-utils/dom/table-body/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-body/styles.selectors.js'; + +export default class TableBodyWrapper extends ComponentWrapper { + static rootSelector: string = styles.body; +} diff --git a/src/test-utils/dom/table-head/index.ts b/src/test-utils/dom/table-head/index.ts new file mode 100644 index 0000000000..1873b20805 --- /dev/null +++ b/src/test-utils/dom/table-head/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-head/styles.selectors.js'; + +export default class TableHeadWrapper extends ComponentWrapper { + static rootSelector: string = styles.head; +} diff --git a/src/test-utils/dom/table-header-cell/index.ts b/src/test-utils/dom/table-header-cell/index.ts new file mode 100644 index 0000000000..10156afb4c --- /dev/null +++ b/src/test-utils/dom/table-header-cell/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-header-cell/styles.selectors.js'; + +export default class TableHeaderCellWrapper extends ComponentWrapper { + static rootSelector: string = styles['header-cell']; +} diff --git a/src/test-utils/dom/table-root/index.ts b/src/test-utils/dom/table-root/index.ts new file mode 100644 index 0000000000..2825d8f158 --- /dev/null +++ b/src/test-utils/dom/table-root/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-root/styles.selectors.js'; + +export default class TableRootWrapper extends ComponentWrapper { + static rootSelector: string = styles.root; +} diff --git a/src/test-utils/dom/table-row/index.ts b/src/test-utils/dom/table-row/index.ts new file mode 100644 index 0000000000..fd60b2e552 --- /dev/null +++ b/src/test-utils/dom/table-row/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../table-row/styles.selectors.js'; + +export default class TableRowWrapper extends ComponentWrapper { + static rootSelector: string = styles.row; +}
/
, 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. + +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 { wrapper } = renderResourcesTable({ items: makeItems(20) }); + const table = findTable(wrapper); + 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 { wrapper } = renderResourcesTable({ items: makeItems(20), grid: true }); + const table = findTable(wrapper); + expect(table.getAttribute('role')).toBe('table'); + + expect(wrapper.findTableHead()!.getElement().getAttribute('role')).toBe('rowgroup'); + expect(wrapper.findTableBody()!.getElement().getAttribute('role')).toBe('rowgroup'); + + table.querySelectorAll('[role="row"]').forEach(row => { + expect(row.closest('[role="rowgroup"]')).not.toBeNull(); + }); + table.querySelectorAll('[role="columnheader"], [role="cell"]').forEach(cell => { + expect(cell.closest('[role="row"]')).not.toBeNull(); + }); + }); + + test('column headers are ', () => { + const { wrapper } = renderResourcesTable({ items: makeItems(20), grid: true }); + const th = wrapper.findAllTableHeaderCells()[0].getElement(); + expect(th.tagName).toBe('TH'); + expect(th.getAttribute('role')).toBe('columnheader'); + expect(th.getAttribute('scope')).toBe('col'); + }); + }); +}); + +describe('horizontal-overflow scroll region', () => { + const setScrollerGeometry = (scroller: Element, scrollWidth: number, clientWidth: number) => { + Object.defineProperty(scroller, 'scrollWidth', { configurable: true, value: scrollWidth }); + Object.defineProperty(scroller, 'clientWidth', { configurable: true, value: clientWidth }); + }; + + test('exposes a focusable labeled region only while the content overflows', () => { + const { wrapper, rerender } = renderResourcesTable({ grid: true, columns: [{ size: 200 }], items: makeItems(1) }); + const scroller = findTable(wrapper).parentElement!; // body-scroller + + expect(scroller.hasAttribute('role')).toBe(false); + expect(scroller.hasAttribute('tabindex')).toBe(false); + + // A grid-template change flips overflow while the table's box stays 100% wide, so no ResizeObserver + // fires — the re-measure effect is the only trigger. jsdom has no layout, so simulate the geometry. + setScrollerGeometry(scroller, 1200, 400); + rerender(); + expect(scroller.getAttribute('role')).toBe('region'); + expect(scroller.getAttribute('tabindex')).toBe('0'); + expect(scroller.getAttribute('aria-label')).toBe('Resources'); + + setScrollerGeometry(scroller, 400, 400); + rerender(); + expect(scroller.hasAttribute('role')).toBe(false); + expect(scroller.hasAttribute('tabindex')).toBe(false); + }); +}); diff --git a/src/table-root/__tests__/basic-table-styling-props.test.tsx b/src/table-root/__tests__/basic-table-styling-props.test.tsx new file mode 100644 index 0000000000..5cb84eae41 --- /dev/null +++ b/src/table-root/__tests__/basic-table-styling-props.test.tsx @@ -0,0 +1,213 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import Table from '../../../lib/components/table'; +import TableBody from '../../../lib/components/table-body'; +import TableBodyCell from '../../../lib/components/table-body-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { findBodyRow, makeItems, renderResourcesTable } from './table-fixtures'; + +import bodyCellStyles from '../../../lib/components/table/body-cell/styles.css.js'; +import legacyHeaderCellStyles from '../../../lib/components/table/header-cell/styles.css.js'; +import cellStyles from '../../../lib/components/table-body-cell/styles.css.js'; +import headerCellStyles from '../../../lib/components/table-header-cell/styles.css.js'; + +// The row `selected`/`shaded` props are visual-only: each emits an independent `data-awsui-*` hook on the +//
with role="rowheader" in grid mode', () => { + const { container } = render( + + + + Name + Value + + + + ); + const cell = createWrapper(container).findAllTableBodyCells()[0].getElement(); + expect(cell.tagName).toBe('TH'); + expect(cell).toHaveAttribute('scope', 'row'); + expect(cell).toHaveAttribute('role', 'rowheader'); + }); + + test('renders a row header as a native without an explicit role in auto mode', () => { + const { container } = render( + + + + Name + Value + + + + ); + const cell = createWrapper(container).findAllTableBodyCells()[0].getElement(); + expect(cell.tagName).toBe('TH'); + expect(cell).toHaveAttribute('scope', 'row'); + expect(cell).not.toHaveAttribute('role'); + }); +}); + +describe('colSpan', () => { + test('auto layout sets the native colspan attribute only', () => { + const { container } = render( + + + + Full width + + + + ); + const cell = createWrapper(container).findAllTableBodyCells()[0].getElement() as HTMLElement; + expect(cell.getAttribute('colspan')).toBe('3'); + expect(cell.style.gridColumn).toBe(''); + expect(cell).not.toHaveAttribute('aria-colspan'); + }); + + test('grid layout spans tracks via grid-column and marks the span with aria-colspan', () => { + const { container } = render( + + + + Full width + + + + ); + const cell = createWrapper(container).findAllTableBodyCells()[0].getElement() as HTMLElement; + expect(cell.style.gridColumn).toBe('span 3'); + expect(cell).toHaveAttribute('aria-colspan', '3'); + expect(cell).toHaveAttribute('role', 'cell'); + expect(cell).not.toHaveAttribute('colspan'); + }); +}); + +describe('nested content is insulated from the table/row context', () => { + test('a classic Table nested in a selected grid cell inherits neither the outer grid layout nor the selected styling', () => { + const { container } = render( + + + + + item.v }]} items={[{ v: 'nested' }]} /> + + + + + ); + // The existing Table resets both atomic contexts at its root, so its own cells read auto layout and + // no selection paint — the outer grid class and selected paint do not leak into the nested table. + const nestedCell = createWrapper(container).findTable()!.findBodyCell(1, 1)!.getElement(); + expect(nestedCell.classList.contains(cellStyles['cell-grid'])).toBe(false); + expect(nestedCell.classList.contains(bodyCellStyles['body-cell-selected'])).toBe(false); + }); +}); diff --git a/src/table-root/__tests__/basic-table.test.tsx b/src/table-root/__tests__/basic-table.test.tsx new file mode 100644 index 0000000000..cbdcd5b520 --- /dev/null +++ b/src/table-root/__tests__/basic-table.test.tsx @@ -0,0 +1,156 @@ +// 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 TableBodyCell from '../../../lib/components/table-body-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableRoot from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; +import { findBodyRow, findBodyRows, findTable, makeItems, renderResourcesTable } from './table-fixtures'; + +// Role semantics, labelling and selection/shading have dedicated suites (basic-table-roles / +// -aria-label / -styling-props); this covers part composition plus the per-part ARIA and data-* props. + +describe('Table atomic parts', () => { + test('renders the declarative header cells, discoverable via the generated finder', () => { + const { wrapper } = renderResourcesTable({ items: makeItems(5) }); + 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'); + expect(headerCells[0].getElement().tagName).toBe('TH'); + expect(headerCells[0].getElement().getAttribute('scope')).toBe('col'); + }); + + test('renders the mapped rows and cells, discoverable via the generated finders', () => { + const { wrapper } = renderResourcesTable({ items: makeItems(5) }); + const rows = findBodyRows(wrapper); + expect(rows).toHaveLength(5); + + const firstRowCells = createWrapper(rows[0].getElement()).findAllTableBodyCells(); + expect(firstRowCells).toHaveLength(2); + expect(firstRowCells[0].getElement().textContent).toBe('Resource 0'); + expect(firstRowCells[1].getElement().textContent).toBe('Available'); + + expect(wrapper.findTableBody()).not.toBeNull(); + expect(wrapper.findTableHead()).not.toBeNull(); + expect(wrapper.findAllTableBodyCells()).toHaveLength(10); + }); + + test('ariaRowcount is applied to aria-rowcount as-is', () => { + const { wrapper } = renderResourcesTable({ items: makeItems(5), ariaRowcount: 40 }); + expect(findTable(wrapper).getAttribute('aria-rowcount')).toBe('40'); + }); + + test('omits aria-rowcount when ariaRowcount is not provided (count derives from the DOM)', () => { + const { wrapper } = renderResourcesTable({ items: makeItems(5) }); + expect(findTable(wrapper).hasAttribute('aria-rowcount')).toBe(false); + }); + + describe('auto column layout (default)', () => { + test('does not emit an inline grid-template-columns on rows', () => { + const { wrapper } = renderResourcesTable({ items: makeItems(5) }); + expect(findBodyRow(wrapper).style.gridTemplateColumns).toBe(''); + }); + }); + + describe('grid column layout', () => { + test('the header row shares the column template with the data rows', () => { + const { wrapper } = renderResourcesTable({ items: makeItems(5), grid: true }); + const template = '200px minmax(0px, 1fr)'; + const headerRow = wrapper.findTableHead()!.find('[role="row"]')!.getElement() as HTMLElement; + expect(headerRow.style.gridTemplateColumns).toBe(template); + expect(findBodyRow(wrapper).style.gridTemplateColumns).toBe(template); + }); + + test('the header row emits aria-rowindex only when set explicitly (consumer-managed numbering)', () => { + const withIndex = render( + + + + Name + + + + ); + const withIndexHeaderRow = createWrapper(withIndex.container).findTableHead()!.find('[role="row"]')!.getElement(); + expect(withIndexHeaderRow.getAttribute('aria-rowindex')).toBe('1'); + + const { wrapper } = renderResourcesTable({ items: makeItems(5), grid: true }); + const plainHeaderRow = wrapper.findTableHead()!.find('[role="row"]')!.getElement(); + expect(plainHeaderRow.hasAttribute('aria-rowindex')).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(createWrapper(container).findTableBody()!.getElement()) + .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(findBodyRow(wrapper).getAttribute('data-index')).toBe('7'); + expect(wrapper.findAllTableBodyCells()[0].getElement().getAttribute('data-column')).toBe('name'); + }); + }); +}); diff --git a/src/table-root/__tests__/grid-template-columns.test.tsx b/src/table-root/__tests__/grid-template-columns.test.tsx new file mode 100644 index 0000000000..a3763b86c7 --- /dev/null +++ b/src/table-root/__tests__/grid-template-columns.test.tsx @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { computeGridTemplateColumns } from '../grid-template-columns'; +import { TableRootProps } from '../interfaces'; + +const COLUMNS: ReadonlyArray = [{ size: 200 }, {}, { size: 100 }]; + +function gridTemplate(columns: ReadonlyArray) { + return computeGridTemplateColumns({ type: 'grid', columns }); +} + +describe('computeGridTemplateColumns', () => { + test('auto layout has no grid template', () => { + expect(computeGridTemplateColumns({ type: 'auto' })).toBeUndefined(); + }); + + describe('grid template 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('an explicit zero flex weight becomes a 0fr track (not the default 1fr)', () => { + expect(gridTemplate([{ size: { flex: 0 } }])).toBe('minmax(0px, 0fr)'); + }); + + test('minWidth floors a flexible track', () => { + expect(gridTemplate([{ minWidth: 150 }])).toBe('minmax(150px, 1fr)'); + }); + + test('maxWidth caps a non-weighted track at a px ceiling', () => { + expect(gridTemplate([{ maxWidth: 300 }])).toBe('minmax(0px, 300px)'); + expect(gridTemplate([{ minWidth: 100, maxWidth: 300 }])).toBe('minmax(100px, 300px)'); + }); + + test('flex and maxWidth are mutually exclusive at the type level', () => { + // @ts-expect-error — a weighted (flex) track cannot also be hard-capped (maxWidth); the union forbids it. + const invalid: TableRootProps.ColumnDefinition = { size: { flex: 2 }, maxWidth: 300 }; + expect(invalid).toBeDefined(); + }); + + describe('malformed numeric values do not invalidate the whole template', () => { + test('a negative fixed size clamps to 0px', () => { + expect(gridTemplate([{ size: -50 }, { size: 100 }])).toBe('0px 100px'); + }); + + test('a non-finite fixed size falls back to the default growable track', () => { + expect(gridTemplate([{ size: NaN }, { size: 100 }])).toBe('minmax(0px, 1fr) 100px'); + expect(gridTemplate([{ size: Infinity }])).toBe('minmax(0px, 1fr)'); + }); + + test('a negative flex weight clamps to 0fr', () => { + expect(gridTemplate([{ size: { flex: -2 } }])).toBe('minmax(0px, 0fr)'); + }); + + test('a non-finite flex weight falls back to the default growable track', () => { + expect(gridTemplate([{ size: { flex: NaN } }])).toBe('minmax(0px, 1fr)'); + }); + + test('a negative minWidth clamps to 0px, a non-finite minWidth is dropped', () => { + expect(gridTemplate([{ minWidth: -10 }])).toBe('minmax(0px, 1fr)'); + expect(gridTemplate([{ minWidth: NaN, maxWidth: 300 }])).toBe('minmax(0px, 300px)'); + }); + + test('a negative maxWidth clamps to 0px, a non-finite maxWidth is dropped', () => { + expect(gridTemplate([{ maxWidth: -300 }])).toBe('minmax(0px, 0px)'); + expect(gridTemplate([{ maxWidth: Infinity }])).toBe('minmax(0px, 1fr)'); + }); + }); + }); +}); diff --git a/src/table-root/__tests__/table-fixtures.tsx b/src/table-root/__tests__/table-fixtures.tsx new file mode 100644 index 0000000000..af6c2af2dc --- /dev/null +++ b/src/table-root/__tests__/table-fixtures.tsx @@ -0,0 +1,94 @@ +// 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 TableBodyCell from '../../../lib/components/table-body-cell'; +import TableHead from '../../../lib/components/table-head'; +import TableHeaderCell from '../../../lib/components/table-header-cell'; +import TableRoot, { TableRootProps } from '../../../lib/components/table-root'; +import TableRow from '../../../lib/components/table-row'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +export interface Item { + id: string; + name: string; + status: string; +} + +export const makeItems = (n: number): Item[] => + Array.from({ length: n }, (_, index) => ({ + id: `row-${index}`, + name: `Resource ${index}`, + status: index % 2 === 0 ? 'Available' : 'Pending', + })); + +export const GRID_COLUMNS: ReadonlyArray = [{ size: 200 }, {}]; + +const HEADERS = ['Name', 'Status']; + +export interface ResourcesTableProps { + grid?: boolean; + items?: Item[]; + columns?: ReadonlyArray; + ariaLabel?: string; + ariaLabelledby?: string; + ariaRowcount?: number; + rowProps?: { selected?: boolean; shaded?: boolean }; +} + +export function ResourcesTable({ + grid, + items = [], + columns = GRID_COLUMNS, + ariaLabel = 'Resources', + ariaLabelledby, + ariaRowcount, + rowProps, +}: ResourcesTableProps) { + const columnLayout: TableRootProps.ColumnLayout = grid ? { type: 'grid', columns } : { type: 'auto' }; + return ( + + + + {columns.map((_, index) => ( + {HEADERS[index] ?? `Column ${index + 1}`} + ))} + + + + {items.map(item => ( + + {columns.map((_, index) => ( + {index === 0 ? item.name : item.status} + ))} + + ))} + + + ); +} + +export function renderResourcesTable(props: ResourcesTableProps = {}) { + const { container, rerender } = render(); + return { container, rerender, wrapper: createWrapper(container) }; +} + +export function findTable(wrapper: ReturnType): HTMLElement { + return wrapper.findTableRoot()!.find('table')!.getElement(); +} + +// Body rows only — the header row is also a `.row`, but lives in TableHead. +export function findBodyRows(wrapper: ReturnType) { + return createWrapper(wrapper.findTableBody()!.getElement()).findAllTableRows(); +} + +export function findBodyRow(wrapper: ReturnType, index = 0): HTMLElement { + return findBodyRows(wrapper)[index].getElement() as HTMLElement; +} diff --git a/src/table-root/context.ts b/src/table-root/context.ts new file mode 100644 index 0000000000..eeeafbc38a --- /dev/null +++ b/src/table-root/context.ts @@ -0,0 +1,26 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { createContext, useContext } from 'react'; + +export interface TableContextValue { + /** Whether the table uses `grid` layout (vs `auto`). */ + isGrid: boolean; + /** The `grid-template-columns` value for `grid` layout; `undefined` in `auto` layout. */ + gridTemplateColumns?: string; +} + +// A part rendered outside `TableRoot` reads this default (auto layout) instead of throwing. It is also +// the value the existing `Table` resets to at its root, so a Table nested inside an atomic grid cell +// renders from auto layout rather than inheriting the outer table's. +export const defaultTableContext: TableContextValue = { + isGrid: false, + gridTemplateColumns: undefined, +}; + +const TableContext = createContext(defaultTableContext); + +export const TableContextProvider = TableContext.Provider; + +export function useTableContext(): TableContextValue { + return useContext(TableContext); +} diff --git a/src/table-root/grid-template-columns.ts b/src/table-root/grid-template-columns.ts new file mode 100644 index 0000000000..1e0cf46c96 --- /dev/null +++ b/src/table-root/grid-template-columns.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableRootProps } from './interfaces'; + +// Clamp negatives to 0 so one malformed dimension can't invalidate the whole +// grid-template-columns string; a non-finite value (NaN/Infinity) returns undefined +// so the caller drops that dimension and falls back to a sensible track. +function clamp(value: number | undefined): number | undefined { + return value === undefined || !Number.isFinite(value) ? undefined : Math.max(0, value); +} + +// Compiles a `grid` layout's columns into a `grid-template-columns` value; `undefined` in `auto` layout. +export function computeGridTemplateColumns(columnLayout: TableRootProps.ColumnLayout): string | undefined { + if (columnLayout.type !== 'grid') { + return undefined; + } + return columnLayout.columns.map(compileColumnTrack).join(' '); +} + +function compileColumnTrack(column: TableRootProps.ColumnDefinition): string { + const { size, minWidth, maxWidth }: { size?: number | { flex: number }; minWidth?: number; maxWidth?: number } = + column; + const min = `${clamp(minWidth) ?? 0}px`; + if (typeof size === 'number') { + const px = clamp(size); + if (px !== undefined) { + return `${px}px`; // fixed track — minWidth/maxWidth don't apply. + } + } else if (size) { + const flex = clamp(size.flex); + if (flex !== undefined) { + return `minmax(${min}, ${flex}fr)`; // weighted track — the type forbids a maxWidth (can't cap an fr track). + } + } + // No (or non-finite) size: a hard-capped track (maxWidth) or the default growable track. + const max = clamp(maxWidth); + return max !== undefined ? `minmax(${min}, ${max}px)` : `minmax(${min}, 1fr)`; +} 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..8f94bed8da --- /dev/null +++ b/src/table-root/interfaces.ts @@ -0,0 +1,56 @@ +// 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: ColumnDefinition[] }` - Renders a CSS grid. The columns are then provided as + * an array of objects with the following properties: + * * `size` (number | { flex: number }) - A number sets a fixed pixel width; `{ flex }` gives the + * column a weight that shares the remaining space in proportion. Omit it for a flexible column + * with the default weight of 1. + * * `minWidth` (number) - The minimum width in pixels for a flexible column. We recommend setting one; + * without it the column can shrink until its content clips or overlaps at narrow widths. + * * `maxWidth` (number) - Caps a flexible column's width in pixels (it grows up to the cap). A capped + * column can't also carry a proportional `flex` weight, so weighting applies to the uncapped columns. + * + * Defaults to `{ type: 'auto' }`. + */ + columnLayout?: TableRootProps.ColumnLayout; + + /** Provides an accessible name for the table. Use this or `ariaLabelledby` to label the table. */ + ariaLabel?: string; + /** Sets the `aria-labelledby` attribute. Use the ID of a visible element that labels the table. */ + ariaLabelledby?: string; + /** Sets the `aria-describedby` attribute. Use the ID of a visible element that describes the table. */ + ariaDescribedby?: string; + + /** + * Sets the table's `aria-rowcount`, counting the header row. Provide it only when you render a + * subset of rows, such as with virtualization; otherwise it is derived from the DOM. + */ + ariaRowcount?: number; +} + +export namespace TableRootProps { + export type ColumnLayout = { type: 'auto' } | { type: 'grid'; columns: ReadonlyArray }; + + export type ColumnDefinition = + | { size: number; minWidth?: never; maxWidth?: never } + | { size: { flex: number }; minWidth?: number; maxWidth?: never } + | { size?: never; minWidth?: number; maxWidth?: number }; +} diff --git a/src/table-root/internal.tsx b/src/table-root/internal.tsx new file mode 100644 index 0000000000..f3bacd4edb --- /dev/null +++ b/src/table-root/internal.tsx @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import clsx from 'clsx'; + +import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { TableContextProvider } from './context'; +import { computeGridTemplateColumns } from './grid-template-columns'; +import { TableRootProps } from './interfaces'; + +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 gridTemplateColumns = computeGridTemplateColumns(columnLayout); + // Memoized because it is the TableContext value; a fresh object re-renders every cell. Both fields are + // primitives compared by value, so it stays stable even when the caller passes a fresh columnLayout. + const tableContext = useMemo(() => ({ isGrid, gridTemplateColumns }), [isGrid, gridTemplateColumns]); + const baseProps = getBaseProps(rest); + + // A wide table's horizontal scroller isn't keyboard-reachable on its own, so a read-only table with no + // focusable cell content can't be scrolled by keyboard. When the content overflows, expose the scroller + // as a focusable region so keyboard users can scroll it horizontally, matching the existing Table's + // getTableWrapperRoleProps. + const scrollerRef = useRef(null); + const [isScrollable, setIsScrollable] = useState(false); + const measureScrollable = useCallback(() => { + const node = scrollerRef.current; + if (node) { + setIsScrollable(node.scrollWidth - node.clientWidth > 1); + } + }, []); + // Observe the scroller and the table it wraps: a viewport resize changes the scroller box, and + // auto-layout content growth changes the table box — either can start or stop the horizontal overflow. + useResizeObserver(scrollerRef, measureScrollable); + useResizeObserver(() => scrollerRef.current?.firstElementChild ?? null, measureScrollable); + // Grid column templates change the tracks' overflow without resizing either observed box, so no observer + // fires — re-measure when the template changes. + useEffect(() => { + measureScrollable(); + }, [gridTemplateColumns, measureScrollable]); + + // Set role="region" whenever scrollable (label passes through even if undefined), matching the + // existing Table's getTableWrapperRoleProps rather than gating the role on a label. + const scrollRegionProps = isScrollable + ? { + role: 'region' as const, + tabIndex: 0, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + } + : {}; + + return ( +
+ {/* TableContext supplies this table's column layout to every part. */} + + {/* 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..37c2eaebd3 --- /dev/null +++ b/src/table-root/styles.scss @@ -0,0 +1,47 @@ +/* + 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; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; + +.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; + @include focus-visible.when-visible { + @include styles.container-focus(); + } +} + +.table { + inline-size: 100%; + // Separate borders: the collapsed model breaks sticky columns and blurs the selected-row outline corners. + border-collapse: separate; + border-spacing: 0; +} + +.table-auto { + table-layout: auto; +} + +.table-grid { + display: block; +} diff --git a/src/table-row/index.tsx b/src/table-row/index.tsx new file mode 100644 index 0000000000..099355f62c --- /dev/null +++ b/src/table-row/index.tsx @@ -0,0 +1,21 @@ +// 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: { selected: props.selected, shaded: props.shaded }, + }); + 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..01e5053a6e --- /dev/null +++ b/src/table-row/interfaces.ts @@ -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 { BaseComponentProps } from '../types/base-component'; + +export interface TableRowProps extends BaseComponentProps { + /** + * Applies selected-row styling. Visual only — it does not set `aria-selected`; convey selection to + * assistive technologies via the selection control in a leading cell. + */ + selected?: boolean; + /** + * Applies a shaded background, for alternating row colors. + */ + shaded?: boolean; + /** Provides an accessible name for the row. Use this or `ariaLabelledby`. */ + ariaLabel?: string; + /** Sets `aria-labelledby`. Use the ID(s) of visible element(s) that label the row. */ + ariaLabelledby?: string; + /** Sets `aria-describedby`. Use the ID(s) of visible element(s) that describe the row. */ + ariaDescribedby?: string; + /** + * Sets the row's `aria-rowindex`, its position in the full dataset counting the header row. Set + * this only when virtualizing; otherwise it is derived from DOM order. + */ + ariaRowindex?: number; + /** + * Applies inline styles to the row element for positioning, such as virtualization or draggable + * rows. Not intended for general styling. + */ + positionStyle?: TableRowProps.PositionStyle; + /** The row's cells, one per column, in order. */ + children?: React.ReactNode; +} + +export namespace TableRowProps { + export interface PositionStyle { + position?: React.CSSProperties['position']; + transform?: React.CSSProperties['transform']; + height?: React.CSSProperties['height']; + } +} diff --git a/src/table-row/internal.tsx b/src/table-row/internal.tsx new file mode 100644 index 0000000000..29259c7a86 --- /dev/null +++ b/src/table-row/internal.tsx @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { useTableContext } from '../table-root/context'; +import { TableRowProps } from './interfaces'; + +import styles from './styles.css.js'; + +// Sanctioned data-* hooks: `data-awsui-selected` / `data-awsui-shaded` on the
offset simply doesn't apply — a low-specificity reset could not override it. + &:first-child.with-paddings:not(.has-striped-rows):not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( .header-cell-grouped ) { @include cell-offset(awsui.$space-xxxs); @@ -294,7 +296,9 @@ settings icon in the pagination slot. shaded background makes the child content appear too close to the table edge. */ - &:first-child.has-striped-rows:not(.sticky-cell-pad-inline-start):not(.header-cell-group):not(.header-cell-grouped) { + &:first-child.with-paddings.has-striped-rows:not(.sticky-cell-pad-inline-start):not(.header-cell-group):not( + .header-cell-grouped + ) { @include cell-offset(awsui.$space-xxs); } diff --git a/src/table/header-cell/th-element.tsx b/src/table/header-cell/th-element.tsx index 68dd326b54..d8e2c0ea94 100644 --- a/src/table/header-cell/th-element.tsx +++ b/src/table/header-cell/th-element.tsx @@ -7,7 +7,7 @@ import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; import { copyAnalyticsMetadataAttribute } from '@cloudscape-design/component-toolkit/internal/analytics-metadata'; -import { useVisualRefresh } from '../../internal/hooks/use-visual-mode'; +import { InternalTableHeaderCell } from '../../table-header-cell/internal'; import { ColumnWidthStyle } from '../column-widths-utils'; import { TableProps } from '../interfaces'; import { StickyColumnsModel, useStickyCellStyles } from '../sticky-columns'; @@ -86,8 +86,6 @@ export function TableThElement({ stickyBoundaryColumnId, ...props }: TableThElementProps) { - const isVisualRefresh = useVisualRefresh(); - const stickyStyles = useStickyCellStyles({ stickyColumns: stickyState, columnId, @@ -99,17 +97,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 = { + colSpan, + rowSpan, + ...getTableColHeaderRoleProps({ + tableRole, + sortingStatus: suppressAriaSort ? undefined : sortingStatus, + colIndex, + }), + scope: scope ?? 'col', + ...(ariaLabel ? { 'aria-label': ariaLabel } : {}), + }; + return ( - {children} -