From ca8e568cecfc74131907654c0689910906abf2bd Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 7 Aug 2026 08:34:10 +0000 Subject: [PATCH 1/6] feat(table): add skeleton renderCell for per-column placeholders --- pages/table/skeleton-render-cell.page.tsx | 87 +++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 13 +-- src/table/__tests__/skeleton.test.tsx | 42 +++++++++ src/table/interfaces.tsx | 24 ++++- src/table/internal.tsx | 2 + src/table/skeleton-rows.tsx | 4 +- 6 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 pages/table/skeleton-render-cell.page.tsx diff --git a/pages/table/skeleton-render-cell.page.tsx b/pages/table/skeleton-render-cell.page.tsx new file mode 100644 index 0000000000..2b09ccce6e --- /dev/null +++ b/pages/table/skeleton-render-cell.page.tsx @@ -0,0 +1,87 @@ +// 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 Button from '~components/button'; +import Header from '~components/header'; +import Skeleton from '~components/skeleton'; +import SpaceBetween from '~components/space-between'; +import StatusIndicator from '~components/status-indicator'; +import Table, { TableProps } from '~components/table'; + +interface Item { + name: string; + summary: string; + detail: string; + status: 'available' | 'error'; +} + +// Columns whose settled content is not a single line of text: a two-line cell, +// a status indicator, and an actions button. A single one-line skeleton bar +// mismatches these and causes a layout jump when data lands. +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'name', header: 'Name', cell: item => item.name }, + { + id: 'description', + header: 'Description', + cell: item => ( + + {item.summary} + + {item.detail} + + + ), + }, + { id: 'status', header: 'Status', cell: item => {item.status} }, + { id: 'actions', header: '', cell: () => }, +]; + +// A single central render function keyed on the column definition. Return +// `undefined` to fall back to the default single-line skeleton (here, the Name column). +const renderCell: NonNullable['renderCell']> = column => { + switch (column.id) { + case 'description': + return ( + + + + + ); + case 'status': + return ; + case 'actions': + return ; + default: + return undefined; + } +}; + +export default function SkeletonRenderCellPage() { + return ( + + +

Table skeleton — per-column renderCell

