diff --git a/package-lock.json b/package-lock.json index 37711f3fd..54b06d7ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3822,6 +3822,33 @@ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.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", @@ -13179,6 +13206,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@tanstack/react-virtual": "^3.13.21", "@vitejs/plugin-react": "^5.2.0", "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 f3a1c7b79..2b759d07a 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.2.0", "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", diff --git a/packages/pxweb2-ui/src/lib/components/SideSheet/SideSheet.module.scss b/packages/pxweb2-ui/src/lib/components/SideSheet/SideSheet.module.scss index 63b0df5bb..06bc835ef 100644 --- a/packages/pxweb2-ui/src/lib/components/SideSheet/SideSheet.module.scss +++ b/packages/pxweb2-ui/src/lib/components/SideSheet/SideSheet.module.scss @@ -15,6 +15,7 @@ padding: 0px 0px 0px 0px; border-style: none; max-height: 100vh; // Override default margins at top and bottom for + overflow: hidden; } .sideSheet::backdrop { 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..670624fa4 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,53 @@ 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; + overflow-y: visible; +} + +.virtualizedTable { + width: max-content; + min-width: 100%; +} + +.tableHeadingText { + display: block; +} + +// -- STICKY HEADER -- +// .tableHeadingStickyDesktop { +// position: relative; +// z-index: 10; +// background: var(--px-color-surface-moderate); +// will-change: transform; +// backface-visibility: hidden; +// } + +.tableHeadingLocked { + will-change: min-height; +} + +.tableHeadingScrolling { + .tableHeadingText { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + text-overflow: ellipsis; + } } diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.spec.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.spec.tsx index 9aa13c68c..f67485afe 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.spec.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.spec.tsx @@ -1,48 +1,101 @@ -import { render } from '@testing-library/react'; -import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const childMocks = vi.hoisted(() => ({ + desktopRender: vi.fn(), + mobileRender: vi.fn(), +})); + +vi.mock('./TableDesktopVirtualized', () => ({ + DesktopVirtualizedTable: ({ + className, + getVerticalScrollElement, + }: { + className?: string; + getVerticalScrollElement?: () => HTMLElement | null; + }) => { + childMocks.desktopRender({ className, getVerticalScrollElement }); + return
; + }, +})); + +vi.mock('./TableMobileVirtualized', () => ({ + MobileVirtualizedTable: ({ + className, + getVerticalScrollElement, + }: { + className?: string; + getVerticalScrollElement?: () => HTMLElement | null; + }) => { + childMocks.mobileRender({ className, getVerticalScrollElement }); + return
; + }, +})); import Table from './Table'; import { pxTable } from './testData'; describe('Table', () => { - it('should render successfully desktop', () => { - const { baseElement } = render( - , - ); - expect(baseElement).toBeTruthy(); + beforeEach(() => { + childMocks.desktopRender.mockClear(); + childMocks.mobileRender.mockClear(); + }); + + it('renders desktop component when isMobile is false', () => { + render(
); + + expect(screen.getByTestId('desktop-virtualized-table')).toBeTruthy(); + expect(screen.queryByTestId('mobile-virtualized-table')).toBeNull(); + expect(childMocks.desktopRender).toHaveBeenCalledTimes(1); + expect(childMocks.mobileRender).not.toHaveBeenCalled(); }); - it('should render successfully mobile', () => { - const { baseElement } = render(
); - expect(baseElement).toBeTruthy(); + it('renders mobile component when isMobile is true', () => { + render(
); + + expect(screen.getByTestId('mobile-virtualized-table')).toBeTruthy(); + expect(screen.queryByTestId('desktop-virtualized-table')).toBeNull(); + expect(childMocks.mobileRender).toHaveBeenCalledTimes(1); + expect(childMocks.desktopRender).not.toHaveBeenCalled(); }); - it('should have a th header named 1968', () => { - const { baseElement } = render( -
, + it('forwards className and getVerticalScrollElement to desktop component', () => { + const getVerticalScrollElement = vi.fn(() => null); + + render( +
, + ); + + expect(childMocks.desktopRender).toHaveBeenCalledWith( + expect.objectContaining({ + className: 'custom-table-class', + getVerticalScrollElement, + }), ); - const ths = baseElement.querySelectorAll('th'); - let found = false; - ths.forEach((th) => { - if (th.innerHTML === '1968') { - found = true; - } - }); - expect(found).toBe(true); - expect(ths.length).toBeGreaterThan(0); }); - it('should NOT have a th header named 1967', () => { - const { baseElement } = render( -
, + it('forwards className and getVerticalScrollElement to mobile component', () => { + const getVerticalScrollElement = vi.fn(() => null); + + render( +
, + ); + + expect(childMocks.mobileRender).toHaveBeenCalledWith( + expect.objectContaining({ + className: 'mobile-table-class', + getVerticalScrollElement, + }), ); - const ths = baseElement.querySelectorAll('th'); - let found = false; - ths.forEach((th) => { - if (th.innerHTML === '1967') { - found = true; - } - }); - expect(found).toBe(false); }); }); diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.stories.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.stories.tsx index 2ac972735..e370438d3 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.stories.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.stories.tsx @@ -1,5 +1,4 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect, within } from 'storybook/test'; import { Table } from './Table'; import { pxTable } from './testData'; @@ -15,17 +14,4 @@ export const Default: Story = { args: { pxtable: pxTable, }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(canvas.getByText(/region_1/i)).toBeTruthy(); - expect(canvas.getByText(/region_2/i)).toBeTruthy(); - expect(canvas.getByText(/region_3/i)).toBeTruthy(); - expect(canvas.getByText(/region_4/i)).toBeTruthy(); - expect(canvas.getByText(/CS_1/i)).toBeTruthy(); - expect(canvas.getByText(/CS_2/i)).toBeTruthy(); - expect(canvas.getByText(/CS_3/i)).toBeTruthy(); - expect(canvas.getByText(/CS_4/i)).toBeTruthy(); - expect(canvas.getByText(/CS_5/i)).toBeTruthy(); - }, }; diff --git a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx index 37a77140c..31dc2049c 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -1,20 +1,56 @@ -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 { DesktopVirtualizedTable } from './TableDesktopVirtualized'; +import { MobileVirtualizedTable } from './TableMobileVirtualized'; import { PxTable } from '../../shared-types/pxTable'; import { calculateRowAndColumnMeta, columnRowMeta } from './columnRowMeta'; -import { getPxTableData } from './cubeHelper'; -import { Value } from '../../shared-types/value'; import { VartypeEnum } from '../../shared-types/vartypeEnum'; -import { Variable } from '../../shared-types/variable'; export interface TableProps { readonly pxtable: PxTable; readonly isMobile: boolean; + readonly getVerticalScrollElement?: () => HTMLElement | null; readonly className?: string; } +export interface VirtualizedTableProps { + readonly pxtable: PxTable; + readonly getVerticalScrollElement?: () => HTMLElement | null; + readonly className?: string; +} + +export interface BaseVirtualizedTableProps { + readonly pxtable: PxTable; + readonly tableMeta: columnRowMeta; + readonly tableColumnSize: number; + readonly contentVarIndex: number; + readonly contentsVariableDecimals?: Record; + readonly scrollContainerRef: React.RefObject; + readonly verticalScrollElement: HTMLElement | null; + readonly tableScrollMargin: number; + readonly className?: string; +} + +export interface VirtualizedTableLayoutProps { + readonly pxtable: PxTable; + readonly className: string; + readonly headingRows: React.JSX.Element[]; + readonly visibleBodyRows: React.JSX.Element[]; + readonly shouldVirtualize: boolean; + readonly shouldVirtualizeColumns: boolean; + readonly topPaddingHeight: number; + readonly bottomPaddingHeight: number; + readonly renderedColumnCount: number; + readonly scrollContainerRef: React.RefObject; + readonly verticalScrollElement: HTMLElement | null; + readonly headingRef?: React.RefObject; + readonly headingStyle?: React.CSSProperties; + readonly headingClassName?: string; +} + /** * Represents the metadata for one dimension of a data cell. */ @@ -26,125 +62,492 @@ type DataCellMeta = { htmlId: string; // id used in th. Will build up the headers attribute for datacells. For accesability }; -interface CreateRowParams { - stubIndex: number; - rowSpan: number; - stubIteration: number; - table: PxTable; - tableMeta: columnRowMeta; - stubDataCellCodes: DataCellCodes; - headingDataCellCodes: DataCellCodes[]; - tableRows: React.JSX.Element[]; - contentVarIndex: number; - contentsVariableDecimals?: Record; -} -interface CreateRowMobileParams { - stubIndex: number; - rowSpan: number; - table: PxTable; - tableMeta: columnRowMeta; - stubDataCellCodes: DataCellCodes; - headingDataCellCodes: DataCellCodes[]; - tableRows: React.JSX.Element[]; - uniqueIdCounter: { idCounter: number }; - contentVarIndex: number; - contentsVariableDecimals?: Record; -} - /** * Represents the metadata for multiple dimensions of a data cell. */ type DataCellCodes = DataCellMeta[]; +interface ColumnRenderWindow { + start: number; + end: number; + leftPadding: number; + rightPadding: number; +} + +export const DESKTOP_COLUMN_VIRTUALIZATION_THRESHOLD = 15; +const ROW_VIRTUALIZATION_THRESHOLD = 30; +const DESKTOP_ROW_ESTIMATE_SIZE = 36; +const MOBILE_ROW_ESTIMATE_SIZE = 44; +const DESKTOP_ROW_OVERSCAN = 12; +const MOBILE_ROW_OVERSCAN = 4; +// Bootstrap rows are a temporary first window used before the virtualizer has +// measured/returned concrete items. This avoids rendering an empty tbody frame. +const DESKTOP_BOOTSTRAP_ROW_COUNT = 24; +const MOBILE_BOOTSTRAP_ROW_COUNT = 12; + +type BodyRowWindow = { + visibleRowStart: number; + visibleRowEnd: number; + topPaddingHeight: number; + bottomPaddingHeight: number; +}; + +type BodyRowWindowResult = BodyRowWindow & { + shouldVirtualize: boolean; +}; + +type VirtualRowItem = { + index: number; + start: number; + end: number; +}; + +function getBodyRowVirtualizationSettings(isMobile: boolean) { + return { + estimateSize: isMobile + ? MOBILE_ROW_ESTIMATE_SIZE + : DESKTOP_ROW_ESTIMATE_SIZE, + overscan: isMobile ? MOBILE_ROW_OVERSCAN : DESKTOP_ROW_OVERSCAN, + bootstrapRowCount: isMobile + ? MOBILE_BOOTSTRAP_ROW_COUNT + : DESKTOP_BOOTSTRAP_ROW_COUNT, + }; +} + +function createBodyRowWindowResult( + shouldVirtualize: boolean, + window: BodyRowWindow, +): BodyRowWindowResult { + return { + shouldVirtualize, + ...window, + }; +} + +function createNonVirtualizedBodyRowWindow(rowCount: number): BodyRowWindow { + return { + visibleRowStart: 0, + visibleRowEnd: rowCount, + topPaddingHeight: 0, + bottomPaddingHeight: 0, + }; +} + +function createBootstrapBodyRowWindow({ + rowCount, + bootstrapRowCount, + estimatedRowSize, + totalSize, +}: { + rowCount: number; + bootstrapRowCount: number; + estimatedRowSize: number; + totalSize: number; +}): BodyRowWindow { + // Render an initial estimated window from row 0 while waiting for a + // non-empty virtualizer result. + const visibleRowEnd = Math.min(rowCount, bootstrapRowCount); + + return { + visibleRowStart: 0, + visibleRowEnd, + topPaddingHeight: 0, + bottomPaddingHeight: Math.max( + 0, + totalSize - visibleRowEnd * estimatedRowSize, + ), + }; +} + +function createComputedBodyRowWindow({ + firstVirtualRow, + lastVirtualRow, + rowCount, + tableScrollMargin, + totalSize, +}: { + firstVirtualRow: VirtualRowItem | undefined; + lastVirtualRow: VirtualRowItem | undefined; + rowCount: number; + tableScrollMargin: number; + totalSize: number; +}): BodyRowWindow { + return { + visibleRowStart: firstVirtualRow?.index ?? 0, + visibleRowEnd: lastVirtualRow ? lastVirtualRow.index + 1 : rowCount, + topPaddingHeight: firstVirtualRow + ? Math.max(0, firstVirtualRow.start - tableScrollMargin) + : 0, + bottomPaddingHeight: lastVirtualRow ? totalSize - lastVirtualRow.end : 0, + }; +} + export const Table = memo(function Table({ pxtable, isMobile, + getVerticalScrollElement, className = '', }: TableProps) { - const cssClasses = className.length > 0 ? ' ' + className : ''; + if (isMobile) { + return ( + + ); + } - const tableMeta: columnRowMeta = calculateRowAndColumnMeta(pxtable); + return ( + + ); +}); - 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( - (variable) => variable.type === 'ContentsVariable', +export function useVirtualizedTableBaseProps({ + pxtable, + getVerticalScrollElement, + className = '', +}: VirtualizedTableProps): BaseVirtualizedTableProps { + const { scrollContainerRef, verticalScrollElement, tableScrollMargin } = + useTableScrollContext(getVerticalScrollElement); + + const tableMeta: columnRowMeta = useMemo( + () => calculateRowAndColumnMeta(pxtable), + [pxtable], ); - let contentVarIndex: number = -1; - if (contentsVariable) { - contentVarIndex = pxtable.data.variableOrder.indexOf(contentsVariable.id); - } + const tableColumnSize: number = tableMeta.columns - tableMeta.columnOffset; + + const contentVarIndex = useMemo(() => { + const contentsVariable = pxtable.metadata.variables.find( + (variable) => variable.type === 'ContentsVariable', + ); + + if (!contentsVariable) { + return -1; + } - 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 }, - ]), + return pxtable.data.variableOrder.indexOf(contentsVariable.id); + }, [pxtable.data.variableOrder, pxtable.metadata.variables]); + + 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. + return { + pxtable, + tableMeta, + tableColumnSize, + contentVarIndex, + contentsVariableDecimals, + scrollContainerRef, + verticalScrollElement, + tableScrollMargin, + className, + }; +} + +function useTableScrollContext( + getVerticalScrollElement?: () => HTMLElement | null, +) { + const scrollContainerRef = useRef(null); + const [verticalScrollElement, setVerticalScrollElement] = + useState(null); + const [tableScrollMargin, setTableScrollMargin] = useState(0); + + useEffect(() => { + // Use outer container scroll if it is provided, otherwise use the table container scroll + let frameId: number | null = null; + + const updateVerticalScrollElement = () => { + if (getVerticalScrollElement) { + setVerticalScrollElement(getVerticalScrollElement()); + } else { + setVerticalScrollElement(null); + } + }; + + const scheduleUpdateVerticalScrollElement = () => { + if (frameId !== null) { + return; + } + + frameId = requestAnimationFrame(() => { + frameId = null; + updateVerticalScrollElement(); + }); + }; + + updateVerticalScrollElement(); + globalThis.addEventListener('resize', scheduleUpdateVerticalScrollElement); + + return () => { + globalThis.removeEventListener( + 'resize', + scheduleUpdateVerticalScrollElement, + ); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, [getVerticalScrollElement]); + + useEffect(() => { + if (!verticalScrollElement || !scrollContainerRef.current) { + setTableScrollMargin(0); + return; + } + + let frameId: number | null = null; + + 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)); + }; + + const scheduleUpdateTableScrollMargin = () => { + if (frameId !== null) { + return; + } + + frameId = requestAnimationFrame(() => { + frameId = null; + updateTableScrollMargin(); + }); + }; + + updateTableScrollMargin(); + globalThis.addEventListener('resize', scheduleUpdateTableScrollMargin); + + const resizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => { + scheduleUpdateTableScrollMargin(); + }); + + if (resizeObserver && scrollContainerRef.current) { + resizeObserver.observe(scrollContainerRef.current); + resizeObserver.observe(verticalScrollElement); + } + + return () => { + globalThis.removeEventListener('resize', scheduleUpdateTableScrollMargin); + resizeObserver?.disconnect(); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, [verticalScrollElement]); + + return { scrollContainerRef, verticalScrollElement, tableScrollMargin }; +} + +export function createHeadingDataCellCodes( + table: PxTable, + tableColumnSize: number, +): DataCellCodes[] { + 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, + table.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 = { + for (let j = 0; j < table.heading.length; j++) { + dataCellCodes[j] = { varId: '', valCode: '', valLabel: '', varPos: 0, htmlId: '', }; - dataCellCodes[j] = dataCellMeta; // add empty object } headingDataCellCodes[i] = dataCellCodes; } + return headingDataCellCodes; +} +export function useBodyRowVirtualizationWindow({ + rowCount, + isMobile, + tableScrollMargin, + verticalScrollElement, + scrollContainerRef, +}: { + rowCount: number; + isMobile: boolean; + tableScrollMargin: number; + verticalScrollElement: HTMLElement | null; + scrollContainerRef: React.RefObject; +}) { + const shouldVirtualize = rowCount > ROW_VIRTUALIZATION_THRESHOLD; + const rowVirtualizationSettings = getBodyRowVirtualizationSettings(isMobile); + + const windowRowVirtualizer = useWindowVirtualizer({ + enabled: shouldVirtualize && verticalScrollElement !== null, + count: rowCount, + scrollMargin: tableScrollMargin, + estimateSize: () => rowVirtualizationSettings.estimateSize, + overscan: rowVirtualizationSettings.overscan, + }); + + const containerRowVirtualizer = useVirtualizer({ + enabled: shouldVirtualize && verticalScrollElement === null, + count: rowCount, + getScrollElement: () => scrollContainerRef.current, + scrollMargin: tableScrollMargin, + estimateSize: () => rowVirtualizationSettings.estimateSize, + overscan: rowVirtualizationSettings.overscan, + }); + + const activeRowVirtualizer = + verticalScrollElement === null + ? containerRowVirtualizer + : windowRowVirtualizer; + + const lastNonEmptyWindowRef = useRef(null); + + if (!shouldVirtualize) { + return createBodyRowWindowResult( + shouldVirtualize, + createNonVirtualizedBodyRowWindow(rowCount), + ); + } + + const virtualRows = activeRowVirtualizer.getVirtualItems(); + const totalSize = activeRowVirtualizer.getTotalSize(); + + if (virtualRows.length === 0) { + // During warm-up the virtualizer can briefly return no items; prefer the + // last stable window, otherwise fall back to the bootstrap window. + if (lastNonEmptyWindowRef.current) { + return createBodyRowWindowResult( + shouldVirtualize, + lastNonEmptyWindowRef.current, + ); + } + + return createBodyRowWindowResult( + shouldVirtualize, + createBootstrapBodyRowWindow({ + rowCount, + bootstrapRowCount: rowVirtualizationSettings.bootstrapRowCount, + estimatedRowSize: rowVirtualizationSettings.estimateSize, + totalSize, + }), + ); + } + + const computedWindow = createComputedBodyRowWindow({ + firstVirtualRow: virtualRows[0], + lastVirtualRow: virtualRows.at(-1), + rowCount, + tableScrollMargin, + totalSize, + }); + + lastNonEmptyWindowRef.current = computedWindow; + + return createBodyRowWindowResult(shouldVirtualize, computedWindow); +} + +export function VirtualizedTableLayout({ + pxtable, + className, + headingRows, + visibleBodyRows, + shouldVirtualize, + shouldVirtualizeColumns, + topPaddingHeight, + bottomPaddingHeight, + renderedColumnCount, + scrollContainerRef, + verticalScrollElement, + headingRef, + headingStyle, + headingClassName, +}: VirtualizedTableLayoutProps) { + const shouldUseInternalScrollContainer = + shouldVirtualizeColumns || + (shouldVirtualize && verticalScrollElement === null); + return ( -
- {createHeading(pxtable, tableMeta, headingDataCellCodes)} - - {useMemo( - () => - createRows( - pxtable, - tableMeta, - headingDataCellCodes, - isMobile, - contentVarIndex, - contentsVariableDecimals, - ), - [ - pxtable, - tableMeta, - headingDataCellCodes, - isMobile, - contentVarIndex, - contentsVariableDecimals, - ], +
-
+ aria-label={pxtable.metadata.label} + > + + {headingRows} + + + {shouldVirtualize && topPaddingHeight > 0 && ( + + + + )} + + {visibleBodyRows} + + {shouldVirtualize && bottomPaddingHeight > 0 && ( + + + + )} + + +
); -}); +} /** * Creates the heading rows for the table. @@ -158,6 +561,9 @@ export function createHeading( table: PxTable, tableMeta: columnRowMeta, headingDataCellCodes: DataCellCodes[], + columnWindow: ColumnRenderWindow, + nextKey: () => string, + headingRowLocks?: number[] | null, ): React.JSX.Element[] { // Number of times to add all values for a variable, default to 1 for first header row let repetitionsCurrentHeaderLevel = 1; @@ -173,7 +579,7 @@ export function createHeading( {emptyText} , @@ -188,6 +594,33 @@ export function createHeading( idxHeadingLevel < table.heading.length; idxHeadingLevel++ ) { + const rowLockHeight = headingRowLocks?.[idxHeadingLevel]; + const rowHeightStyle = + rowLockHeight && rowLockHeight > 0 + ? { + height: `${rowLockHeight}px`, + minHeight: `${rowLockHeight}px`, + maxHeight: `${rowLockHeight}px`, + } + : undefined; + + 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 +633,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 +642,38 @@ 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,557 +689,44 @@ export function createHeading( } } - headerRows.push({headerRow}); - - // Set repetiton for the next header variable - repetitionsCurrentHeaderLevel *= - table.heading[idxHeadingLevel].values.length; - headerRow = []; - } - - return headerRows; -} - -/** - * Creates an array of React.JSX elements representing the rows of a table. - * @param table The PxWeb table. - * @param tableMeta Metadata of the table structure - rows and columns. - * @param headingDataCellCodes Metadata structure for the dimensions of the header cells. - * @returns An array of React.JSX elements representing the rows of the table. - */ -export function createRows( - table: PxTable, - tableMeta: columnRowMeta, - headingDataCellCodes: DataCellCodes[], - isMobile: boolean, - contentVarIndex: number, - contentsVariableDecimals?: Record, -): React.JSX.Element[] { - const tableRows: React.JSX.Element[] = []; - const stubDatacellCodes: DataCellCodes = new Array(); - if (table.stub.length > 0) { - if (isMobile) { - createRowMobile({ - stubIndex: 0, - rowSpan: tableMeta.rows - tableMeta.rowOffset, - table, - tableMeta, - stubDataCellCodes: stubDatacellCodes, - headingDataCellCodes, - tableRows, - uniqueIdCounter: { idCounter: 0 }, - contentsVariableDecimals, - contentVarIndex, - }); - } else { - createRowDesktop({ - stubIndex: 0, - rowSpan: tableMeta.rows - tableMeta.rowOffset, - stubIteration: 0, - table, - tableMeta, - stubDataCellCodes: stubDatacellCodes, - headingDataCellCodes, - tableRows, - contentsVariableDecimals, - contentVarIndex, - }); - } - } else { - const tableRow: React.JSX.Element[] = []; - fillData( - table, - tableMeta, - stubDatacellCodes, - headingDataCellCodes, - tableRow, - ); - tableRows.push( - - {tableRow} - , - ); - } - - return tableRows; -} - -/** - * Creates the rows for the table based on the stub variables. For desktop devices. - * - * @param stubIndex - The index of the current stub variable. - * @param rowSpan - The rowspan for the cells to add in this call. - * @param stubIteration - Iteration for the value - * @param table - The PxTable object representing the PxWeb table data. - * @param tableMeta - The metadata for the table columns and rows. - * @param stubDataCellCodes - The metadata structure for the dimensions of the stub cells. - * @param headingDataCellCodes - The metadata structure for the dimensions of the header cells. - * @param tableRows - An array of React.JSX.Element representing the rows of the table. - * @param contentsVarIndex - The index of the contents variable in the variable order. - * @param contentsVariableDecimals - The metadata structure for the contents variable decimals. - * @returns An array of React.JSX.Element representing the rows of the table. - */ -function createRowDesktop({ - stubIndex, - rowSpan, - stubIteration, - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRows, - contentVarIndex, - contentsVariableDecimals, -}: CreateRowParams): React.JSX.Element[] { - // Calculate the rowspan for all the cells to add in this call - rowSpan = rowSpan / table.stub[stubIndex].values.length; - - let tableRow: React.JSX.Element[] = []; - - const variable = table.stub[stubIndex]; - - // Loop through all the values in the stub variable - for (const val of table.stub[stubIndex].values) { - if (stubIndex === 0) { - stubIteration++; - } - - const cellMeta: DataCellMeta = { - varId: variable.id, - valCode: val.code, - valLabel: val.label, - varPos: table.data.variableOrder.indexOf(variable.id), - htmlId: 'R.' + stubIndex + val.code + '.I' + stubIteration, - }; - stubDataCellCodes.push(cellMeta); - // Fix the rowspan - if (rowSpan === 0) { - rowSpan = 1; - } - - tableRow.push( - - {val.label} - , - ); - - // 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); - tableRows.push( - - {tableRow} - , - ); - tableRow = []; - - // Create a new row for the next stub - createRowDesktop({ - stubIndex: stubIndex + 1, - rowSpan, - stubIteration, - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRows, - contentVarIndex, - contentsVariableDecimals, - }); - stubDataCellCodes.pop(); - } else { - // If no more stubs need to add headers then fill the row with data - fillData( - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRow, - ); - tableRows.push({tableRow}); - tableRow = []; - stubDataCellCodes.pop(); - } - } - - return tableRows; -} - -/** - * Creates the rows for the table based on the stub variables. For mobile devices - * - * @param stubIndex - The index of the current stub variable. - * @param rowSpan - The rowspan for the cells to add in this call. - * @param stubIteration - Iteration for the value - * @param table - The PxTable object representing the PxWeb table data. - * @param tableMeta - The metadata for the table columns and rows. - * @param stubDataCellCodes - The metadata structure for the dimensions of the stub cells. - * @param headingDataCellCodes - The metadata structure for the dimensions of the header cells. - * @param tableRows - An array of React.JSX.Element representing the rows of the table. - * @param contentsVarIndex - The index of the contents variable in the variable order. - * @param contentsVariableDecimals - The metadata structure for the contents variable decimals. - * @returns An array of React.JSX.Element representing the rows of the table. - */ -function createRowMobile({ - stubIndex, - rowSpan, - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRows, - uniqueIdCounter, - contentVarIndex, - contentsVariableDecimals, -}: CreateRowMobileParams): React.JSX.Element[] { - const stubValuesLength = table.stub[stubIndex].values.length; - const stubLength = table.stub.length; - // Calculate the rowspan for all the cells to add in this call - rowSpan = rowSpan / stubValuesLength; - - let tableRow: React.JSX.Element[] = []; - - // Loop through all the values in the stub variable - //const stubValuesLength = table.stub[stubIndex].values.length; - for (let i = 0; i < stubValuesLength; i++) { - const variable = table.stub[stubIndex]; - uniqueIdCounter.idCounter++; - const val = table.stub[stubIndex].values[i]; - const cellMeta: DataCellMeta = { - varId: table.stub[stubIndex].id, - valCode: val.code, - valLabel: val.label, - varPos: table.data.variableOrder.indexOf(table.stub[stubIndex].id), - htmlId: '', - }; - stubDataCellCodes.push(cellMeta); - // Fix the rowspan - if (rowSpan === 0) { - rowSpan = 1; - } - let lastValueOfLastStub = false; - if (stubIndex === stubLength - 1 && i === stubValuesLength - 1) { - // the last value of last level stub - lastValueOfLastStub = true; - } - // If there are more stub variables that need to add headers to this row - if (stubLength > stubIndex + 1) { - switch (stubIndex) { - case stubLength - 3: { - // third last level - // Repeat the headers for all stubs except the 2 last levels - createRepeatedMobileHeader( - table, - stubLength, - stubIndex, - stubDataCellCodes, - tableRows, - uniqueIdCounter, - ); - break; - } - case stubLength - 2: { - // second last level - createSecondLastMobileHeader( - stubLength, - stubIndex, - cellMeta, - variable, - val, - i, - tableRows, - uniqueIdCounter, - ); - break; - } - } - // Create a new row for the next stub - createRowMobile({ - stubIndex: stubIndex + 1, - rowSpan, - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRows, - uniqueIdCounter, - contentVarIndex, - contentsVariableDecimals, - }); - stubDataCellCodes.pop(); - } else { - // last level - let tempid = - cellMeta.varId + - '_' + - cellMeta.valCode + - '_I' + - uniqueIdCounter.idCounter; - cellMeta.htmlId = tempid; - tableRow.push( - 0) { + headerRow.push( + - {val.label} - , + />, ); - fillData( - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRow, - ); - tableRows.push( - - {tableRow} - , - ); - tableRow = []; - stubDataCellCodes.pop(); } - } - - return tableRows; -} - -/** - * Fills a row with empty cells. This is used when we are not on the last dimension of the stub. No data is available for these cells. - * - * @param tableMeta - The metadata for the table columns and rows. - * @param tableRow - The array of React.JSX.Element representing the row of the table. - */ -function fillEmpty( - tableMeta: columnRowMeta, - tableRow: React.JSX.Element[], -): void { - const emptyText = ''; - // 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 - for (let i = 0; i < maxCols; i++) { - tableRow.push({emptyText}); - } -} - -/* - * Fills a row with data cells. - * - * @param table - The PxTable object representing the PxWeb table. - * @param tableMeta - The metadata for the table columns and rows. - * @param stubDataCellCodes - The metadata structure for the dimensions of the stub cells. - * @param headingDataCellCodes - The metadata structure for the dimensions of the header cells. - * @param tableRow - The array of React.JSX.Element representing the row of the table. - */ -function fillData( - table: PxTable, - tableMeta: columnRowMeta, - 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 - - for (let i = 0; i < maxCols; 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); - const headers: string = datacellIds.toString().replace(/,/g, ' '); - const dimensions: string[] = []; - // Arrange the dimensons in the right order according to how data is stored is the cube - for (const dataCell of dataCellCodes) { - dimensions[dataCell.varPos] = dataCell.valCode; - } - - // Example of how to get data from the cube (men in Stockholm in 1970): - // const dataValue = getPxTableData(table.data, [ - // '0180', - // 'men', - // '1970', - // ]); - - const dataValue = getPxTableData(table.data.cube, dimensions); - - tableRow.push( - - {dataValue?.formattedValue} - , - ); - } -} -/** - * Creates repeated mobile headers for a table and appends them to the provided table rows. - * - * @param {PxTable} table - The PxTable object. - * @param {number} stubLength - The length of the stub. - * @param {number} stubIndex - The index of the stub. - * @param {DataCellCodes} stubDataCellCodes - An array of data cell codes containing HTML IDs and value labels. - * @param {React.JSX.Element[]} tableRows - An array of table row elements to which the repeated headers will be appended. - */ -function createRepeatedMobileHeader( - table: PxTable, - stubLength: number, - stubIndex: number, - stubDataCellCodes: DataCellCodes, - tableRows: React.JSX.Element[], - uniqueIdCounter: { idCounter: number }, -) { - let tableRowRepeatHeader: React.JSX.Element[] = []; - for (let n = 0; n <= stubLength - 3; n++) { - uniqueIdCounter.idCounter++; - let variable = table.stub[n]; - let tempid = - stubDataCellCodes[n].varId + - '_' + - stubDataCellCodes[n].valCode + - '_I' + - uniqueIdCounter.idCounter; - - stubDataCellCodes[n].htmlId = tempid; - tableRowRepeatHeader.push( - - {stubDataCellCodes[n].valLabel} - , - ); - tableRows.push( - - {tableRowRepeatHeader} + headerRows.push( + + {headerRow} , ); - tableRowRepeatHeader = []; - } -} -/** - * Creates and appends a second last level mobile header row to the table rows. - * - * @param {number} stubIndex - The index of the stub. - * @param {DataCellMeta} cellMeta - Metadata for the data cell. - * @param {Variable} variable - The variable object containing the label. - * @param {Value} val - The value object containing the label. - * @param {number} i - The index of the current iteration. - * @param {React.JSX.Element[]} tableRows - The array of table rows to which the new row will be appended. - */ -function createSecondLastMobileHeader( - stubLength: number, - stubIndex: number, - cellMeta: DataCellMeta, - variable: Variable, - val: Value, - i: number, - tableRows: React.JSX.Element[], - uniqueIdCounter: { idCounter: number }, -): void { - // second last level - let tableRowSecondLastHeader: React.JSX.Element[] = []; - let tempid = - cellMeta.varId + '_' + cellMeta.valCode + '_I' + uniqueIdCounter.idCounter; - cellMeta.htmlId = tempid; - tableRowSecondLastHeader.push( - - {val.label} - , - ); + // Set repetiton for the next header variable + repetitionsCurrentHeaderLevel *= + table.heading[idxHeadingLevel].values.length; + headerRow = []; + } - tableRows.push( - 2, - }, - { - [classes.mobileRowHeadLevel1]: stubLength === 2, - }, - - { - [classes.mobileRowHeadFirstValueOfSecondLastStub]: i === 0, - }, - )} - key={getNewKey()} - > - {tableRowSecondLastHeader} - , - ); + return headerRows; } -let number = 0; +export function createKeyFactory(): () => string { + let counter = 0; -// TODO: Get keys from id:s in the PxTable object -function getNewKey(): string { - number = number + 1; - return number.toString(); + return () => { + counter += 1; + return counter.toString(); + }; } export default Table; diff --git a/packages/pxweb2-ui/src/lib/components/Table/TableDesktopVirtualized.spec.tsx b/packages/pxweb2-ui/src/lib/components/Table/TableDesktopVirtualized.spec.tsx new file mode 100644 index 000000000..bc892f1b4 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableDesktopVirtualized.spec.tsx @@ -0,0 +1,269 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const virtualizerState = vi.hoisted(() => ({ + columnItems: [] as Array<{ index: number; start: number; end: number }>, + columnTotalSize: 0, + rowItems: [] as Array<{ index: number; start: number; end: number }>, + rowTotalSize: 0, + windowRowItems: [] as Array<{ index: number; start: number; end: number }>, + windowRowTotalSize: 0, +})); + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (options?: { horizontal?: boolean }) => { + if (options?.horizontal) { + return { + getVirtualItems: () => virtualizerState.columnItems, + getTotalSize: () => virtualizerState.columnTotalSize, + }; + } + + return { + getVirtualItems: () => virtualizerState.rowItems, + getTotalSize: () => virtualizerState.rowTotalSize, + }; + }, + useWindowVirtualizer: () => ({ + getVirtualItems: () => virtualizerState.windowRowItems, + getTotalSize: () => virtualizerState.windowRowTotalSize, + }), +})); + +import classes from './Table.module.scss'; +import { DesktopVirtualizedTable } from './TableDesktopVirtualized'; +import { pxTable } from './testData'; +import { PxTable } from '../../shared-types/pxTable'; + +function createVirtualItems(startIndex: number, count: number, size: number) { + return Array.from({ length: count }, (_, offset) => { + const index = startIndex + offset; + const start = index * size; + + return { + index, + start, + end: start + size, + }; + }); +} + +function cloneTable(table: PxTable): PxTable { + return structuredClone(table); +} + +function createDomRect(height: number): DOMRect { + return { + x: 0, + y: 0, + width: 640, + height, + top: 0, + left: 0, + right: 640, + bottom: height, + toJSON: () => ({}), + } as DOMRect; +} + +describe('TableDesktopVirtualized', () => { + beforeEach(() => { + virtualizerState.columnItems = createVirtualItems(0, 8, 88); + virtualizerState.columnTotalSize = 16 * 88; + virtualizerState.rowItems = createVirtualItems(0, 20, 36); + virtualizerState.rowTotalSize = 50 * 36; + virtualizerState.windowRowItems = []; + virtualizerState.windowRowTotalSize = 0; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('enables column virtualization when column count exceeds threshold', () => { + const { container } = render( + , + ); + + const table = container.querySelector('table'); + + expect(table).toBeTruthy(); + expect(table?.classList.contains(classes.virtualizedTable)).toBe(true); + }); + + it('does not enable column virtualization at or below threshold', () => { + const smallHeadingTable = cloneTable(pxTable); + smallHeadingTable.heading[0].values = + smallHeadingTable.heading[0].values.slice(0, 3); + + const { container } = render( + , + ); + + const table = container.querySelector('table'); + + expect(table).toBeTruthy(); + expect(table?.classList.contains(classes.virtualizedTable)).toBe(false); + }); + + // -- STICKY HEADER -- + // it('applies sticky class to desktop heading', () => { + // const { container } = render(); + + // const heading = container.querySelector('thead'); + + // expect(heading).toBeTruthy(); + // expect(heading?.classList.contains(classes.tableHeadingStickyDesktop)).toBe( + // true, + // ); + // }); + + it('uses bootstrap column window when virtualizer initially returns no columns', () => { + virtualizerState.columnItems = []; + virtualizerState.columnTotalSize = 16 * 88; + virtualizerState.rowItems = createVirtualItems(49, 1, 36); + + const { container } = render(); + + const dataCell = container.querySelector('tbody td[headers]'); + + expect(dataCell).toBeTruthy(); + }); + + it('renders one body row in no-stub mode', () => { + const noStubTable = cloneTable(pxTable); + noStubTable.stub = []; + + const { container } = render( + , + ); + + const bodyRows = container.querySelectorAll('tbody tr'); + + expect(bodyRows.length).toBe(1); + }); + + it('adds both stub and heading ids to desktop data cell headers', () => { + virtualizerState.rowItems = createVirtualItems(49, 1, 36); + + const { container } = render(); + + const dataCell = container.querySelector('tbody td[headers]'); + const headers = dataCell?.getAttribute('headers') ?? ''; + + expect(headers).toContain('R.'); + expect(headers).toContain('H0.'); + }); + + it('sets aria-label for time variable row headers', () => { + virtualizerState.rowItems = createVirtualItems(49, 1, 36); + + const { container } = render(); + + const timeRowHeader = container.querySelector( + 'tbody th[scope="row"][aria-label^="tid "]', + ); + + expect(timeRowHeader).toBeTruthy(); + }); + + it('does not render row virtualization padding when row count is at or below threshold', () => { + const smallRowTable = cloneTable(pxTable); + smallRowTable.stub[0].values = smallRowTable.stub[0].values.slice(0, 1); + smallRowTable.stub[1].values = smallRowTable.stub[1].values.slice(0, 1); + smallRowTable.stub[2].values = smallRowTable.stub[2].values.slice(0, 1); + + const { container } = render( + , + ); + + const paddingCells = container.querySelectorAll( + 'tbody td[style*="height"]', + ); + + expect(paddingCells.length).toBe(0); + }); + + it('keeps heading lock active across horizontal scroll updates', () => { + vi.useFakeTimers(); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => createDomRect(52), + ); + + const { container } = render(); + const scrollContainer = container.querySelector( + `.${classes.virtualizedWrapper}`, + ); + const heading = container.querySelector('thead'); + + expect(scrollContainer).toBeTruthy(); + expect(heading).toBeTruthy(); + + Object.defineProperty(scrollContainer as HTMLElement, 'scrollLeft', { + configurable: true, + writable: true, + value: 120, + }); + + fireEvent.scroll(scrollContainer as HTMLElement); + + expect(heading?.classList.contains(classes.tableHeadingLocked)).toBe(true); + expect(heading?.classList.contains(classes.tableHeadingScrolling)).toBe( + true, + ); + + act(() => { + vi.advanceTimersByTime(451); + }); + + expect(heading?.classList.contains(classes.tableHeadingScrolling)).toBe( + false, + ); + }); + + it('releases fixed heading height after scroll idle so full headers can expand', () => { + vi.useFakeTimers(); + let headingHeight = 80; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => createDomRect(headingHeight), + ); + + const { container } = render(); + const scrollContainer = container.querySelector( + `.${classes.virtualizedWrapper}`, + ); + const heading = container.querySelector('thead'); + const firstHeaderRow = container.querySelector('thead tr'); + + expect(scrollContainer).toBeTruthy(); + expect(heading).toBeTruthy(); + expect(firstHeaderRow).toBeTruthy(); + + Object.defineProperty(scrollContainer as HTMLElement, 'scrollLeft', { + configurable: true, + writable: true, + value: 88, + }); + + fireEvent.scroll(scrollContainer as HTMLElement); + + headingHeight = 40; + + Object.defineProperty(scrollContainer as HTMLElement, 'scrollLeft', { + configurable: true, + writable: true, + value: 140, + }); + + fireEvent.scroll(scrollContainer as HTMLElement); + + expect((firstHeaderRow as HTMLElement).style.minHeight).toBe('80px'); + + act(() => { + vi.advanceTimersByTime(451); + }); + + expect((firstHeaderRow as HTMLElement).style.minHeight).toBe(''); + }); +}); diff --git a/packages/pxweb2-ui/src/lib/components/Table/TableDesktopVirtualized.tsx b/packages/pxweb2-ui/src/lib/components/Table/TableDesktopVirtualized.tsx new file mode 100644 index 000000000..34f2f06ab --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableDesktopVirtualized.tsx @@ -0,0 +1,697 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import cl from 'clsx'; + +import { + VirtualizedTableProps, + DESKTOP_COLUMN_VIRTUALIZATION_THRESHOLD, + createHeading, + createHeadingDataCellCodes, + createKeyFactory, + useBodyRowVirtualizationWindow, + useVirtualizedTableBaseProps, + VirtualizedTableLayout, +} from './Table'; +import classes from './Table.module.scss'; +import { getPxTableData } from './cubeHelper'; +import { VartypeEnum } from '../../shared-types/vartypeEnum'; +import { Variable } from '../../shared-types/variable'; + +type StubCellMeta = { + varPos: number; + valCode: string; + htmlId: string; +}; + +type DesktopRowEntry = { + key: string; + level: number; + label: string; + isDataRow: boolean; + isFirstDimGroupRow: boolean; + variable: Variable; + stubCellCodes: StubCellMeta[]; +}; + +type ColumnWindow = { + start: number; + end: number; + leftPadding: number; + rightPadding: number; +}; + +const HORIZONTAL_SCROLL_IDLE_DELAY_MS = 450; + +// function parseCssPx(value: string): number { +// const parsed = Number.parseFloat(value); +// return Number.isFinite(parsed) ? parsed : 0; +// } + +function createPaddingCell( + width: number, + nextKey: () => string, +): React.JSX.Element { + return ( + + ); +} + +function buildDataCellMeta( + stubCodes: StubCellMeta[], + headingCodes: StubCellMeta[], + cube: VirtualizedTableProps['pxtable']['data']['cube'], +): { headers: string; formattedValue: string | undefined } { + const dataCodes = stubCodes.concat(headingCodes); + const headers = dataCodes.map((cell) => cell.htmlId).join(' '); + const dimensions: string[] = []; + + for (const cell of dataCodes) { + dimensions[cell.varPos] = cell.valCode; + } + + return { + headers, + formattedValue: getPxTableData(cube, dimensions)?.formattedValue, + }; +} + +function renderNoStubBodyRows({ + columnWindow, + headingDataCellCodes, + cube, +}: { + columnWindow: ColumnWindow; + headingDataCellCodes: StubCellMeta[][]; + cube: VirtualizedTableProps['pxtable']['data']['cube']; +}): React.JSX.Element[] { + const nextKey = createKeyFactory(); + const rowCells: React.JSX.Element[] = []; + + if (columnWindow.leftPadding > 0) { + rowCells.push(createPaddingCell(columnWindow.leftPadding, nextKey)); + } + + for ( + let colIndex = columnWindow.start; + colIndex < columnWindow.end; + colIndex++ + ) { + const headingCodes = headingDataCellCodes[colIndex]; + const headers = headingCodes.map((cell) => cell.htmlId).join(' '); + const dimensions: string[] = []; + + for (const cell of headingCodes) { + dimensions[cell.varPos] = cell.valCode; + } + + const dataValue = getPxTableData(cube, dimensions); + rowCells.push( + + {dataValue?.formattedValue} + , + ); + } + + if (columnWindow.rightPadding > 0) { + rowCells.push(createPaddingCell(columnWindow.rightPadding, nextKey)); + } + + return [ + + {rowCells} + , + ]; +} + +function renderDesktopBodyRows({ + rows, + columnWindow, + headingDataCellCodes, + cube, +}: { + rows: DesktopRowEntry[]; + columnWindow: ColumnWindow; + headingDataCellCodes: StubCellMeta[][]; + cube: VirtualizedTableProps['pxtable']['data']['cube']; +}): React.JSX.Element[] { + const nextKey = createKeyFactory(); + const renderedRows: React.JSX.Element[] = []; + + for (const rowEntry of rows) { + const rowCells: React.JSX.Element[] = [ + + {rowEntry.label} + , + ]; + + if (columnWindow.leftPadding > 0) { + rowCells.push(createPaddingCell(columnWindow.leftPadding, nextKey)); + } + + for ( + let colIndex = columnWindow.start; + colIndex < columnWindow.end; + colIndex++ + ) { + if (!rowEntry.isDataRow) { + rowCells.push(); + continue; + } + + const headingCodes = headingDataCellCodes[colIndex]; + const dataMeta = buildDataCellMeta( + rowEntry.stubCellCodes, + headingCodes, + cube, + ); + + rowCells.push( + + {dataMeta.formattedValue} + , + ); + } + + if (columnWindow.rightPadding > 0) { + rowCells.push(createPaddingCell(columnWindow.rightPadding, nextKey)); + } + + renderedRows.push( + + {rowCells} + , + ); + } + + return renderedRows; +} + +function buildDesktopRowEntries(pxtable: VirtualizedTableProps['pxtable']) { + if (pxtable.stub.length === 0) { + return [] as DesktopRowEntry[]; + } + + const rowEntries: DesktopRowEntry[] = []; + const lastStubLevel = pxtable.stub.length - 1; + let stubIteration = 0; + + const walk = (level: number, stubCellCodes: StubCellMeta[]) => { + const variable = pxtable.stub[level]; + + for (const value of variable.values) { + if (level === 0) { + stubIteration++; + } + + const nextStubCellCodes = stubCellCodes.slice(0, level); + nextStubCellCodes[level] = { + varPos: pxtable.data.variableOrder.indexOf(variable.id), + valCode: value.code, + htmlId: `R.${level}${value.code}.I${stubIteration}`, + }; + + rowEntries.push({ + key: `${level}:${value.code}:${stubIteration}:${rowEntries.length}`, + level, + label: value.label, + isDataRow: level === lastStubLevel, + isFirstDimGroupRow: level === 0 && pxtable.stub.length > 1, + variable, + stubCellCodes: nextStubCellCodes, + }); + + if (level < lastStubLevel) { + walk(level + 1, nextStubCellCodes); + } + } + }; + + walk(0, []); + return rowEntries; +} + +export function DesktopVirtualizedTable({ + pxtable, + getVerticalScrollElement, + className = '', +}: VirtualizedTableProps) { + const { + tableMeta, + tableColumnSize, + scrollContainerRef, + verticalScrollElement, + tableScrollMargin, + } = useVirtualizedTableBaseProps({ + pxtable, + getVerticalScrollElement, + className, + }); + + const shouldVirtualizeColumns = + tableColumnSize > DESKTOP_COLUMN_VIRTUALIZATION_THRESHOLD; + const headingRef = useRef(null); + const lastScrollLeftRef = useRef(0); + const scrollIdleTimerRef = useRef | null>(null); + const captureLockFrameRef = useRef(null); + const isScrollingHorizontallyRef = useRef(false); + const isPointerDraggingRef = useRef(false); + const stableHeadingRowLocksRef = useRef(null); + const [isScrollingHorizontally, setIsScrollingHorizontally] = useState(false); + const [headingRowLocks, setHeadingRowLocks] = useState(null); + + const columnVirtualizer = useVirtualizer({ + horizontal: true, + enabled: shouldVirtualizeColumns, + count: tableColumnSize, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => 88, + overscan: 8, + }); + + const virtualColumns = shouldVirtualizeColumns + ? columnVirtualizer.getVirtualItems() + : []; + const lastNonEmptyColumnWindowRef = useRef<{ + start: number; + end: number; + leftPadding: number; + rightPadding: number; + } | null>(null); + const bootstrapColumnEnd = Math.min(tableColumnSize, 12); + const bootstrapColumnWindow = useMemo( + () => ({ + start: 0, + end: bootstrapColumnEnd, + leftPadding: 0, + rightPadding: Math.max( + 0, + columnVirtualizer.getTotalSize() - bootstrapColumnEnd * 88, + ), + }), + [bootstrapColumnEnd, columnVirtualizer], + ); + + const columnWindow = (() => { + if (!shouldVirtualizeColumns) { + return { + start: 0, + end: tableColumnSize, + leftPadding: 0, + rightPadding: 0, + }; + } + + if (virtualColumns.length === 0) { + if (lastNonEmptyColumnWindowRef.current) { + return lastNonEmptyColumnWindowRef.current; + } + + return bootstrapColumnWindow; + } + + const firstVirtualColumn = virtualColumns[0]; + const lastVirtualColumn = virtualColumns.at(-1); + + const window = { + start: firstVirtualColumn?.index ?? 0, + end: lastVirtualColumn ? lastVirtualColumn.index + 1 : tableColumnSize, + leftPadding: firstVirtualColumn?.start ?? 0, + rightPadding: lastVirtualColumn + ? columnVirtualizer.getTotalSize() - lastVirtualColumn.end + : 0, + }; + + lastNonEmptyColumnWindowRef.current = window; + return window; + })(); + + const headingDataCellCodes = useMemo( + () => createHeadingDataCellCodes(pxtable, tableColumnSize), + [pxtable, tableColumnSize], + ); + + const headingRows = useMemo( + () => + createHeading( + pxtable, + tableMeta, + headingDataCellCodes, + columnWindow, + createKeyFactory(), + headingRowLocks, + ), + [pxtable, tableMeta, headingDataCellCodes, columnWindow, headingRowLocks], + ); + + const desktopRowEntries = useMemo( + () => buildDesktopRowEntries(pxtable), + [pxtable], + ); + + const hasNoStub = pxtable.stub.length === 0; + + const rowCount = hasNoStub ? 1 : desktopRowEntries.length; + + const { + shouldVirtualize, + visibleRowStart, + visibleRowEnd, + topPaddingHeight, + bottomPaddingHeight, + } = useBodyRowVirtualizationWindow({ + rowCount, + isMobile: false, + tableScrollMargin, + verticalScrollElement, + scrollContainerRef, + }); + + const visibleDesktopRows = shouldVirtualize + ? desktopRowEntries.slice(visibleRowStart, visibleRowEnd) + : desktopRowEntries; + + const visibleBodyRows = useMemo(() => { + if (hasNoStub) { + return renderNoStubBodyRows({ + columnWindow, + headingDataCellCodes, + cube: pxtable.data.cube, + }); + } + + return renderDesktopBodyRows({ + rows: visibleDesktopRows, + columnWindow, + headingDataCellCodes, + cube: pxtable.data.cube, + }); + }, [ + columnWindow, + hasNoStub, + headingDataCellCodes, + pxtable.data.cube, + visibleDesktopRows, + ]); + + const renderedColumnCount = + tableMeta.columnOffset + + (columnWindow.end - columnWindow.start) + + (columnWindow.leftPadding > 0 ? 1 : 0) + + (columnWindow.rightPadding > 0 ? 1 : 0); + + const clearScrollIdleTimer = () => { + if (scrollIdleTimerRef.current !== null) { + globalThis.clearTimeout(scrollIdleTimerRef.current); + scrollIdleTimerRef.current = null; + } + }; + + const clearCaptureLockFrame = () => { + if (captureLockFrameRef.current !== null) { + cancelAnimationFrame(captureLockFrameRef.current); + captureLockFrameRef.current = null; + } + }; + + const captureCurrentHeaderRowHeights = () => { + const headingElement = headingRef.current; + if (!headingElement) { + return null; + } + + const rowElements = Array.from(headingElement.querySelectorAll('tr')); + if (rowElements.length === 0) { + return null; + } + + const rowHeights = rowElements.map((rowElement) => + Math.ceil(rowElement.getBoundingClientRect().height), + ); + + if (rowHeights.some((height) => height <= 0)) { + return null; + } + + return rowHeights; + }; + + useEffect(() => { + if (!shouldVirtualizeColumns || isScrollingHorizontally) { + return; + } + + const headingElement = headingRef.current; + if (!headingElement || typeof ResizeObserver === 'undefined') { + return; + } + + const captureStableRowLocks = () => { + const rowHeights = captureCurrentHeaderRowHeights(); + if (rowHeights) { + stableHeadingRowLocksRef.current = rowHeights; + } + }; + + captureStableRowLocks(); + + const resizeObserver = new ResizeObserver(() => { + captureStableRowLocks(); + }); + + resizeObserver.observe(headingElement); + + return () => { + resizeObserver.disconnect(); + }; + }, [headingRows, isScrollingHorizontally, shouldVirtualizeColumns]); + + useEffect(() => { + if (!shouldVirtualizeColumns) { + clearScrollIdleTimer(); + clearCaptureLockFrame(); + stableHeadingRowLocksRef.current = null; + isScrollingHorizontallyRef.current = false; + isPointerDraggingRef.current = false; + setIsScrollingHorizontally(false); + setHeadingRowLocks(null); + return; + } + + const scrollContainer = scrollContainerRef.current; + if (!scrollContainer) { + return; + } + + lastScrollLeftRef.current = scrollContainer.scrollLeft; + + const scheduleScrollIdleRelease = () => { + clearScrollIdleTimer(); + scrollIdleTimerRef.current = globalThis.setTimeout(() => { + if (isPointerDraggingRef.current) { + scheduleScrollIdleRelease(); + return; + } + + isScrollingHorizontallyRef.current = false; + setIsScrollingHorizontally(false); + setHeadingRowLocks(null); + scrollIdleTimerRef.current = null; + }, HORIZONTAL_SCROLL_IDLE_DELAY_MS); + }; + + const handlePointerDown = () => { + isPointerDraggingRef.current = true; + }; + + const handlePointerUp = () => { + isPointerDraggingRef.current = false; + if (isScrollingHorizontallyRef.current) { + scheduleScrollIdleRelease(); + } + }; + + const handleScroll = () => { + const scrollLeft = scrollContainer.scrollLeft; + if (scrollLeft === lastScrollLeftRef.current) { + return; + } + + lastScrollLeftRef.current = scrollLeft; + + if (!isScrollingHorizontallyRef.current) { + const rowHeights = captureCurrentHeaderRowHeights(); + if (rowHeights) { + isScrollingHorizontallyRef.current = true; + setHeadingRowLocks(rowHeights); + setIsScrollingHorizontally(true); + } else { + const stableRowHeights = stableHeadingRowLocksRef.current; + if (stableRowHeights) { + isScrollingHorizontallyRef.current = true; + setHeadingRowLocks(stableRowHeights.slice()); + setIsScrollingHorizontally(true); + } else { + // If layout is still settling at scroll start, retry on the next frame. + clearCaptureLockFrame(); + captureLockFrameRef.current = requestAnimationFrame(() => { + captureLockFrameRef.current = null; + const fallbackRowHeights = captureCurrentHeaderRowHeights(); + if (fallbackRowHeights) { + isScrollingHorizontallyRef.current = true; + setHeadingRowLocks(fallbackRowHeights); + setIsScrollingHorizontally(true); + } + }); + } + } + } + + if (!isPointerDraggingRef.current) { + scheduleScrollIdleRelease(); + } + }; + + scrollContainer.addEventListener('pointerdown', handlePointerDown, { + passive: true, + }); + globalThis.addEventListener('pointerup', handlePointerUp, { + passive: true, + }); + scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + scrollContainer.removeEventListener('pointerdown', handlePointerDown); + globalThis.removeEventListener('pointerup', handlePointerUp); + scrollContainer.removeEventListener('scroll', handleScroll); + clearScrollIdleTimer(); + clearCaptureLockFrame(); + setHeadingRowLocks(null); + isPointerDraggingRef.current = false; + }; + }, [scrollContainerRef, shouldVirtualizeColumns]); + + // -- STICKY HEADER -- + // useLayoutEffect(() => { + // const scrollContainer = scrollContainerRef.current; + // const headingElement = headingRef.current; + + // if (!scrollContainer || !headingElement) { + // return; + // } + + // const tableElement = scrollContainer.querySelector('table'); + // if (!tableElement) { + // return; + // } + + // const scrollSource: HTMLElement | Window = + // verticalScrollElement ?? globalThis.window; + // let frameId: number | null = null; + // let lastAppliedTranslate = Number.NaN; + + // const updateStickyPosition = () => { + // frameId = null; + + // const containerRect = scrollContainer.getBoundingClientRect(); + // const tableRect = tableElement.getBoundingClientRect(); + // const stickyOffset = parseCssPx( + // getComputedStyle(document.body).getPropertyValue( + // '--px-skip-to-main-sticky-offset', + // ), + // ); + // const headingHeight = headingElement.offsetHeight; + + // const desiredTop = Math.min( + // Math.max(containerRect.top, stickyOffset), + // tableRect.bottom - headingHeight, + // ); + // const translate = Math.max(0, desiredTop - containerRect.top); + + // if (Math.abs(lastAppliedTranslate - translate) < 0.5) { + // return; + // } + + // headingElement.style.transform = `translateY(${translate}px)`; + // lastAppliedTranslate = translate; + // }; + + // const requestUpdate = () => { + // if (frameId !== null) { + // return; + // } + + // frameId = requestAnimationFrame(updateStickyPosition); + // }; + + // updateStickyPosition(); + + // scrollSource.addEventListener('scroll', requestUpdate, { passive: true }); + // globalThis.addEventListener('resize', requestUpdate, { passive: true }); + + // const resizeObserver = + // typeof ResizeObserver === 'undefined' + // ? null + // : new ResizeObserver(() => requestUpdate()); + // resizeObserver?.observe(headingElement); + // resizeObserver?.observe(scrollContainer); + + // return () => { + // if (frameId !== null) { + // cancelAnimationFrame(frameId); + // } + + // scrollSource.removeEventListener('scroll', requestUpdate); + // globalThis.removeEventListener('resize', requestUpdate); + // resizeObserver?.disconnect(); + // headingElement.style.transform = ''; + // }; + // }, [headingRows, scrollContainerRef, verticalScrollElement]); + + const headingClassName = cl({ + // [classes.tableHeadingStickyDesktop]: true, + [classes.tableHeadingLocked]: + shouldVirtualizeColumns && headingRowLocks !== null, + [classes.tableHeadingScrolling]: + shouldVirtualizeColumns && + isScrollingHorizontally && + headingRowLocks !== null, + }); + + return ( + + ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Table/TableMobileVirtualized.spec.tsx b/packages/pxweb2-ui/src/lib/components/Table/TableMobileVirtualized.spec.tsx new file mode 100644 index 000000000..5e51bd320 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableMobileVirtualized.spec.tsx @@ -0,0 +1,138 @@ +import { render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const virtualizerState = vi.hoisted(() => ({ + rowItems: [] as Array<{ index: number; start: number; end: number }>, + rowTotalSize: 0, + windowRowItems: [] as Array<{ index: number; start: number; end: number }>, + windowRowTotalSize: 0, +})); + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: () => ({ + getVirtualItems: () => virtualizerState.rowItems, + getTotalSize: () => virtualizerState.rowTotalSize, + }), + useWindowVirtualizer: () => ({ + getVirtualItems: () => virtualizerState.windowRowItems, + getTotalSize: () => virtualizerState.windowRowTotalSize, + }), +})); + +import classes from './Table.module.scss'; +import { MobileVirtualizedTable } from './TableMobileVirtualized'; +import { pxTable } from './testData'; +import { PxTable } from '../../shared-types/pxTable'; + +function createVirtualItems(startIndex: number, count: number, size: number) { + return Array.from({ length: count }, (_, offset) => { + const index = startIndex + offset; + const start = index * size; + + return { + index, + start, + end: start + size, + }; + }); +} + +function cloneTable(table: PxTable): PxTable { + return structuredClone(table); +} + +describe('TableMobileVirtualized', () => { + beforeEach(() => { + virtualizerState.rowItems = createVirtualItems(0, 40, 44); + virtualizerState.rowTotalSize = 200 * 44; + virtualizerState.windowRowItems = []; + virtualizerState.windowRowTotalSize = 0; + }); + + it('includes second-last stub header id in mobile td headers', () => { + const { container } = render(); + const firstDataCell = container.querySelector('tbody tr td[headers]'); + + expect(firstDataCell).toBeTruthy(); + + const headerTokens = + firstDataCell?.getAttribute('headers')?.split(' ').filter(Boolean) ?? []; + + expect(headerTokens.some((token) => token.startsWith('Civilstatus_'))).toBe( + true, + ); + expect(headerTokens.some((token) => token.startsWith('Kon_'))).toBe(true); + expect(headerTokens.some((token) => token.startsWith('TIME_'))).toBe(true); + expect(headerTokens.some((token) => token.startsWith('H0.'))).toBe(true); + }); + + it('never enables desktop-style column virtualization class', () => { + const { container } = render(); + const table = container.querySelector('table'); + + expect(table).toBeTruthy(); + expect(table?.classList.contains(classes.virtualizedTable)).toBe(false); + }); + + it('renders one body row in no-stub mode', () => { + const noStubTable = cloneTable(pxTable); + noStubTable.stub = []; + + const { container } = render( + , + ); + + const bodyRows = container.querySelectorAll('tbody tr'); + + expect(bodyRows.length).toBe(1); + }); + + it('uses bootstrap row window when virtualizer initially returns no rows', () => { + virtualizerState.rowItems = []; + + const { container } = render(); + + const dataCell = container.querySelector('tbody td[headers]'); + + expect(dataCell).toBeTruthy(); + }); + + it('sets aria-label for time variable row headers', () => { + const { container } = render(); + + const timeRowHeader = container.querySelector( + 'tbody th[scope="row"][aria-label^="tid "]', + ); + + expect(timeRowHeader).toBeTruthy(); + }); + + it('renders vertical padding rows when mobile row window starts after index 0', () => { + virtualizerState.rowItems = createVirtualItems(10, 8, 44); + + const { container } = render(); + + const paddingCells = container.querySelectorAll( + 'tbody td[style*="height"]', + ); + + expect(paddingCells.length).toBeGreaterThan(0); + }); + + it('does not render row virtualization padding when row count is at or below threshold', () => { + const smallRowTable = cloneTable(pxTable); + smallRowTable.stub[0].values = smallRowTable.stub[0].values.slice(0, 1); + smallRowTable.stub[1].values = smallRowTable.stub[1].values.slice(0, 1); + smallRowTable.stub[2].values = smallRowTable.stub[2].values.slice(0, 1); + + const { container } = render( + , + ); + + const paddingCells = container.querySelectorAll( + 'tbody td[style*="height"]', + ); + + expect(paddingCells.length).toBe(0); + }); +}); diff --git a/packages/pxweb2-ui/src/lib/components/Table/TableMobileVirtualized.tsx b/packages/pxweb2-ui/src/lib/components/Table/TableMobileVirtualized.tsx new file mode 100644 index 000000000..33348ae44 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableMobileVirtualized.tsx @@ -0,0 +1,392 @@ +import { useMemo } from 'react'; +import cl from 'clsx'; + +import { + VirtualizedTableProps, + createHeading, + createHeadingDataCellCodes, + createKeyFactory, + useBodyRowVirtualizationWindow, + useVirtualizedTableBaseProps, + VirtualizedTableLayout, +} from './Table'; +import classes from './Table.module.scss'; +import { getPxTableData } from './cubeHelper'; +import { VartypeEnum } from '../../shared-types/vartypeEnum'; +import { Variable } from '../../shared-types/variable'; + +type StubCellMeta = { + varPos: number; + valCode: string; + htmlId: string; + valLabel: string; +}; + +type MobileHeaderCell = { + id: string; + scope: 'col' | 'row'; + label: string; + className: string; + colSpan?: number; + ariaLabel?: string; +}; + +type MobileRowEntry = { + key: string; + className?: string; + headerCell?: MobileHeaderCell; + isDataRow: boolean; + stubCellCodes?: StubCellMeta[]; +}; + +type ColumnWindow = { + start: number; + end: number; + leftPadding: number; + rightPadding: number; +}; + +function getAriaLabel(variable: Variable, label: string): string | undefined { + return variable.type === VartypeEnum.TIME_VARIABLE + ? `${variable.label} ${label}` + : undefined; +} + +function buildMobileRowEntries(pxtable: VirtualizedTableProps['pxtable']) { + const stubLength = pxtable.stub.length; + + if (stubLength === 0) { + return [ + { + key: 'mobile-no-stub', + className: classes.firstColNoStub, + isDataRow: true, + stubCellCodes: [], + }, + ] as MobileRowEntry[]; + } + + const rows: MobileRowEntry[] = []; + const stubDataCellCodes: StubCellMeta[] = new Array(stubLength); + const uniqueIdCounter = { idCounter: 0 }; + + const createCellId = (varId: string, valCode: string) => + `${varId}_${valCode}_I${uniqueIdCounter.idCounter}`; + + const pushRepeatedHeaders = (stubIndex: number) => { + for (let n = 0; n <= stubLength - 3; n++) { + uniqueIdCounter.idCounter++; + const currentMeta = stubDataCellCodes[n]; + const variable = pxtable.stub[n]; + if (!currentMeta || !variable) { + continue; + } + + currentMeta.htmlId = createCellId(variable.id, currentMeta.valCode); + + rows.push({ + key: `mobile-repeat-${n}-${currentMeta.htmlId}`, + className: cl( + { [classes.firstdim]: n === 0 }, + { [classes.mobileRowHeadLevel1]: n === stubLength - 3 }, + classes.mobileEmptyRowCell, + ), + headerCell: { + id: currentMeta.htmlId, + scope: 'col', + label: currentMeta.valLabel, + className: cl(classes.stub, classes[`stub-${stubIndex}`]), + colSpan: 2, + ariaLabel: getAriaLabel(variable, currentMeta.valLabel), + }, + isDataRow: false, + }); + } + }; + + const pushSecondLastHeader = ( + stubIndex: number, + variable: Variable, + valCode: string, + valLabel: string, + valueIndex: number, + ) => { + const cellId = createCellId(variable.id, valCode); + stubDataCellCodes[stubIndex].htmlId = cellId; + + rows.push({ + key: `mobile-second-last-${cellId}`, + className: cl( + { [classes.firstdim]: stubIndex === 0 }, + classes.mobileEmptyRowCell, + { [classes.mobileRowHeadLevel2]: stubLength > 2 }, + { [classes.mobileRowHeadLevel1]: stubLength === 2 }, + { [classes.mobileRowHeadFirstValueOfSecondLastStub]: valueIndex === 0 }, + ), + headerCell: { + id: cellId, + scope: 'col', + label: valLabel, + className: cl(classes.stub, classes[`stub-${stubIndex}`]), + colSpan: 2, + ariaLabel: getAriaLabel(variable, valLabel), + }, + isDataRow: false, + }); + }; + + const walk = (stubIndex: number) => { + const variable = pxtable.stub[stubIndex]; + const values = variable.values; + + for (let valueIndex = 0; valueIndex < values.length; valueIndex++) { + uniqueIdCounter.idCounter++; + const value = values[valueIndex]; + stubDataCellCodes[stubIndex] = { + varPos: pxtable.data.variableOrder.indexOf(variable.id), + valCode: value.code, + htmlId: '', + valLabel: value.label, + }; + + const isLeafLevel = stubIndex === stubLength - 1; + + if (!isLeafLevel) { + if (stubIndex === stubLength - 3) { + pushRepeatedHeaders(stubIndex); + } else if (stubIndex === stubLength - 2) { + pushSecondLastHeader( + stubIndex, + variable, + value.code, + value.label, + valueIndex, + ); + } + + walk(stubIndex + 1); + continue; + } + + const cellId = createCellId(variable.id, value.code); + stubDataCellCodes[stubIndex].htmlId = cellId; + const lastValueOfLastStub = valueIndex === values.length - 1; + + rows.push({ + key: `mobile-leaf-${cellId}`, + className: cl( + classes.mobileRowHeadLastStub, + { [classes.mobileRowHeadlastValueOfLastStub]: lastValueOfLastStub }, + { + [classes.mobileRowHeadfirstValueOfLastStub2Dim]: + valueIndex === 0 && stubLength === 2, + }, + ), + headerCell: { + id: cellId, + scope: 'row', + label: value.label, + className: cl(classes.stub, classes[`stub-${stubIndex}`]), + ariaLabel: getAriaLabel(variable, value.label), + }, + isDataRow: true, + stubCellCodes: stubDataCellCodes.map((code) => ({ ...code })), + }); + } + }; + + walk(0); + return rows; +} + +function createPaddingCell(width: number, nextKey: () => string) { + return ( + + ); +} + +function renderMobileBodyRows({ + rows, + columnWindow, + headingDataCellCodes, + cube, +}: { + rows: MobileRowEntry[]; + columnWindow: ColumnWindow; + headingDataCellCodes: StubCellMeta[][]; + cube: VirtualizedTableProps['pxtable']['data']['cube']; +}): React.JSX.Element[] { + const nextKey = createKeyFactory(); + const renderedRows: React.JSX.Element[] = []; + + const createDataCells = (stubCodes: StubCellMeta[]) => { + const dataCells: React.JSX.Element[] = []; + + for ( + let colIndex = columnWindow.start; + colIndex < columnWindow.end; + colIndex++ + ) { + const headingCodes = headingDataCellCodes[colIndex]; + const dataCodes = stubCodes.concat(headingCodes); + const headers = dataCodes.map((meta) => meta.htmlId).join(' '); + const dimensions: string[] = []; + + for (const meta of dataCodes) { + dimensions[meta.varPos] = meta.valCode; + } + + const dataValue = getPxTableData(cube, dimensions); + dataCells.push( + + {dataValue?.formattedValue} + , + ); + } + + return dataCells; + }; + + for (const row of rows) { + const cells: React.JSX.Element[] = []; + + if (row.headerCell) { + cells.push( + + {row.headerCell.label} + , + ); + } + + if (columnWindow.leftPadding > 0) { + cells.push(createPaddingCell(columnWindow.leftPadding, nextKey)); + } + + if (row.isDataRow) { + const stubCodes = row.stubCellCodes ?? []; + cells.push(...createDataCells(stubCodes)); + } + + if (columnWindow.rightPadding > 0) { + cells.push(createPaddingCell(columnWindow.rightPadding, nextKey)); + } + + renderedRows.push( + + {cells} + , + ); + } + + return renderedRows; +} + +export function MobileVirtualizedTable({ + pxtable, + getVerticalScrollElement, + className = '', +}: VirtualizedTableProps) { + const { + tableMeta, + tableColumnSize, + scrollContainerRef, + verticalScrollElement, + tableScrollMargin, + } = useVirtualizedTableBaseProps({ + pxtable, + getVerticalScrollElement, + className, + }); + + const shouldVirtualizeColumns = false; + + const columnWindow = useMemo( + () => ({ + start: 0, + end: tableColumnSize, + leftPadding: 0, + rightPadding: 0, + }), + [tableColumnSize], + ); + + const headingDataCellCodes = useMemo( + () => createHeadingDataCellCodes(pxtable, tableColumnSize), + [pxtable, tableColumnSize], + ); + + const headingRows = useMemo( + () => + createHeading( + pxtable, + tableMeta, + headingDataCellCodes, + columnWindow, + createKeyFactory(), + ), + [pxtable, tableMeta, headingDataCellCodes, columnWindow], + ); + + const mobileRowEntries = useMemo( + () => buildMobileRowEntries(pxtable), + [pxtable], + ); + + const { + shouldVirtualize, + visibleRowStart, + visibleRowEnd, + topPaddingHeight, + bottomPaddingHeight, + } = useBodyRowVirtualizationWindow({ + rowCount: mobileRowEntries.length, + isMobile: true, + tableScrollMargin, + verticalScrollElement, + scrollContainerRef, + }); + + const visibleMobileRows = shouldVirtualize + ? mobileRowEntries.slice(visibleRowStart, visibleRowEnd) + : mobileRowEntries; + + const visibleBodyRows = useMemo( + () => + renderMobileBodyRows({ + rows: visibleMobileRows, + columnWindow, + headingDataCellCodes, + cube: pxtable.data.cube, + }), + [visibleMobileRows, columnWindow, headingDataCellCodes, pxtable.data.cube], + ); + + const renderedColumnCount = tableMeta.columnOffset + tableColumnSize; + + return ( + + ); +} diff --git a/packages/pxweb2-ui/src/lib/components/Table/testData.ts b/packages/pxweb2-ui/src/lib/components/Table/testData.ts index 618fdd2b3..55829f589 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/testData.ts +++ b/packages/pxweb2-ui/src/lib/components/Table/testData.ts @@ -71,6 +71,11 @@ function getPxTable(): PxTable { aggregationAllowed: false, contents: '', descriptionDefault: false, + definitions: { + statisticsHomepage: undefined, + statisticsDefinitions: undefined, + variablesDefinitions: undefined, + }, }; const table: PxTable = { metadata: tableMeta, 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( - -