diff --git a/pages/table/skeleton-render-cell.page.tsx b/pages/table/skeleton-render-cell.page.tsx new file mode 100644 index 0000000000..79865928b0 --- /dev/null +++ b/pages/table/skeleton-render-cell.page.tsx @@ -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[] = [ + { 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..8cde305dad 100644 --- a/src/table/__tests__/skeleton.test.tsx +++ b/src/table/__tests__/skeleton.test.tsx @@ -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 => {`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('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: () => ( + <> + + + + ), + }, + }); + 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' ? : 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 => {`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); + }); + + 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 a1bf7273cf..89588536a4 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,33 @@ 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; 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) => 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..6d8f62be0b 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,14 @@ export function SkeletonRows({ ariaLabels={ariaLabels} column={{ ...column, - cell: () => , + cell: () => { + const customSkeleton = renderCell?.(column); + return customSkeleton === undefined ? ( + + ) : ( + customSkeleton + ); + }, }} item={{}} wrapLines={wrapLines}