Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bb3ca2e
feat(table): add atomic table components (TableRoot/Head/HeaderRow/He…
gethinwebster Sep 11, 2026
d93805b
refactor(table): build the classic Table on the atomic substrates + c…
gethinwebster Sep 11, 2026
8be61ff
feat: Add ariaLabel, ariaLabelledby, and ariaDescribedby to RadioButton
gethinwebster Sep 17, 2026
a9bd1d3
chore: Refactor table atomic dev pages and persist page settings in URL
gethinwebster Sep 17, 2026
e89fe62
chore: Adopt SimplePage helper for remaining table atomic dev pages
gethinwebster Sep 17, 2026
a523847
refactor: Rename TableBody and TableRow style prop to positionStyle
gethinwebster Sep 17, 2026
5970480
chore: Clean up review-flagged comments in table atomic components
gethinwebster Sep 17, 2026
6888143
refactor: Match existing Table scrollable region role and document co…
gethinwebster Sep 17, 2026
fdb52ae
feat: Add TableCell isRowHeader and prefix row-variant data attribute…
gethinwebster Sep 17, 2026
adbc155
Merge remote-tracking branch 'origin/main' into dev-v3-gethinw-table-…
gethinwebster Sep 18, 2026
e44ac38
chore: Fold striped-rows demo into the simple page as a toggle; Simpl…
gethinwebster Sep 18, 2026
2f95d54
refactor: Consolidate table atomic parts into TableRow variants and T…
gethinwebster Sep 18, 2026
7f62157
refactor: Remove TableRow header variant and grid-auto-rows floor
gethinwebster Sep 21, 2026
be58dfd
refactor: Reuse useResizeObserver in TableRoot and add overflow-regio…
gethinwebster Sep 21, 2026
96fb243
refactor: Remove RowVariantContext, painting shaded rows from the row…
gethinwebster Sep 21, 2026
a164105
test: Drop vestigial assertions for the removed class-based row painting
gethinwebster Sep 21, 2026
2d99de8
refactor: Make nativeAttributes passing more consistent with other co…
gethinwebster Sep 21, 2026
e1e2030
refactor: Address review nits — narrow flex-weight check and drop unu…
gethinwebster Sep 21, 2026
ffb5ac3
docs: Recommend setting minWidth on flexible grid columns
gethinwebster Sep 21, 2026
b66e2b8
Update table-root columnLayout docs
gethinwebster Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions build-tools/utils/pluralize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
177 changes: 177 additions & 0 deletions pages/table-root/column-sizing.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// 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';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike other components, the table fragments are meant to be used together. We don't have much benefit from tree-shaking. Should we instead give a single ns, e.g.:

import { TableRoot, TableBody, ... } from "~components/table-fragments"

// Alternative imports
import TableRoot from "~components/table-fragments/table-root"
import TableBody from "~components/table-fragments/table-body"
// ...

In the future, we will likely need to have a place from where we export the related hooks, like:

import {
  TableRoot,
  TableBody,
  ...,
  useStickyColumns,
  useExpandableRows,
  ...
} from "~components/table-fragments"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could export everything form the /table/* namespace:

import { TableRoot, TableBody, ... } from "~components/table/fragments";

// Alternative imports
import TableRoot from "~components/table/fragments/root"
import TableBody from "~components/table/fragments/body"
// ...

import { useStickyColumns, useExpandableRows, ... } from "~components/table/hooks"

// Alternative imports
import useStickyColumns from "~components/table/hooks/use-sticky-columns"
import useExpandableRows from "~components/table/hooks/use-expandable-rows"
// ...

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';

// Column-sizing playground (grid layout). Adjust each column's sizing mode and widths to explore how
// `columnLayout: 'grid'` compiles `ColumnDefinition`s into a grid-template-columns track list. A CSS grid
// track can't be both fr-weighted and px-capped, so `flex` and `maxWidth` are mutually exclusive in the
// type: use `flex` (weighted, shares free space) or `capped` (grows only up to maxWidth), not both.

type Mode = 'fixed' | 'flex' | 'capped';
interface ColConfig {
label: string;
field: keyof Item;
mode: Mode;
value: number; // pixels when fixed, flex weight when flex; unused when capped
minWidth: string; // raw input text; '' means unset
maxWidth: string;
}

const MODE_OPTIONS: ReadonlyArray<SelectProps.Option> = [
{ 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<ColConfig, 'mode' | 'value' | 'minWidth' | 'maxWidth'>;

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<StoredCol>[];
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<ColConfig>) =>
setUrlParams({
columns: serializeConfigs(configs.map((config, i) => (i === index ? { ...config, ...patch } : config))),
});

const columns = useMemo(() => configs.map(toColumnDefinition), [configs]);

return (
<SimplePage title="Table atomics — column-sizing playground (grid layout)" screenshotArea={{}}>
<SpaceBetween size="l">
Comment thread
pan-kot marked this conversation as resolved.
<Box color="text-body-secondary">
Adjust each column below and watch the table re-lay out. A CSS grid track can&apos;t be both weighted and
hard-capped, so a column is either <em>flex</em> (shares free space by weight) or <em>capped</em> (grows only
up to maxWidth) — never both.
</Box>

<ColumnLayout columns={configs.length}>
{configs.map((config, index) => (
<SpaceBetween key={config.label} size="xs">
<Box variant="h2">{config.label}</Box>
<FormField label="Mode">
<Select
selectedOption={MODE_OPTIONS.find(option => option.value === config.mode) ?? MODE_OPTIONS[0]}
options={MODE_OPTIONS}
onChange={({ detail }) => update(index, { mode: detail.selectedOption.value as Mode })}
/>
</FormField>
{config.mode !== 'capped' && (
<FormField label={config.mode === 'fixed' ? 'Width (px)' : 'Flex weight'}>
<Input
type="number"
value={String(config.value)}
onChange={({ detail }) => update(index, { value: Number(detail.value) || 0 })}
/>
</FormField>
)}
<FormField label="minWidth (px)" description="Flex or capped">
<Input
type="number"
value={config.minWidth}
disabled={config.mode === 'fixed'}
onChange={({ detail }) => update(index, { minWidth: detail.value })}
/>
</FormField>
<FormField label="maxWidth (px)" description="Capped mode only">
<Input
type="number"
value={config.maxWidth}
disabled={config.mode !== 'capped'}
onChange={({ detail }) => update(index, { maxWidth: detail.value })}
/>
</FormField>
</SpaceBetween>
))}
</ColumnLayout>

<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap' }}>
{`columnLayout = { type: 'grid', columns: ${JSON.stringify(columns)} }`}
</pre>

<SpaceBetween size="s">
<Header counter={`(${items.length})`}>Resources</Header>
<TableRoot columnLayout={{ type: 'grid', columns }} ariaLabel="Resources">
<TableHead>
<TableRow>
{configs.map(config => (
<TableHeaderCell key={config.label}>{config.label}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{items.map((item: Item) => (
<TableRow key={item.id}>
{configs.map(config => (
<TableBodyCell key={config.label}>{item[config.field]}</TableBodyCell>
))}
</TableRow>
))}
</TableBody>
</TableRoot>
</SpaceBetween>
</SpaceBetween>
</SimplePage>
);
}
59 changes: 59 additions & 0 deletions pages/table-root/common.tsx
Original file line number Diff line number Diff line change
@@ -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<TableRootProps.ColumnDefinition> = [
{ 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 (
<TableHead>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Type</TableHeaderCell>
<TableHeaderCell>Size</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
</TableHead>
);
}

export function DataBody({ items }: { items: Item[] }) {
return (
<TableBody>
{items.map(item => (
<TableRow key={item.id}>
<TableBodyCell>{item.name}</TableBodyCell>
<TableBodyCell>{item.type}</TableBodyCell>
<TableBodyCell>{item.size}</TableBodyCell>
<TableBodyCell>{item.status}</TableBodyCell>
</TableRow>
))}
</TableBody>
);
}
79 changes: 79 additions & 0 deletions pages/table-root/loading-and-empty.page.tsx
Original file line number Diff line number Diff line change
@@ -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
// `<table>`, so a single full-width status row is a plain `<td colSpan>` 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 (
<SimplePage
title="Table atomics — loading & empty states"
settings={
<SegmentedControl
selectedId={state}
onChange={event => setUrlParams({ dataState: event.detail.selectedId as State })}
label="Data state"
options={[
{ id: 'loaded', text: 'Loaded' },
{ id: 'loading', text: 'Loading' },
{ id: 'empty', text: 'Empty' },
]}
/>
}
screenshotArea={{}}
>
<SpaceBetween size="s">
<Header counter={`(${items.length})`}>Resources</Header>
<TableRoot ariaLabel="Resources">
<DataHeader />
{state === 'loaded' ? (
<DataBody items={items} />
) : (
<TableBody>
<TableRow>
<td colSpan={COLUMN_COUNT}>
<Box padding="m" textAlign="center" color="inherit">
{state === 'loading' ? (
<StatusIndicator type="loading">Loading resources</StatusIndicator>
) : (
<SpaceBetween size="xxs">
<b>No resources</b>
<Box variant="p" color="inherit">
No resources to display.
</Box>
</SpaceBetween>
)}
</Box>
</td>
</TableRow>
</TableBody>
)}
</TableRoot>
</SpaceBetween>
</SimplePage>
);
}
Loading
Loading