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:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
Added a `loading` prop and `data-loading` data attribute to `TableRoot`, allowing consumers to distinguish between stale data and initial loading states. Both `stale` and `loading` set `aria-busy` on the table.
|
||||
|
||||
Affected components: TableRoot
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@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.
|
||||
|
||||
Affected components: Table
|
||||
@@ -430,6 +430,17 @@ export const tableRootPropDefs: Record<string, PropDef> = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
loading: {
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
description: (
|
||||
<>
|
||||
Whether the table is in a loading state (e.g., initial data fetch). Adds{' '}
|
||||
<Chip>aria-busy</Chip> attribute and <Chip>data-loading</Chip> data
|
||||
attribute for styling.
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const columnPropDefs: Record<string, PropDef> = {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user