Skip to content
Draft
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
668 changes: 668 additions & 0 deletions pages/table/progressive-loading.page.tsx

Large diffs are not rendered by default.

173 changes: 173 additions & 0 deletions pages/table/skeleton-rows.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';

import Checkbox from '~components/checkbox';
import ColumnLayout from '~components/column-layout';
import Container from '~components/container';
import FormField from '~components/form-field';
import Header from '~components/header';
import Input from '~components/input';
import RadioGroup from '~components/radio-group';
import SpaceBetween from '~components/space-between';
import Table from '~components/table';

import { useAppContext } from '../app/app-context';

interface Item {
id: string;
name: string;
description: string;
type: string;
}

function generateItems(count: number): Item[] {
return Array.from({ length: count }, (_, i) => ({
id: `item-${i + 1}`,
name: `Item ${i + 1}`,
description: `Description for item ${i + 1}`,
type: i % 2 === 0 ? 'Type A' : 'Type B',
}));
}

type LoadingState = 'skeleton' | 'loading' | 'data';
type SelectionMode = 'none' | 'single' | 'multi';

export default function TableSkeletonRowsPage() {
const { urlParams, setUrlParams } = useAppContext<
'loadingState' | 'skeletonRows' | 'dataRows' | 'stripedRows' | 'selectionMode'
>();

const loadingState = (urlParams.loadingState || 'skeleton') as LoadingState;
const skeletonRowsCount = String(urlParams.skeletonRows || '5');
const dataRowsCount = String(urlParams.dataRows || '10');
const stripedRows = urlParams.stripedRows !== 'false' && urlParams.stripedRows !== false;
const selectionMode = (urlParams.selectionMode || 'multi') as SelectionMode;

const [selectedItems, setSelectedItems] = useState<Item[]>([]);

const skeletonRows = parseInt(skeletonRowsCount, 10) || 0;
const dataRows = parseInt(dataRowsCount, 10) || 0;
const items = loadingState === 'data' ? generateItems(dataRows) : [];

const columnDefinitions = [
{
id: 'id',
header: 'ID',
cell: (item: Item) => item.id,
},
{
id: 'name',
header: 'Name',
cell: (item: Item) => item.name,
},
{
id: 'description',
header: 'Description',
cell: (item: Item) => item.description,
},
{
id: 'type',
header: 'Type',
cell: (item: Item) => item.type,
},
];

return (
<div style={{ padding: '20px' }}>
<SpaceBetween size="l">
<Header variant="h1">Table with Skeleton Rows - Interactive Demo</Header>

<Container header={<Header variant="h2">Controls</Header>}>
<SpaceBetween size="l">
<ColumnLayout columns={2}>
<FormField label="Loading State">
<RadioGroup
value={loadingState}
onChange={({ detail }) => setUrlParams({ loadingState: detail.value })}
items={[
{ value: 'skeleton', label: 'Skeleton Rows', description: 'Show skeleton placeholders' },
{ value: 'loading', label: 'Standard Loading', description: 'Show spinner with loading text' },
{ value: 'data', label: 'Actual Data', description: 'Display real table data' },
]}
/>
</FormField>

<FormField label="Selection Mode">
<RadioGroup
value={selectionMode}
onChange={({ detail }) => setUrlParams({ selectionMode: detail.value })}
items={[
{ value: 'none', label: 'None', description: 'No selection' },
{ value: 'single', label: 'Single', description: 'Single row selection' },
{ value: 'multi', label: 'Multi', description: 'Multiple row selection' },
]}
/>
</FormField>
</ColumnLayout>

<ColumnLayout columns={3}>
<FormField label="Number of Skeleton Rows" stretch={false}>
<Input
value={skeletonRowsCount}
onChange={({ detail }) => setUrlParams({ skeletonRows: detail.value })}
type="number"
inputMode="numeric"
step={1}
/>
</FormField>

<FormField label="Number of Data Rows" stretch={false}>
<Input
value={dataRowsCount}
onChange={({ detail }) => setUrlParams({ dataRows: detail.value })}
type="number"
inputMode="numeric"
step={1}
/>
</FormField>

<FormField label="Table Options" stretch={false}>
<Checkbox
checked={stripedRows}
onChange={({ detail }) => setUrlParams({ stripedRows: detail.checked })}
>
Striped rows
</Checkbox>
</FormField>
</ColumnLayout>
</SpaceBetween>
</Container>

<Table
columnDefinitions={columnDefinitions}
items={items}
enableKeyboardNavigation={true}
skeleton={loadingState === 'skeleton' ? { totalRows: skeletonRows } : undefined}
loading={loadingState !== 'data'}
loadingText="Loading items..."
empty="No items to display"
selectionType={selectionMode === 'none' ? undefined : selectionMode}
selectedItems={selectionMode !== 'none' ? selectedItems : undefined}
onSelectionChange={
selectionMode !== 'none' ? ({ detail }) => setSelectedItems(detail.selectedItems) : undefined
}
ariaLabels={{
selectionGroupLabel: 'Item selection',
allItemsSelectionLabel: () => 'Select all',
itemSelectionLabel: (_, item) => item.name,
}}
stripedRows={stripedRows}
header={
<Header
counter={loadingState === 'data' ? `(${items.length})` : undefined}
description="Interactive demo showing skeleton rows, standard loading, and actual data"
>
Table Demo
</Header>
}
/>
</SpaceBetween>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -28136,7 +28136,8 @@ by the \`cell\` property of each column definition in the \`columnDefinitions\`
"type": "boolean",
},
{
"description": "Specifies the text that's displayed when the table is in a loading state.",
"description": "Specifies the text that's displayed when the table is in a loading state.
In skeleton-loading mode this will be used as a label for screenreaders.",
"name": "loadingText",
"optional": true,
"type": "string",
Expand Down Expand Up @@ -28280,6 +28281,26 @@ the table items array is empty.",
"optional": true,
"type": "string",
},
{
"description": "Renders skeleton placeholder rows to fill the table while data is loading. Accepts:
- \`totalRows\` (number) - The total number of rows that should be rendered. If \`items\`
are also provided, those items will be rendered first, and \`totalRows - items.length\`
additional skeleton rows rendered after.",
"inlineType": {
"name": "TableProps.SkeletonConfig",
"properties": [
{
"name": "totalRows",
"optional": false,
"type": "number",
},
],
"type": "object",
},
"name": "skeleton",
"optional": true,
"type": "TableProps.SkeletonConfig",
},
{
"description": "Specifies the definition object of the currently sorted column. Make sure you pass an object that's
present in the \`columnDefinitions\` array.",
Expand Down
98 changes: 98 additions & 0 deletions src/table/__tests__/skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React from 'react';
import { render } from '@testing-library/react';

import Table, { TableProps } from '../../../lib/components/table';
import createWrapper from '../../../lib/components/test-utils/dom';

interface Item {
id: number;
name: string;
}

const defaultColumns: TableProps.ColumnDefinition<Item>[] = [
{ header: 'id', cell: item => item.id },
{ header: 'name', cell: item => item.name },
];

const defaultItems: Item[] = [
{ id: 1, name: 'Apples' },
{ id: 2, name: 'Oranges' },
{ id: 3, name: 'Bananas' },
];

function renderTable(props?: Partial<TableProps>) {
const { container } = render(<Table items={defaultItems} columnDefinitions={defaultColumns} {...props} />);
const wrapper = createWrapper(container);
return wrapper;
}

describe('Table skeleton loading', () => {
describe('initial load (no items)', () => {
test('renders skeleton rows with skeleton components when loading', () => {
const wrapper = renderTable({ items: [], loading: true, skeleton: { totalRows: 5 } });
const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]');
expect(skeletonRows).toHaveLength(5);
expect(wrapper.findAllSkeletons()).toHaveLength(10); // 2 columns × 5 rows
});

test('does not render skeleton rows when skeleton prop is not provided', () => {
const wrapper = renderTable({ items: [], loading: true });
const rows = wrapper.findAll('tr[aria-hidden="true"]');
expect(rows).toHaveLength(0);
});

test('does not render skeleton rows when not loading', () => {
const wrapper = renderTable({ items: [], loading: false, skeleton: { totalRows: 5 } });
const rows = wrapper.findAll('tr[aria-hidden="true"]');
expect(rows).toHaveLength(0);
});

test('renders a screen-reader-only loading announcement', () => {
const wrapper = renderTable({
items: [],
loading: true,
loadingText: 'Loading resources',
skeleton: { totalRows: 3 },
});
expect(wrapper.getElement().textContent).toContain('Loading resources');
});
});

describe('progressive loading (partial items)', () => {
test('renders data rows and skeleton rows for remaining items', () => {
const wrapper = renderTable({ items: defaultItems, loading: true, skeleton: { totalRows: 6 } });
const table = wrapper.findTable()!;
const allRows = table.findRows();
const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]');
// 3 data + 3 skeleton = 6 rows with .row class
expect(allRows).toHaveLength(6);
expect(skeletonRows).toHaveLength(3);
expect(wrapper.findAllSkeletons()).toHaveLength(6); // 2 columns × 3 rows
expect(table.findBodyCell(1, 2)!.getElement().textContent).toBe('Apples');
});

test('does not render skeleton rows when items fill totalRows', () => {
const wrapper = renderTable({ items: defaultItems, loading: true, skeleton: { totalRows: 3 } });
const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]');
expect(skeletonRows).toHaveLength(0);
});

test('does not render skeleton rows when loading is false', () => {
const wrapper = renderTable({ items: defaultItems, loading: false, skeleton: { totalRows: 6 } });
const skeletonRows = wrapper.findAll('tr[aria-hidden="true"]');
expect(skeletonRows).toHaveLength(0);
});

test('renders a screen-reader-only loading announcement', () => {
const wrapper = renderTable({
items: defaultItems,
loading: true,
loadingText: 'Loading more',
skeleton: { totalRows: 6 },
});
expect(wrapper.getElement().textContent).toContain('Loading more');
});
});
});
13 changes: 13 additions & 0 deletions src/table/interfaces.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,18 @@ export interface TableProps<T = any> extends BaseComponentProps {

/**
* Specifies the text that's displayed when the table is in a loading state.
* In skeleton-loading mode this will be used as a label for screenreaders.
*/
loadingText?: string;

/**
* Renders skeleton placeholder rows to fill the table while data is loading. Accepts:
* - `totalRows` (number) - The total number of rows that should be rendered. If `items`
* are also provided, those items will be rendered first, and `totalRows - items.length`
* additional skeleton rows rendered after.
*/
skeleton?: TableProps.SkeletonConfig;

/**
* Specifies a property that uniquely identifies an individual item.
* When it's set, it's used to provide [keys for React](https://reactjs.org/docs/lists-and-keys.html#keys)
Expand Down Expand Up @@ -696,6 +705,10 @@ export namespace TableProps {
export interface RenderLoaderEmptyDetail<T> {
item: T;
}

export interface SkeletonConfig {
totalRows: number;
}
}

export type TableRow<T> = TableDataRow<T> | TableLoaderRow<T>;
Expand Down
Loading
Loading