From 4f9b0df9329669be17af9fca76dbe7135e4998a2 Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Mon, 23 Feb 2026 14:39:25 +0100 Subject: [PATCH 1/2] virtualize rows --- package-lock.json | 28 +++ packages/pxweb2-ui/package.json | 1 + .../lib/components/Table/Table.module.scss | 11 ++ .../src/lib/components/Table/Table.tsx | 173 ++++++++++++------ .../NavigationDrawer/Drawers/DrawerEdit.tsx | 2 +- 5 files changed, 155 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 06ad5f7e6..2d4bb6cc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3454,6 +3454,33 @@ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", + "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", + "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -12956,6 +12983,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@tanstack/react-virtual": "^3.13.12", "@vitejs/plugin-react": "^5.1.3", "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", diff --git a/packages/pxweb2-ui/package.json b/packages/pxweb2-ui/package.json index 373297634..93bbdbf62 100644 --- a/packages/pxweb2-ui/package.json +++ b/packages/pxweb2-ui/package.json @@ -16,6 +16,7 @@ "author": "", "license": "MIT", "dependencies": { + "@tanstack/react-virtual": "^3.13.12", "@vitejs/plugin-react": "^5.1.3", "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss b/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss index 40b0a785d..181a8ad67 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss @@ -255,4 +255,15 @@ background: var(--px-color-surface-moderate); border: none; } + + .virtualPaddingCell { + padding: 0; + border: none; + background: none; + } +} + +.virtualizedWrapper { + max-height: 70vh; + overflow-y: auto; } diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx index 37a77140c..6e0dd9439 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -1,5 +1,6 @@ -import { memo, useMemo } from 'react'; +import { memo, useMemo, useRef } from 'react'; import cl from 'clsx'; +import { useVirtualizer } from '@tanstack/react-virtual'; import classes from './Table.module.scss'; import { PxTable } from '../../shared-types/pxTable'; @@ -61,15 +62,15 @@ export const Table = memo(function Table({ isMobile, className = '', }: TableProps) { + const scrollContainerRef = useRef(null); const cssClasses = className.length > 0 ? ' ' + className : ''; - const tableMeta: columnRowMeta = calculateRowAndColumnMeta(pxtable); + const tableMeta: columnRowMeta = useMemo( + () => calculateRowAndColumnMeta(pxtable), + [pxtable], + ); const tableColumnSize: number = tableMeta.columns - tableMeta.columnOffset; - const headingDataCellCodes = useMemo( - () => new Array(tableColumnSize), - [tableColumnSize], - ); // Contains header variable and value codes for each column in the table // Find the contents variable const contentsVariable = pxtable.metadata.variables.find( @@ -81,68 +82,122 @@ export const Table = memo(function Table({ contentVarIndex = pxtable.data.variableOrder.indexOf(contentsVariable.id); } - const contentsVariableDecimals = Object.fromEntries( - pxtable.metadata.variables - .filter((variable) => variable.type === 'ContentsVariable') - .flatMap((variable) => - variable.values.map((value) => [ - value.code, - { decimals: value.contentInfo?.decimals ?? 6 }, - ]), + const contentsVariableDecimals = useMemo( + () => + Object.fromEntries( + pxtable.metadata.variables + .filter((variable) => variable.type === 'ContentsVariable') + .flatMap((variable) => + variable.values.map((value) => [ + value.code, + { decimals: value.contentInfo?.decimals ?? 6 }, + ]), + ), ), + [pxtable.metadata.variables], ); - // Create empty metadata structure for the dimensions in the header. - // This structure will be filled with metadata when the header is created. + const { headingRows, bodyRows } = useMemo(() => { + const headingDataCellCodes = new Array(tableColumnSize); - // Loop through all columns in the table. i is the column index - for (let i = 0; i < tableColumnSize; i++) { - const dataCellCodes: DataCellCodes = new Array( - pxtable.heading.length, - ); + for (let i = 0; i < tableColumnSize; i++) { + const dataCellCodes: DataCellCodes = new Array( + pxtable.heading.length, + ); - // Loop through all header variables. j is the header variable index - for (let j = 0; j < pxtable.heading.length; j++) { - const dataCellMeta: DataCellMeta = { - varId: '', - valCode: '', - valLabel: '', - varPos: 0, - htmlId: '', - }; - dataCellCodes[j] = dataCellMeta; // add empty object + for (let j = 0; j < pxtable.heading.length; j++) { + dataCellCodes[j] = { + varId: '', + valCode: '', + valLabel: '', + varPos: 0, + htmlId: '', + }; + } + headingDataCellCodes[i] = dataCellCodes; } - headingDataCellCodes[i] = dataCellCodes; - } + return { + headingRows: createHeading(pxtable, tableMeta, headingDataCellCodes), + bodyRows: createRows( + pxtable, + tableMeta, + headingDataCellCodes, + isMobile, + contentVarIndex, + contentsVariableDecimals, + ), + }; + }, [ + tableColumnSize, + pxtable, + tableMeta, + isMobile, + contentVarIndex, + contentsVariableDecimals, + ]); + + const shouldVirtualize = bodyRows.length > 100; + const rowVirtualizer = useVirtualizer({ + count: bodyRows.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => (isMobile ? 44 : 36), + overscan: 12, + }); + + const virtualRows = shouldVirtualize ? rowVirtualizer.getVirtualItems() : []; + const firstVirtualRow = virtualRows[0]; + const lastVirtualRow = virtualRows[virtualRows.length - 1]; + const topPaddingHeight = firstVirtualRow ? firstVirtualRow.start : 0; + const bottomPaddingHeight = lastVirtualRow + ? rowVirtualizer.getTotalSize() - lastVirtualRow.end + : 0; + const visibleRowStart = firstVirtualRow?.index ?? 0; + const visibleRowEnd = lastVirtualRow ? lastVirtualRow.index + 1 : 0; + const visibleBodyRows = shouldVirtualize + ? bodyRows.slice(visibleRowStart, visibleRowEnd) + : bodyRows; + console.log( + 'rendering table with ' + + bodyRows.length + + ' body rows, virtualized: ' + + shouldVirtualize, + ); return ( - - {createHeading(pxtable, tableMeta, headingDataCellCodes)} - - {useMemo( - () => - createRows( - pxtable, - tableMeta, - headingDataCellCodes, - isMobile, - contentVarIndex, - contentsVariableDecimals, - ), - [ - pxtable, - tableMeta, - headingDataCellCodes, - isMobile, - contentVarIndex, - contentsVariableDecimals, - ], - )} - -
+ + {headingRows} + + {shouldVirtualize && topPaddingHeight > 0 && ( + + + )} + + {visibleBodyRows} + + {shouldVirtualize && bottomPaddingHeight > 0 && ( + + + )} + +
+
+
+ ); }); diff --git a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx index 95930f4e4..728241e6e 100644 --- a/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx +++ b/packages/pxweb2/src/app/components/NavigationDrawer/Drawers/DrawerEdit.tsx @@ -40,7 +40,7 @@ function PivotButton({ setIsFadingTable(true); setAnnounceOnNextChange(true); setLoadingPivotType(pivotType); - await new Promise((resolve) => setTimeout(resolve, 1000)); // Allow spinner to render + // await new Promise((resolve) => setTimeout(resolve, 1000)); // Allow spinner to render try { await Promise.resolve(pivot(pivotType)); } finally { From 8eec9328d5a1609ef5711aa882ed50a50af458f9 Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Mon, 23 Feb 2026 15:03:27 +0100 Subject: [PATCH 2/2] virtualize coulmn too --- .../lib/components/Table/Table.module.scss | 6 + .../src/lib/components/Table/Table.tsx | 236 ++++++++++++++---- 2 files changed, 194 insertions(+), 48 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss b/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss index 181a8ad67..b2b05f07a 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss @@ -265,5 +265,11 @@ .virtualizedWrapper { max-height: 70vh; + overflow-x: auto; overflow-y: auto; } + +.virtualizedTable { + width: max-content; + min-width: 100%; +} diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx index 6e0dd9439..b7563cecd 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -33,6 +33,7 @@ interface CreateRowParams { stubIteration: number; table: PxTable; tableMeta: columnRowMeta; + columnWindow: ColumnRenderWindow; stubDataCellCodes: DataCellCodes; headingDataCellCodes: DataCellCodes[]; tableRows: React.JSX.Element[]; @@ -44,6 +45,7 @@ interface CreateRowMobileParams { rowSpan: number; table: PxTable; tableMeta: columnRowMeta; + columnWindow: ColumnRenderWindow; stubDataCellCodes: DataCellCodes; headingDataCellCodes: DataCellCodes[]; tableRows: React.JSX.Element[]; @@ -57,6 +59,13 @@ interface CreateRowMobileParams { */ type DataCellCodes = DataCellMeta[]; +interface ColumnRenderWindow { + start: number; + end: number; + leftPadding: number; + rightPadding: number; +} + export const Table = memo(function Table({ pxtable, isMobile, @@ -97,6 +106,49 @@ export const Table = memo(function Table({ [pxtable.metadata.variables], ); + const shouldVirtualizeColumns = !isMobile && tableColumnSize > 60; + const columnVirtualizer = useVirtualizer({ + horizontal: true, + count: tableColumnSize, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 88, + overscan: 8, + }); + + const virtualColumns = shouldVirtualizeColumns + ? columnVirtualizer.getVirtualItems() + : []; + const firstVirtualColumn = virtualColumns[0]; + const lastVirtualColumn = virtualColumns.at(-1); + + const columnWindow: ColumnRenderWindow = useMemo( + () => + shouldVirtualizeColumns + ? { + start: firstVirtualColumn?.index ?? 0, + end: lastVirtualColumn + ? lastVirtualColumn.index + 1 + : tableColumnSize, + leftPadding: firstVirtualColumn?.start ?? 0, + rightPadding: lastVirtualColumn + ? columnVirtualizer.getTotalSize() - lastVirtualColumn.end + : 0, + } + : { + start: 0, + end: tableColumnSize, + leftPadding: 0, + rightPadding: 0, + }, + [ + shouldVirtualizeColumns, + firstVirtualColumn, + lastVirtualColumn, + tableColumnSize, + columnVirtualizer, + ], + ); + const { headingRows, bodyRows } = useMemo(() => { const headingDataCellCodes = new Array(tableColumnSize); @@ -118,11 +170,17 @@ export const Table = memo(function Table({ } return { - headingRows: createHeading(pxtable, tableMeta, headingDataCellCodes), + headingRows: createHeading( + pxtable, + tableMeta, + headingDataCellCodes, + columnWindow, + ), bodyRows: createRows( pxtable, tableMeta, headingDataCellCodes, + columnWindow, isMobile, contentVarIndex, contentsVariableDecimals, @@ -132,6 +190,7 @@ export const Table = memo(function Table({ tableColumnSize, pxtable, tableMeta, + columnWindow, isMobile, contentVarIndex, contentsVariableDecimals, @@ -147,7 +206,7 @@ export const Table = memo(function Table({ const virtualRows = shouldVirtualize ? rowVirtualizer.getVirtualItems() : []; const firstVirtualRow = virtualRows[0]; - const lastVirtualRow = virtualRows[virtualRows.length - 1]; + const lastVirtualRow = virtualRows.at(-1); const topPaddingHeight = firstVirtualRow ? firstVirtualRow.start : 0; const bottomPaddingHeight = lastVirtualRow ? rowVirtualizer.getTotalSize() - lastVirtualRow.end @@ -157,19 +216,27 @@ export const Table = memo(function Table({ const visibleBodyRows = shouldVirtualize ? bodyRows.slice(visibleRowStart, visibleRowEnd) : bodyRows; - console.log( - 'rendering table with ' + - bodyRows.length + - ' body rows, virtualized: ' + - shouldVirtualize, - ); + + const renderedColumnCount = + tableMeta.columnOffset + + (columnWindow.end - columnWindow.start) + + (columnWindow.leftPadding > 0 ? 1 : 0) + + (columnWindow.rightPadding > 0 ? 1 : 0); + return (
{headingRows} @@ -177,7 +244,7 @@ export const Table = memo(function Table({ {shouldVirtualize && topPaddingHeight > 0 && ( , - ); + const visibleSpanStart = Math.max(spanStart, columnWindow.start); + const visibleSpanEnd = Math.min(spanEnd, columnWindow.end); + const visibleSpan = visibleSpanEnd - visibleSpanStart; + + if (visibleSpan > 0) { + headerRow.push( + , + ); + } + // Repeat for the number of columns in the column span for (let j = 0; j < columnSpan; j++) { // Fill the metadata structure for the dimensions of the header cells @@ -298,6 +386,16 @@ export function createHeading( } } + if (columnWindow.rightPadding > 0) { + headerRow.push( + {headerRow}); // Set repetiton for the next header variable @@ -320,6 +418,7 @@ export function createRows( table: PxTable, tableMeta: columnRowMeta, headingDataCellCodes: DataCellCodes[], + columnWindow: ColumnRenderWindow, isMobile: boolean, contentVarIndex: number, contentsVariableDecimals?: Record, @@ -333,6 +432,7 @@ export function createRows( rowSpan: tableMeta.rows - tableMeta.rowOffset, table, tableMeta, + columnWindow, stubDataCellCodes: stubDatacellCodes, headingDataCellCodes, tableRows, @@ -347,6 +447,7 @@ export function createRows( stubIteration: 0, table, tableMeta, + columnWindow, stubDataCellCodes: stubDatacellCodes, headingDataCellCodes, tableRows, @@ -358,7 +459,7 @@ export function createRows( const tableRow: React.JSX.Element[] = []; fillData( table, - tableMeta, + columnWindow, stubDatacellCodes, headingDataCellCodes, tableRow, @@ -394,6 +495,7 @@ function createRowDesktop({ stubIteration, table, tableMeta, + columnWindow, stubDataCellCodes, headingDataCellCodes, tableRows, @@ -445,7 +547,7 @@ function createRowDesktop({ // If there are more stub variables that need to add headers to this row if (table.stub.length > stubIndex + 1) { // make the rest of this row empty - fillEmpty(tableMeta, tableRow); + fillEmpty(tableRow, columnWindow); tableRows.push( 0) { + tableRow.push( + , + ); + } - // Loop through all data columns in the table - for (let i = 0; i < maxCols; i++) { + for (let i = columnWindow.start; i < columnWindow.end; i++) { tableRow.push(); } + + if (columnWindow.rightPadding > 0) { + tableRow.push( + , + ); + } } /* @@ -678,17 +803,22 @@ function fillEmpty( */ function fillData( table: PxTable, - tableMeta: columnRowMeta, + columnWindow: ColumnRenderWindow, stubDataCellCodes: DataCellCodes, headingDataCellCodes: DataCellCodes[], tableRow: React.JSX.Element[], ): void { - // Loop through cells that need to be added to the row - const maxCols = tableMeta.columns - tableMeta.columnOffset; - - // Loop through all data columns in the table + if (columnWindow.leftPadding > 0) { + tableRow.push( + , ); } + + if (columnWindow.rightPadding > 0) { + tableRow.push( +
@@ -189,7 +256,7 @@ export const Table = memo(function Table({ {shouldVirtualize && bottomPaddingHeight > 0 && (
@@ -213,6 +280,7 @@ export function createHeading( table: PxTable, tableMeta: columnRowMeta, headingDataCellCodes: DataCellCodes[], + columnWindow: ColumnRenderWindow, ): React.JSX.Element[] { // Number of times to add all values for a variable, default to 1 for first header row let repetitionsCurrentHeaderLevel = 1; @@ -243,6 +311,16 @@ export function createHeading( idxHeadingLevel < table.heading.length; idxHeadingLevel++ ) { + if (columnWindow.leftPadding > 0) { + headerRow.push( + , + ); + } + // Set the column span for the header cells for the current row columnSpan = columnSpan / table.heading[idxHeadingLevel].values.length; const variable = table.heading[idxHeadingLevel]; @@ -255,6 +333,8 @@ export function createHeading( ) { // loop trough all the values for the header variable for (let i = 0; i < variable.values.length; i++) { + const spanStart = columnIndex; + const spanEnd = columnIndex + columnSpan; const htmlId: string = 'H' + idxHeadingLevel + @@ -262,27 +342,35 @@ export function createHeading( variable.values[i].code + '.I' + idxRepetitionCurrentHeadingLevel; - headerRow.push( - - {variable.values[i].label} - + {variable.values[i].label} + , + ); + } + headerRows.push(
+ {emptyText} + {emptyText} + {emptyText} + , + ); + } - for (let i = 0; i < maxCols; i++) { + for (let i = columnWindow.start; i < columnWindow.end; i++) { // Merge the metadata structure for the dimensions of the stub and header cells const dataCellCodes = stubDataCellCodes.concat(headingDataCellCodes[i]); const datacellIds: string[] = dataCellCodes.map((obj) => obj.htmlId); @@ -714,6 +844,16 @@ function fillData( , + ); + } } /** * Creates repeated mobile headers for a table and appends them to the provided table rows.