Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
91 changes: 91 additions & 0 deletions pages/table/skeleton-render-cell.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// 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<Item>[] = [
{ id: 'name', header: 'Name', cell: item => item.name },
{
id: 'description',
header: 'Description',
cell: item => (
<SpaceBetween size="xxs">
<Box>{item.summary}</Box>
<Box color="text-body-secondary" fontSize="body-s">
{item.detail}
</Box>
</SpaceBetween>
),
},
{
id: 'status',
header: 'Status',
cell: item => <StatusIndicator type={item.status === 'error' ? 'error' : 'success'}>{item.status}</StatusIndicator>,
},
{ id: 'actions', header: '', cell: () => <Button>Edit</Button> },
];

// 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<TableProps.SkeletonConfig<Item>['renderCell']> = column => {
switch (column.id) {
case 'description':
return (
<SpaceBetween size="xxs">
<Skeleton variant="text-body-m" width="90%" />
<Skeleton variant="text-body-s" width="60%" />
</SpaceBetween>
);
case 'status':
return <Skeleton variant="text-body-m" width="80px" />;
case 'actions':
return <Skeleton variant="text-body-m" display="inline-block" width="64px" height="2rem" />;
default:
return undefined;
}
};

export default function SkeletonRenderCellPage() {
return (
<Box padding="l">
<SpaceBetween size="xl">
<h1>Table skeleton — per-column renderCell</h1>

<Table
items={[]}
loading={true}
loadingText="Loading resources"
columnDefinitions={columnDefinitions}
skeleton={{ totalRows: 5 }}
header={<Header>Default single-line skeleton</Header>}
/>

<Table
items={[]}
loading={true}
loadingText="Loading resources"
columnDefinitions={columnDefinitions}
skeleton={{ totalRows: 5, renderCell }}
header={<Header>Column-shaped skeleton (renderCell)</Header>}
/>
</SpaceBetween>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>",
"type": "union",
"values": [
"TableProps.FixedSkeletonConfig",
"TableProps.AutoSkeletonConfig",
"TableProps.FixedSkeletonConfig<T>",
"TableProps.AutoSkeletonConfig<T>",
],
},
"name": "skeleton",
"optional": true,
"type": "TableProps.SkeletonConfig",
"type": "TableProps.SkeletonConfig<T>",
},
{
"description": "Specifies the definition object of the currently sorted column. Make sure you pass an object that's
Expand Down
125 changes: 125 additions & 0 deletions src/table/__tests__/skeleton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,5 +268,130 @@ 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 => <span data-testid="custom-skeleton">{`skeleton-${column.header}`}</span>,
},
});
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('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: () => (
<>
<span data-testid="tall-skeleton" />
<span data-testid="tall-skeleton" />
</>
),
},
});
expect(wrapper.findAll('tr[aria-hidden="true"]')).toHaveLength(2);
});

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' ? <span data-testid="custom-skeleton" /> : 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)', () => {
test('renders custom skeleton content per column', () => {
const wrapper = renderTable({
items: [],
loading: true,
skeleton: {
totalRows: 2,
renderCell: column => <span data-testid="custom-skeleton">{`skeleton-${column.header}`}</span>,
},
});
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' ? <span data-testid="custom-skeleton" /> : 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: () => <span data-testid="custom-skeleton" /> },
});
const hiddenRows = wrapper.findAll('tr[aria-hidden="true"]');
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);
});
Comment on lines +383 to +395

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.

Should we add a similar test for automatic rows too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I've taken another look at what tests there were, and updated to make them a bit more meaningful in terms of combinations they test.

});
});
25 changes: 21 additions & 4 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,11 @@ export interface TableProps<T = any> 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<T>;

/**
* Specifies a property that uniquely identifies an individual item.
Expand Down Expand Up @@ -775,19 +778,33 @@ export namespace TableProps {
item: T;
}

export interface FixedSkeletonConfig {
interface BaseSkeletonConfig<T> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Leaving as-is intentionally. The declaration-emit concern doesn't apply here: BaseSkeletonConfig lives in the same module/namespace as the interfaces that extend it, so there's no TS4023 "private name" issue — confirmed by build/build (which runs declaration emit) passing green. It's also not against convention: this repo already has non-exported Base* helper interfaces inside *Props namespaces (e.g. BaseUtility inside TopNavigationProps). Keeping it non-exported is deliberate — it exists only to share the single optional renderCell prop across the fixed/auto configs, and exporting it would add a public type to the API surface for no consumer benefit.

/**
* 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; 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.
*/
renderCell?: (column: TableProps.ColumnDefinition<T>) => React.ReactNode;
}

export interface FixedSkeletonConfig<T = any> extends BaseSkeletonConfig<T> {
totalRows: number;
maxAutoRows?: never;
minAutoRows?: never;
}

export interface AutoSkeletonConfig {
export interface AutoSkeletonConfig<T = any> extends BaseSkeletonConfig<T> {
totalRows: 'auto';
maxAutoRows?: number;
minAutoRows?: number;
}

export type SkeletonConfig = FixedSkeletonConfig | AutoSkeletonConfig;
export type SkeletonConfig<T = any> = FixedSkeletonConfig<T> | AutoSkeletonConfig<T>;
}

export type TableRow<T> = TableDataRow<T> | TableLoaderRow<T>;
Expand Down
2 changes: 2 additions & 0 deletions src/table/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,7 @@ const InternalTable = React.forwardRef(
wrapLines={wrapLines}
resizableColumns={resizableColumns}
colIndexOffset={colIndexOffset}
renderCell={skeleton?.renderCell}
/>
) : !skeleton && (loading || allItems.length === 0) ? (
<tr>
Expand Down Expand Up @@ -895,6 +896,7 @@ const InternalTable = React.forwardRef(
wrapLines={wrapLines}
resizableColumns={resizableColumns}
colIndexOffset={colIndexOffset}
renderCell={skeleton?.renderCell}
/>
)}
</tbody>
Expand Down
11 changes: 10 additions & 1 deletion src/table/skeleton-rows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ interface SkeletonRowsProps {
wrapLines: boolean | undefined;
resizableColumns: boolean | undefined;
colIndexOffset: number;
renderCell: TableProps.SkeletonConfig<any>['renderCell'];
}

export function SkeletonRows({
Expand All @@ -48,6 +49,7 @@ export function SkeletonRows({
wrapLines,
resizableColumns,
colIndexOffset,
renderCell,
}: SkeletonRowsProps) {
return (
<>
Expand Down Expand Up @@ -82,7 +84,14 @@ export function SkeletonRows({
ariaLabels={ariaLabels}
column={{
...column,
cell: () => <InternalSkeleton variant="dynamic" tagOverride="span" />,
cell: () => {
const customSkeleton = renderCell?.(column);
return customSkeleton === undefined ? (
<InternalSkeleton variant="dynamic" tagOverride="span" />
) : (
customSkeleton
);
},
}}
Comment thread
gethinwebster marked this conversation as resolved.
item={{}}
wrapLines={wrapLines}
Expand Down
Loading