diff --git a/packages/bento-design-system/package.json b/packages/bento-design-system/package.json index b3a548ea4..8a316df17 100644 --- a/packages/bento-design-system/package.json +++ b/packages/bento-design-system/package.json @@ -108,6 +108,8 @@ "@react-types/overlays": "3.8.1", "@react-types/radio": "3.5.0", "@react-types/shared": "3.19.0", + "@tanstack/react-virtual": "3.0.0-beta.60", + "@tanstack/virtual-core": "3.0.0-beta.60", "@vanilla-extract/css": "1.13.0", "@vanilla-extract/dynamic": "2.0.3", "@vanilla-extract/recipes": "0.5.0", @@ -142,8 +144,8 @@ "@storybook/addon-essentials": "7.4.0", "@storybook/addon-links": "7.4.0", "@storybook/addons": "7.4.0", - "@storybook/react": "7.4.0", "@storybook/builder-vite": "7.4.0", + "@storybook/react": "7.4.0", "@storybook/react-vite": "7.4.0", "@storybook/testing-library": "0.2.1", "@storybook/types": "7.4.0", diff --git a/packages/bento-design-system/src/Table/Table.tsx b/packages/bento-design-system/src/Table/Table.tsx index bf0e45453..dfa8c38e7 100644 --- a/packages/bento-design-system/src/Table/Table.tsx +++ b/packages/bento-design-system/src/Table/Table.tsx @@ -48,11 +48,12 @@ import { GridWidth, Row as RowType, } from "./types"; -import { useLayoutEffect, useMemo, useState, CSSProperties, useEffect } from "react"; +import { useLayoutEffect, useMemo, useState, CSSProperties, useEffect, useRef } from "react"; import { IconQuestionSolid, IconInfo } from "../Icons"; import { match, __ } from "ts-pattern"; import { useBentoConfig } from "../BentoConfigContext"; import { assignInlineVars } from "@vanilla-extract/dynamic"; +import { useVirtualizer } from "@tanstack/react-virtual"; type SortFn< C extends @@ -93,11 +94,6 @@ type Props< > = { columns: C; data: ReadonlyArray>; - groupBy?: C extends ReadonlyArray> - ? C[number]["accessor"] - : C extends ReadonlyArray> - ? C[number]["columns"][number]["accessor"] - : never; noResultsTitle?: LocalizedString; noResultsDescription?: LocalizedString; noResultsFeedbackSize?: FeedbackProps["size"]; @@ -105,7 +101,18 @@ type Props< stickyHeaders?: boolean; height?: { custom: string | number }; onRowPress?: (row: Row>) => void; -} & SortingProps; +} & ( + | { + groupBy?: C extends ReadonlyArray> + ? C[number]["accessor"] + : C extends ReadonlyArray> + ? C[number]["columns"][number]["accessor"] + : never; + virtualizeRows?: never; + } + | { groupBy?: never; virtualizeRows?: boolean | { estimateRowHeight: (index: number) => number } } +) & + SortingProps; /** * A component that renders a Table, with sorting capabilities @@ -143,6 +150,7 @@ export function Table< stickyHeaders, height, onRowPress, + virtualizeRows: virtualizeRowsConfig, }: Props) { const config = useBentoConfig().table; const customOrderByFn = useMemo( @@ -292,6 +300,29 @@ export function Table< } }, [data.length, headerGroups, stickyLeftColumnsIds, stickyLeftColumnGroupsIds]); + const tableContainerRef = useRef(null); + + const virtualizeRows = + typeof virtualizeRowsConfig === "boolean" ? virtualizeRowsConfig : virtualizeRowsConfig != null; + const estimateSize = + typeof virtualizeRowsConfig === "boolean" + ? () => 52 // Default height of a medium-sized text cell + : virtualizeRowsConfig?.estimateRowHeight ?? (() => 0); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => tableContainerRef.current, + estimateSize, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + + const virtualPaddingTop = + virtualizeRows && virtualRows.length > 0 ? virtualRows[0]?.start ?? 0 : 0; + const virtualPaddingBottom = + virtualizeRows && virtualRows.length > 0 + ? rowVirtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0) + : 0; + if (data.length === 0) { return ( ( +
+ )) + .concat( + virtualRows.map((virtualRow) => { + const index = virtualRow.index; + const row = rows[index]; + prepareRow(row); + return ( + + {renderCells(row.cells, index, onRowPress !== undefined)} + + ); + }) + ) + .concat( + columns.map((_, index) => ( +
+ )) + ) + : rows.flatMap((row, index) => { + if (row.isGrouped) { + return [ + , + ...row.leafRows.map((row, index) => { + prepareRow(row); + return renderCells(row.cells, index, false); + }), + ]; + } else { + prepareRow(row); + return ( + + {renderCells(row.cells, index, onRowPress !== undefined)} + + ); + } + }); + return ( {headerGroups.map((headerGroup) => headerGroup.headers.map((header, index) => ( @@ -388,29 +469,7 @@ export function Table< /> )) )} - - {rows.flatMap((row, index) => { - if (row.isGrouped) { - return [ - , - ...row.leafRows.map((row, index) => { - prepareRow(row); - return renderCells(row.cells, index, false); - }), - ]; - } else { - prepareRow(row); - return ( - - {renderCells(row.cells, index, onRowPress !== undefined)} - - ); - } - })} + {renderedRows} ); } diff --git a/packages/bento-design-system/stories/Components/Table.stories.tsx b/packages/bento-design-system/stories/Components/Table.stories.tsx index 5eafc02ec..7ac70ebda 100644 --- a/packages/bento-design-system/stories/Components/Table.stories.tsx +++ b/packages/bento-design-system/stories/Components/Table.stories.tsx @@ -559,3 +559,21 @@ export const GroupedHeaders = { height: { custom: 320 }, }, } satisfies Story; + +function repeatToLength(arr: T[], n: number): T[] { + if (arr.length <= 0) return []; + let result: T[] = []; + while (result.length < n) { + result = result.concat(arr); + } + return result.slice(0, n); +} + +export const VirtualizedRows = { + args: { + stickyHeaders: true, + height: { custom: 340 }, + virtualizeRows: true, + data: repeatToLength(exampleData, 1_000), + }, +} satisfies Story; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2449bb29d..ea266ee75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,8 @@ importers: '@storybook/react-vite': 7.4.0 '@storybook/testing-library': 0.2.1 '@storybook/types': 7.4.0 + '@tanstack/react-virtual': 3.0.0-beta.60 + '@tanstack/virtual-core': 3.0.0-beta.60 '@testing-library/dom': 9.3.1 '@testing-library/jest-dom': 6.1.2 '@testing-library/react': 14.0.0 @@ -188,6 +190,8 @@ importers: '@react-types/overlays': 3.8.1_react@18.2.0 '@react-types/radio': 3.5.0_react@18.2.0 '@react-types/shared': 3.19.0_react@18.2.0 + '@tanstack/react-virtual': 3.0.0-beta.60_react@18.2.0 + '@tanstack/virtual-core': 3.0.0-beta.60 '@vanilla-extract/css': 1.13.0 '@vanilla-extract/dynamic': 2.0.3 '@vanilla-extract/recipes': 0.5.0_3qusp464cmatvw2qbjjzblbe54 @@ -7288,6 +7292,19 @@ packages: dependencies: defer-to-connect: 1.1.3 + /@tanstack/react-virtual/3.0.0-beta.60_react@18.2.0: + resolution: {integrity: sha512-F0wL9+byp7lf/tH6U5LW0ZjBqs+hrMXJrj5xcIGcklI0pggvjzMNW9DdIBcyltPNr6hmHQ0wt8FDGe1n1ZAThA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || 18 + dependencies: + '@tanstack/virtual-core': 3.0.0-beta.60 + react: 18.2.0 + dev: false + + /@tanstack/virtual-core/3.0.0-beta.60: + resolution: {integrity: sha512-QlCdhsV1+JIf0c0U6ge6SQmpwsyAT0oQaOSZk50AtEeAyQl9tQrd6qCHAslxQpgphrfe945abvKG8uYvw3hIGA==} + dev: false + /@testing-library/dom/9.3.1: resolution: {integrity: sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==} engines: {node: '>=14'}