diff --git a/.changeset/lovely-corners-refuse.md b/.changeset/lovely-corners-refuse.md new file mode 100644 index 0000000000..b44698f230 --- /dev/null +++ b/.changeset/lovely-corners-refuse.md @@ -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. diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index fe8306afa8..f3bb31015b 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -555,6 +555,7 @@ validators Valkey varchar viewport +virtualized vite VMware Vodafone diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index f55efe8c89..406ac5316e 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -2522,6 +2522,8 @@ export interface TableProps { sort?: SortState; // (undocumented) style?: React.CSSProperties; + // (undocumented) + virtualized?: VirtualizedProp; } // @public (undocumented) @@ -3045,6 +3047,16 @@ export interface UtilityProps extends SpaceProps { rowSpan?: Responsive; } +// @public (undocumented) +export type VirtualizedProp = + | boolean + | { + rowHeight: number; + } + | { + estimatedRowHeight: number; + }; + // @public export const VisuallyHidden: (props: VisuallyHiddenProps) => JSX_2.Element; diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css index a42d86f503..62723db6c8 100644 --- a/packages/ui/src/components/Table/Table.module.css +++ b/packages/ui/src/components/Table/Table.module.css @@ -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'] { diff --git a/packages/ui/src/components/Table/components/Table.tsx b/packages/ui/src/components/Table/components/Table.tsx index 96e7cfe166..2376760b69 100644 --- a/packages/ui/src/components/Table/components/Table.tsx +++ b/packages/ui/src/components/Table/components/Table.tsx @@ -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({ emptyState, className, style, + virtualized, }: TableProps) { + const { + ownProps: { classes }, + } = useDefinition(TableWrapperDefinition, { className }); const liveRegionId = useId(); const visibleColumns = useMemo( @@ -125,7 +136,7 @@ export function Table({ if (error) { return ( -
+
Error: {error.message}
); @@ -148,89 +159,105 @@ export function Table({ const wrapResizable = manualColumnSizing ? (elem: React.ReactNode) => ( - {elem} + + {elem} + ) : (elem: React.ReactNode) => <>{elem}; + const layoutOptions = + typeof virtualized === 'object' ? virtualized : undefined; + + const wrapVirtualized = (elem: React.ReactNode) => + virtualized ? ( + + {elem} + + ) : ( + elem + ); + return ( -
+
{liveRegionLabel} {wrapResizable( - - - {column => - column.header ? ( - column.header() - ) : ( - - {column.label} - - ) - } - - {isInitialLoading ? ( - - ) : ( - {emptyState} : undefined - } - > - {item => { - const itemIndex = data?.indexOf(item) ?? -1; - - if (isRowRenderFn(rowConfig)) { - return rowConfig({ - item, - index: itemIndex, - }); - } - - return ( - rowConfig?.onClick?.(item) - : undefined - } + wrapVirtualized( + + + {column => + column.header ? ( + column.header() + ) : ( + - {column => column.cell(item)} - - ); - }} - - )} - , + {column.label} + + ) + } + + {isInitialLoading ? ( + + ) : ( + {emptyState} : undefined + } + > + {item => { + const itemIndex = data?.indexOf(item) ?? -1; + + if (isRowRenderFn(rowConfig)) { + return rowConfig({ + item, + index: itemIndex, + }); + } + + return ( + rowConfig?.onClick?.(item) + : undefined + } + > + {column => column.cell(item)} + + ); + }} + + )} + , + ), )} {pagination.type === 'page' && ( ()({ + styles, + classNames: { + root: 'bui-TableWrapper', + resizableContainer: 'bui-TableResizableContainer', + }, + propDefs: { + className: {}, + }, +}); + /** * Component definition for Table * @public diff --git a/packages/ui/src/components/Table/index.ts b/packages/ui/src/components/Table/index.ts index 188db635e2..033d51cdaa 100644 --- a/packages/ui/src/components/Table/index.ts +++ b/packages/ui/src/components/Table/index.ts @@ -53,6 +53,7 @@ export type { NoPagination, PagePagination, TablePaginationType, + VirtualizedProp, } from './types'; export type { UseTableOptions, diff --git a/packages/ui/src/components/Table/stories/Table.dev.stories.tsx b/packages/ui/src/components/Table/stories/Table.dev.stories.tsx index 503665ffed..24b8246c4d 100644 --- a/packages/ui/src/components/Table/stories/Table.dev.stories.tsx +++ b/packages/ui/src/components/Table/stories/Table.dev.stories.tsx @@ -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 => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => largeData, + paginationOptions: { pageSize: 50 }, + }); + + return ( + + ); + }, +}; + +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 => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => largeData, + paginationOptions: { pageSize: 50 }, + }); + + return ( +
+ ); + }, +}; + +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 => ( + + ), + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + const { tableProps } = useTable({ + mode: 'complete', + getData: () => largeData, + paginationOptions: { pageSize: 50 }, + }); + + return ( +
+ ); + }, +}; + // Type filter interface for ComprehensiveServerSide story interface TypeFilter { type: string | null; diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts index 030ac44ae0..3cfbdff34e 100644 --- a/packages/ui/src/components/Table/types.ts +++ b/packages/ui/src/components/Table/types.ts @@ -253,6 +253,12 @@ export interface TableSelection { onSelectionChange?: ReactAriaTableProps['onSelectionChange']; } +/** @public */ +export type VirtualizedProp = + | boolean + | { rowHeight: number } + | { estimatedRowHeight: number }; + /** @public */ export interface TableProps { columnConfig: readonly ColumnConfig[]; @@ -267,4 +273,5 @@ export interface TableProps { emptyState?: ReactNode; className?: string; style?: React.CSSProperties; + virtualized?: VirtualizedProp; }