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 fbfc5e623..52738b160 100644 --- a/packages/bento-design-system/src/Table/Table.tsx +++ b/packages/bento-design-system/src/Table/Table.tsx @@ -41,11 +41,12 @@ import { table, } from "./Table.css"; import { Column as ColumnType, 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>> = ( a: Row>, @@ -77,7 +78,6 @@ type SortingProps>> = type Props>> = { columns: C; data: ReadonlyArray>; - groupBy?: C[number]["accessor"]; noResultsTitle?: LocalizedString; noResultsDescription?: LocalizedString; noResultsFeedbackSize?: FeedbackProps["size"]; @@ -85,7 +85,11 @@ type Props>> = { stickyHeaders?: boolean; height?: { custom: string | number }; onRowPress?: (row: Row>) => void; -} & SortingProps; +} & ( + | { groupBy?: C[number]["accessor"]; virtualizeRows?: never } + | { groupBy?: never; virtualizeRows?: { estimateRowHeight: (index: number) => number } } +) & + SortingProps; /** * A component that renders a Table, with sorting capabilities @@ -119,6 +123,7 @@ export function Table>>({ stickyHeaders, height, onRowPress, + virtualizeRows, }: Props) { const config = useBentoConfig().table; const customOrderByFn = useMemo( @@ -236,6 +241,22 @@ export function Table>>({ } }, [data.length, stickyLeftColumnsIds]); + const tableContainerRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: virtualizeRows?.estimateRowHeight ?? (() => 0), + }); + 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 ( >>({ } return ( - - {headerGroups.map((headerGroup) => - headerGroup.headers.map((column, index) => ( - - )) - )} - - {rows.flatMap((row, index) => { - if (row.isGrouped) { - return [ - , - ...row.leafRows.map((row, index) => { + + + {headerGroups.map((headerGroup) => + headerGroup.headers.map((column, index) => ( + + )) + )} + {virtualizeRows + ? virtualRows.map((virtualRow) => { + const index = virtualRow.index; + const row = rows[index]; prepareRow(row); - return renderCells(row.cells, index, false); - }), - ]; - } else { - prepareRow(row); - return ( - - {renderCells(row.cells, index, onRowPress !== undefined)} - - ); - } - })} + return ( + + {renderCells(row.cells, index, onRowPress !== undefined)} + + ); + }) + : 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)} + + ); + } + })} + ); } diff --git a/packages/bento-design-system/stories/Components/Table.stories.tsx b/packages/bento-design-system/stories/Components/Table.stories.tsx index 692a94ed0..8257bd7c6 100644 --- a/packages/bento-design-system/stories/Components/Table.stories.tsx +++ b/packages/bento-design-system/stories/Components/Table.stories.tsx @@ -473,3 +473,21 @@ export const InteractiveRow = { onRowPress: action("onRowPress"), }, } 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: { estimateRowHeight: () => 92 }, + data: repeatToLength(exampleData, 10_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'}