Merge pull request #33322 from backstage/jonathanroebuck/bucks-2919-fe-reusable-skeletonloading-state-for-bui-table-components

feat(ui): replace Table loading text with skeleton rows
This commit is contained in:
Charles de Dreuille
2026-03-16 11:12:29 +00:00
committed by GitHub
10 changed files with 145 additions and 44 deletions
+4
View File
@@ -2332,6 +2332,9 @@ export const TableDefinition: {
readonly stale: {
readonly dataAttribute: true;
};
readonly loading: {
readonly dataAttribute: true;
};
};
};
@@ -2449,6 +2452,7 @@ export const TableRoot: (props: TableRootProps) => JSX_2.Element;
// @public (undocumented)
export type TableRootOwnProps = {
stale?: boolean;
loading?: boolean;
};
// @public (undocumented)
@@ -24,7 +24,8 @@
table-layout: fixed;
transition: opacity 0.2s ease-in-out;
&[data-stale='true'] {
&[data-stale='true'],
&[data-loading='true'] {
opacity: 0.6;
}
}
@@ -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,
@@ -61,8 +62,13 @@ function useDisabledRows<T extends TableItem>({
function useLiveRegionLabel(
pagination: TablePaginationType,
isStale: boolean,
isLoading: boolean,
hasData: boolean,
): string {
if (isLoading) {
return 'Loading table data.';
}
if (!hasData || pagination.type === 'none') {
return '';
}
@@ -115,13 +121,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 (
@@ -134,6 +134,7 @@ export function Table<T extends TableItem>({
const liveRegionLabel = useLiveRegionLabel(
pagination,
isStale,
isInitialLoading,
data !== undefined,
);
@@ -158,14 +159,19 @@ export function Table<T extends TableItem>({
</VisuallyHidden>
{wrapResizable(
<TableRoot
selectionMode={selectionMode}
selectionBehavior={selectionBehavior}
selectedKeys={selectedKeys}
onSelectionChange={onSelectionChange}
{...(isInitialLoading
? {}
: {
selectionMode,
selectionBehavior,
selectedKeys,
onSelectionChange,
})}
sortDescriptor={sort?.descriptor ?? undefined}
onSortChange={sort?.onSortChange}
disabledKeys={disabledRows}
stale={isStale}
loading={isInitialLoading}
aria-describedby={liveRegionId}
>
<TableHeader columns={visibleColumns}>
@@ -187,39 +193,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>
);
}
@@ -30,7 +30,7 @@ export const TableRoot = (props: TableRootProps) => {
<ReactAriaTable
className={ownProps.classes.root}
aria-label="Data table"
aria-busy={ownProps.stale}
aria-busy={ownProps.stale || ownProps.loading}
{...dataAttributes}
{...restProps}
/>
@@ -38,6 +38,7 @@ export const TableDefinition = defineComponent<TableRootOwnProps>()({
},
propDefs: {
stale: { dataAttribute: true },
loading: { dataAttribute: true },
},
});
@@ -42,6 +42,7 @@ export interface SortState {
/** @public */
export type TableRootOwnProps = {
stale?: boolean;
loading?: boolean;
};
/** @public */