mirror of
https://github.com/nextui-org/nextui.git
synced 2025-12-08 19:26:11 +00:00
feat(table): virtualization (#4285)
* feat: baseline virtualization for table * merge branch canary * fix: table layout * fix: calc header height w layouteffect to offset padding * Merge branch 'canary' into feat/eng-1633-virtualization-for-table * chore: remove unused files and comments * chore: add missing package * feat: add shouldVirtualize conditional to render virtualized-table * feat: update docs for table * feat: use wrapper to support theme styles * chore: add changeset * chore(changeset): update package name * chore(deps): pnpm-lock.yaml * fix(table): outdated package name * chore(changeset): add issue number * fix(deps): keep the version consistent with other components * fix(table): incorrect displayName * refactor(table): use VirtualizedTemplate * chore(deps): bump `@tanstack/react-virtua` * chore(deps): typecheck issue * fix(table): do not use any type * chore: remove auto virtualization --------- Co-authored-by: աӄա <wingkwong.code@gmail.com> Co-authored-by: Junior Garcia <jrgarciadev@gmail.com>
This commit is contained in:
parent
a1cc378887
commit
fbc361c3b1
5
.changeset/fresh-windows-check.md
Normal file
5
.changeset/fresh-windows-check.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@heroui/table": patch
|
||||
---
|
||||
|
||||
Virtualization support added to Table component (#3697)
|
||||
@ -423,7 +423,8 @@
|
||||
"key": "table",
|
||||
"title": "Table",
|
||||
"keywords": "table, data display, grid, spreadsheet",
|
||||
"path": "/docs/components/table.mdx"
|
||||
"path": "/docs/components/table.mdx",
|
||||
"updated": true
|
||||
},
|
||||
{
|
||||
"key": "tabs",
|
||||
|
||||
@ -19,6 +19,10 @@ import asyncPagination from "./async-pagination";
|
||||
import infinitePagination from "./infinite-pagination";
|
||||
import useCase from "./use-case";
|
||||
import customStyles from "./custom-styles";
|
||||
import virtualization from "./virtualization";
|
||||
import virtualizationCustomItemHeight from "./virtualization-custom-row-height";
|
||||
import virtualizationCustomMaxTableHeight from "./virtualization-custom-max-table-height";
|
||||
import virtualizationTenThousand from "./virtualization-ten-thousand";
|
||||
|
||||
export const tableContent = {
|
||||
usage,
|
||||
@ -42,4 +46,8 @@ export const tableContent = {
|
||||
infinitePagination,
|
||||
useCase,
|
||||
customStyles,
|
||||
virtualization,
|
||||
virtualizationCustomItemHeight,
|
||||
virtualizationCustomMaxTableHeight,
|
||||
virtualizationTenThousand,
|
||||
};
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import {Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@nextui-org/react";
|
||||
|
||||
function generateRows(count) {
|
||||
return Array.from({length: count}, (_, index) => ({
|
||||
key: index.toString(),
|
||||
name: `Item ${index + 1}`,
|
||||
value: `Value ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const rows = generateRows(500);
|
||||
const columns = [
|
||||
{key: "name", label: "Name"},
|
||||
{key: "value", label: "Value"},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
isVirtualized
|
||||
aria-label="Example of virtualized table with a large dataset"
|
||||
maxTableHeight={300}
|
||||
rowHeight={40}
|
||||
>
|
||||
<TableHeader columns={columns}>
|
||||
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
|
||||
</TableHeader>
|
||||
<TableBody items={rows}>
|
||||
{(item) => (
|
||||
<TableRow key={item.key}>
|
||||
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import App from "./virtualization-custom-max-table-height.raw.jsx?raw";
|
||||
|
||||
const react = {
|
||||
"/App.jsx": App,
|
||||
};
|
||||
|
||||
export default {
|
||||
...react,
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
import {Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@nextui-org/react";
|
||||
|
||||
function generateRows(count) {
|
||||
return Array.from({length: count}, (_, index) => ({
|
||||
key: index.toString(),
|
||||
name: `Item ${index + 1}`,
|
||||
value: `Value ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const rows = generateRows(500);
|
||||
const columns = [
|
||||
{key: "name", label: "Name"},
|
||||
{key: "value", label: "Value"},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
isVirtualized
|
||||
aria-label="Example of virtualized table with a large dataset"
|
||||
maxTableHeight={500}
|
||||
rowHeight={70}
|
||||
>
|
||||
<TableHeader columns={columns}>
|
||||
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
|
||||
</TableHeader>
|
||||
<TableBody items={rows}>
|
||||
{(item) => (
|
||||
<TableRow key={item.key}>
|
||||
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import App from "./virtualization-custom-row-height.raw.jsx?raw";
|
||||
|
||||
const react = {
|
||||
"/App.jsx": App,
|
||||
};
|
||||
|
||||
export default {
|
||||
...react,
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
import {Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@nextui-org/react";
|
||||
|
||||
function generateRows(count) {
|
||||
return Array.from({length: count}, (_, index) => ({
|
||||
key: index.toString(),
|
||||
name: `Item ${index + 1}`,
|
||||
value: `Value ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const rows = generateRows(10000);
|
||||
const columns = [
|
||||
{key: "name", label: "Name"},
|
||||
{key: "value", label: "Value"},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
isVirtualized
|
||||
aria-label="Example of virtualized table with a large dataset"
|
||||
maxTableHeight={500}
|
||||
rowHeight={40}
|
||||
>
|
||||
<TableHeader columns={columns}>
|
||||
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
|
||||
</TableHeader>
|
||||
<TableBody items={rows}>
|
||||
{(item) => (
|
||||
<TableRow key={item.key}>
|
||||
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import App from "./virtualization-ten-thousand.raw.jsx?raw";
|
||||
|
||||
const react = {
|
||||
"/App.jsx": App,
|
||||
};
|
||||
|
||||
export default {
|
||||
...react,
|
||||
};
|
||||
37
apps/docs/content/components/table/virtualization.raw.jsx
Normal file
37
apps/docs/content/components/table/virtualization.raw.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import {Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@nextui-org/react";
|
||||
|
||||
function generateRows(count) {
|
||||
return Array.from({length: count}, (_, index) => ({
|
||||
key: index.toString(),
|
||||
name: `Item ${index + 1}`,
|
||||
value: `Value ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const rows = generateRows(500);
|
||||
const columns = [
|
||||
{key: "name", label: "Name"},
|
||||
{key: "value", label: "Value"},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
isVirtualized
|
||||
aria-label="Example of virtualized table with a large dataset"
|
||||
maxTableHeight={500}
|
||||
rowHeight={40}
|
||||
>
|
||||
<TableHeader columns={columns}>
|
||||
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
|
||||
</TableHeader>
|
||||
<TableBody items={rows}>
|
||||
{(item) => (
|
||||
<TableRow key={item.key}>
|
||||
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
9
apps/docs/content/components/table/virtualization.ts
Normal file
9
apps/docs/content/components/table/virtualization.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import App from "./virtualization.raw.jsx?raw";
|
||||
|
||||
const react = {
|
||||
"/App.jsx": App,
|
||||
};
|
||||
|
||||
export default {
|
||||
...react,
|
||||
};
|
||||
@ -317,6 +317,36 @@ import { useAsyncList } from "@react-stately/data";
|
||||
iframeSrc="/examples/table/infinite-pagination"
|
||||
/>
|
||||
|
||||
|
||||
### Virtualization
|
||||
|
||||
Table supports virtualization, which allows efficient rendering of large lists by only rendering items that are visible in the viewport. You can enable virtualization by setting the `isVirtualized` prop to `true`.
|
||||
|
||||
<CodeDemo
|
||||
title="Virtualization"
|
||||
files={tableContent.virtualization}
|
||||
/>
|
||||
|
||||
> **Note**: The virtualization strategy is based on the [@tanstack/react-virtual](https://tanstack.com/virtual/latest) package, which provides efficient rendering of large lists by only rendering items that are visible in the viewport.
|
||||
|
||||
#### Ten Thousand Items
|
||||
|
||||
Here's an example of using virtualization with 10,000 items.
|
||||
|
||||
<CodeDemo title="Ten Thousand Items" files={tableContent.virtualizationTenThousand} />
|
||||
|
||||
#### Max Table Height
|
||||
|
||||
The `maxTableHeight` prop is used to set the maximum height of the table. This is required when using virtualization. By default, it's set to `600`.
|
||||
|
||||
<CodeDemo title="Max Table Height" files={tableContent.virtualizationCustomMaxTableHeight} />
|
||||
|
||||
#### Custom Row Height
|
||||
|
||||
The `rowHeight` prop is used to set the height of each row in the table. This is required when using virtualization. By default, it's set to `40`.
|
||||
|
||||
<CodeDemo title="Custom Row Height" files={tableContent.virtualizationCustomItemHeight} />
|
||||
|
||||
### Use Case Example
|
||||
|
||||
When creating a table, you usually need core functionalities like sorting, pagination, and filtering. In the
|
||||
@ -456,6 +486,24 @@ You can customize the `Table` component by passing custom Tailwind CSS classes t
|
||||
type: "none | sm | md | lg",
|
||||
description: "The shadow size of the table.",
|
||||
default: "sm"
|
||||
},
|
||||
{
|
||||
attribute: "maxTableHeight",
|
||||
type: "number",
|
||||
description: "The maximum height of the table in pixels. Required when using virtualization.",
|
||||
default: 600
|
||||
},
|
||||
{
|
||||
attribute: "rowHeight",
|
||||
type: "number",
|
||||
description: "The fixed height of each row item in pixels. Required when using virtualization.",
|
||||
default: 40
|
||||
},
|
||||
{
|
||||
attribute: "isVirtualized",
|
||||
type: "boolean",
|
||||
description: "Whether to enable virtualization. By default, it's enabled when the number of items exceeds 50.",
|
||||
default: "undefined"
|
||||
},
|
||||
{
|
||||
attribute: "hideHeader",
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
"@react-stately/tree": "3.8.7",
|
||||
"@rehooks/local-storage": "^2.4.5",
|
||||
"@stackblitz/sdk": "^1.11.0",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
"@tanstack/react-virtual": "3.11.3",
|
||||
"@vercel/analytics": "^1.4.1",
|
||||
"canvas-confetti": "^1.9.2",
|
||||
"clsx": "^1.2.1",
|
||||
@ -136,7 +136,7 @@
|
||||
"prettier": "^2.7.1",
|
||||
"tailwindcss": "3.4.14",
|
||||
"tsx": "^3.8.2",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript": "^5.7.3",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
@ -133,7 +133,7 @@
|
||||
"tsup": "8.3.5",
|
||||
"tsx": "^4.19.2",
|
||||
"turbo": "1.6.3",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript": "^5.7.3",
|
||||
"webpack": "^5.53.0",
|
||||
"webpack-bundle-analyzer": "^4.4.2",
|
||||
"webpack-cli": "^3.3.11",
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
"@heroui/react-utils": "workspace:*",
|
||||
"@heroui/shared-utils": "workspace:*",
|
||||
"@heroui/use-is-mobile": "workspace:*",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
"@tanstack/react-virtual": "3.11.3",
|
||||
"@react-aria/utils": "3.27.0",
|
||||
"@react-aria/listbox": "3.14.0",
|
||||
"@react-stately/list": "3.11.2",
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
"@react-aria/utils": "3.27.0",
|
||||
"@react-aria/visually-hidden": "3.8.19",
|
||||
"@react-types/shared": "3.27.0",
|
||||
"@tanstack/react-virtual": "3.11.2"
|
||||
"@tanstack/react-virtual": "3.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@heroui/avatar": "workspace:*",
|
||||
|
||||
@ -53,7 +53,8 @@
|
||||
"@react-stately/table": "3.13.1",
|
||||
"@react-stately/virtualizer": "4.2.1",
|
||||
"@react-types/grid": "3.2.11",
|
||||
"@react-types/table": "3.10.4"
|
||||
"@react-types/table": "3.10.4",
|
||||
"@tanstack/react-virtual": "3.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@heroui/theme": "workspace:*",
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import {forwardRef, HTMLHeroUIProps} from "@heroui/system";
|
||||
import type {HTMLHeroUIProps} from "@heroui/system";
|
||||
|
||||
import {forwardRef} from "react";
|
||||
import {useDOMRef} from "@heroui/react-utils";
|
||||
import {clsx} from "@heroui/shared-utils";
|
||||
import {useTableRowGroup} from "@react-aria/table";
|
||||
@ -11,10 +13,11 @@ export interface TableRowGroupProps extends HTMLHeroUIProps<"thead"> {
|
||||
classNames?: ValuesType["classNames"];
|
||||
}
|
||||
|
||||
const TableRowGroup = forwardRef<"thead", TableRowGroupProps>((props, ref) => {
|
||||
const TableRowGroup = forwardRef<HTMLTableSectionElement, TableRowGroupProps>((props, ref) => {
|
||||
const {as, className, children, slots, classNames, ...otherProps} = props;
|
||||
|
||||
const Component = as || "thead";
|
||||
|
||||
const domRef = useDOMRef(ref);
|
||||
|
||||
const {rowGroupProps} = useTableRowGroup();
|
||||
|
||||
@ -3,13 +3,19 @@ import {Spacer} from "@heroui/spacer";
|
||||
import {forwardRef} from "@heroui/system";
|
||||
|
||||
import {UseTableProps, useTable} from "./use-table";
|
||||
import VirtualizedTable from "./virtualized-table";
|
||||
import TableRowGroup from "./table-row-group";
|
||||
import TableHeaderRow from "./table-header-row";
|
||||
import TableColumnHeader from "./table-column-header";
|
||||
import TableSelectAllCheckbox from "./table-select-all-checkbox";
|
||||
import TableBody from "./table-body";
|
||||
|
||||
export interface TableProps extends Omit<UseTableProps, "isSelectable" | "isMultiSelectable"> {}
|
||||
export interface TableProps<T = object>
|
||||
extends Omit<UseTableProps<T>, "isSelectable" | "isMultiSelectable"> {
|
||||
isVirtualized?: boolean;
|
||||
rowHeight?: number;
|
||||
maxTableHeight?: number;
|
||||
}
|
||||
|
||||
const Table = forwardRef<"table", TableProps>((props, ref) => {
|
||||
const {
|
||||
@ -30,6 +36,14 @@ const Table = forwardRef<"table", TableProps>((props, ref) => {
|
||||
ref,
|
||||
});
|
||||
|
||||
const {isVirtualized, rowHeight = 40, maxTableHeight = 600} = props;
|
||||
|
||||
// TODO: remove this after testing the table on production, users can only
|
||||
// enable the virtualization if the passed `isVirtualized` prop is true
|
||||
// const shouldVirtualize = values.collection.size > 50 || isVirtualized;
|
||||
|
||||
const shouldVirtualize = isVirtualized;
|
||||
|
||||
const Wrapper = useCallback(
|
||||
({children}: {children: JSX.Element}) => {
|
||||
if (removeWrapper) {
|
||||
@ -41,6 +55,17 @@ const Table = forwardRef<"table", TableProps>((props, ref) => {
|
||||
[removeWrapper, getWrapperProps],
|
||||
);
|
||||
|
||||
if (shouldVirtualize) {
|
||||
return (
|
||||
<VirtualizedTable
|
||||
{...(props as TableProps)}
|
||||
ref={ref}
|
||||
maxTableHeight={maxTableHeight}
|
||||
rowHeight={rowHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...getBaseProps()}>
|
||||
{topContentPlacement === "outside" && topContent}
|
||||
|
||||
169
packages/components/table/src/virtualized-table-body.tsx
Normal file
169
packages/components/table/src/virtualized-table-body.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import {forwardRef, HTMLHeroUIProps} from "@heroui/system";
|
||||
import {useDOMRef} from "@heroui/react-utils";
|
||||
import {clsx, dataAttr} from "@heroui/shared-utils";
|
||||
import {useTableRowGroup} from "@react-aria/table";
|
||||
import {filterDOMProps} from "@heroui/react-utils";
|
||||
import {mergeProps} from "@react-aria/utils";
|
||||
import {Virtualizer} from "@tanstack/react-virtual";
|
||||
|
||||
import TableRow from "./table-row";
|
||||
import TableCell from "./table-cell";
|
||||
import TableCheckboxCell from "./table-checkbox-cell";
|
||||
import {ValuesType} from "./use-table";
|
||||
|
||||
// @internal
|
||||
export interface VirtualizedTableBodyProps extends HTMLHeroUIProps<"tbody"> {
|
||||
slots: ValuesType["slots"];
|
||||
collection: ValuesType["collection"];
|
||||
state: ValuesType["state"];
|
||||
isSelectable: ValuesType["isSelectable"];
|
||||
color: ValuesType["color"];
|
||||
disableAnimation: ValuesType["disableAnimation"];
|
||||
checkboxesProps: ValuesType["checkboxesProps"];
|
||||
selectionMode: ValuesType["selectionMode"];
|
||||
classNames?: ValuesType["classNames"];
|
||||
rowVirtualizer: Virtualizer<any, Element>;
|
||||
}
|
||||
|
||||
const VirtualizedTableBody = forwardRef<"tbody", VirtualizedTableBodyProps>((props, ref) => {
|
||||
const {
|
||||
as,
|
||||
className,
|
||||
slots,
|
||||
state,
|
||||
collection,
|
||||
isSelectable,
|
||||
color,
|
||||
disableAnimation,
|
||||
checkboxesProps,
|
||||
selectionMode,
|
||||
classNames,
|
||||
rowVirtualizer,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const Component = as || "tbody";
|
||||
const shouldFilterDOMProps = typeof Component === "string";
|
||||
|
||||
const domRef = useDOMRef(ref);
|
||||
|
||||
const {rowGroupProps} = useTableRowGroup();
|
||||
|
||||
const tbodyStyles = clsx(classNames?.tbody, className);
|
||||
const bodyProps = collection?.body.props;
|
||||
|
||||
const isLoading =
|
||||
bodyProps?.isLoading ||
|
||||
bodyProps?.loadingState === "loading" ||
|
||||
bodyProps?.loadingState === "loadingMore";
|
||||
|
||||
const items = [...collection.body.childNodes];
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
|
||||
let emptyContent;
|
||||
let loadingContent;
|
||||
|
||||
if (collection.size === 0 && bodyProps.emptyContent) {
|
||||
emptyContent = (
|
||||
<tr role="row">
|
||||
<td
|
||||
className={slots?.emptyWrapper({class: classNames?.emptyWrapper})}
|
||||
colSpan={collection.columnCount}
|
||||
role="gridcell"
|
||||
>
|
||||
{!isLoading && bodyProps.emptyContent}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && bodyProps.loadingContent) {
|
||||
loadingContent = (
|
||||
<tr role="row">
|
||||
<td
|
||||
className={slots?.loadingWrapper({class: classNames?.loadingWrapper})}
|
||||
colSpan={collection.columnCount}
|
||||
role="gridcell"
|
||||
>
|
||||
{bodyProps.loadingContent}
|
||||
</td>
|
||||
{!emptyContent && collection.size === 0 ? (
|
||||
<td className={slots?.emptyWrapper({class: classNames?.emptyWrapper})} />
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={domRef}
|
||||
{...mergeProps(
|
||||
rowGroupProps,
|
||||
filterDOMProps(bodyProps, {
|
||||
enabled: shouldFilterDOMProps,
|
||||
}),
|
||||
otherProps,
|
||||
)}
|
||||
className={slots.tbody?.({class: tbodyStyles})}
|
||||
data-empty={dataAttr(collection.size === 0)}
|
||||
data-loading={dataAttr(isLoading)}
|
||||
>
|
||||
{virtualItems.map((virtualRow, index) => {
|
||||
const row = items[virtualRow.index];
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={String(row.key)}
|
||||
classNames={classNames}
|
||||
isSelectable={isSelectable}
|
||||
node={row}
|
||||
slots={slots}
|
||||
state={state}
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`,
|
||||
height: `${virtualRow.size}px`,
|
||||
}}
|
||||
>
|
||||
{[...row.childNodes].map((cell) =>
|
||||
cell.props.isSelectionCell ? (
|
||||
<TableCheckboxCell
|
||||
key={String(cell.key)}
|
||||
checkboxesProps={checkboxesProps}
|
||||
classNames={classNames}
|
||||
color={color}
|
||||
disableAnimation={disableAnimation}
|
||||
node={cell}
|
||||
rowKey={row.key}
|
||||
selectionMode={selectionMode}
|
||||
slots={slots}
|
||||
state={state}
|
||||
/>
|
||||
) : (
|
||||
<TableCell
|
||||
key={String(cell.key)}
|
||||
classNames={classNames}
|
||||
node={cell}
|
||||
rowKey={row.key}
|
||||
slots={slots}
|
||||
state={state}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
|
||||
{loadingContent}
|
||||
{emptyContent}
|
||||
</Component>
|
||||
);
|
||||
});
|
||||
|
||||
VirtualizedTableBody.displayName = "HeroUI.VirtualizedTableBody";
|
||||
|
||||
export default VirtualizedTableBody;
|
||||
155
packages/components/table/src/virtualized-table.tsx
Normal file
155
packages/components/table/src/virtualized-table.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import {useCallback, useLayoutEffect, useRef, useState} from "react";
|
||||
import {Spacer} from "@heroui/spacer";
|
||||
import {forwardRef} from "@heroui/system";
|
||||
import {useVirtualizer} from "@tanstack/react-virtual";
|
||||
|
||||
import {UseTableProps, useTable} from "./use-table";
|
||||
import TableRowGroup from "./table-row-group";
|
||||
import TableHeaderRow from "./table-header-row";
|
||||
import TableColumnHeader from "./table-column-header";
|
||||
import TableSelectAllCheckbox from "./table-select-all-checkbox";
|
||||
import VirtualizedTableBody from "./virtualized-table-body";
|
||||
|
||||
export interface TableProps<T = object>
|
||||
extends Omit<UseTableProps<T>, "isSelectable" | "isMultiSelectable"> {
|
||||
isVirtualized?: boolean;
|
||||
rowHeight?: number;
|
||||
maxTableHeight?: number;
|
||||
}
|
||||
|
||||
const VirtualizedTable = forwardRef<"table", TableProps>((props, ref) => {
|
||||
const {
|
||||
BaseComponent,
|
||||
Component,
|
||||
collection,
|
||||
values,
|
||||
topContent,
|
||||
topContentPlacement,
|
||||
bottomContentPlacement,
|
||||
bottomContent,
|
||||
removeWrapper,
|
||||
getBaseProps,
|
||||
getWrapperProps,
|
||||
getTableProps,
|
||||
} = useTable({
|
||||
...props,
|
||||
ref,
|
||||
});
|
||||
|
||||
const {rowHeight = 40, maxTableHeight = 600} = props;
|
||||
|
||||
const Wrapper = useCallback(
|
||||
({children}: {children: JSX.Element}) => {
|
||||
if (removeWrapper) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseComponent
|
||||
{...getWrapperProps()}
|
||||
ref={parentRef}
|
||||
/* Display must be block to maintain the scroll "progress" */
|
||||
style={{height: maxTableHeight, display: "block"}}
|
||||
>
|
||||
{children}
|
||||
</BaseComponent>
|
||||
);
|
||||
},
|
||||
[removeWrapper, getWrapperProps, maxTableHeight],
|
||||
);
|
||||
|
||||
const items = [...collection.body.childNodes];
|
||||
|
||||
const count = items.length;
|
||||
|
||||
const parentRef = useRef(null);
|
||||
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
|
||||
const headerRef = useRef<HTMLTableSectionElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (headerRef.current) {
|
||||
setHeaderHeight(headerRef.current.getBoundingClientRect().height);
|
||||
}
|
||||
}, [headerRef]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...getBaseProps()}>
|
||||
{/* We need to add p-1 to make the shadow-sm visible */}
|
||||
<Wrapper>
|
||||
<>
|
||||
{topContentPlacement === "outside" && topContent}
|
||||
<div style={{height: `calc(${rowVirtualizer.getTotalSize() + headerHeight}px)`}}>
|
||||
<>
|
||||
{topContentPlacement === "inside" && topContent}
|
||||
<Component {...getTableProps()}>
|
||||
<TableRowGroup ref={headerRef} classNames={values.classNames} slots={values.slots}>
|
||||
{collection.headerRows.map((headerRow) => (
|
||||
<TableHeaderRow
|
||||
key={headerRow?.key}
|
||||
classNames={values.classNames}
|
||||
node={headerRow}
|
||||
slots={values.slots}
|
||||
state={values.state}
|
||||
>
|
||||
{[...headerRow.childNodes].map((column) =>
|
||||
column?.props?.isSelectionCell ? (
|
||||
<TableSelectAllCheckbox
|
||||
key={column?.key}
|
||||
checkboxesProps={values.checkboxesProps}
|
||||
classNames={values.classNames}
|
||||
color={values.color}
|
||||
disableAnimation={values.disableAnimation}
|
||||
node={column}
|
||||
selectionMode={values.selectionMode}
|
||||
slots={values.slots}
|
||||
state={values.state}
|
||||
/>
|
||||
) : (
|
||||
<TableColumnHeader
|
||||
key={column?.key}
|
||||
classNames={values.classNames}
|
||||
node={column}
|
||||
slots={values.slots}
|
||||
state={values.state}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</TableHeaderRow>
|
||||
))}
|
||||
<Spacer as="tr" tabIndex={-1} y={1} />
|
||||
</TableRowGroup>
|
||||
<VirtualizedTableBody
|
||||
checkboxesProps={values.checkboxesProps}
|
||||
classNames={values.classNames}
|
||||
collection={values.collection}
|
||||
color={values.color}
|
||||
disableAnimation={values.disableAnimation}
|
||||
isSelectable={values.isSelectable}
|
||||
rowVirtualizer={rowVirtualizer}
|
||||
selectionMode={values.selectionMode}
|
||||
slots={values.slots}
|
||||
state={values.state}
|
||||
/>
|
||||
</Component>
|
||||
{bottomContentPlacement === "inside" && bottomContent}
|
||||
</>
|
||||
</div>
|
||||
{bottomContentPlacement === "outside" && bottomContent}
|
||||
</>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VirtualizedTable.displayName = "HeroUI.VirtualizedTable";
|
||||
|
||||
export default VirtualizedTable;
|
||||
@ -120,6 +120,14 @@ type SWCharacter = {
|
||||
birth_year: string;
|
||||
};
|
||||
|
||||
const generateRows = (rowCount: number) => {
|
||||
return Array.from({length: rowCount}, (_, index) => ({
|
||||
key: index.toString(),
|
||||
name: `Item ${index + 1}`,
|
||||
value: `Value ${index + 1}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const StaticTemplate = (args: TableProps) => (
|
||||
<Table aria-label="Example static collection table" {...args}>
|
||||
<TableHeader>
|
||||
@ -912,6 +920,38 @@ const InfinitePaginationTemplate = (args: TableProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const VirtualizedTemplate = (args: TableProps & {rowCount: number}) => {
|
||||
const {rowCount, ...rest} = args;
|
||||
const rows = generateRows(rowCount);
|
||||
const columns = [
|
||||
{key: "name", label: "Name"},
|
||||
{key: "value", label: "Value"},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
aria-label="Example of virtualized table with a large dataset"
|
||||
{...rest}
|
||||
isVirtualized
|
||||
maxTableHeight={300}
|
||||
rowHeight={40}
|
||||
>
|
||||
<TableHeader columns={columns}>
|
||||
{(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
|
||||
</TableHeader>
|
||||
<TableBody items={rows}>
|
||||
{(item) => (
|
||||
<TableRow key={item.key}>
|
||||
{(columnKey) => <TableCell>{item[columnKey]}</TableCell>}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
render: StaticTemplate,
|
||||
|
||||
@ -1110,3 +1150,21 @@ export const TableWithSwitch = {
|
||||
selectionMode: "multiple",
|
||||
},
|
||||
};
|
||||
|
||||
export const Virtualized = {
|
||||
render: VirtualizedTemplate,
|
||||
args: {
|
||||
...defaultProps,
|
||||
className: "max-w-3xl",
|
||||
rowCount: 500,
|
||||
},
|
||||
};
|
||||
|
||||
export const TenThousandRows = {
|
||||
render: VirtualizedTemplate,
|
||||
args: {
|
||||
...defaultProps,
|
||||
className: "max-w-3xl",
|
||||
rowCount: 10000,
|
||||
},
|
||||
};
|
||||
|
||||
570
pnpm-lock.yaml
generated
570
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user