feat(ui): replace Table loading text with skeleton rows

Add a skeleton loading state to the BUI Table component that shows the
table header with animated skeleton rows instead of plain "Loading..."
text. Includes accessibility support via aria-busy and live region
announcements.

BUCKS-2919

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Jonathan Roebuck <jroebuck@spotify.com>
This commit is contained in:
Jonathan Roebuck
2026-03-12 15:48:18 +00:00
parent b951b629d1
commit 690786f84d
5 changed files with 228 additions and 44 deletions
@@ -0,0 +1,107 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { render, screen } from '@testing-library/react';
import { Table } from './Table';
import { CellText } from './CellText';
import type { ColumnConfig } from '../types';
type TestItem = { id: number; name: string; type: string };
const testColumns: ColumnConfig<TestItem>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
describe('Table', () => {
describe('loading state', () => {
it('renders a table with header and skeleton rows when loading with no data', async () => {
render(
<Table
columnConfig={testColumns}
data={undefined}
loading={true}
pagination={{ type: 'none' }}
/>,
);
const table = screen.getByRole('grid');
expect(table).toBeInTheDocument();
// Header columns should be visible
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Type')).toBeInTheDocument();
// Should render 5 skeleton rows (plus 1 header row = 6 total)
const rows = screen.getAllByRole('row');
expect(rows).toHaveLength(6);
// Table should indicate it's in a loading/stale state via data-stale
// (react-aria-components Table renders this as a data attribute rather
// than aria-busy on the DOM element)
expect(table).toHaveAttribute('data-stale', 'true');
// Each skeleton row should contain Skeleton placeholder elements
// (5 rows * 2 columns = 10 skeleton divs)
const skeletonElements = table.querySelectorAll('.bui-Skeleton');
expect(skeletonElements).toHaveLength(10);
// Skeleton elements should have varying widths for visual variety
const widths = Array.from(skeletonElements).map(
el => (el as HTMLElement).style.width,
);
expect(new Set(widths).size).toBeGreaterThan(1);
// Should NOT render "Loading..." text - skeleton is purely visual
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
it('renders data rows normally when not loading', async () => {
const data: TestItem[] = [
{ id: 1, name: 'Service A', type: 'service' },
{ id: 2, name: 'Library B', type: 'library' },
];
render(
<Table
columnConfig={testColumns}
data={data}
pagination={{ type: 'none' }}
/>,
);
expect(screen.getByText('Service A')).toBeInTheDocument();
expect(screen.getByText('Library B')).toBeInTheDocument();
const table = screen.getByRole('grid');
// When not loading, data-stale should be "false"
expect(table).toHaveAttribute('data-stale', 'false');
// Should not contain skeleton elements
const skeletonElements = table.querySelectorAll('.bui-Skeleton');
expect(skeletonElements).toHaveLength(0);
});
});
});
@@ -32,6 +32,7 @@ import type {
import { useMemo } from 'react';
import { VisuallyHidden } from '../../VisuallyHidden';
import { Flex } from '../../Flex';
import { TableBodySkeleton } from './TableBodySkeleton';
function isRowRenderFn<T extends TableItem>(
rowConfig: RowConfig<T> | RowRenderFn<T> | undefined,
@@ -115,13 +116,7 @@ export function Table<T extends TableItem>({
onSelectionChange,
} = selection || {};
if (loading && !data) {
return (
<div className={className} style={style}>
Loading...
</div>
);
}
const isInitialLoading = loading && !data;
if (error) {
return (
@@ -131,11 +126,9 @@ export function Table<T extends TableItem>({
);
}
const liveRegionLabel = useLiveRegionLabel(
pagination,
isStale,
data !== undefined,
);
const liveRegionLabel = isInitialLoading
? 'Loading table data.'
: useLiveRegionLabel(pagination, isStale, data !== undefined);
const manualColumnSizing = columnConfig.some(
col =>
@@ -165,7 +158,7 @@ export function Table<T extends TableItem>({
sortDescriptor={sort?.descriptor ?? undefined}
onSortChange={sort?.onSortChange}
disabledKeys={disabledRows}
stale={isStale}
stale={isStale || isInitialLoading}
aria-describedby={liveRegionId}
>
<TableHeader columns={visibleColumns}>
@@ -187,39 +180,43 @@ export function Table<T extends TableItem>({
)
}
</TableHeader>
<TableBody
items={data}
dependencies={[visibleColumns]}
renderEmptyState={
emptyState ? () => <Flex p="3">{emptyState}</Flex> : undefined
}
>
{item => {
const itemIndex = data?.indexOf(item) ?? -1;
if (isRowRenderFn(rowConfig)) {
return rowConfig({
item,
index: itemIndex,
});
{isInitialLoading ? (
<TableBodySkeleton columns={visibleColumns} />
) : (
<TableBody
items={data}
dependencies={[visibleColumns]}
renderEmptyState={
emptyState ? () => <Flex p="3">{emptyState}</Flex> : undefined
}
>
{item => {
const itemIndex = data?.indexOf(item) ?? -1;
return (
<Row
id={String(item.id)}
columns={visibleColumns}
href={rowConfig?.getHref?.(item)}
onAction={
rowConfig?.onClick
? () => rowConfig?.onClick?.(item)
: undefined
}
>
{column => column.cell(item)}
</Row>
);
}}
</TableBody>
if (isRowRenderFn(rowConfig)) {
return rowConfig({
item,
index: itemIndex,
});
}
return (
<Row
id={String(item.id)}
columns={visibleColumns}
href={rowConfig?.getHref?.(item)}
onAction={
rowConfig?.onClick
? () => rowConfig?.onClick?.(item)
: undefined
}
>
{column => column.cell(item)}
</Row>
);
}}
</TableBody>
)}
</TableRoot>,
)}
{pagination.type === 'page' && (
@@ -0,0 +1,59 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TableBody } from './TableBody';
import { Row } from './Row';
import { Cell } from './Cell';
import { Skeleton } from '../../Skeleton';
import type { ColumnConfig, TableItem } from '../types';
const SKELETON_ROW_COUNT = 5;
const SKELETON_WIDTHS = ['75%', '50%', '60%', '45%', '70%'];
const skeletonItems = Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => ({
id: `skeleton-${i}`,
}));
/** @internal */
export function TableBodySkeleton<T extends TableItem>({
columns,
}: {
columns: readonly ColumnConfig<T>[];
}) {
return (
<TableBody items={skeletonItems} dependencies={[columns]}>
{item => {
const rowIndex = Number(item.id.split('-')[1]);
return (
<Row id={item.id} columns={columns}>
{column => (
<Cell key={column.id} aria-hidden="true">
<Skeleton
width={
SKELETON_WIDTHS[
(rowIndex + columns.indexOf(column)) %
SKELETON_WIDTHS.length
]
}
/>
</Cell>
)}
</Row>
);
}}
</TableBody>
);
}
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';