From 07afff400151587ec0539ee09b69bc55931eec97 Mon Sep 17 00:00:00 2001 From: lukasbash Date: Thu, 16 Jan 2025 08:53:50 +0100 Subject: [PATCH] Implement basic functionality for virtualization --- app/config.ts | 5 ++ .../VirtualizationExample.module.css | 26 ++++++++ .../virtualization/VirtualizationExample.tsx | 63 +++++++++++++++++++ app/examples/virtualization/page.tsx | 35 +++++++++++ package.json | 3 + package/DataTable.tsx | 58 ++++++++++++++--- package/DataTableRow.tsx | 6 +- package/types/DataTableProps.ts | 6 ++ yarn.lock | 12 ++++ 9 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 app/examples/virtualization/VirtualizationExample.module.css create mode 100644 app/examples/virtualization/VirtualizationExample.tsx create mode 100644 app/examples/virtualization/page.tsx diff --git a/app/config.ts b/app/config.ts index 251386c35..58caf625c 100644 --- a/app/config.ts +++ b/app/config.ts @@ -257,6 +257,11 @@ export const ROUTES: RouteInfo[] = [ title: 'Complex usage scenario', description: `Example: a complex usage scenario for ${PRODUCT_NAME} featuring custom column definitions, asynchronous data loading with React Query, sorting, pagination, custom cell rendering, multiple row selection, and more`, }, + { + href: '/examples/virtualization', + title: 'Virtualization', + description: `Example: how to enable virtualization on ${PRODUCT_NAME}`, + }, { href: '/type-definitions', title: 'Type definitions', diff --git a/app/examples/virtualization/VirtualizationExample.module.css b/app/examples/virtualization/VirtualizationExample.module.css new file mode 100644 index 000000000..cca0de0dd --- /dev/null +++ b/app/examples/virtualization/VirtualizationExample.module.css @@ -0,0 +1,26 @@ +.buttons { + width: 100%; + display: flex; + gap: var(--mantine-spacing-md); + flex-direction: column; + @media (min-width: rem(400px)) { + flex-direction: row; + flex-wrap: wrap; + } +} + +.button { + width: 100%; + @media (min-width: rem(400px)) { + width: calc(50% - (var(--mantine-spacing-md) / 2)); + } + @media (min-width: rem(660px)) { + width: calc((100% - var(--mantine-spacing-md) * 3) / 4); + } + @media (min-width: $mantine-breakpoint-sm) { + width: calc(50% - (var(--mantine-spacing-md) / 2)); + } + @media (min-width: $mantine-breakpoint-md) { + width: calc((100% - var(--mantine-spacing-md) * 3) / 4); + } +} diff --git a/app/examples/virtualization/VirtualizationExample.tsx b/app/examples/virtualization/VirtualizationExample.tsx new file mode 100644 index 000000000..027752bf9 --- /dev/null +++ b/app/examples/virtualization/VirtualizationExample.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { faker } from '@faker-js/faker'; +import { Button, Center, Paper } from '@mantine/core'; +import { DataTable } from '__PACKAGE__'; +import { useState } from 'react'; +import classes from './VirtualizationExample.module.css'; + +type User = { + id: string; + name: string; + age: number; +}; + +const userData: User[] = Array.from({ length: 2000 }, () => ({ + id: faker.string.uuid(), + name: faker.person.fullName(), + age: faker.number.int({ min: 18, max: 65 }), +})); + +export function VirtualizationExample() { + const [virtualized, setVirtualized] = useState(false); + + const toggleVirtualized = () => setVirtualized((current) => !current); + + // example-start + // ... + + return ( + <> + ( +
+
ID: {record.id}
+
Name: {record.name}
+
Age: {record.age}
+
+ ), + }} + /> + {/* example-skip */} + +
+
+ +
+
+
+ {/* example-resume */} + + ); + // example-end +} diff --git a/app/examples/virtualization/page.tsx b/app/examples/virtualization/page.tsx new file mode 100644 index 000000000..7c55c475f --- /dev/null +++ b/app/examples/virtualization/page.tsx @@ -0,0 +1,35 @@ +import { Code } from '@mantine/core'; +import type { Route } from 'next'; +import { CodeBlock } from '~/components/CodeBlock'; +import { ExternalLink } from '~/components/ExternalLink'; +import { PageNavigation } from '~/components/PageNavigation'; +import { PageTitle } from '~/components/PageTitle'; +import { Txt } from '~/components/Txt'; +import { readCodeFile } from '~/lib/code'; +import { getRouteMetadata } from '~/lib/utils'; +import { VirtualizationExample } from './VirtualizationExample'; + +const PATH: Route = '/examples/virtualization'; + +export const metadata = getRouteMetadata(PATH); + +export default async function VirtualizationExamplePage() { + const code = await readCodeFile(`${PATH}/VirtualizationExample.tsx`); + + return ( + <> + + + The DataTable component exposes a bodyRef property that can be used to pass a ref to + the underlying table tbody element. This ref can be passed to the useAutoAnimate(){' '} + hook from the excellent AutoAnimate library + to animate table rows when they are added, removed or reordered. + + + Here is the code: + + Head over to the next example to learn more. + + + ); +} diff --git a/package.json b/package.json index 0fea182bb..3228d9f19 100644 --- a/package.json +++ b/package.json @@ -116,5 +116,8 @@ "@mantine/hooks": ">=7.14", "clsx": ">=2", "react": ">=18.2" + }, + "dependencies": { + "@tanstack/react-virtual": "^3.10.8" } } diff --git a/package/DataTable.tsx b/package/DataTable.tsx index 86e5c8bba..d6963a4c8 100644 --- a/package/DataTable.tsx +++ b/package/DataTable.tsx @@ -1,7 +1,7 @@ import { Box, Table, type MantineSize } from '@mantine/core'; import { useDebouncedCallback, useMergedRef } from '@mantine/hooks'; import clsx from 'clsx'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { DataTableColumnsProvider } from './DataTableDragToggleProvider'; import { DataTableEmptyRow } from './DataTableEmptyRow'; import { DataTableEmptyState } from './DataTableEmptyState'; @@ -22,6 +22,7 @@ import { import type { DataTableProps } from './types'; import { TEXT_SELECTION_DISABLED } from './utilityClasses'; import { differenceBy, getRecordId, uniqBy } from './utils'; +import { useVirtualizer } from '@tanstack/react-virtual'; export function DataTable({ withTableBorder, @@ -129,6 +130,7 @@ export function DataTable({ styles, rowFactory, tableWrapper, + virtualize, ...otherProps }: DataTableProps) { const { @@ -263,14 +265,27 @@ export function DataTable({ const marginProperties = { m, my, mx, mt, mb, ml, mr }; + const virtualizer = useVirtualizer({ + count: recordsLength ?? 0, + enabled: virtualize, + getScrollElement: () => localScrollViewportRef.current, + estimateSize: () => 40, + overscan: 20, + }); + const TableWrapper = useCallback( ({ children }: { children: React.ReactNode }) => { + if (virtualize) return
{children}
; if (tableWrapper) return tableWrapper({ children }); return children; }, - [tableWrapper] + [tableWrapper, virtualize, virtualizer] ); + useEffect(() => { + virtualizer.measure(); + }, [records, virtualizer]); + return ( ({ )} {recordsLength ? ( - records.map((record, index) => { + virtualizer.getVirtualItems().map((virtualRow, index) => { + const record = records[virtualRow.index]; const recordId = getRecordId(record, idAccessor); const isSelected = selectedRecordIds?.includes(recordId) || false; @@ -383,13 +399,13 @@ export function DataTable({ handleSelectionChange = (e) => { if (e.nativeEvent.shiftKey && lastSelectionChangeIndex !== null) { const targetRecords = records.filter( - index > lastSelectionChangeIndex + virtualRow.index > lastSelectionChangeIndex ? (rec, idx) => idx >= lastSelectionChangeIndex && - idx <= index && + idx <= virtualRow.index && (isRecordSelectable ? isRecordSelectable(rec, idx) : true) : (rec, idx) => - idx >= index && + idx >= virtualRow.index && idx <= lastSelectionChangeIndex && (isRecordSelectable ? isRecordSelectable(rec, idx) : true) ); @@ -405,15 +421,39 @@ export function DataTable({ : uniqBy([...selectedRecords, record], (rec) => getRecordId(rec, idAccessor)) ); } - setLastSelectionChangeIndex(index); + setLastSelectionChangeIndex(virtualRow.index); }; } + const virtualRowStyle = virtualize + ? () => { + return { + ...(rowStyle ? rowStyle(record, virtualRow.index) : {}), + height: `${virtualRow.size}px`, + + // transform: `translateY(${virtualRow.start}px)`, + + transform: `translateY(${virtualRow.start - index * virtualRow.size}px)`, + + // display: 'table', + + // position: 'absolute', + // display: 'table', + // // top: 0, + // // left: 0, + // transform: `translateY(${virtualRow.start}px)`, + // width: '100%', + }; + } + : rowStyle; + return ( key={recordId as React.Key} + // key={virtualRow.key} + rowRef={virtualizer.measureElement} record={record} - index={index} + index={virtualRow.index} columns={effectiveColumns} defaultColumnProps={defaultColumnProps} defaultColumnRender={defaultColumnRender} @@ -434,7 +474,7 @@ export function DataTable({ color={rowColor} backgroundColor={rowBackgroundColor} className={rowClassName} - style={rowStyle} + style={virtualRowStyle} customAttributes={customRowAttributes} selectorCellShadowVisible={selectorCellShadowVisible} selectionColumnClassName={selectionColumnClassName} diff --git a/package/DataTableRow.tsx b/package/DataTableRow.tsx index 9f91d811e..a98bd298a 100644 --- a/package/DataTableRow.tsx +++ b/package/DataTableRow.tsx @@ -50,6 +50,7 @@ type DataTableRowProps = { selectionColumnClassName: string | undefined; selectionColumnStyle: MantineStyleProp | undefined; idAccessor: string; + rowRef: React.Ref; } & Pick, 'rowFactory'>; export function DataTableRow({ @@ -81,6 +82,7 @@ export function DataTableRow({ selectionColumnClassName, selectionColumnStyle, rowFactory, + rowRef, }: Readonly>) { const cols = ( <> @@ -189,7 +191,9 @@ export function DataTableRow({ return ( <> - {cols} + + {cols} + {expandedElement} ); diff --git a/package/types/DataTableProps.ts b/package/types/DataTableProps.ts index 28f2759d5..3a0501881 100644 --- a/package/types/DataTableProps.ts +++ b/package/types/DataTableProps.ts @@ -250,6 +250,12 @@ export type DataTableProps> = { * Ref pointing to the table body element. */ bodyRef?: ((instance: HTMLTableSectionElement | null) => void) | React.RefObject; + + /** + * Determines whether the table should be virtualized. Note that virtualization is not compatible with + * using a tableWrapper function. + */ + virtualize?: boolean; } & Omit< TableProps, | 'onScroll' diff --git a/yarn.lock b/yarn.lock index d150aa566..ef05059c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -835,6 +835,18 @@ dependencies: "@tanstack/query-core" "5.62.8" +"@tanstack/react-virtual@^3.10.8": + version "3.10.8" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz#bf4b06f157ed298644a96ab7efc1a2b01ab36e3c" + integrity sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA== + dependencies: + "@tanstack/virtual-core" "3.10.8" + +"@tanstack/virtual-core@3.10.8": + version "3.10.8" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.10.8.tgz#975446a667755222f62884c19e5c3c66d959b8b4" + integrity sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA== + "@trysound/sax@0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"