diff --git a/package-lock.json b/package-lock.json index 9d15b9eed..ee3d42ef3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3819,6 +3819,33 @@ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.21.tgz", + "integrity": "sha512-SYXFrmrbPgXBvf+HsOsKhFgqSe4M6B29VHOsX9Jih9TlNkNkDWx0hWMiMLUghMEzyUz772ndzdEeCEBx+3GIZw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.21" + }, + "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.21", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.21.tgz", + "integrity": "sha512-ww+fmLHyCbPSf7JNbWZP3g7wl6SdNo3ah5Aiw+0e9FDErkVHLKprYUrwTm7dF646FtEkN/KkAKPYezxpmvOjxw==", + "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", @@ -13202,6 +13229,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@tanstack/react-virtual": "^3.13.21", "@vitejs/plugin-react": "^5.1.4", "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 6f214d977..3e33874e6 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.21", "@vitejs/plugin-react": "^5.1.4", "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..868b567e2 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,25 @@ background: var(--px-color-surface-moderate); border: none; } + + .virtualPaddingCell { + padding: 0; + border: none; + background: none; + } +} + +.virtualizedWrapper { + max-height: 70vh; + overflow-x: auto; + 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 37a77140c..3c1e46af6 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, useEffect, useMemo, useRef, useState } from 'react'; import cl from 'clsx'; +import { useVirtualizer, useWindowVirtualizer } from '@tanstack/react-virtual'; import classes from './Table.module.scss'; import { PxTable } from '../../shared-types/pxTable'; @@ -12,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; } @@ -32,6 +34,7 @@ interface CreateRowParams { stubIteration: number; table: PxTable; tableMeta: columnRowMeta; + columnWindow: ColumnRenderWindow; stubDataCellCodes: DataCellCodes; headingDataCellCodes: DataCellCodes[]; tableRows: React.JSX.Element[]; @@ -43,6 +46,7 @@ interface CreateRowMobileParams { rowSpan: number; table: PxTable; tableMeta: columnRowMeta; + columnWindow: ColumnRenderWindow; stubDataCellCodes: DataCellCodes; headingDataCellCodes: DataCellCodes[]; tableRows: React.JSX.Element[]; @@ -56,20 +60,88 @@ interface CreateRowMobileParams { */ type DataCellCodes = DataCellMeta[]; +interface ColumnRenderWindow { + start: number; + end: number; + leftPadding: number; + rightPadding: number; +} + export const Table = memo(function Table({ pxtable, isMobile, + getVerticalScrollElement, className = '', }: TableProps) { + const scrollContainerRef = useRef(null); + const [verticalScrollElement, setVerticalScrollElement] = + useState(null); + const [tableScrollMargin, setTableScrollMargin] = useState(0); const cssClasses = className.length > 0 ? ' ' + className : ''; - const tableMeta: columnRowMeta = calculateRowAndColumnMeta(pxtable); + useEffect(() => { + // Use outer container scroll if it is provided, otherwise use the table container scroll + const updateVerticalScrollElement = () => { + if (getVerticalScrollElement) { + setVerticalScrollElement(getVerticalScrollElement()); + } else { + setVerticalScrollElement(null); + } + }; + + updateVerticalScrollElement(); + globalThis.addEventListener('resize', updateVerticalScrollElement); + + return () => { + globalThis.removeEventListener('resize', updateVerticalScrollElement); + }; + }, [getVerticalScrollElement]); + + 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], + ); 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 +153,208 @@ 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 shouldVirtualizeColumns = !isMobile && tableColumnSize > 60; + const columnVirtualizer = useVirtualizer({ + horizontal: true, + count: tableColumnSize, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 88, + overscan: 8, + }); - // 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, - ); + const virtualColumns = shouldVirtualizeColumns + ? columnVirtualizer.getVirtualItems() + : []; + const firstVirtualColumn = virtualColumns[0]; + const lastVirtualColumn = virtualColumns.at(-1); - // 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 + 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); + + for (let i = 0; i < tableColumnSize; i++) { + const dataCellCodes: DataCellCodes = new Array( + pxtable.heading.length, + ); + + 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, + columnWindow, + ), + bodyRows: createRows( + pxtable, + tableMeta, + headingDataCellCodes, + columnWindow, + isMobile, + contentVarIndex, + contentsVariableDecimals, + ), + }; + }, [ + tableColumnSize, + pxtable, + tableMeta, + columnWindow, + isMobile, + contentVarIndex, + contentsVariableDecimals, + ]); + + const shouldVirtualize = bodyRows.length > 100; + + const windowRowVirtualizer = useWindowVirtualizer({ + count: bodyRows.length, + scrollMargin: tableScrollMargin, + estimateSize: () => (isMobile ? 44 : 36), + overscan: 12, + }); + + const containerRowVirtualizer = useVirtualizer({ + count: bodyRows.length, + getScrollElement: () => scrollContainerRef.current, + scrollMargin: tableScrollMargin, + estimateSize: () => (isMobile ? 44 : 36), + overscan: 12, + }); + + const rowVirtualizer = + verticalScrollElement === null + ? containerRowVirtualizer + : windowRowVirtualizer; + + const virtualRows = shouldVirtualize ? rowVirtualizer.getVirtualItems() : []; + const firstVirtualRow = virtualRows[0]; + const lastVirtualRow = virtualRows.at(-1); + const topPaddingHeight = firstVirtualRow + ? Math.max(0, firstVirtualRow.start - tableScrollMargin) + : 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; + + const renderedColumnCount = + tableMeta.columnOffset + + (columnWindow.end - columnWindow.start) + + (columnWindow.leftPadding > 0 ? 1 : 0) + + (columnWindow.rightPadding > 0 ? 1 : 0); + + /*** + * Sjur's notes about mobile performance: + * + * Pivot and rendering in mobile view is slow + * but ram usage seems stable + * the problem is how the createMobileRow function works + * - it creates all rows and then we slice the array to get the visible rows + * (this is computationally expensive, basically we are doing the work of creating all rows even if we only show a fraction of them) + * */ 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 && ( + + + )} + +
+
+
+ ); }); @@ -158,6 +370,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; @@ -188,6 +401,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]; @@ -200,6 +423,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 + @@ -207,27 +432,35 @@ export function createHeading( variable.values[i].code + '.I' + idxRepetitionCurrentHeadingLevel; - headerRow.push( - - {variable.values[i].label} - , - ); + const visibleSpanStart = Math.max(spanStart, columnWindow.start); + const visibleSpanEnd = Math.min(spanEnd, columnWindow.end); + const visibleSpan = visibleSpanEnd - visibleSpanStart; + + if (visibleSpan > 0) { + headerRow.push( + + {variable.values[i].label} + , + ); + } + // 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 @@ -243,6 +476,16 @@ export function createHeading( } } + if (columnWindow.rightPadding > 0) { + headerRow.push( + , + ); + } + headerRows.push({headerRow}); // Set repetiton for the next header variable @@ -265,6 +508,7 @@ export function createRows( table: PxTable, tableMeta: columnRowMeta, headingDataCellCodes: DataCellCodes[], + columnWindow: ColumnRenderWindow, isMobile: boolean, contentVarIndex: number, contentsVariableDecimals?: Record, @@ -278,6 +522,7 @@ export function createRows( rowSpan: tableMeta.rows - tableMeta.rowOffset, table, tableMeta, + columnWindow, stubDataCellCodes: stubDatacellCodes, headingDataCellCodes, tableRows, @@ -292,6 +537,7 @@ export function createRows( stubIteration: 0, table, tableMeta, + columnWindow, stubDataCellCodes: stubDatacellCodes, headingDataCellCodes, tableRows, @@ -303,7 +549,7 @@ export function createRows( const tableRow: React.JSX.Element[] = []; fillData( table, - tableMeta, + columnWindow, stubDatacellCodes, headingDataCellCodes, tableRow, @@ -339,6 +585,7 @@ function createRowDesktop({ stubIteration, table, tableMeta, + columnWindow, stubDataCellCodes, headingDataCellCodes, tableRows, @@ -390,7 +637,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( + + {emptyText} + , + ); + } - // 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({emptyText}); } + + if (columnWindow.rightPadding > 0) { + tableRow.push( + + {emptyText} + , + ); + } } /* @@ -623,17 +893,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( + , + ); + } - 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); @@ -659,7 +934,18 @@ function fillData( , ); } + + if (columnWindow.rightPadding > 0) { + tableRow.push( + , + ); + } } + /** * Creates repeated mobile headers for a table and appends them to the provided table rows. * diff --git a/packages/pxweb2/src/app/components/ContentTop/ContentTop.module.scss b/packages/pxweb2/src/app/components/ContentTop/ContentTop.module.scss index 359e93f28..ad97dfe1b 100644 --- a/packages/pxweb2/src/app/components/ContentTop/ContentTop.module.scss +++ b/packages/pxweb2/src/app/components/ContentTop/ContentTop.module.scss @@ -30,6 +30,11 @@ align-items: flex-start; gap: fixed.$spacing-4; align-self: stretch; + + // Offset anchor jump only for desktop widths where TableViewer header is sticky. + @media (min-width: fixed.$breakpoints-large-min-width) { + scroll-margin-top: var(--px-skip-to-main-sticky-offset, 0px); + } } .information { display: flex; diff --git a/packages/pxweb2/src/app/components/ContentTop/ContentTop.spec.tsx b/packages/pxweb2/src/app/components/ContentTop/ContentTop.spec.tsx index 2f26088fe..31723c688 100644 --- a/packages/pxweb2/src/app/components/ContentTop/ContentTop.spec.tsx +++ b/packages/pxweb2/src/app/components/ContentTop/ContentTop.spec.tsx @@ -321,9 +321,13 @@ let mockIsXXLargeDesktop = true; vi.mock('../../context/useApp', () => ({ default: () => ({ isXXLargeDesktop: mockIsXXLargeDesktop, + isTablet: false, setTitle: () => { vi.fn(); }, + setTableInformationWantsToHidePageScrollbar: () => { + vi.fn(); + }, }), })); diff --git a/packages/pxweb2/src/app/components/ContentTop/ContentTop.tsx b/packages/pxweb2/src/app/components/ContentTop/ContentTop.tsx index 365675c91..d98e49002 100644 --- a/packages/pxweb2/src/app/components/ContentTop/ContentTop.tsx +++ b/packages/pxweb2/src/app/components/ContentTop/ContentTop.tsx @@ -115,7 +115,12 @@ export function ContentTop({ const { pxTableMetadata, selectedVBValues } = useVariables(); const selectedMetadata = useTableData().data?.metadata; const buildTableTitle = useTableData().buildTableTitle; - const { setTitle, isXXLargeDesktop, isTablet } = useApp(); + const { + setTitle, + isXXLargeDesktop, + isTablet, + setTableInformationWantsToHidePageScrollbar, + } = useApp(); const openInformationButtonRef = useRef(null); const openInformationLinkRef = useRef(null); @@ -128,6 +133,7 @@ export function ContentTop({ setActiveTab(selectedTab); } setIsTableInformationOpen(true); + setTableInformationWantsToHidePageScrollbar(true); }; const noteInfo = @@ -166,6 +172,13 @@ export function ContentTop({ }); }, [isTableInformationOpen, tableInformationOpener, accessibility]); + // Release table info lock on unmount. + useEffect(() => { + return () => { + setTableInformationWantsToHidePageScrollbar(false); + }; + }, [setTableInformationWantsToHidePageScrollbar]); + let tableTitle = ''; if (selectedMetadata) { const titleBy = t('presentation_page.common.table_title_by'); @@ -279,6 +292,7 @@ export function ContentTop({ selectedTab={activeTab} onClose={() => { setIsTableInformationOpen(false); + setTableInformationWantsToHidePageScrollbar(false); }} > )} diff --git a/packages/pxweb2/src/app/components/Errors/NotFound/NotFound.spec.tsx b/packages/pxweb2/src/app/components/Errors/NotFound/NotFound.spec.tsx index 3385a8abd..033730c02 100644 --- a/packages/pxweb2/src/app/components/Errors/NotFound/NotFound.spec.tsx +++ b/packages/pxweb2/src/app/components/Errors/NotFound/NotFound.spec.tsx @@ -41,6 +41,7 @@ vi.mock('@pxweb2/pxweb2-ui', () => ({ ), BreakpointsXsmallMaxWidth: '575px', + BreakpointsSmallMaxWidth: '600px', BreakpointsMediumMaxWidth: '767px', BreakpointsLargeMaxWidth: '991px', BreakpointsXlargeMaxWidth: '1199px', diff --git a/packages/pxweb2/src/app/components/Footer/Footer.spec.tsx b/packages/pxweb2/src/app/components/Footer/Footer.spec.tsx index bd483c893..518c9ccd6 100644 --- a/packages/pxweb2/src/app/components/Footer/Footer.spec.tsx +++ b/packages/pxweb2/src/app/components/Footer/Footer.spec.tsx @@ -97,43 +97,32 @@ describe('Footer', () => { }); describe('scrollToTop', () => { - it('should scroll the container to the top quickly', () => { - // Create a mock container - const container = document.createElement('div'); - container.scrollTop = 1000; - Object.defineProperty(container, 'scrollTop', { - writable: true, - value: 1000, + it('scrolls window to top when the page is scrolled', () => { + const scrollSpy = vi + .spyOn(globalThis, 'scrollTo') + .mockImplementation(() => { + vi.fn(); + }); + const rafSpy = vi + .spyOn(globalThis, 'requestAnimationFrame') + .mockImplementation((callback: FrameRequestCallback) => { + callback(performance.now() + 250); + return 1; + }); + + Object.defineProperty(document, 'scrollingElement', { + configurable: true, + value: { scrollTop: 1000 }, }); - // Mock ref - const ref = { current: container }; - // Use fake timers - vi.useFakeTimers(); - scrollToTop(ref); - // Fast-forward all timers - vi.runAllTimers(); + scrollToTop(); - expect(container.scrollTop).toBe(0); - vi.useRealTimers(); - }); - - it('shows Top button when containerRef is provided', () => { - (useLocaleContent as Mock).mockReturnValue(footerContent); - const ref = { - current: document.createElement('div'), - } as React.RefObject; - render( - -