+ + Default single-line skeleton} + /> + +
Column-shaped skeleton (renderCell)} + /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 7777066606..c3562d7c3d 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -29188,18 +29188,21 @@ the table items array is empty.", - \`maxAutoRows\` (number) - Limits the number of skeleton rows rendered when \`totalRows\` is set to \`'auto'\`. - \`minAutoRows\` (number) - Sets the minimum number of skeleton rows rendered when \`totalRows\` is set to \`'auto'\`. Defaults to 1. Useful for tables rendered off-screen, where the calculated available height would - otherwise yield a single row.", + otherwise yield a single row. +- \`renderCell\` ((column) => ReactNode) - Renders a custom skeleton placeholder per column, for cells whose + final content is not a single line of text (for example, multi-line cells, status indicators, or actions). + Return \`undefined\` for a column to fall back to the default single-line skeleton.", "inlineType": { - "name": "TableProps.SkeletonConfig", + "name": "TableProps.SkeletonConfig", "type": "union", "values": [ - "TableProps.FixedSkeletonConfig", - "TableProps.AutoSkeletonConfig", + "TableProps.FixedSkeletonConfig", + "TableProps.AutoSkeletonConfig", ], }, "name": "skeleton", "optional": true, - "type": "TableProps.SkeletonConfig", + "type": "TableProps.SkeletonConfig", }, { "description": "Specifies the definition object of the currently sorted column. Make sure you pass an object that's diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index f4b1e00813..af271d6039 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -269,4 +269,46 @@ describe('Table skeleton loading', () => { } }); }); + + describe('renderCell (custom column skeleton)', () => { + test('renders custom skeleton content per column', () => { + const wrapper = renderTable({ + items: [], + loading: true, + skeleton: { + totalRows: 2, + renderCell: column => {`skeleton-${column.header}`}, + }, + }); + const custom = wrapper.findAll('[data-testid="custom-skeleton"]'); + expect(custom).toHaveLength(4); // 2 columns × 2 rows + expect(custom.map(w => w.getElement().textContent)).toEqual( + expect.arrayContaining(['skeleton-id', 'skeleton-name']) + ); + }); + + test('falls back to the default skeleton when renderCell returns undefined for a column', () => { + const wrapper = renderTable({ + items: [], + loading: true, + skeleton: { + totalRows: 2, + renderCell: column => (column.header === 'name' ? : undefined), + }, + }); + expect(wrapper.findAll('[data-testid="custom-skeleton"]')).toHaveLength(2); // name column, 2 rows + expect(wrapper.findAllSkeletons()).toHaveLength(2); // id column falls back to default, 2 rows + }); + + test('renders custom skeleton content inside aria-hidden rows', () => { + const wrapper = renderTable({ + items: [], + loading: true, + skeleton: { totalRows: 1, renderCell: () => }, + }); + const hiddenRows = wrapper.findAll('tr[aria-hidden="true"]'); + expect(hiddenRows).toHaveLength(1); + expect(hiddenRows[0].findAll('[data-testid="custom-skeleton"]')).toHaveLength(2); + }); + }); }); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index a1bf7273cf..6ec01c03c5 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -72,8 +72,11 @@ export interface TableProps extends BaseComponentProps { * - `minAutoRows` (number) - Sets the minimum number of skeleton rows rendered when `totalRows` is set to `'auto'`. * Defaults to 1. Useful for tables rendered off-screen, where the calculated available height would * otherwise yield a single row. + * - `renderCell` ((column) => ReactNode) - Renders a custom skeleton placeholder per column, for cells whose + * final content is not a single line of text (for example, multi-line cells, status indicators, or actions). + * Return `undefined` for a column to fall back to the default single-line skeleton. */ - skeleton?: TableProps.SkeletonConfig; + skeleton?: TableProps.SkeletonConfig; /** * Specifies a property that uniquely identifies an individual item. @@ -775,19 +778,32 @@ export namespace TableProps { item: T; } - export interface FixedSkeletonConfig { + interface BaseSkeletonConfig { + /** + * Renders a custom skeleton placeholder for each cell of the given column while data is loading. + * Use for columns whose final content is not a single line of text, so the placeholder matches the + * settled cell shape and the load-to-settle transition stays stable. Compose the returned content + * from the `Skeleton` component. Return `undefined` for a column to use the default single-line skeleton. + * + * The returned content is rendered inside an `aria-hidden` row, so it is not announced to screen + * readers; do not render focusable or interactive elements. + */ + renderCell?: (column: TableProps.ColumnDefinition) => React.ReactNode; + } + + export interface FixedSkeletonConfig extends BaseSkeletonConfig { totalRows: number; maxAutoRows?: never; minAutoRows?: never; } - export interface AutoSkeletonConfig { + export interface AutoSkeletonConfig extends BaseSkeletonConfig { totalRows: 'auto'; maxAutoRows?: number; minAutoRows?: number; } - export type SkeletonConfig = FixedSkeletonConfig | AutoSkeletonConfig; + export type SkeletonConfig = FixedSkeletonConfig | AutoSkeletonConfig; } export type TableRow = TableDataRow | TableLoaderRow; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 1946b247a6..8877eb304b 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -680,6 +680,7 @@ const InternalTable = React.forwardRef( wrapLines={wrapLines} resizableColumns={resizableColumns} colIndexOffset={colIndexOffset} + renderCell={skeleton?.renderCell} /> ) : !skeleton && (loading || allItems.length === 0) ? ( @@ -895,6 +896,7 @@ const InternalTable = React.forwardRef( wrapLines={wrapLines} resizableColumns={resizableColumns} colIndexOffset={colIndexOffset} + renderCell={skeleton?.renderCell} /> )} diff --git a/src/table/skeleton-rows.tsx b/src/table/skeleton-rows.tsx index e342dfe9de..ba66b67276 100644 --- a/src/table/skeleton-rows.tsx +++ b/src/table/skeleton-rows.tsx @@ -30,6 +30,7 @@ interface SkeletonRowsProps { wrapLines: boolean | undefined; resizableColumns: boolean | undefined; colIndexOffset: number; + renderCell: TableProps.SkeletonConfig['renderCell']; } export function SkeletonRows({ @@ -48,6 +49,7 @@ export function SkeletonRows({ wrapLines, resizableColumns, colIndexOffset, + renderCell, }: SkeletonRowsProps) { return ( <> @@ -82,7 +84,7 @@ export function SkeletonRows({ ariaLabels={ariaLabels} column={{ ...column, - cell: () => , + cell: () => renderCell?.(column) ?? , }} item={{}} wrapLines={wrapLines} From 8a7ebb1d90a1d0f6f69f9dfb076dc4d74c4f2cb1 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 7 Aug 2026 08:41:15 +0000 Subject: [PATCH 2/6] chore(table): fix prettier formatting in skeleton renderCell dev page --- pages/table/skeleton-render-cell.page.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pages/table/skeleton-render-cell.page.tsx b/pages/table/skeleton-render-cell.page.tsx index 2b09ccce6e..79865928b0 100644 --- a/pages/table/skeleton-render-cell.page.tsx +++ b/pages/table/skeleton-render-cell.page.tsx @@ -34,7 +34,11 @@ const columnDefinitions: TableProps.ColumnDefinition[] = [ ), }, - { id: 'status', header: 'Status', cell: item => {item.status} }, + { + id: 'status', + header: 'Status', + cell: item => {item.status}, + }, { id: 'actions', header: '', cell: () => }, ]; From c1cfd060b9120155cd0f245fc2d6e684163de490 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 7 Aug 2026 08:46:50 +0000 Subject: [PATCH 3/6] fix(table): only fall back to default skeleton on undefined, not null Address Copilot review: renderCell returns ReactNode (incl. null); use an explicit undefined check instead of ?? so a null return renders an empty placeholder. Document the null vs undefined distinction and add a test. --- src/table/__tests__/skeleton.test.tsx | 14 ++++++++++++++ src/table/interfaces.tsx | 3 ++- src/table/skeleton-rows.tsx | 11 ++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index af271d6039..0543cccbdd 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -310,5 +310,19 @@ describe('Table skeleton loading', () => { expect(hiddenRows).toHaveLength(1); expect(hiddenRows[0].findAll('[data-testid="custom-skeleton"]')).toHaveLength(2); }); + + test('renders an empty placeholder when renderCell returns null (only undefined falls back)', () => { + const wrapper = renderTable({ + items: [], + loading: true, + skeleton: { + totalRows: 2, + renderCell: column => (column.header === 'id' ? null : undefined), + }, + }); + // id column returns null -> empty placeholder (no default skeleton); + // name column returns undefined -> default skeleton (2 rows). + expect(wrapper.findAllSkeletons()).toHaveLength(2); + }); }); }); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 6ec01c03c5..89588536a4 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -783,7 +783,8 @@ export namespace TableProps { * Renders a custom skeleton placeholder for each cell of the given column while data is loading. * Use for columns whose final content is not a single line of text, so the placeholder matches the * settled cell shape and the load-to-settle transition stays stable. Compose the returned content - * from the `Skeleton` component. Return `undefined` for a column to use the default single-line skeleton. + * from the `Skeleton` component. Return `undefined` for a column to use the default single-line + * skeleton; return `null` to render an empty placeholder for that column. * * The returned content is rendered inside an `aria-hidden` row, so it is not announced to screen * readers; do not render focusable or interactive elements. diff --git a/src/table/skeleton-rows.tsx b/src/table/skeleton-rows.tsx index ba66b67276..fd7d77f6fc 100644 --- a/src/table/skeleton-rows.tsx +++ b/src/table/skeleton-rows.tsx @@ -84,7 +84,16 @@ export function SkeletonRows({ ariaLabels={ariaLabels} column={{ ...column, - cell: () => renderCell?.(column) ?? , + // Only `undefined` (including when `renderCell` is absent) falls back to the default + // skeleton; an explicit `null` renders an empty placeholder, so do not use `??` here. + cell: () => { + const customSkeleton = renderCell?.(column); + return customSkeleton === undefined ? ( + + ) : ( + customSkeleton + ); + }, }} item={{}} wrapLines={wrapLines} From a0cb1c8de9a4ed7683ce42316d4bfed23fe16fbd Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 7 Aug 2026 13:14:58 +0000 Subject: [PATCH 4/6] test(table): cover renderCell interaction with auto skeleton rows --- src/table/__tests__/skeleton.test.tsx | 44 +++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index 0543cccbdd..5c16320e13 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -268,6 +268,50 @@ describe('Table skeleton loading', () => { expect(lastDataRow.compareDocumentPosition(skeletonRow.getElement())).toBe(Node.DOCUMENT_POSITION_FOLLOWING); } }); + + describe('with renderCell', () => { + test('renders custom skeleton content in automatic rows', () => { + const wrapper = renderTable({ + items: [], + loading: true, + skeleton: { + totalRows: 'auto', + maxAutoRows: 2, + renderCell: column => {`skeleton-${column.header}`}, + }, + }); + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); + expect(wrapper.findAll('[data-testid="custom-skeleton"]')).toHaveLength(4); // 2 columns × 2 auto rows + expect(wrapper.findAll('[data-testid="custom-skeleton"]').map(w => w.getElement().textContent)).toEqual( + expect.arrayContaining(['skeleton-id', 'skeleton-name']) + ); + }); + + test('renderCell does not change the automatic row count', () => { + const wrapper = renderTable({ + items: [], + loading: true, + skeleton: { totalRows: 'auto', renderCell: () => }, + }); + // Same viewport mock as the plain-auto "fills the viewport automatically" case -> 3 rows. + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(3); + }); + + test('falls back to the default skeleton per column in automatic rows', () => { + const wrapper = renderTable({ + items: [], + loading: true, + skeleton: { + totalRows: 'auto', + maxAutoRows: 2, + renderCell: column => (column.header === 'name' ? : undefined), + }, + }); + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); + expect(wrapper.findAll('[data-testid="custom-skeleton"]')).toHaveLength(2); // name column, 2 rows + expect(wrapper.findAllSkeletons()).toHaveLength(2); // id column falls back to default, 2 rows + }); + }); }); describe('renderCell (custom column skeleton)', () => { From 4b5464ab85eccef9e0c55e71e89424b0fdf63cea Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 10 Aug 2026 10:21:35 +0000 Subject: [PATCH 5/6] chore(table): drop redundant inline comment in skeleton cell --- src/table/skeleton-rows.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/table/skeleton-rows.tsx b/src/table/skeleton-rows.tsx index fd7d77f6fc..6d8f62be0b 100644 --- a/src/table/skeleton-rows.tsx +++ b/src/table/skeleton-rows.tsx @@ -84,8 +84,6 @@ export function SkeletonRows({ ariaLabels={ariaLabels} column={{ ...column, - // Only `undefined` (including when `renderCell` is absent) falls back to the default - // skeleton; an explicit `null` renders an empty placeholder, so do not use `??` here. cell: () => { const customSkeleton = renderCell?.(column); return customSkeleton === undefined ? ( From 78564fd24b504508116f38916b03603fe95b3859 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Mon, 10 Aug 2026 14:13:38 +0000 Subject: [PATCH 6/6] test(table): verify auto skeleton sizing accounts for taller renderCell rows Replace a misleading test that asserted renderCell never changes the auto row count (only true because the harness pinned every row to 40px) with one that reports taller custom rows and asserts auto fits fewer rows (3 -> 2). --- src/table/__tests__/skeleton.test.tsx | 33 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx index 5c16320e13..8cde305dad 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -287,14 +287,39 @@ describe('Table skeleton loading', () => { ); }); - test('renderCell does not change the automatic row count', () => { + test('fits fewer automatic rows when renderCell produces taller rows', () => { + // Auto-sizing measures the real rendered skeleton-row height, so a taller custom + // placeholder must fit fewer rows than the default single-line case (which fits 3, + // per "fills the viewport automatically"). Report custom rows as 80px vs the default 40px. + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this === document.body) { + return { top: 0, bottom: 400, height: 400 } as DOMRect; + } + if (this.matches('tr[aria-hidden="true"]')) { + return this.querySelector('[data-testid="tall-skeleton"]') + ? ({ top: 200, bottom: 280, height: 80 } as DOMRect) + : ({ top: 200, bottom: 240, height: 40 } as DOMRect); + } + if (this.querySelector('tbody')) { + return { top: 0, bottom: 300, height: 300 } as DOMRect; + } + return { top: 0, bottom: 0, height: 0 } as DOMRect; + }); + const wrapper = renderTable({ items: [], loading: true, - skeleton: { totalRows: 'auto', renderCell: () => }, + skeleton: { + totalRows: 'auto', + renderCell: () => ( + <> + + + + ), + }, }); - // Same viewport mock as the plain-auto "fills the viewport automatically" case -> 3 rows. - expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(3); + expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2); }); test('falls back to the default skeleton per column in automatic rows', () => {