-
Notifications
You must be signed in to change notification settings - Fork 244
feat: add composable low-level table components (TableRoot, TableHead, TableRow, TableHeaderCell, TableBodyCell) #4977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
gethinwebster
wants to merge
20
commits into
main
Choose a base branch
from
dev-v3-gethinw-table-cell-extract
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
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 d93805b
refactor(table): build the classic Table on the atomic substrates + c…
gethinwebster 8be61ff
feat: Add ariaLabel, ariaLabelledby, and ariaDescribedby to RadioButton
gethinwebster a9bd1d3
chore: Refactor table atomic dev pages and persist page settings in URL
gethinwebster e89fe62
chore: Adopt SimplePage helper for remaining table atomic dev pages
gethinwebster a523847
refactor: Rename TableBody and TableRow style prop to positionStyle
gethinwebster 5970480
chore: Clean up review-flagged comments in table atomic components
gethinwebster 6888143
refactor: Match existing Table scrollable region role and document co…
gethinwebster fdb52ae
feat: Add TableCell isRowHeader and prefix row-variant data attribute…
gethinwebster adbc155
Merge remote-tracking branch 'origin/main' into dev-v3-gethinw-table-…
gethinwebster e44ac38
chore: Fold striped-rows demo into the simple page as a toggle; Simpl…
gethinwebster 2f95d54
refactor: Consolidate table atomic parts into TableRow variants and T…
gethinwebster 7f62157
refactor: Remove TableRow header variant and grid-auto-rows floor
gethinwebster be58dfd
refactor: Reuse useResizeObserver in TableRoot and add overflow-regio…
gethinwebster 96fb243
refactor: Remove RowVariantContext, painting shaded rows from the row…
gethinwebster a164105
test: Drop vestigial assertions for the removed class-based row painting
gethinwebster 2d99de8
refactor: Make nativeAttributes passing more consistent with other co…
gethinwebster e1e2030
refactor: Address review nits — narrow flex-weight check and drop unu…
gethinwebster ffb5ac3
docs: Recommend setting minWidth on flexible grid columns
gethinwebster b66e2b8
Update table-root columnLayout docs
gethinwebster File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| 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"> | ||
|
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'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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.:
In the future, we will likely need to have a place from where we export the related hooks, like:
There was a problem hiding this comment.
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: