From 4f9b0df9329669be17af9fca76dbe7135e4998a2 Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Mon, 23 Feb 2026 14:39:25 +0100 Subject: [PATCH 1/7] 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/7] 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. From f0bd7490ceba2391580295e1d695402c0a8f45d0 Mon Sep 17 00:00:00 2001 From: PerIngeVaaje Date: Wed, 25 Feb 2026 15:45:52 +0100 Subject: [PATCH 3/7] Use outer scroll --- .../lib/components/Table/Table.module.scss | 4 + .../src/lib/components/Table/Table.tsx | 85 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 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 b2b05f07a..868b567e2 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss @@ -269,6 +269,10 @@ overflow-y: auto; } +.virtualizedWrapperUseParentScroll { + max-height: none; +} + .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 b7563cecd..1e247d7e9 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useRef } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import cl from 'clsx'; import { useVirtualizer } from '@tanstack/react-virtual'; @@ -72,8 +72,81 @@ export const Table = memo(function Table({ className = '', }: TableProps) { const scrollContainerRef = useRef(null); + const [verticalScrollElement, setVerticalScrollElement] = + useState(null); + const [tableScrollMargin, setTableScrollMargin] = useState(0); const cssClasses = className.length > 0 ? ' ' + className : ''; + useEffect(() => { + const findContentAndFooterContainer = ( + element: HTMLElement | null, + ): HTMLElement | null => { + let parent = element?.parentElement ?? null; + + while (parent) { + if ( + typeof parent.className === 'string' && + parent.className.includes('contentAndFooterContainer') + ) { + return parent; + } + parent = parent.parentElement; + } + return null; + }; + + const updateVerticalScrollElement = () => { + setVerticalScrollElement( + findContentAndFooterContainer(scrollContainerRef.current), + ); + }; + + updateVerticalScrollElement(); + globalThis.addEventListener('resize', updateVerticalScrollElement); + + return () => { + globalThis.removeEventListener('resize', updateVerticalScrollElement); + }; + }, []); + + useEffect(() => { + if (!verticalScrollElement || !scrollContainerRef.current) { + setTableScrollMargin(0); + return; + } + + const updateTableScrollMargin = () => { + if (!scrollContainerRef.current) { + return; + } + + const tableTop = scrollContainerRef.current.getBoundingClientRect().top; + const containerTop = verticalScrollElement.getBoundingClientRect().top; + const margin = tableTop - containerTop + verticalScrollElement.scrollTop; + setTableScrollMargin(Math.max(0, margin)); + }; + + updateTableScrollMargin(); + globalThis.addEventListener('resize', updateTableScrollMargin); + + const resizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => { + updateTableScrollMargin(); + }); + + if (resizeObserver && scrollContainerRef.current) { + resizeObserver.observe(scrollContainerRef.current); + resizeObserver.observe(verticalScrollElement); + } + + return () => { + globalThis.removeEventListener('resize', updateTableScrollMargin); + resizeObserver?.disconnect(); + }; + }, [verticalScrollElement]); + const tableMeta: columnRowMeta = useMemo( () => calculateRowAndColumnMeta(pxtable), [pxtable], @@ -199,7 +272,8 @@ export const Table = memo(function Table({ const shouldVirtualize = bodyRows.length > 100; const rowVirtualizer = useVirtualizer({ count: bodyRows.length, - getScrollElement: () => scrollContainerRef.current, + getScrollElement: () => verticalScrollElement ?? scrollContainerRef.current, + scrollMargin: tableScrollMargin, estimateSize: () => (isMobile ? 44 : 36), overscan: 12, }); @@ -207,7 +281,9 @@ export const Table = memo(function Table({ const virtualRows = shouldVirtualize ? rowVirtualizer.getVirtualItems() : []; const firstVirtualRow = virtualRows[0]; const lastVirtualRow = virtualRows.at(-1); - const topPaddingHeight = firstVirtualRow ? firstVirtualRow.start : 0; + const topPaddingHeight = firstVirtualRow + ? Math.max(0, firstVirtualRow.start - tableScrollMargin) + : 0; const bottomPaddingHeight = lastVirtualRow ? rowVirtualizer.getTotalSize() - lastVirtualRow.end : 0; @@ -229,6 +305,8 @@ export const Table = memo(function Table({ className={cl({ [classes.virtualizedWrapper]: shouldVirtualize || shouldVirtualizeColumns, + [classes.virtualizedWrapperUseParentScroll]: + verticalScrollElement !== null, })} > Date: Fri, 27 Feb 2026 10:01:11 +0100 Subject: [PATCH 4/7] Add getVerticalScrollElement prop to Table and Presentation components --- .../src/lib/components/Table/Table.tsx | 30 ++++--------- .../components/Presentation/Presentation.tsx | 45 ++++++++++++++++--- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx index 1e247d7e9..4345b5036 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -13,6 +13,7 @@ import { Variable } from '../../shared-types/variable'; export interface TableProps { readonly pxtable: PxTable; readonly isMobile: boolean; + readonly getVerticalScrollElement?: () => HTMLElement | null; readonly className?: string; } @@ -69,6 +70,7 @@ interface ColumnRenderWindow { export const Table = memo(function Table({ pxtable, isMobile, + getVerticalScrollElement, className = '', }: TableProps) { const scrollContainerRef = useRef(null); @@ -78,27 +80,13 @@ export const Table = memo(function Table({ const cssClasses = className.length > 0 ? ' ' + className : ''; useEffect(() => { - const findContentAndFooterContainer = ( - element: HTMLElement | null, - ): HTMLElement | null => { - let parent = element?.parentElement ?? null; - - while (parent) { - if ( - typeof parent.className === 'string' && - parent.className.includes('contentAndFooterContainer') - ) { - return parent; - } - parent = parent.parentElement; - } - return null; - }; - + // Use outer container scroll if it is provided, otherwise use the table container scroll const updateVerticalScrollElement = () => { - setVerticalScrollElement( - findContentAndFooterContainer(scrollContainerRef.current), - ); + if (getVerticalScrollElement) { + setVerticalScrollElement(getVerticalScrollElement()); + } else { + setVerticalScrollElement(null); + } }; updateVerticalScrollElement(); @@ -107,7 +95,7 @@ export const Table = memo(function Table({ return () => { globalThis.removeEventListener('resize', updateVerticalScrollElement); }; - }, []); + }, [getVerticalScrollElement]); useEffect(() => { if (!verticalScrollElement || !scrollContainerRef.current) { diff --git a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx index 3f48d6014..eef8ffb21 100644 --- a/packages/pxweb2/src/app/components/Presentation/Presentation.tsx +++ b/packages/pxweb2/src/app/components/Presentation/Presentation.tsx @@ -1,6 +1,12 @@ import cl from 'clsx'; import { useTranslation } from 'react-i18next'; -import React, { useRef, useEffect, useState, useLayoutEffect } from 'react'; +import React, { + useRef, + useEffect, + useState, + useLayoutEffect, + useCallback, +} from 'react'; import isEqual from 'lodash/isEqual'; import classes from './Presentation.module.scss'; @@ -20,12 +26,25 @@ type propsType = { }; const MemoizedTable = React.memo( - ({ pxtable, isMobile }: { pxtable: PxTable; isMobile: boolean }) => ( -
+ ({ + pxtable, + isMobile, + getVerticalScrollElement, + }: { + pxtable: PxTable; + isMobile: boolean; + getVerticalScrollElement?: () => HTMLElement | null; + }) => ( +
), (prevProps, nextProps) => isEqual(prevProps.pxtable, nextProps.pxtable) && - prevProps.isMobile === nextProps.isMobile, + prevProps.isMobile === nextProps.isMobile && + prevProps.getVerticalScrollElement === nextProps.getVerticalScrollElement, ); export function Presentation({ selectedTabId, @@ -46,6 +65,12 @@ export function Presentation({ selectedVBValues, } = variables; const tableId: string = selectedTabId; + const getVerticalScrollElement = useCallback((): HTMLElement | null => { + if (!scrollRef || typeof scrollRef === 'function') { + return null; + } + return scrollRef.current; + }, [scrollRef]); const [isMissingMandatoryVariables, setIsMissingMandatoryVariables] = useState(false); const [initialRun, setInitialRun] = useState(true); @@ -270,7 +295,11 @@ export function Presentation({ ref={gradientContainerRef} >
- +
)} @@ -282,7 +311,11 @@ export function Presentation({ ref={gradientContainerRef} >
- +
)} From d180d0d181b6427d2f7fcb1738e61e77956bbac8 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Mon, 2 Mar 2026 15:42:52 +0100 Subject: [PATCH 5/7] Add precalculated heading row height and update table component for virtualization --- .../src/lib/components/Table/Table.tsx | 55 ++++++++++++++++++- .../src/lib/components/Table/columnRowMeta.ts | 29 ++++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx index 4345b5036..131c8061d 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -67,6 +67,12 @@ interface ColumnRenderWindow { rightPadding: number; } +const TABLE_COLUMN_ESTIMATE_WIDTH_PX = 88; +const HEADER_CELL_HORIZONTAL_PADDING_PX = 24; +const HEADER_CELL_VERTICAL_PADDING_PX = 16; +const HEADER_ROW_LINE_HEIGHT_PX = 24; +const AVERAGE_CHARACTER_WIDTH_PX = 8; + export const Table = memo(function Table({ pxtable, isMobile, @@ -168,11 +174,15 @@ export const Table = memo(function Table({ ); const shouldVirtualizeColumns = !isMobile && tableColumnSize > 60; + const headingRowHeights = useMemo( + () => calculateHeadingRowHeights(pxtable, tableMeta), + [pxtable, tableMeta], + ); const columnVirtualizer = useVirtualizer({ horizontal: true, count: tableColumnSize, getScrollElement: () => scrollContainerRef.current, - estimateSize: () => 88, + estimateSize: () => TABLE_COLUMN_ESTIMATE_WIDTH_PX, overscan: 8, }); @@ -236,6 +246,7 @@ export const Table = memo(function Table({ tableMeta, headingDataCellCodes, columnWindow, + headingRowHeights, ), bodyRows: createRows( pxtable, @@ -252,6 +263,7 @@ export const Table = memo(function Table({ pxtable, tableMeta, columnWindow, + headingRowHeights, isMobile, contentVarIndex, contentsVariableDecimals, @@ -347,6 +359,7 @@ export function createHeading( tableMeta: columnRowMeta, headingDataCellCodes: DataCellCodes[], columnWindow: ColumnRenderWindow, + headingRowHeights: number[], ): React.JSX.Element[] { // Number of times to add all values for a variable, default to 1 for first header row let repetitionsCurrentHeaderLevel = 1; @@ -377,12 +390,17 @@ export function createHeading( idxHeadingLevel < table.heading.length; idxHeadingLevel++ ) { + const headingRowHeight = headingRowHeights[idxHeadingLevel]; + if (columnWindow.leftPadding > 0) { headerRow.push(
, ); } @@ -431,6 +449,7 @@ export function createHeading( table.stub.length === 0 && visibleSpanStart === 0, })} + style={{ height: `${headingRowHeight}px` }} > {variable.values[i].label} , @@ -457,7 +476,10 @@ export function createHeading( , ); } @@ -473,6 +495,33 @@ export function createHeading( return headerRows; } +export function calculateHeadingRowHeights( + table: PxTable, + tableMeta: columnRowMeta, +): number[] { + const headerCellTextWidthPx = Math.max( + 1, + TABLE_COLUMN_ESTIMATE_WIDTH_PX - HEADER_CELL_HORIZONTAL_PADDING_PX, + ); + const estimatedCharsPerLine = Math.max( + 1, + Math.floor(headerCellTextWidthPx / AVERAGE_CHARACTER_WIDTH_PX), + ); + + return table.heading.map((variable) => { + const longestLabelLength = + tableMeta.longestValueTextByVariableId[variable.id] ?? 0; + const lineCount = Math.max( + 1, + Math.ceil(longestLabelLength / estimatedCharsPerLine), + ); + + return ( + lineCount * HEADER_ROW_LINE_HEIGHT_PX + HEADER_CELL_VERTICAL_PADDING_PX + ); + }); +} + /** * Creates an array of React.JSX elements representing the rows of a table. * @param table The PxWeb table. diff --git a/packages/pxweb2-ui/src/lib/components/Table/columnRowMeta.ts b/packages/pxweb2-ui/src/lib/components/Table/columnRowMeta.ts index 7367c7da1..e33a2c388 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/columnRowMeta.ts +++ b/packages/pxweb2-ui/src/lib/components/Table/columnRowMeta.ts @@ -20,6 +20,10 @@ export type columnRowMeta = { * The number of rows that contain headers */ rowOffset: number; + /** + * The longest value label for each variable, keyed by variable id. + */ + longestValueTextByVariableId: Record; }; /** @@ -38,13 +42,29 @@ export function calculateRowAndColumnMeta(pxtable: PxTable): columnRowMeta { let rowCount = 1; const columnOffset = 1; const rowOffset = pxtable.heading.length; + const longestValueTextByVariableId: Record = {}; + + for (const headingVariable of pxtable.heading) { + columnCount *= headingVariable.values.length; + } - for (let i = 0; i < pxtable.heading.length; i++) { - columnCount *= pxtable.heading[i].values.length; + for (const stubVariable of pxtable.stub) { + rowCount *= stubVariable.values.length; } - for (let i = 0; i < pxtable.stub.length; i++) { - rowCount *= pxtable.stub[i].values.length; + const allVariables = [...pxtable.heading, ...pxtable.stub]; + for (const variable of allVariables) { + let longestValueText = ''; + + for (const value of variable.values) { + const valueLabel = value.label; + + if (valueLabel.length > longestValueText.length) { + longestValueText = valueLabel; + } + } + + longestValueTextByVariableId[variable.id] = longestValueText.length; } rowCount += pxtable.heading.length; @@ -55,5 +75,6 @@ export function calculateRowAndColumnMeta(pxtable: PxTable): columnRowMeta { columns: columnCount, columnOffset: columnOffset, rowOffset: rowOffset, + longestValueTextByVariableId: longestValueTextByVariableId, }; } From 2e9c55a9654edc720d4bcb6d04c1c879da9492a5 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Mon, 2 Mar 2026 16:39:22 +0100 Subject: [PATCH 6/7] Refactor heading row height calculation to account for column span and improve text width estimation --- .../src/lib/components/Table/Table.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx index 131c8061d..b5ddb152a 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -178,6 +178,7 @@ export const Table = memo(function Table({ () => calculateHeadingRowHeights(pxtable, tableMeta), [pxtable, tableMeta], ); + const columnVirtualizer = useVirtualizer({ horizontal: true, count: tableColumnSize, @@ -499,16 +500,24 @@ export function calculateHeadingRowHeights( table: PxTable, tableMeta: columnRowMeta, ): number[] { - const headerCellTextWidthPx = Math.max( - 1, - TABLE_COLUMN_ESTIMATE_WIDTH_PX - HEADER_CELL_HORIZONTAL_PADDING_PX, - ); - const estimatedCharsPerLine = Math.max( - 1, - Math.floor(headerCellTextWidthPx / AVERAGE_CHARACTER_WIDTH_PX), - ); + let columnSpan = tableMeta.columns - tableMeta.columnOffset; return table.heading.map((variable) => { + columnSpan = columnSpan / variable.values.length; + + const estimatedCellWidthPx = Math.max( + TABLE_COLUMN_ESTIMATE_WIDTH_PX, + columnSpan * TABLE_COLUMN_ESTIMATE_WIDTH_PX, + ); + const headerCellTextWidthPx = Math.max( + 1, + estimatedCellWidthPx - HEADER_CELL_HORIZONTAL_PADDING_PX, + ); + const estimatedCharsPerLine = Math.max( + 1, + Math.floor(headerCellTextWidthPx / AVERAGE_CHARACTER_WIDTH_PX), + ); + const longestLabelLength = tableMeta.longestValueTextByVariableId[variable.id] ?? 0; const lineCount = Math.max( From bdf56ee50fe37b11c896aeade8b981631f27ae17 Mon Sep 17 00:00:00 2001 From: MikaelNordberg Date: Tue, 3 Mar 2026 15:39:32 +0100 Subject: [PATCH 7/7] Enhance header cell width calculation and trim value labels for improved rendering --- .../src/lib/components/Table/Table.tsx | 66 ++++++++++++++----- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx index b5ddb152a..1c8b92747 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -72,6 +72,27 @@ const HEADER_CELL_HORIZONTAL_PADDING_PX = 24; const HEADER_CELL_VERTICAL_PADDING_PX = 16; const HEADER_ROW_LINE_HEIGHT_PX = 24; const AVERAGE_CHARACTER_WIDTH_PX = 8; +const HEADER_BASE_TEXT_LENGTH = 10; +const HEADER_ADDITIONAL_WIDTH_PER_CHAR_PX = 4; +const HEADER_MAX_WIDTH_PX = 280; +const HEADER_MAX_LINE_COUNT = 25; + +function trimValueLabel(valueLabel: string): string { + return valueLabel.trim(); +} + +function calculateHeaderCellWidthPx(valueLabelLength: number): number { + const extraCharacterCount = Math.max( + 0, + valueLabelLength - HEADER_BASE_TEXT_LENGTH, + ); + + return Math.min( + HEADER_MAX_WIDTH_PX, + TABLE_COLUMN_ESTIMATE_WIDTH_PX + + extraCharacterCount * HEADER_ADDITIONAL_WIDTH_PER_CHAR_PX, + ); +} export const Table = memo(function Table({ pxtable, @@ -418,6 +439,7 @@ export function createHeading( ) { // loop trough all the values for the header variable for (let i = 0; i < variable.values.length; i++) { + const valueLabel = variable.values[i].label; const spanStart = columnIndex; const spanEnd = columnIndex + columnSpan; const htmlId: string = @@ -440,7 +462,7 @@ export function createHeading( key={getNewKey()} aria-label={ variable.type === VartypeEnum.TIME_VARIABLE - ? `${variable.label} ${variable.values[i].label}` + ? `${variable.label} ${valueLabel}` : undefined } className={cl({ @@ -450,9 +472,15 @@ export function createHeading( table.stub.length === 0 && visibleSpanStart === 0, })} - style={{ height: `${headingRowHeight}px` }} + style={{ + height: `${headingRowHeight}px`, + width: + visibleSpan === 1 + ? `${calculateHeaderCellWidthPx(valueLabel.length)}px` + : undefined, + }} > - {variable.values[i].label} + {valueLabel} , ); } @@ -509,21 +537,26 @@ export function calculateHeadingRowHeights( TABLE_COLUMN_ESTIMATE_WIDTH_PX, columnSpan * TABLE_COLUMN_ESTIMATE_WIDTH_PX, ); + const longestLabelLength = + tableMeta.longestValueTextByVariableId[variable.id] ?? 0; + const adjustedEstimatedCellWidthPx = Math.max( + estimatedCellWidthPx, + calculateHeaderCellWidthPx(longestLabelLength), + ); const headerCellTextWidthPx = Math.max( 1, - estimatedCellWidthPx - HEADER_CELL_HORIZONTAL_PADDING_PX, + adjustedEstimatedCellWidthPx - HEADER_CELL_HORIZONTAL_PADDING_PX, ); const estimatedCharsPerLine = Math.max( 1, Math.floor(headerCellTextWidthPx / AVERAGE_CHARACTER_WIDTH_PX), ); - const longestLabelLength = - tableMeta.longestValueTextByVariableId[variable.id] ?? 0; - const lineCount = Math.max( + let lineCount = Math.max( 1, Math.ceil(longestLabelLength / estimatedCharsPerLine), ); + lineCount = Math.min(lineCount, HEADER_MAX_LINE_COUNT); return ( lineCount * HEADER_ROW_LINE_HEIGHT_PX + HEADER_CELL_VERTICAL_PADDING_PX @@ -635,6 +668,7 @@ function createRowDesktop({ // Loop through all the values in the stub variable for (const val of table.stub[stubIndex].values) { + const trimmedValueLabel = trimValueLabel(val.label); if (stubIndex === 0) { stubIteration++; } @@ -642,7 +676,7 @@ function createRowDesktop({ const cellMeta: DataCellMeta = { varId: variable.id, valCode: val.code, - valLabel: val.label, + valLabel: trimmedValueLabel, varPos: table.data.variableOrder.indexOf(variable.id), htmlId: 'R.' + stubIndex + val.code + '.I' + stubIteration, }; @@ -658,13 +692,13 @@ function createRowDesktop({ scope="row" aria-label={ variable.type === VartypeEnum.TIME_VARIABLE - ? `${variable.label} ${val.label}` + ? `${variable.label} ${trimmedValueLabel}` : undefined } className={cl(classes.stub, classes[`stub-${stubIndex}`])} key={getNewKey()} > - {val.label} + {trimmedValueLabel} , ); @@ -756,10 +790,11 @@ function createRowMobile({ const variable = table.stub[stubIndex]; uniqueIdCounter.idCounter++; const val = table.stub[stubIndex].values[i]; + const trimmedValueLabel = trimValueLabel(val.label); const cellMeta: DataCellMeta = { varId: table.stub[stubIndex].id, valCode: val.code, - valLabel: val.label, + valLabel: trimmedValueLabel, varPos: table.data.variableOrder.indexOf(table.stub[stubIndex].id), htmlId: '', }; @@ -834,13 +869,13 @@ function createRowMobile({ scope="row" aria-label={ variable.type === VartypeEnum.TIME_VARIABLE - ? `${variable.label} ${val.label}` + ? `${variable.label} ${trimmedValueLabel}` : undefined } className={cl(classes.stub, classes[`stub-${stubIndex}`])} key={getNewKey()} > - {val.label} + {trimmedValueLabel} , ); fillData( @@ -1063,6 +1098,7 @@ function createSecondLastMobileHeader( tableRows: React.JSX.Element[], uniqueIdCounter: { idCounter: number }, ): void { + const trimmedValueLabel = trimValueLabel(val.label); // second last level let tableRowSecondLastHeader: React.JSX.Element[] = []; let tempid = @@ -1075,13 +1111,13 @@ function createSecondLastMobileHeader( scope="col" aria-label={ variable.type === VartypeEnum.TIME_VARIABLE - ? `${variable.label} ${val.label}` + ? `${variable.label} ${trimmedValueLabel}` : undefined } className={cl(classes.stub, classes[`stub-${stubIndex}`])} key={getNewKey()} > - {val.label} + {trimmedValueLabel} , );