diff --git a/.changeset/bui-table-root-loading-prop.md b/.changeset/bui-table-root-loading-prop.md new file mode 100644 index 0000000000..7c3f453c82 --- /dev/null +++ b/.changeset/bui-table-root-loading-prop.md @@ -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 diff --git a/.changeset/bui-table-skeleton-loading.md b/.changeset/bui-table-skeleton-loading.md new file mode 100644 index 0000000000..df20393c50 --- /dev/null +++ b/.changeset/bui-table-skeleton-loading.md @@ -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 diff --git a/docs-ui/src/app/components/table/props-definition.tsx b/docs-ui/src/app/components/table/props-definition.tsx index abfdd45549..6d35cb3350 100644 --- a/docs-ui/src/app/components/table/props-definition.tsx +++ b/docs-ui/src/app/components/table/props-definition.tsx @@ -430,6 +430,17 @@ export const tableRootPropDefs: Record = { ), }, + loading: { + type: 'boolean', + default: 'false', + description: ( + <> + Whether the table is in a loading state (e.g., initial data fetch). Adds{' '} + aria-busy attribute and data-loading data + attribute for styling. + + ), + }, }; export const columnPropDefs: Record = { diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 95bc6d86b8..09454cecc9 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -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) diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css index 55cf235e56..472e0733ac 100644 --- a/packages/ui/src/components/Table/Table.module.css +++ b/packages/ui/src/components/Table/Table.module.css @@ -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; } } diff --git a/packages/ui/src/components/Table/components/Table.tsx b/packages/ui/src/components/Table/components/Table.tsx index 523e5bd6c3..96e7cfe166 100644 --- a/packages/ui/src/components/Table/components/Table.tsx +++ b/packages/ui/src/components/Table/components/Table.tsx @@ -32,6 +32,7 @@ import type { import { useMemo } from 'react'; import { VisuallyHidden } from '../../VisuallyHidden'; import { Flex } from '../../Flex'; +import { TableBodySkeleton } from './TableBodySkeleton'; function isRowRenderFn( rowConfig: RowConfig | RowRenderFn | undefined, @@ -61,8 +62,13 @@ function useDisabledRows({ 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({ onSelectionChange, } = selection || {}; - if (loading && !data) { - return ( -
- Loading... -
- ); - } + const isInitialLoading = loading && !data; if (error) { return ( @@ -134,6 +134,7 @@ export function Table({ const liveRegionLabel = useLiveRegionLabel( pagination, isStale, + isInitialLoading, data !== undefined, ); @@ -158,14 +159,19 @@ export function Table({ {wrapResizable( @@ -187,39 +193,43 @@ export function Table({ ) } - {emptyState} : undefined - } - > - {item => { - const itemIndex = data?.indexOf(item) ?? -1; - - if (isRowRenderFn(rowConfig)) { - return rowConfig({ - item, - index: itemIndex, - }); + {isInitialLoading ? ( + + ) : ( + {emptyState} : undefined } + > + {item => { + const itemIndex = data?.indexOf(item) ?? -1; - return ( - rowConfig?.onClick?.(item) - : undefined - } - > - {column => column.cell(item)} - - ); - }} - + if (isRowRenderFn(rowConfig)) { + return rowConfig({ + item, + index: itemIndex, + }); + } + + return ( + rowConfig?.onClick?.(item) + : undefined + } + > + {column => column.cell(item)} + + ); + }} + + )} , )} {pagination.type === 'page' && ( diff --git a/packages/ui/src/components/Table/components/TableBodySkeleton.tsx b/packages/ui/src/components/Table/components/TableBodySkeleton.tsx new file mode 100644 index 0000000000..1623fdc9cf --- /dev/null +++ b/packages/ui/src/components/Table/components/TableBodySkeleton.tsx @@ -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({ + columns, +}: { + columns: readonly ColumnConfig[]; +}) { + return ( + + {item => { + const rowIndex = Number(item.id.split('-')[1]); + return ( + + {column => ( + + )} + + ); + }} + + ); +} diff --git a/packages/ui/src/components/Table/components/TableRoot.tsx b/packages/ui/src/components/Table/components/TableRoot.tsx index af96d1809e..223e9d648b 100644 --- a/packages/ui/src/components/Table/components/TableRoot.tsx +++ b/packages/ui/src/components/Table/components/TableRoot.tsx @@ -30,7 +30,7 @@ export const TableRoot = (props: TableRootProps) => { diff --git a/packages/ui/src/components/Table/definition.ts b/packages/ui/src/components/Table/definition.ts index eb41617b93..1c3953a19a 100644 --- a/packages/ui/src/components/Table/definition.ts +++ b/packages/ui/src/components/Table/definition.ts @@ -38,6 +38,7 @@ export const TableDefinition = defineComponent()({ }, propDefs: { stale: { dataAttribute: true }, + loading: { dataAttribute: true }, }, }); diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts index 099a855d33..0736afa05b 100644 --- a/packages/ui/src/components/Table/types.ts +++ b/packages/ui/src/components/Table/types.ts @@ -42,6 +42,7 @@ export interface SortState { /** @public */ export type TableRootOwnProps = { stale?: boolean; + loading?: boolean; }; /** @public */