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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user