From 00a8498eb003a37fc1536cd23bf18e20953d357d Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 29 May 2026 13:24:28 +0000 Subject: [PATCH] feat: Add skeleton loading support to table Add skeleton prop ({ totalRows: number }) that renders skeleton placeholder rows to fill the table while data is loading. - skeleton.totalRows defines total visible rows (typically page size) - Skeleton rows rendered = max(0, totalRows - items.length) - Standard loading: items=[] + loading=true + skeleton -> all skeleton rows - Progressive loading: items=[partial] + skeleton -> real rows + trailing skeleton - A11y: visually-hidden row with ScreenreaderOnly loadingText before skeleton rows - Skeleton rows use aria-hidden=true - Includes interactive demo pages for both patterns --- pages/table/progressive-loading.page.tsx | 668 ++++++++++++++++++ pages/table/skeleton-rows.page.tsx | 173 +++++ .../__snapshots__/documenter.test.ts.snap | 23 +- src/table/__tests__/skeleton.test.tsx | 98 +++ src/table/interfaces.tsx | 13 + src/table/internal.tsx | 65 +- src/table/skeleton-row.tsx | 94 +++ src/table/styles.scss | 6 + 8 files changed, 1136 insertions(+), 4 deletions(-) create mode 100644 pages/table/progressive-loading.page.tsx create mode 100644 pages/table/skeleton-rows.page.tsx create mode 100644 src/table/__tests__/skeleton.test.tsx create mode 100644 src/table/skeleton-row.tsx diff --git a/pages/table/progressive-loading.page.tsx b/pages/table/progressive-loading.page.tsx new file mode 100644 index 0000000000..c8e9cf2e4b --- /dev/null +++ b/pages/table/progressive-loading.page.tsx @@ -0,0 +1,668 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef, useState } from 'react'; + +import Box from '~components/box'; +import Button from '~components/button'; +import Container from '~components/container'; +import FormField from '~components/form-field'; +import Grid from '~components/grid'; +import Header from '~components/header'; +import Input from '~components/input'; +import Pagination from '~components/pagination'; +import Skeleton from '~components/skeleton'; +import SpaceBetween from '~components/space-between'; +import Spinner from '~components/spinner'; +import StatusIndicator from '~components/status-indicator'; +import Table from '~components/table'; +import TextFilter from '~components/text-filter'; +import Toggle from '~components/toggle'; + +import { useAppContext } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; + +interface DataItem { + id: string; + name: string; + description: string; + status: string; + date: string; +} + +const sampleData: DataItem[] = [ + { + id: 'item-1', + name: 'Production Database', + description: 'Primary production database instance', + status: 'Active', + date: '2026-04-15', + }, + { + id: 'item-2', + name: 'Development Environment', + description: 'Testing and development environment', + status: 'Active', + date: '2026-04-14', + }, + { + id: 'item-3', + name: 'Analytics Pipeline', + description: 'Data processing pipeline for analytics', + status: 'Pending', + date: '2026-04-13', + }, + { + id: 'item-4', + name: 'Backup Server', + description: 'Secondary backup server for disaster recovery', + status: 'Active', + date: '2026-04-12', + }, +]; + +const statuses = ['Active', 'Pending', 'Stopped', 'Terminated']; + +function generateLargeDataset(count: number): DataItem[] { + return Array.from({ length: count }, (_, i) => ({ + id: `item-${i + 1}`, + name: `Resource ${i + 1}`, + description: `Description for resource ${i + 1}`, + status: statuses[i % statuses.length], + date: `2026-04-${String((i % 28) + 1).padStart(2, '0')}`, + })); +} + +const largeData = generateLargeDataset(25); +const PAGE_SIZE = 10; + +export default function ProgressiveLoadingExplorations() { + const { urlParams, setUrlParams } = useAppContext<'manyItems' | 'skeletonRows'>(); + + // Page-level settings from URL params + const manyItems = urlParams.manyItems !== 'false' && urlParams.manyItems !== false && !!urlParams.manyItems; + const skeletonRowsCount = String(urlParams.skeletonRows || '10'); + const skeletonRows = parseInt(skeletonRowsCount, 10) || 10; + const items = manyItems ? largeData : sampleData; + + // Shared pagination helper + function paginate(allItems: DataItem[], page: number) { + const start = (page - 1) * PAGE_SIZE; + return allItems.slice(start, start + PAGE_SIZE); + } + + function totalPages(allItems: DataItem[]) { + return Math.ceil(allItems.length / PAGE_SIZE); + } + + // Progressive Row Loading state + const [rowPage, setRowPage] = useState(1); + const rowPageTimerRef = useRef | null>(null); + const rowPageItems = paginate(items, rowPage); + + const [rowLoadedState, setRowLoadedState] = useState(() => rowPageItems.map(() => false)); + const rowTimerRefs = useRef[]>([]); + const [rowLoading, setRowLoading] = useState(false); + const [rowPaused, setRowPaused] = useState(false); + const rowNextIndexRef = useRef(0); + + // Reset row loading when items change (toggle switch) + useEffect(() => { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowLoadedState(rowPageItems.map(() => false)); + setRowPage(1); + }, [items]); // eslint-disable-line react-hooks/exhaustive-deps + + // Once all rows are loaded, show only real items. + const allRowsLoaded = rowLoadedState.length > 0 && rowLoadedState.every(Boolean); + + function handleRowPageChange(page: number) { + if (rowPageTimerRef.current) { + clearTimeout(rowPageTimerRef.current); + } + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowPage(page); + const pageItems = paginate(items, page); + setRowLoadedState(pageItems.map(() => false)); + rowNextIndexRef.current = 0; + setRowLoading(true); + setRowPaused(false); + scheduleRowLoading(0); + } + + function scheduleRowLoading(startIndex: number) { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + rowPageItems.slice(startIndex).forEach((_, i) => { + const timer = setTimeout( + () => { + setRowLoadedState(prev => { + const next = [...prev]; + next[startIndex + i] = true; + return next; + }); + rowNextIndexRef.current = startIndex + i + 1; + if (startIndex + i === rowPageItems.length - 1) { + setRowLoading(false); + } + }, + (i + 1) * 800 + ); + rowTimerRefs.current.push(timer); + }); + } + + function startRowLoading() { + const initial = rowPageItems.map(() => false); + setRowLoadedState(initial); + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + rowNextIndexRef.current = 0; + setRowLoading(true); + setRowPaused(false); + scheduleRowLoading(0); + } + + function pauseRowLoading() { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowPaused(true); + } + + function resumeRowLoading() { + setRowPaused(false); + scheduleRowLoading(rowNextIndexRef.current); + } + + function resetRowLoading() { + rowTimerRefs.current.forEach(clearTimeout); + rowTimerRefs.current = []; + setRowLoadedState(rowPageItems.map(() => false)); + setRowLoading(false); + setRowPaused(false); + rowNextIndexRef.current = 0; + } + + // Progressive Column Loading state + // Step 1: initial data load (all columns except Status) + // Step 2: Status column loads later + const [colPage, setColPage] = useState(1); + const [colPageLoading, setColPageLoading] = useState(false); + const colPageTimerRef = useRef | null>(null); + const colPageItems = paginate(items, colPage); + + function handleColPageChange(page: number) { + setColPageLoading(true); + setColDataLoaded(false); + setColStatusLoaded(false); + if (colPageTimerRef.current) { + clearTimeout(colPageTimerRef.current); + } + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + colPageTimerRef.current = setTimeout(() => { + setColPage(page); + setColPageLoading(false); + // After page loads, simulate the 2-step column loading + const timer1 = setTimeout(() => { + setColDataLoaded(true); + }, 1500); + const timer2 = setTimeout(() => { + setColStatusLoaded(true); + }, 3500); + colTimerRefs.current.push(timer1, timer2); + }, 1000); + } + + const [colDataLoaded, setColDataLoaded] = useState(false); + const [colStatusLoaded, setColStatusLoaded] = useState(false); + const colTimerRefs = useRef[]>([]); + + function startColLoading() { + setColDataLoaded(true); + setColStatusLoaded(false); + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + // Status column loads after 2s + const timer = setTimeout(() => { + setColStatusLoaded(true); + }, 2000); + colTimerRefs.current.push(timer); + } + + function resetColLoading() { + colTimerRefs.current.forEach(clearTimeout); + colTimerRefs.current = []; + setColDataLoaded(false); + setColStatusLoaded(false); + } + + // Async Filtering state + const [filterPage, setFilterPage] = useState(1); + const [filterText, setFilterText] = useState(''); + const [filterLoading, setFilterLoading] = useState(false); + const [filteredItems, setFilteredItems] = useState(sampleData); + const filterTimerRef = useRef | null>(null); + + // Reset filtering when items change (toggle switch) + useEffect(() => { + setFilterText(''); + setFilterLoading(false); + setFilteredItems(items); + setFilterPage(1); + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + }, [items]); + + function handleFilterChange(text: string) { + setFilterText(text); + setFilterLoading(true); + setFilterPage(1); + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + filterTimerRef.current = setTimeout(() => { + const lower = text.toLowerCase(); + setFilteredItems( + items.filter( + item => + item.name.toLowerCase().includes(lower) || + item.description.toLowerCase().includes(lower) || + item.status.toLowerCase().includes(lower) + ) + ); + setFilterLoading(false); + }, 1500); + } + + function resetFiltering() { + if (filterTimerRef.current) { + clearTimeout(filterTimerRef.current); + } + setFilterText(''); + setFilterLoading(false); + setFilteredItems(items); + setFilterPage(1); + } + + const filterPageTimerRef = useRef | null>(null); + const [filterPageLoading, setFilterPageLoading] = useState(false); + const filterPageItems = paginate(filterLoading ? [] : filteredItems, filterPage); + + function handleFilterPageChange(page: number) { + setFilterPageLoading(true); + if (filterPageTimerRef.current) { + clearTimeout(filterPageTimerRef.current); + } + filterPageTimerRef.current = setTimeout(() => { + setFilterPage(page); + setFilterPageLoading(false); + }, 1000); + } + + useEffect(() => { + const rowTimers = rowTimerRefs.current; + const colTimers = colTimerRefs.current; + const filterTimer = filterTimerRef.current; + const rowPageTimer = rowPageTimerRef.current; + const colPageTimer = colPageTimerRef.current; + const filterPageTimer = filterPageTimerRef.current; + return () => { + rowTimers.forEach(clearTimeout); + colTimers.forEach(clearTimeout); + if (filterTimer) { + clearTimeout(filterTimer); + } + if (rowPageTimer) { + clearTimeout(rowPageTimer); + } + if (colPageTimer) { + clearTimeout(colPageTimer); + } + if (filterPageTimer) { + clearTimeout(filterPageTimer); + } + }; + }, []); + + return ( + + + +
+ Progressive Loading Explorations +
+ setUrlParams({ manyItems: detail.checked })}> + Many items (25) + + + setUrlParams({ skeletonRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + Progressive Row Loading}> + + + {!rowLoading ? ( + + ) : rowPaused ? ( + + ) : ( + + )} + + + + Skeleton} + items={rowPageItems.filter((_, index) => rowLoadedState[index])} + trackBy="id" + loading={!allRowsLoaded} + loadingText="Loading items..." + skeleton={!allRowsLoaded ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={rowPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleRowPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> +
Spinner} + items={rowPageItems.filter((_, index) => rowLoadedState[index])} + trackBy="id" + loading={!rowLoadedState.some(Boolean)} + loadingText="Loading items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={rowPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleRowPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + getLoadingStatus={() => (rowLoadedState.every(Boolean) ? 'finished' : 'loading')} + renderLoaderLoading={() => Loading items} + /> + + + + Progressive Column Loading}> + + + + + + +
Skeleton} + items={colDataLoaded && !colPageLoading ? colPageItems : []} + loading={!colDataLoaded || colPageLoading} + loadingText="Loading items..." + skeleton={!colDataLoaded || colPageLoading ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={colPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleColPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => (colStatusLoaded ? item.status : ), + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> +
Spinner} + items={colDataLoaded && !colPageLoading ? colPageItems : []} + loading={!colDataLoaded || colPageLoading} + loadingText="Loading items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={colPage} + pagesCount={totalPages(items)} + onChange={({ detail }) => handleColPageChange(detail.currentPageIndex)} + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: + colDataLoaded && !colStatusLoaded ? ( + + Status + + + ) : ( + 'Status' + ), + cell: (item: DataItem) => (colStatusLoaded ? item.status : ''), + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + /> + + + + Asynchronous Filtering}> + + + + + +
Skeleton} + items={filterLoading || filterPageLoading ? [] : filterPageItems} + loading={filterLoading || filterPageLoading} + loadingText="Filtering items..." + skeleton={filterLoading || filterPageLoading ? { totalRows: skeletonRows } : undefined} + pagination={ + `Page ${n}`, + }} + currentPageIndex={filterPage} + pagesCount={totalPages(filteredItems)} + onChange={({ detail }) => handleFilterPageChange(detail.currentPageIndex)} + /> + } + filter={ + handleFilterChange(detail.filteringText)} + filteringPlaceholder="Filter items" + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + empty={No matching items} + /> +
Spinner} + items={filterLoading || filterPageLoading ? [] : filterPageItems} + loading={filterLoading || filterPageLoading} + loadingText="Filtering items..." + pagination={ + `Page ${n}`, + }} + currentPageIndex={filterPage} + pagesCount={totalPages(filteredItems)} + onChange={({ detail }) => handleFilterPageChange(detail.currentPageIndex)} + /> + } + filter={ + handleFilterChange(detail.filteringText)} + filteringPlaceholder="Filter items" + /> + } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: DataItem) => item.name, + }, + { + id: 'description', + header: 'Description', + cell: (item: DataItem) => item.description, + }, + { + id: 'status', + header: 'Status', + cell: (item: DataItem) => item.status, + }, + { + id: 'date', + header: 'Date', + cell: (item: DataItem) => item.date, + }, + ]} + empty={No matching items} + /> + + + + + + + ); +} diff --git a/pages/table/skeleton-rows.page.tsx b/pages/table/skeleton-rows.page.tsx new file mode 100644 index 0000000000..1c6fae6d38 --- /dev/null +++ b/pages/table/skeleton-rows.page.tsx @@ -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([]); + + 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 ( +
+ +
Table with Skeleton Rows - Interactive Demo
+ + Controls}> + + + + 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' }, + ]} + /> + + + + 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' }, + ]} + /> + + + + + + setUrlParams({ skeletonRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + + + setUrlParams({ dataRows: detail.value })} + type="number" + inputMode="numeric" + step={1} + /> + + + + setUrlParams({ stripedRows: detail.checked })} + > + Striped rows + + + + + + +
setSelectedItems(detail.selectedItems) : undefined + } + ariaLabels={{ + selectionGroupLabel: 'Item selection', + allItemsSelectionLabel: () => 'Select all', + itemSelectionLabel: (_, item) => item.name, + }} + stripedRows={stripedRows} + header={ +
+ Table Demo +
+ } + /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a327e6590a..5f33c3ee4f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -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", @@ -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.", diff --git a/src/table/__tests__/skeleton.test.tsx b/src/table/__tests__/skeleton.test.tsx new file mode 100644 index 0000000000..331fb55115 --- /dev/null +++ b/src/table/__tests__/skeleton.test.tsx @@ -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[] = [ + { 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) { + const { container } = render(
); + 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'); + }); + }); +}); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index c083d57ba9..74ea81b4bc 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -58,9 +58,18 @@ export interface TableProps 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) @@ -696,6 +705,10 @@ export namespace TableProps { export interface RenderLoaderEmptyDetail { item: T; } + + export interface SkeletonConfig { + totalRows: number; + } } export type TableRow = TableDataRow | TableLoaderRow; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index fdfbf85194..774f1dd14e 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -13,6 +13,7 @@ import { import InternalContainer, { InternalContainerProps } from '../container/internal'; import { useFunnelSubStep } from '../internal/analytics/hooks/use-funnel'; import { getAnalyticsMetadataProps, getBaseProps } from '../internal/base-component'; +import ScreenreaderOnly from '../internal/components/screenreader-only'; import { getVisualContextClassname } from '../internal/components/visual-context'; import { CollectionLabelContext } from '../internal/context/collection-label-context'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; @@ -50,6 +51,7 @@ import { ResizeTracker } from './resizer'; import { focusMarkers, useSelection, useSelectionFocusMove } from './selection'; import { TableBodySelectionCell } from './selection/selection-cell'; import { useGroupSelection } from './selection/use-group-selection'; +import { SkeletonRow } from './skeleton-row'; import { useStickyColumns } from './sticky-columns'; import StickyHeader, { StickyHeaderRef } from './sticky-header'; import { StickyScrollbar } from './sticky-scrollbar'; @@ -113,6 +115,7 @@ const InternalTable = React.forwardRef( trackBy, loading, loadingText, + skeleton, selectionType: externalSelectionType, selectedItems, isItemDisabled, @@ -603,7 +606,33 @@ const InternalTable = React.forwardRef( {...theadProps} /> - {loading || allItems.length === 0 ? ( + {skeleton && allItems.length === 0 && loading ? ( + <> + + + + {Array.from({ length: skeleton.totalRows }, (_, rowIndex) => ( + + ))} + + ) : !skeleton && (loading || allItems.length === 0) ? ( { const isFirstRow = rowIndex === 0; - const isLastRow = rowIndex === allRows.length - 1; + const hasSkeletonBelow = + loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0; + const isLastDataRow = rowIndex === allRows.length - 1; + const isLastRow = isLastDataRow && !hasSkeletonBelow; const rowExpandableProps = row.type === 'data' ? expandableRows.getExpandableItemProps(row.item) : undefined; const rowRoleProps = getTableRowRoleProps({ @@ -635,7 +667,7 @@ const InternalTable = React.forwardRef( isLastRow, isSelected: hasSelection && isRowSelected(row), isPrevSelected: hasSelection && !isFirstRow && isRowSelected(allRows[rowIndex - 1]), - isNextSelected: hasSelection && !isLastRow && isRowSelected(allRows[rowIndex + 1]), + isNextSelected: hasSelection && !isLastDataRow && isRowSelected(allRows[rowIndex + 1]), isEvenRow: rowIndex % 2 === 0, stripedRows, hasSelection, @@ -797,6 +829,33 @@ const InternalTable = React.forwardRef( ); }) )} + {loading && skeleton && allItems.length > 0 && skeleton.totalRows - allItems.length > 0 && ( + <> + + + + {Array.from({ length: Math.max(0, skeleton.totalRows - allItems.length) }, (_, rowIndex) => ( + + ))} + + )}
+ {loadingText} +
+ {loadingText} +
diff --git a/src/table/skeleton-row.tsx b/src/table/skeleton-row.tsx new file mode 100644 index 0000000000..8d919f742a --- /dev/null +++ b/src/table/skeleton-row.tsx @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import InternalSkeleton from '../skeleton/internal'; +import { TableBodyCell } from './body-cell'; +import { getColumnKey } from './utils'; + +import styles from './styles.css.js'; + +const noop = () => {}; + +interface SkeletonRowProps { + rowIndex: number; + totalSkeletonRows: number; + hasSelection: boolean; + hasFooter: boolean; + stickyState: any; + tableRole: any; + ariaLabels: any; + cellVerticalAlign: any; + computedVariant: any; + visibleColumnDefinitions: readonly any[]; + wrapLines: any; + resizableColumns: any; + colIndexOffset: number; +} + +export function SkeletonRow({ + rowIndex, + totalSkeletonRows, + hasSelection, + hasFooter, + stickyState, + tableRole, + ariaLabels, + cellVerticalAlign, + computedVariant, + visibleColumnDefinitions, + wrapLines, + resizableColumns, + colIndexOffset, +}: SkeletonRowProps) { + const isFirstRow = rowIndex === 0; + const isLastRow = rowIndex === totalSkeletonRows - 1; + const sharedCellProps = { + isFirstRow, + isLastRow, + isSelected: false, + isPrevSelected: false, + isNextSelected: false, + hasSelection, + hasFooter, + stickyState, + tableRole, + }; + + return ( + + {hasSelection && } + {visibleColumnDefinitions.map((column: any, colIndex: number) => { + const colId = `skeleton-${getColumnKey(column, colIndex)}`; + return ( + , + }} + item={{}} + wrapLines={wrapLines} + isEditable={false} + isEditing={false} + isRowHeader={column.isRowHeader} + resizableColumns={resizableColumns} + onEditStart={noop} + onEditEnd={noop} + columnId={column.id ?? colIndex} + colIndex={colIndex + colIndexOffset} + verticalAlign={column.verticalAlign ?? cellVerticalAlign} + tableVariant={computedVariant} + /> + ); + })} + + ); +} diff --git a/src/table/styles.scss b/src/table/styles.scss index 2260dd7f03..2585b019ab 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -232,3 +232,9 @@ filter search icon. .row-selected { /* used in test-utils */ } + +.skeleton-loading-cell { + padding-block: 0; + padding-inline: 0; + border-block-end: none; +}