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