Add virtualized prop to Table component (#33246)

Adding a new virtualized prop to the Table component to better support rendering large numbers of rows
This commit is contained in:
James Brooks
2026-03-17 08:31:43 +00:00
committed by GitHub
parent 1b42218ca3
commit 05594087b9
9 changed files with 309 additions and 76 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Added `virtualized` prop to `Table` component for virtualized rendering of large datasets. Accepts `true` for default row height, `{ rowHeight: number }` for fixed height, or `{ estimatedRowHeight: number }` for variable height rows.
@@ -555,6 +555,7 @@ validators
Valkey
varchar
viewport
virtualized
vite
VMware
Vodafone
+12
View File
@@ -2522,6 +2522,8 @@ export interface TableProps<T extends TableItem> {
sort?: SortState;
// (undocumented)
style?: React.CSSProperties;
// (undocumented)
virtualized?: VirtualizedProp;
}
// @public (undocumented)
@@ -3045,6 +3047,16 @@ export interface UtilityProps extends SpaceProps {
rowSpan?: Responsive<Columns | 'full'>;
}
// @public (undocumented)
export type VirtualizedProp =
| boolean
| {
rowHeight: number;
}
| {
estimatedRowHeight: number;
};
// @public
export const VisuallyHidden: (props: VisuallyHiddenProps) => JSX_2.Element;
@@ -17,12 +17,28 @@
@layer tokens, base, components, utilities;
@layer components {
.bui-TableWrapper {
display: flex;
flex-direction: column;
}
.bui-TableResizableContainer {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.bui-Table {
width: 100%;
caption-side: bottom;
border-collapse: collapse;
table-layout: fixed;
transition: opacity 0.2s ease-in-out;
overflow: auto;
flex: 1;
min-height: 0;
&[data-stale='true'],
&[data-loading='true'] {
@@ -15,7 +15,14 @@
*/
import { useId } from 'react-aria';
import { type Key, ResizableTableContainer } from 'react-aria-components';
import {
type Key,
ResizableTableContainer,
Virtualizer,
} from 'react-aria-components';
import { TableLayout } from '@react-stately/layout';
import { useDefinition } from '../../../hooks/useDefinition';
import { TableWrapperDefinition } from '../definition';
import { TableRoot } from './TableRoot';
import { TableHeader } from './TableHeader';
import { TableBody } from './TableBody';
@@ -105,7 +112,11 @@ export function Table<T extends TableItem>({
emptyState,
className,
style,
virtualized,
}: TableProps<T>) {
const {
ownProps: { classes },
} = useDefinition(TableWrapperDefinition, { className });
const liveRegionId = useId();
const visibleColumns = useMemo(
@@ -125,7 +136,7 @@ export function Table<T extends TableItem>({
if (error) {
return (
<div className={className} style={style}>
<div className={classes.root} style={style}>
Error: {error.message}
</div>
);
@@ -148,89 +159,105 @@ export function Table<T extends TableItem>({
const wrapResizable = manualColumnSizing
? (elem: React.ReactNode) => (
<ResizableTableContainer>{elem}</ResizableTableContainer>
<ResizableTableContainer className={classes.resizableContainer}>
{elem}
</ResizableTableContainer>
)
: (elem: React.ReactNode) => <>{elem}</>;
const layoutOptions =
typeof virtualized === 'object' ? virtualized : undefined;
const wrapVirtualized = (elem: React.ReactNode) =>
virtualized ? (
<Virtualizer layout={TableLayout} layoutOptions={layoutOptions}>
{elem}
</Virtualizer>
) : (
elem
);
return (
<div className={className} style={style}>
<div className={classes.root} style={style}>
<VisuallyHidden aria-live="polite" id={liveRegionId}>
{liveRegionLabel}
</VisuallyHidden>
{wrapResizable(
<TableRoot
{...(isInitialLoading
? {}
: {
selectionMode,
selectionBehavior,
selectedKeys,
onSelectionChange,
})}
sortDescriptor={sort?.descriptor ?? undefined}
onSortChange={sort?.onSortChange}
disabledKeys={disabledRows}
stale={isStale}
loading={isInitialLoading}
aria-describedby={liveRegionId}
>
<TableHeader columns={visibleColumns}>
{column =>
column.header ? (
column.header()
) : (
<Column
id={column.id}
isRowHeader={column.isRowHeader}
allowsSorting={column.isSortable}
width={column.width}
defaultWidth={column.defaultWidth}
minWidth={column.minWidth}
maxWidth={column.maxWidth}
>
{column.label}
</Column>
)
}
</TableHeader>
{isInitialLoading ? (
<TableBodySkeleton columns={visibleColumns} />
) : (
<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,
});
}
return (
<Row
id={String(item.id)}
columns={visibleColumns}
href={rowConfig?.getHref?.(item)}
onAction={
rowConfig?.onClick
? () => rowConfig?.onClick?.(item)
: undefined
}
wrapVirtualized(
<TableRoot
{...(isInitialLoading
? {}
: {
selectionMode,
selectionBehavior,
selectedKeys,
onSelectionChange,
})}
sortDescriptor={sort?.descriptor ?? undefined}
onSortChange={sort?.onSortChange}
disabledKeys={disabledRows}
stale={isStale}
loading={isInitialLoading}
aria-describedby={liveRegionId}
>
<TableHeader columns={visibleColumns}>
{column =>
column.header ? (
column.header()
) : (
<Column
id={column.id}
isRowHeader={column.isRowHeader}
allowsSorting={column.isSortable}
width={column.width}
defaultWidth={column.defaultWidth}
minWidth={column.minWidth}
maxWidth={column.maxWidth}
>
{column => column.cell(item)}
</Row>
);
}}
</TableBody>
)}
</TableRoot>,
{column.label}
</Column>
)
}
</TableHeader>
{isInitialLoading ? (
<TableBodySkeleton columns={visibleColumns} />
) : (
<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,
});
}
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' && (
<TablePagination
@@ -27,6 +27,20 @@ import type {
} from './types';
import styles from './Table.module.css';
/** @internal */
export const TableWrapperDefinition = defineComponent<{
className?: string;
}>()({
styles,
classNames: {
root: 'bui-TableWrapper',
resizableContainer: 'bui-TableResizableContainer',
},
propDefs: {
className: {},
},
});
/**
* Component definition for Table
* @public
@@ -53,6 +53,7 @@ export type {
NoPagination,
PagePagination,
TablePaginationType,
VirtualizedProp,
} from './types';
export type {
UseTableOptions,
@@ -798,6 +798,156 @@ export const SelectionReplaceWithRowLinks: Story = {
},
};
export const VirtualizedTable: Story = {
render: () => {
const largeData = Array.from({ length: 500 }, (_, i) => ({
id: String(i),
name: `Service ${i}`,
owner: { name: `Team ${i % 10}` },
type: ['service', 'website', 'library'][i % 3],
lifecycle: ['production', 'experimental'][i % 2],
description: `Description for service ${i}`,
}));
const columns: ColumnConfig<(typeof largeData)[0]>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => (
<CellText title={item.name} description={item.description} />
),
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => largeData,
paginationOptions: { pageSize: 50 },
});
return (
<Table
columnConfig={columns}
{...tableProps}
virtualized
style={{ height: 400 }}
/>
);
},
};
export const VirtualizedWithCustomRowHeight: Story = {
render: () => {
const largeData = Array.from({ length: 500 }, (_, i) => ({
id: String(i),
name: `Service ${i}`,
owner: { name: `Team ${i % 10}` },
type: ['service', 'website', 'library'][i % 3],
lifecycle: ['production', 'experimental'][i % 2],
description: `Description for service ${i}`,
}));
const columns: ColumnConfig<(typeof largeData)[0]>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => (
<CellText title={item.name} description={item.description} />
),
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => largeData,
paginationOptions: { pageSize: 50 },
});
return (
<Table
columnConfig={columns}
{...tableProps}
virtualized={{ rowHeight: 56 }}
style={{ height: 400 }}
/>
);
},
};
export const VirtualizedWithEstimatedRowHeight: Story = {
render: () => {
const largeData = Array.from({ length: 500 }, (_, i) => ({
id: String(i),
name: `Service ${i}`,
owner: { name: `Team ${i % 10}` },
type: ['service', 'website', 'library'][i % 3],
lifecycle: ['production', 'experimental'][i % 2],
description:
i % 5 === 0
? `This is a much longer description for service ${i} that spans multiple lines to demonstrate variable height row rendering in the virtualized table`
: `Description for service ${i}`,
}));
const columns: ColumnConfig<(typeof largeData)[0]>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => (
<CellText title={item.name} description={item.description} />
),
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const { tableProps } = useTable({
mode: 'complete',
getData: () => largeData,
paginationOptions: { pageSize: 50 },
});
return (
<Table
columnConfig={columns}
{...tableProps}
virtualized={{ estimatedRowHeight: 48 }}
style={{ height: 400 }}
/>
);
},
};
// Type filter interface for ComprehensiveServerSide story
interface TypeFilter {
type: string | null;
@@ -253,6 +253,12 @@ export interface TableSelection {
onSelectionChange?: ReactAriaTableProps['onSelectionChange'];
}
/** @public */
export type VirtualizedProp =
| boolean
| { rowHeight: number }
| { estimatedRowHeight: number };
/** @public */
export interface TableProps<T extends TableItem> {
columnConfig: readonly ColumnConfig<T>[];
@@ -267,4 +273,5 @@ export interface TableProps<T extends TableItem> {
emptyState?: ReactNode;
className?: string;
style?: React.CSSProperties;
virtualized?: VirtualizedProp;
}