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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Improved the `Table` component loading state to show a skeleton UI with visible headers instead of plain "Loading..." text. The table now renders its full structure during loading, with animated skeleton rows in place of data. The loading state includes proper accessibility support with `aria-busy` on the table and screen reader announcements.
@@ -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';