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..17f3155c6 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.module.scss @@ -5,12 +5,37 @@ border-collapse: separate; /* Ensure border-radius works */ border-spacing: 0; /* Optional: Makes sure there is no spacing between the cells */ width: 100%; + position: relative; border-radius: var(--px-border-radius-medium); border: 1px solid var(--px-color-border-default); overflow: hidden; /* Ensures content stays within rounded borders */ color: var(--px-color-text-default); + &.tableStickyDesktop { + border: 1px solid var(--px-color-border-default); + overflow: visible; + + &::after { + display: none; + } + + tbody th.stub { + position: sticky; + left: 0; + inset-inline-start: 0; + z-index: 2; + background: var(--px-color-surface-moderate); + box-shadow: + inset 1px 0 0 var(--px-color-border-default), + 1px 0 0 var(--px-color-border-default); + } + + tbody tr:hover th.stub { + background: var(--px-color-surface-subtle); + } + } + @media screen and (max-width: fixed.$breakpoints-xsmall-max-width) { line-height: fixed.$spacing-6; } @@ -46,10 +71,83 @@ thead th { font-family: PxWeb-font, sans-serif; font-weight: 700; + + @media screen and (min-width: fixed.$breakpoints-small-min-width) { + min-width: 200px; + } + } + + thead td { + background: var(--px-color-surface-moderate); + } + + .tableHeadingStickyDesktop th, + .tableHeadingStickyDesktop td { + z-index: 10; + background: var(--px-color-surface-moderate); + position: relative; + transform: translateY(var(--table-header-translate-y, 0px)); + } + + .tableHeadingStickyDesktop .cornerHeaderCell { + position: relative; + z-index: 7; + min-width: 200px; + width: 200px; + background: var(--px-color-surface-moderate); + } + + .tableHeadingStickyDesktop { + position: relative; + z-index: 9; + + &[data-sticky-pinned='true'] { + .cornerHeaderCell { + border-top: 1px solid var(--px-color-border-default); + border-bottom: 2px solid var(--px-color-border-default); + } + + tr:first-child th, + tr:first-child td { + border-top: 1px solid var(--px-color-border-default); + } + + tr:last-child th, + tr:last-child td { + border-bottom: 2px solid var(--px-color-border-default); + } + } } + + .desktopHeaderLabelWrapper { + @media screen and (min-width: fixed.$breakpoints-small-min-width) { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(1.25em * var(--desktop-header-lines, 3)); + } + } + .desktopHeaderLabel { + @media screen and (min-width: fixed.$breakpoints-small-min-width) { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--desktop-header-lines, 3); + line-clamp: var(--desktop-header-lines, 3); + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + line-height: 1.25; + word-break: break-word; + } + } + thead tr:last-child th { text-align: end; border-bottom-width: 2px; + + @media screen and (min-width: fixed.$breakpoints-small-min-width) { + text-align: center; + } } th.stub { text-align: left; @@ -255,4 +353,64 @@ background: var(--px-color-surface-moderate); border: none; } + + .cornerHeaderCell { + background: var(--px-color-surface-moderate); + border-left: none; + border-top: none; + } + + .virtualPaddingCell { + padding: 0; + border: none; + background: none; + } +} + +.virtualizedWrapper { + position: relative; + max-height: 70vh; + overflow-x: auto; + overflow-y: auto; +} + +.virtualizedWrapperUseParentScroll { + position: relative; + max-height: none; + overflow-y: visible; +} + +.cornerHeaderOverlay { + position: absolute; + left: calc(1px + var(--table-row-header-nudge-x, 0px)); + top: 0; + width: calc(var(--table-corner-width, 200px) + 1px); + height: var(--table-header-height, 0px); + transform: translate3d( + var(--table-header-translate-x, 0px), + var(--table-header-translate-y, 0px), + 0 + ); + box-sizing: border-box; + background: var(--px-color-surface-moderate); + border-top: 1px solid var(--px-color-border-default); + border-left: 1px solid var(--px-color-border-default); + border-right: 1px solid var(--px-color-border-default); + border-top-left-radius: var(--px-border-radius-medium); + pointer-events: none; + z-index: 11; +} + +.cornerHeaderOverlay[data-sticky-pinned='true'] { + transform: translate3d( + var(--table-header-translate-x, 0px), + calc(var(--table-header-translate-y, 0px) + 1px), + 0 + ); + border-bottom: 2px solid var(--px-color-border-default); +} + +.virtualizedTable { + width: max-content; + min-width: 100%; } 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..d20e7f97b 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/Table.tsx +++ b/packages/pxweb2-ui/src/lib/components/Table/Table.tsx @@ -1,799 +1,1273 @@ -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'; +/** Public props for the table component that selects desktop/mobile rendering. */ export interface TableProps { readonly pxtable: PxTable; + readonly getVerticalScrollElement?: () => HTMLElement | null; + readonly className?: string; readonly isMobile: boolean; +} + +/** Props shared by virtualized table entry points. */ +export interface VirtualizedTableProps extends Omit {} + +/** Computed values and refs needed to render virtualized table variants. */ +export interface BaseVirtualizedTableProps { + readonly pxtable: PxTable; readonly className?: string; + readonly tableMeta: columnRowMeta; + readonly tableColumnSize: number; + readonly scrollContainerRef: React.RefObject; + readonly verticalScrollElement: HTMLElement | null; + readonly tableScrollMargin: number; + readonly stickyHeaderTopPx: number; +} + +/** Inputs required by the generic virtualized table layout wrapper. */ +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 enableDesktopStickyHeader?: boolean; + readonly stickyHeaderTopPx?: number; } /** * Represents the metadata for one dimension of a data cell. */ -type DataCellMeta = { +interface DataCellMeta { varId: string; // id of variable valCode: string; // value code valLabel: string; // value label varPos: number; // variable position in stored data 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 DataCellCodes extends Array {} + +/** Horizontal column slice and matching virtual padding in pixels. */ +interface VisibleColumnsWindow { + visibleColumnStart: number; // Index of the first visible column + visibleColumnEnd: number; // Index of the last visible column + startPadding: number; // Start spacer pixel width for skipped columns + endPadding: number; // End spacer pixel width for skipped columns +} + +/** Vertical row slice and spacer heights for body virtualization. */ +interface VisibleRowsWindow { + visibleRowStart: number; // Index of the first visible row + visibleRowEnd: number; // Index of the last visible row + topPaddingHeight: number; // Top spacer height in pixels + bottomPaddingHeight: number; // Bottom spacer height in pixels +} + +/** Row window plus a flag indicating whether virtualization is active. */ +interface VisibleRowsWindowResult extends VisibleRowsWindow { + shouldVirtualize: boolean; +} + +/** Minimal row item shape used from virtualizer results. */ +interface VirtualRowItem { + index: number; // Row index in the full dataset + start: number; // Row start offset in pixels + end: number; // Row end offset in pixels +} +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; +const HEADER_LINE_CHAR_THRESHOLD = 18; // Approximate character count per header line used to determine when to wrap header text. +const DESKTOP_STICKY_HEADER_OFFSET_CSS_VAR = '--px-skip-to-main-sticky-offset'; + +/** Returns row virtualization sizing and overscan tuned for desktop/mobile. */ +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, + }; +} + +/** Combines a row window with its virtualization state flag. */ +function createVisibleRowsWindowResult( + shouldVirtualize: boolean, + window: VisibleRowsWindow, +): VisibleRowsWindowResult { + return { + shouldVirtualize, + ...window, + }; +} + +/** Builds the full non-virtualized row window covering all rows. */ +function createNonVirtualizedVisibleRowsWindow( + rowCount: number, +): VisibleRowsWindow { + return { + visibleRowStart: 0, + visibleRowEnd: rowCount, + topPaddingHeight: 0, + bottomPaddingHeight: 0, + }; +} + +/** Builds an initial estimated row window before virtual items are available. */ +function createBootstrapVisibleRowsWindow({ + rowCount, + bootstrapRowCount, + estimatedRowSize, + totalSize, +}: { + rowCount: number; + bootstrapRowCount: number; + estimatedRowSize: number; + totalSize: number; +}): VisibleRowsWindow { + // 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, + ), + }; +} + +/** Converts virtual row items into visible row bounds and padding heights. */ +function createComputedVisibleRowsWindow({ + firstVirtualRow, + lastVirtualRow, + rowCount, + tableScrollMargin, + totalSize, +}: { + firstVirtualRow: VirtualRowItem | undefined; + lastVirtualRow: VirtualRowItem | undefined; + rowCount: number; + tableScrollMargin: number; + totalSize: number; +}): VisibleRowsWindow { + 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, + }; +} + +/** Chooses bootstrap/last/computed row window from current virtualizer output. */ +function resolveVisibleRowsWindow({ + virtualRows, + lastNonEmptyWindow, + rowCount, + tableScrollMargin, + totalSize, + bootstrapRowCount, + estimatedRowSize, +}: { + virtualRows: VirtualRowItem[]; + lastNonEmptyWindow: VisibleRowsWindow | null; + rowCount: number; + tableScrollMargin: number; + totalSize: number; + bootstrapRowCount: number; + estimatedRowSize: number; +}): VisibleRowsWindow { + if (virtualRows.length === 0) { + if (lastNonEmptyWindow) { + return lastNonEmptyWindow; + } + + return createBootstrapVisibleRowsWindow({ + rowCount, + bootstrapRowCount, + estimatedRowSize, + totalSize, + }); + } + + return createComputedVisibleRowsWindow({ + firstVirtualRow: virtualRows[0], + lastVirtualRow: virtualRows.at(-1), + rowCount, + tableScrollMargin, + totalSize, + }); +} + +/** Renders the mobile or desktop table variant based on viewport mode. */ export const Table = memo(function Table({ pxtable, isMobile, + getVerticalScrollElement, className = '', }: TableProps) { - const cssClasses = className.length > 0 ? ' ' + className : ''; - - const tableMeta: columnRowMeta = calculateRowAndColumnMeta(pxtable); + if (isMobile) { + 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', + return ( + ); +}); - let contentVarIndex: number = -1; - if (contentsVariable) { - contentVarIndex = pxtable.data.variableOrder.indexOf(contentsVariable.id); - } +/** Computes shared table metadata, refs, and derived values for virtualized tables. */ +export function useVirtualizedTableBaseProps({ + pxtable, + getVerticalScrollElement, + className = '', +}: VirtualizedTableProps): BaseVirtualizedTableProps { + const { scrollContainerRef, verticalScrollElement, tableScrollMargin } = + useTableScrollContext(getVerticalScrollElement); + const stickyHeaderTopPx = useDesktopStickyHeaderTopOffset(); - 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 tableMeta: columnRowMeta = useMemo( + () => calculateRowAndColumnMeta(pxtable), + [pxtable], ); - // Create empty metadata structure for the dimensions in the header. - // This structure will be filled with metadata when the header is created. + const tableColumnSize: number = tableMeta.columns - tableMeta.columnOffset; + + return { + pxtable, + tableMeta, + tableColumnSize, + scrollContainerRef, + verticalScrollElement, + tableScrollMargin, + stickyHeaderTopPx, + className, + }; +} + +/** Resolves desktop sticky-header top offset from the global skip-to-main CSS variable. */ +function useDesktopStickyHeaderTopOffset() { + const [stickyHeaderTopPx, setStickyHeaderTopPx] = useState(0); + + useEffect(() => { + let frameId: number | null = null; + + const updateStickyHeaderTopOffset = () => { + if (typeof document === 'undefined' || !document.body) { + setStickyHeaderTopPx(0); + return; + } + + const parsedOffset = Number.parseFloat( + getComputedStyle(document.body) + .getPropertyValue(DESKTOP_STICKY_HEADER_OFFSET_CSS_VAR) + .trim(), + ); + + setStickyHeaderTopPx( + Number.isFinite(parsedOffset) ? Math.max(0, parsedOffset) : 0, + ); + }; + + const scheduleUpdateStickyHeaderTopOffset = () => { + if (frameId !== null) { + return; + } + + frameId = requestAnimationFrame(() => { + frameId = null; + updateStickyHeaderTopOffset(); + }); + }; + + updateStickyHeaderTopOffset(); + globalThis.addEventListener('resize', scheduleUpdateStickyHeaderTopOffset); + + return () => { + globalThis.removeEventListener( + 'resize', + scheduleUpdateStickyHeaderTopOffset, + ); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, []); + + return stickyHeaderTopPx; +} + +/** + * Resolves which element drives vertical scrolling and computes the table + * scroll margin used by virtualization when the table lives inside another + * scroll container. + */ +function useTableScrollContext( + getVerticalScrollElement?: () => HTMLElement | null, +) { + const scrollContainerRef = useRef(null); + const [verticalScrollElement, setVerticalScrollElement] = + useState(null); + const [tableScrollMargin, setTableScrollMargin] = useState(0); + + // Resolve the vertical scroll element + useEffect(() => { + // Use outer container scroll if it is provided, otherwise use the table container scroll + let frameId: number | null = null; + let retryFrameId: number | null = null; + + const resolveVerticalScrollElement = () => { + if (!getVerticalScrollElement) { + return null; + } + + return getVerticalScrollElement(); + }; + + const updateVerticalScrollElement = () => { + setVerticalScrollElement(resolveVerticalScrollElement()); + }; + + const scheduleUpdateVerticalScrollElement = () => { + if (frameId !== null) { + return; + } + + frameId = requestAnimationFrame(() => { + frameId = null; + updateVerticalScrollElement(); + }); + }; + + updateVerticalScrollElement(); + + // Some hosts set scroll root refs in their own effects; retry until available. + const retryUntilResolved = () => { + const resolvedElement = resolveVerticalScrollElement(); + if (resolvedElement) { + setVerticalScrollElement(resolvedElement); + return; + } + + retryFrameId = requestAnimationFrame(retryUntilResolved); + }; + + if (getVerticalScrollElement) { + retryFrameId = requestAnimationFrame(retryUntilResolved); + } + + // Keep the resolved scroll element in sync with layout/viewport changes. + globalThis.addEventListener('resize', scheduleUpdateVerticalScrollElement); + + return () => { + globalThis.removeEventListener( + 'resize', + scheduleUpdateVerticalScrollElement, + ); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + if (retryFrameId !== null) { + cancelAnimationFrame(retryFrameId); + } + }; + }, [getVerticalScrollElement]); + + // Update the table scroll margin used for virtualization when the scroll element or table geometry changes + useEffect(() => { + if (!verticalScrollElement || !scrollContainerRef.current) { + setTableScrollMargin(0); + return; + } + + let frameId: number | null = null; + + const updateTableScrollMargin = () => { + if (!scrollContainerRef.current) { + return; + } + + // Margin aligns virtualizer coordinates with the active scroll source. + 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(); + // Recalculate on viewport changes. + globalThis.addEventListener('resize', scheduleUpdateTableScrollMargin); + + const resizeObserver = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => { + scheduleUpdateTableScrollMargin(); + }); + + if (resizeObserver && scrollContainerRef.current) { + // Recalculate if table or scroll container geometry changes. + resizeObserver.observe(scrollContainerRef.current); + resizeObserver.observe(verticalScrollElement); + } + + return () => { + globalThis.removeEventListener('resize', scheduleUpdateTableScrollMargin); + resizeObserver?.disconnect(); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, [verticalScrollElement]); + + return { scrollContainerRef, verticalScrollElement, tableScrollMargin }; +} + +/** Prepares empty heading metadata slots for all rendered data columns. */ +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; +} + +/** Builds heading rows and aligned heading metadata in one call. */ +export function createHeadingRowsAndDataCellCodes({ + table, + tableMeta, + tableColumnSize, + columnWindow, + nextKey, +}: { + table: PxTable; + tableMeta: columnRowMeta; + tableColumnSize: number; + columnWindow: { + visibleColumnStart: number; + visibleColumnEnd: number; + startPadding: number; + endPadding: number; + }; + nextKey: () => string; +}): { + headingRows: React.JSX.Element[]; + headingDataCellCodes: DataCellCodes[]; +} { + const headingDataCellCodes = createHeadingDataCellCodes( + table, + tableColumnSize, + ); + const headingRows = createHeading( + table, + tableMeta, + headingDataCellCodes, + columnWindow, + nextKey, + ); + + return { headingRows, headingDataCellCodes }; +} + +/** Creates a spacer table cell used for virtual start/end padding. */ +export function createVirtualPaddingCell( + width: number, + nextKey: () => string, +): React.JSX.Element { return ( -
- {createHeading(pxtable, tableMeta, headingDataCellCodes)} - - {useMemo( - () => - createRows( - pxtable, - tableMeta, - headingDataCellCodes, - isMobile, - contentVarIndex, - contentsVariableDecimals, - ), - [ - pxtable, - tableMeta, - headingDataCellCodes, - isMobile, - contentVarIndex, - contentsVariableDecimals, - ], - )} - -
+ ); -}); +} -/** - * Creates the heading rows for the table. - * - * @param table - The PxTable object representing the table data. - * @param tableMeta - The metadata for the table columns and rows. - * @param headingDataCellCodes - Empty metadata structure for the dimensions of the header cells. - * @returns An array of React.JSX.Element representing the heading rows. - */ -export function createHeading( - table: PxTable, - tableMeta: columnRowMeta, - headingDataCellCodes: DataCellCodes[], -): React.JSX.Element[] { - // Number of times to add all values for a variable, default to 1 for first header row - let repetitionsCurrentHeaderLevel = 1; - let columnSpan = 1; - const emptyText = ''; +/** Computes the currently visible row window and spacer heights for virtualization. */ +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); - let headerRow: React.JSX.Element[] = []; - const headerRows: React.JSX.Element[] = []; + const windowRowVirtualizer = useWindowVirtualizer({ + enabled: shouldVirtualize && verticalScrollElement !== null, + count: rowCount, + scrollMargin: tableScrollMargin, + estimateSize: () => rowVirtualizationSettings.estimateSize, + overscan: rowVirtualizationSettings.overscan, + }); - // If we have any variables in the stub create a empty cell at top left corner of the table - if (table.stub.length > 0) { - headerRow.push( - - {emptyText} - , + 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 createVisibleRowsWindowResult( + shouldVirtualize, + createNonVirtualizedVisibleRowsWindow(rowCount), ); } - // Otherwise calculate columnspan start value - columnSpan = tableMeta.columns - tableMeta.columnOffset; - // loop trough all the variables in the header. idxHeadingLevel is the header variable index - for ( - let idxHeadingLevel = 0; - idxHeadingLevel < table.heading.length; - idxHeadingLevel++ - ) { - // Set the column span for the header cells for the current row - columnSpan = columnSpan / table.heading[idxHeadingLevel].values.length; - const variable = table.heading[idxHeadingLevel]; - let columnIndex = 0; - // Repeat for number of times in repetion, first time only once. idxRepetitionCurrentHeadingLevel is the repetition counter - for ( - let idxRepetitionCurrentHeadingLevel = 1; - idxRepetitionCurrentHeadingLevel <= repetitionsCurrentHeaderLevel; - idxRepetitionCurrentHeadingLevel++ - ) { - // loop trough all the values for the header variable - for (let i = 0; i < variable.values.length; i++) { - const htmlId: string = - 'H' + - idxHeadingLevel + - '.' + - variable.values[i].code + - '.I' + - idxRepetitionCurrentHeadingLevel; - 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 - headingDataCellCodes[columnIndex][idxHeadingLevel].varId = - variable.id; - headingDataCellCodes[columnIndex][idxHeadingLevel].valCode = - variable.values[i].code; - headingDataCellCodes[columnIndex][idxHeadingLevel].varPos = - table.data.variableOrder.indexOf(variable.id); - headingDataCellCodes[columnIndex][idxHeadingLevel].htmlId = htmlId; - columnIndex++; - } - } - } + const virtualRows = activeRowVirtualizer.getVirtualItems(); + const totalSize = activeRowVirtualizer.getTotalSize(); - headerRows.push({headerRow}); + const resolvedWindow = resolveVisibleRowsWindow({ + virtualRows, + lastNonEmptyWindow: lastNonEmptyWindowRef.current, + rowCount, + tableScrollMargin, + totalSize, + bootstrapRowCount: rowVirtualizationSettings.bootstrapRowCount, + estimatedRowSize: rowVirtualizationSettings.estimateSize, + }); - // Set repetiton for the next header variable - repetitionsCurrentHeaderLevel *= - table.heading[idxHeadingLevel].values.length; - headerRow = []; + if (virtualRows.length > 0) { + lastNonEmptyWindowRef.current = resolvedWindow; } - return headerRows; + return createVisibleRowsWindowResult(shouldVirtualize, resolvedWindow); } -/** - * 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, - }); +/** Renders the shared table shell with optional virtual top/bottom spacer rows. */ +export function VirtualizedTableLayout({ + pxtable, + className, + headingRows, + visibleBodyRows, + shouldVirtualize, + shouldVirtualizeColumns, + topPaddingHeight, + bottomPaddingHeight, + renderedColumnCount, + scrollContainerRef, + verticalScrollElement, + enableDesktopStickyHeader = false, + stickyHeaderTopPx = 0, +}: VirtualizedTableLayoutProps) { + const shouldUseInternalScrollContainer = + shouldVirtualizeColumns || + (shouldVirtualize && verticalScrollElement === null); + const tableRef = useRef(null); + const headerRef = useRef(null); + const cornerOverlayRef = useRef(null); + const lastHeaderTranslateYRef = useRef(0); + const lastHeaderTranslateXRef = useRef(0); + + useEffect(() => { + if (!enableDesktopStickyHeader) { + lastHeaderTranslateYRef.current = 0; + lastHeaderTranslateXRef.current = 0; + if (scrollContainerRef.current) { + scrollContainerRef.current.dataset.stickyPinned = 'false'; + } + if (cornerOverlayRef.current) { + cornerOverlayRef.current.dataset.stickyPinned = 'false'; + cornerOverlayRef.current.dataset.rowHeadersPinned = 'false'; + } + scrollContainerRef.current?.style.setProperty( + '--table-header-translate-y', + '0px', + ); + scrollContainerRef.current?.style.setProperty( + '--table-header-translate-x', + '0px', + ); + scrollContainerRef.current?.style.setProperty( + '--table-header-height', + '0px', + ); + scrollContainerRef.current?.style.setProperty( + '--table-corner-width', + '200px', + ); + scrollContainerRef.current?.style.setProperty( + '--table-row-header-nudge-x', + '0px', + ); + tableRef.current?.style.setProperty('--table-header-translate-y', '0px'); + tableRef.current?.style.setProperty('--table-header-translate-x', '0px'); + tableRef.current?.style.setProperty('--table-header-height', '0px'); + tableRef.current?.style.setProperty('--table-corner-width', '200px'); + tableRef.current?.style.setProperty('--table-row-header-nudge-x', '0px'); + if (headerRef.current) { + headerRef.current.dataset.stickyPinned = 'false'; + } + return; } - } else { - const tableRow: React.JSX.Element[] = []; - fillData( - table, - tableMeta, - stubDatacellCodes, - headingDataCellCodes, - tableRow, - ); - tableRows.push( - - {tableRow} - , - ); - } - return tableRows; -} + let frameId: number | null = null; -/** - * 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; + const updateDesktopHeaderTranslateY = () => { + if (!tableRef.current || !headerRef.current) { + return; + } - let tableRow: React.JSX.Element[] = []; + const headerHeight = headerRef.current.getBoundingClientRect().height; + const cornerHeaderCell = headerRef.current.querySelector( + '[data-corner-header="true"]', + ); + const cornerWidth = + cornerHeaderCell?.getBoundingClientRect().width ?? 200; + scrollContainerRef.current?.style.setProperty( + '--table-header-height', + `${headerHeight}px`, + ); + scrollContainerRef.current?.style.setProperty( + '--table-corner-width', + `${cornerWidth}px`, + ); + tableRef.current.style.setProperty( + '--table-header-height', + `${headerHeight}px`, + ); + tableRef.current.style.setProperty( + '--table-corner-width', + `${cornerWidth}px`, + ); + const tableHeight = tableRef.current.offsetHeight; + const maxTranslate = Math.max(0, tableHeight - headerHeight); + const isInternalScrollMode = verticalScrollElement === null; - const variable = table.stub[stubIndex]; + let nextTranslateY = 0; - // Loop through all the values in the stub variable - for (const val of table.stub[stubIndex].values) { - if (stubIndex === 0) { - stubIteration++; - } + if (isInternalScrollMode) { + nextTranslateY = Math.min( + maxTranslate, + Math.max(0, scrollContainerRef.current?.scrollTop ?? 0), + ); + } else { + const tableRect = tableRef.current.getBoundingClientRect(); + nextTranslateY = Math.min( + maxTranslate, + Math.max(0, stickyHeaderTopPx - tableRect.top), + ); + } - 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; - } + const devicePixelRatio = globalThis.devicePixelRatio || 1; + const roundedTranslateY = + Math.round(nextTranslateY * devicePixelRatio) / devicePixelRatio; + const roundedTranslateX = + Math.round( + (scrollContainerRef.current?.scrollLeft ?? 0) * devicePixelRatio, + ) / devicePixelRatio; - tableRow.push( - 0; + headerRef.current.dataset.stickyPinned = isStickyPinned + ? 'true' + : 'false'; + if (scrollContainerRef.current) { + scrollContainerRef.current.dataset.stickyPinned = isStickyPinned + ? 'true' + : 'false'; } - className={cl(classes.stub, classes[`stub-${stubIndex}`])} - key={getNewKey()} - > - {val.label} - , - ); + if (cornerOverlayRef.current) { + cornerOverlayRef.current.dataset.stickyPinned = isStickyPinned + ? 'true' + : 'false'; + cornerOverlayRef.current.dataset.rowHeadersPinned = + roundedTranslateX > 0 ? 'true' : 'false'; + } + return; + } - // 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} - , + lastHeaderTranslateYRef.current = roundedTranslateY; + lastHeaderTranslateXRef.current = roundedTranslateX; + tableRef.current.style.setProperty( + '--table-header-translate-y', + `${roundedTranslateY}px`, ); - tableRow = []; - - // Create a new row for the next stub - createRowDesktop({ - stubIndex: stubIndex + 1, - rowSpan, - stubIteration, - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRows, - contentVarIndex, - contentsVariableDecimals, + scrollContainerRef.current?.style.setProperty( + '--table-header-translate-y', + `${roundedTranslateY}px`, + ); + tableRef.current.style.setProperty( + '--table-header-translate-x', + `${roundedTranslateX}px`, + ); + scrollContainerRef.current?.style.setProperty( + '--table-header-translate-x', + `${roundedTranslateX}px`, + ); + const rowHeaderNudgeX = roundedTranslateX > 0 ? '-1px' : '0px'; + tableRef.current.style.setProperty( + '--table-row-header-nudge-x', + rowHeaderNudgeX, + ); + scrollContainerRef.current?.style.setProperty( + '--table-row-header-nudge-x', + rowHeaderNudgeX, + ); + const isStickyPinned = roundedTranslateY > 0; + headerRef.current.dataset.stickyPinned = isStickyPinned + ? 'true' + : 'false'; + if (scrollContainerRef.current) { + scrollContainerRef.current.dataset.stickyPinned = isStickyPinned + ? 'true' + : 'false'; + } + if (cornerOverlayRef.current) { + cornerOverlayRef.current.dataset.stickyPinned = isStickyPinned + ? 'true' + : 'false'; + cornerOverlayRef.current.dataset.rowHeadersPinned = + roundedTranslateX > 0 ? 'true' : 'false'; + } + }; + + const scheduleDesktopHeaderTranslateYUpdate = () => { + if (frameId !== null) { + return; + } + + frameId = requestAnimationFrame(() => { + frameId = null; + updateDesktopHeaderTranslateY(); }); - stubDataCellCodes.pop(); - } else { - // If no more stubs need to add headers then fill the row with data - fillData( - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRow, + }; + + updateDesktopHeaderTranslateY(); + + const activeScrollTarget = + verticalScrollElement ?? scrollContainerRef.current; + const horizontalScrollTarget = scrollContainerRef.current; + + activeScrollTarget?.addEventListener( + 'scroll', + scheduleDesktopHeaderTranslateYUpdate, + { passive: true }, + ); + horizontalScrollTarget?.addEventListener( + 'scroll', + scheduleDesktopHeaderTranslateYUpdate, + { passive: true }, + ); + document.addEventListener('scroll', scheduleDesktopHeaderTranslateYUpdate, { + passive: true, + capture: true, + }); + + globalThis.addEventListener( + 'resize', + scheduleDesktopHeaderTranslateYUpdate, + ); + + return () => { + activeScrollTarget?.removeEventListener( + 'scroll', + scheduleDesktopHeaderTranslateYUpdate, ); - tableRows.push({tableRow}); - tableRow = []; - stubDataCellCodes.pop(); - } + horizontalScrollTarget?.removeEventListener( + 'scroll', + scheduleDesktopHeaderTranslateYUpdate, + ); + document.removeEventListener( + 'scroll', + scheduleDesktopHeaderTranslateYUpdate, + true, + ); + globalThis.removeEventListener( + 'resize', + scheduleDesktopHeaderTranslateYUpdate, + ); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, [ + enableDesktopStickyHeader, + scrollContainerRef, + stickyHeaderTopPx, + verticalScrollElement, + ]); + + return ( +
+ {enableDesktopStickyHeader && pxtable.stub.length > 0 && ( + + ); +} + +function writeHeadingCellMetadata({ + headingDataCellCodes, + headingLevel, + startColumnIndex, + columnSpan, + variableId, + valueCode, + variablePosition, + htmlId, +}: { + headingDataCellCodes: DataCellCodes[]; + headingLevel: number; + startColumnIndex: number; + columnSpan: number; + variableId: string; + valueCode: string; + variablePosition: number; + htmlId: string; +}): number { + let columnIndex = startColumnIndex; + + for (let spanOffset = 0; spanOffset < columnSpan; spanOffset++) { + headingDataCellCodes[columnIndex][headingLevel].varId = variableId; + headingDataCellCodes[columnIndex][headingLevel].valCode = valueCode; + headingDataCellCodes[columnIndex][headingLevel].varPos = variablePosition; + headingDataCellCodes[columnIndex][headingLevel].htmlId = htmlId; + columnIndex++; } - return tableRows; + return columnIndex; } -/** - * 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, +function createVisibleHeadingCell({ + variable, + headingLines, + valueIndex, + repetitionIndex, + spanStart, + spanEnd, + columnWindow, + hasStub, + htmlId, + nextKey, +}: { + variable: PxTable['heading'][number]; + headingLines: number; + valueIndex: number; + repetitionIndex: number; + spanStart: number; + spanEnd: number; + columnWindow: VisibleColumnsWindow; + hasStub: boolean; + htmlId: string; + nextKey: () => string; +}): React.JSX.Element | null { + const visibleSpanStart = Math.max(spanStart, columnWindow.visibleColumnStart); + const visibleSpanEnd = Math.min(spanEnd, columnWindow.visibleColumnEnd); + const visibleSpan = visibleSpanEnd - visibleSpanStart; + + if (visibleSpan <= 0) { + return null; + } + + return ( + + + + {variable.values[valueIndex].label} + + + + ); +} + +function createHeadingRowForLevel({ table, - tableMeta, - stubDataCellCodes, + headingLevel, + headingLines, + repetitionsCurrentHeaderLevel, + columnSpan, + columnWindow, 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; - } + nextKey, +}: { + table: PxTable; + headingLevel: number; + headingLines: number; + repetitionsCurrentHeaderLevel: number; + columnSpan: number; + columnWindow: VisibleColumnsWindow; + headingDataCellCodes: DataCellCodes[]; + nextKey: () => string; +}): React.JSX.Element[] { + const headerRow: React.JSX.Element[] = []; + const variable = table.heading[headingLevel]; + const variablePosition = table.data.variableOrder.indexOf(variable.id); + let columnIndex = 0; + + if (columnWindow.startPadding > 0) { + headerRow.push( + createVirtualPaddingCell(columnWindow.startPadding, nextKey), + ); + } + + for ( + let repetitionIndex = 1; + repetitionIndex <= repetitionsCurrentHeaderLevel; + repetitionIndex++ + ) { + for ( + let valueIndex = 0; + valueIndex < variable.values.length; + valueIndex++ + ) { + const value = variable.values[valueIndex]; + const spanStart = columnIndex; + const spanEnd = columnIndex + columnSpan; + const htmlId = `H${headingLevel}.${value.code}.I${repetitionIndex}`; + + const headingCell = createVisibleHeadingCell({ + variable, + headingLines, + valueIndex, + repetitionIndex, + spanStart, + spanEnd, + columnWindow, + hasStub: table.stub.length > 0, + htmlId, + nextKey, + }); + + if (headingCell) { + headerRow.push(headingCell); } - // Create a new row for the next stub - createRowMobile({ - stubIndex: stubIndex + 1, - rowSpan, - table, - tableMeta, - stubDataCellCodes, + + columnIndex = writeHeadingCellMetadata({ headingDataCellCodes, - tableRows, - uniqueIdCounter, - contentVarIndex, - contentsVariableDecimals, + headingLevel, + startColumnIndex: columnIndex, + columnSpan, + variableId: variable.id, + valueCode: value.code, + variablePosition, + htmlId, }); - stubDataCellCodes.pop(); - } else { - // last level - let tempid = - cellMeta.varId + - '_' + - cellMeta.valCode + - '_I' + - uniqueIdCounter.idCounter; - cellMeta.htmlId = tempid; - tableRow.push( - - {val.label} - , - ); - fillData( - table, - tableMeta, - stubDataCellCodes, - headingDataCellCodes, - tableRow, - ); - tableRows.push( - - {tableRow} - , - ); - tableRow = []; - stubDataCellCodes.pop(); } } - return tableRows; -} + if (columnWindow.endPadding > 0) { + headerRow.push(createVirtualPaddingCell(columnWindow.endPadding, nextKey)); + } -/** - * 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 = ''; + return headerRow; +} - // Loop through cells that need to be added to the row - const maxCols = tableMeta.columns - tableMeta.columnOffset; +type HeadingLevelLayout = { + headingLevel: number; + headingLines: number; + columnSpan: number; + repetitionsCurrentHeaderLevel: number; +}; - // Loop through all data columns in the table - for (let i = 0; i < maxCols; i++) { - tableRow.push({emptyText}); - } +function calculateHeadingLevelLines( + totalHeadingLevels: number, + headingLevel: number, + longestValueTextLength: number, + totalColumnSpan: number, +): number { + const weightedLength = + totalColumnSpan > 4 + ? longestValueTextLength / (totalHeadingLevels - headingLevel) + : longestValueTextLength; + const lineChars = + totalColumnSpan > 4 + ? HEADER_LINE_CHAR_THRESHOLD + : HEADER_LINE_CHAR_THRESHOLD * 2; + return Math.max(1, Math.ceil(weightedLength / lineChars)); } -/* - * 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( +function createHeadingLevelLayouts( 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; - } +): HeadingLevelLayout[] { + const layouts: HeadingLevelLayout[] = []; + let repetitionsCurrentHeaderLevel = 1; + let columnSpan = tableMeta.columns - tableMeta.columnOffset; - // Example of how to get data from the cube (men in Stockholm in 1970): - // const dataValue = getPxTableData(table.data, [ - // '0180', - // 'men', - // '1970', - // ]); + for ( + let headingLevel = 0; + headingLevel < table.heading.length; + headingLevel++ + ) { + const valueCount = table.heading[headingLevel].values.length; + const longestValueTextLength = + tableMeta.longestValueTextByVariableId[table.heading[headingLevel].id] || + 1; + const headingLines = calculateHeadingLevelLines( + table.heading.length, + headingLevel, + longestValueTextLength, + columnSpan, + ); + columnSpan /= valueCount; - const dataValue = getPxTableData(table.data.cube, dimensions); + layouts.push({ + headingLevel, + headingLines, + columnSpan, + repetitionsCurrentHeaderLevel, + }); - tableRow.push( - - {dataValue?.formattedValue} - , - ); + repetitionsCurrentHeaderLevel *= valueCount; } + + return layouts; } + /** - * Creates repeated mobile headers for a table and appends them to the provided table rows. + * Creates the heading rows for the table. * - * @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. + * @param table - The PxTable object representing the table data. + * @param tableMeta - The metadata for the table columns and rows. + * @param headingDataCellCodes - Empty metadata structure for the dimensions of the header cells. + * @returns An array of React.JSX.Element representing the heading rows. */ -function createRepeatedMobileHeader( +export function createHeading( 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} - , - ); - tableRowRepeatHeader = []; - } -} + tableMeta: columnRowMeta, + headingDataCellCodes: DataCellCodes[], + columnWindow: VisibleColumnsWindow, + nextKey: () => string, +): React.JSX.Element[] { + const headerRows: React.JSX.Element[] = []; + const hasStub = table.stub.length > 0; + const headingLevelLayouts = createHeadingLevelLayouts(table, tableMeta); -/** - * 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( + const createCornerHeaderCell = () => ( - {val.label} - , + {''} + ); - tableRows.push( - 2, - }, - { - [classes.mobileRowHeadLevel1]: stubLength === 2, - }, + for (const headingLevelLayout of headingLevelLayouts) { + const headerRow = createHeadingRowForLevel({ + table, + headingLevel: headingLevelLayout.headingLevel, + headingLines: headingLevelLayout.headingLines, + repetitionsCurrentHeaderLevel: + headingLevelLayout.repetitionsCurrentHeaderLevel, + columnSpan: headingLevelLayout.columnSpan, + columnWindow, + headingDataCellCodes, + nextKey, + }); - { - [classes.mobileRowHeadFirstValueOfSecondLastStub]: i === 0, - }, - )} - key={getNewKey()} - > - {tableRowSecondLastHeader} - , - ); + const rowCells = + hasStub && headingLevelLayout.headingLevel === 0 + ? [createCornerHeaderCell(), ...headerRow] + : headerRow; + + headerRows.push({rowCells}); + } + + return headerRows; } -let number = 0; +/** Creates a monotonic key generator for deterministic render keys. */ +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/TableCellData.ts b/packages/pxweb2-ui/src/lib/components/Table/TableCellData.ts new file mode 100644 index 000000000..29ccbcd0f --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableCellData.ts @@ -0,0 +1,26 @@ +import { PxTable } from '../../shared-types/pxTable'; +import { getPxTableData } from './cubeHelper'; + +export type CellLookupMeta = { + varPos: number; + valCode: string; + htmlId: string; +}; + +/** Builds headers and resolves the formatted cube value for one data cell path. */ +export function resolveDataCell( + dataCodes: CellLookupMeta[], + cube: PxTable['data']['cube'], +): { headers: string; formattedValue: string | undefined } { + const headers = dataCodes.map((meta) => meta.htmlId).join(' '); + const dimensions: string[] = []; + + for (const meta of dataCodes) { + dimensions[meta.varPos] = meta.valCode; + } + + return { + headers, + formattedValue: getPxTableData(cube, dimensions)?.formattedValue, + }; +} 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..ddffaf482 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableDesktopVirtualized.spec.tsx @@ -0,0 +1,224 @@ +import { render, waitFor } from '@testing-library/react'; +import { 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, + calls: [] as Array<{ + horizontal?: boolean; + count?: number; + getScrollElement?: () => HTMLElement | null; + }>, + windowCalls: [] as Array<{ + count?: number; + scrollMargin?: number; + }>, +})); + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: (options?: { + horizontal?: boolean; + count?: number; + getScrollElement?: () => HTMLElement | null; + }) => { + virtualizerState.calls.push(options ?? {}); + + if (options?.horizontal) { + return { + getVirtualItems: () => virtualizerState.columnItems, + getTotalSize: () => virtualizerState.columnTotalSize, + }; + } + + return { + getVirtualItems: () => virtualizerState.rowItems, + getTotalSize: () => virtualizerState.rowTotalSize, + }; + }, + useWindowVirtualizer: (options?: { + count?: number; + scrollMargin?: number; + }) => { + virtualizerState.windowCalls.push(options ?? {}); + + return { + 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); +} + +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; + virtualizerState.calls = []; + virtualizerState.windowCalls = []; + }); + + 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); + }); + + 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('applies desktop sticky heading class on thead', () => { + const { container } = render(); + + const thead = container.querySelector('thead'); + + expect(thead?.classList.contains(classes.tableHeadingStickyDesktop)).toBe( + true, + ); + }); + + it('uses window row virtualizer when external scroll element is provided', async () => { + const externalScrollElement = document.createElement('div'); + + render( + externalScrollElement} + />, + ); + + await waitFor(() => { + const hasWindowRowVirtualizerCall = virtualizerState.windowCalls.some( + (call) => typeof call.count === 'number' && call.count > 0, + ); + + expect(hasWindowRowVirtualizerCall).toBe(true); + }); + }); + + it('uses internal row virtualizer when external scroll element is not provided', async () => { + render(); + + await waitFor(() => { + const usesInternalScrollElement = virtualizerState.calls.some( + (call) => + !call.horizontal && + typeof call.getScrollElement === 'function' && + call.getScrollElement() !== null, + ); + + expect(usesInternalScrollElement).toBe(true); + }); + }); +}); 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..109011b9e --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableDesktopVirtualized.tsx @@ -0,0 +1,419 @@ +import { useMemo, useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import cl from 'clsx'; + +import { + createHeadingRowsAndDataCellCodes, + createVirtualPaddingCell, + createKeyFactory, + DESKTOP_COLUMN_VIRTUALIZATION_THRESHOLD, + useBodyRowVirtualizationWindow, + useVirtualizedTableBaseProps, + VirtualizedTableLayout, + VirtualizedTableProps, +} from './Table'; +import classes from './Table.module.scss'; +import { resolveDataCell } from './TableCellData'; +import { walkStubTree } from './TableStubTraversal'; +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 = { + visibleColumnStart: number; + visibleColumnEnd: number; + startPadding: number; + endPadding: number; +}; + +type VirtualColumnItem = { + index: number; + start: number; + end: number; +}; + +const DESKTOP_COLUMN_ESTIMATE_SIZE = 88; +const DESKTOP_COLUMN_OVERSCAN = 8; +const DESKTOP_BOOTSTRAP_COLUMN_COUNT = 12; +const EMPTY_VIRTUAL_COLUMNS: never[] = []; + +/** Chooses bootstrap/last/computed desktop column window from virtualizer output. */ +function resolveVisibleColumnsWindow({ + shouldVirtualizeColumns, + virtualColumns, + lastNonEmptyColumnWindow, + tableColumnSize, + totalSize, + bootstrapColumnWindow, +}: { + shouldVirtualizeColumns: boolean; + virtualColumns: VirtualColumnItem[]; + lastNonEmptyColumnWindow: ColumnWindow | null; + tableColumnSize: number; + totalSize: number; + bootstrapColumnWindow: ColumnWindow; +}): ColumnWindow { + if (!shouldVirtualizeColumns) { + return { + visibleColumnStart: 0, + visibleColumnEnd: tableColumnSize, + startPadding: 0, + endPadding: 0, + }; + } + + if (virtualColumns.length === 0) { + return lastNonEmptyColumnWindow ?? bootstrapColumnWindow; + } + + const firstVirtualColumn = virtualColumns[0]; + const lastVirtualColumn = virtualColumns.at(-1); + + return { + visibleColumnStart: firstVirtualColumn?.index ?? 0, + visibleColumnEnd: lastVirtualColumn + ? lastVirtualColumn.index + 1 + : tableColumnSize, + startPadding: firstVirtualColumn?.start ?? 0, + endPadding: lastVirtualColumn ? totalSize - lastVirtualColumn.end : 0, + }; +} + +/** Renders a single body row when the table has no stub dimensions. */ +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.startPadding > 0) { + rowCells.push(createVirtualPaddingCell(columnWindow.startPadding, nextKey)); + } + + for ( + let colIndex = columnWindow.visibleColumnStart; + colIndex < columnWindow.visibleColumnEnd; + colIndex++ + ) { + const dataValue = resolveDataCell(headingDataCellCodes[colIndex], cube); + rowCells.push( + + {dataValue.formattedValue} + , + ); + } + + if (columnWindow.endPadding > 0) { + rowCells.push(createVirtualPaddingCell(columnWindow.endPadding, nextKey)); + } + + return [ + + {rowCells} + , + ]; +} + +/** Renders desktop body rows for the current visible row and column windows. */ +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.startPadding > 0) { + rowCells.push( + createVirtualPaddingCell(columnWindow.startPadding, nextKey), + ); + } + + for ( + let colIndex = columnWindow.visibleColumnStart; + colIndex < columnWindow.visibleColumnEnd; + colIndex++ + ) { + if (!rowEntry.isDataRow) { + rowCells.push(); + continue; + } + + const dataMeta = resolveDataCell( + rowEntry.stubCellCodes.concat(headingDataCellCodes[colIndex]), + cube, + ); + + rowCells.push( + + {dataMeta.formattedValue} + , + ); + } + + if (columnWindow.endPadding > 0) { + rowCells.push(createVirtualPaddingCell(columnWindow.endPadding, nextKey)); + } + + renderedRows.push( + + {rowCells} + , + ); + } + + return renderedRows; +} + +/** Expands stub dimensions into flat desktop row entries with data-row markers. */ +function buildDesktopRowEntries(pxtable: VirtualizedTableProps['pxtable']) { + if (pxtable.stub.length === 0) { + return [] as DesktopRowEntry[]; + } + + const rowEntries: DesktopRowEntry[] = []; + let stubIteration = 0; + + walkStubTree({ + pxtable, + createPathItem: ({ level, variable, value }) => { + if (level === 0) { + stubIteration++; + } + + return { + varPos: pxtable.data.variableOrder.indexOf(variable.id), + valCode: value.code, + htmlId: `R.${level}${value.code}.I${stubIteration}`, + }; + }, + onVisit: ({ level, variable, value, isLeaf, path }) => { + rowEntries.push({ + key: `${level}:${value.code}:${stubIteration}:${rowEntries.length}`, + level, + label: value.label, + isDataRow: isLeaf, + isFirstDimGroupRow: level === 0 && pxtable.stub.length > 1, + variable, + stubCellCodes: path, + }); + }, + }); + + return rowEntries; +} + +/** Renders the desktop table using row and column virtualization windows. */ +export function DesktopVirtualizedTable({ + pxtable, + getVerticalScrollElement, + className = '', +}: VirtualizedTableProps) { + const { + tableMeta, + tableColumnSize, + scrollContainerRef, + verticalScrollElement, + tableScrollMargin, + stickyHeaderTopPx, + } = useVirtualizedTableBaseProps({ + pxtable, + getVerticalScrollElement, + className, + }); + + const shouldVirtualizeColumns = + tableColumnSize > DESKTOP_COLUMN_VIRTUALIZATION_THRESHOLD; + const columnVirtualizer = useVirtualizer({ + horizontal: true, + enabled: shouldVirtualizeColumns, + count: tableColumnSize, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => DESKTOP_COLUMN_ESTIMATE_SIZE, + overscan: DESKTOP_COLUMN_OVERSCAN, + }); + + const virtualColumns = shouldVirtualizeColumns + ? columnVirtualizer.getVirtualItems() + : EMPTY_VIRTUAL_COLUMNS; + + const lastNonEmptyColumnWindowRef = useRef<{ + visibleColumnStart: number; + visibleColumnEnd: number; + startPadding: number; + endPadding: number; + } | null>(null); + const bootstrapColumnEnd = Math.min( + tableColumnSize, + DESKTOP_BOOTSTRAP_COLUMN_COUNT, + ); + const bootstrapColumnWindow = useMemo( + () => ({ + visibleColumnStart: 0, + visibleColumnEnd: bootstrapColumnEnd, + startPadding: 0, + endPadding: Math.max( + 0, + columnVirtualizer.getTotalSize() - + bootstrapColumnEnd * DESKTOP_COLUMN_ESTIMATE_SIZE, + ), + }), + [bootstrapColumnEnd, columnVirtualizer], + ); + + const columnWindow = useMemo(() => { + const resolvedWindow = resolveVisibleColumnsWindow({ + shouldVirtualizeColumns, + virtualColumns, + lastNonEmptyColumnWindow: lastNonEmptyColumnWindowRef.current, + tableColumnSize, + totalSize: columnVirtualizer.getTotalSize(), + bootstrapColumnWindow, + }); + + if (virtualColumns.length > 0) { + lastNonEmptyColumnWindowRef.current = resolvedWindow; + } + + return resolvedWindow; + }, [ + shouldVirtualizeColumns, + virtualColumns, + tableColumnSize, + bootstrapColumnWindow, + columnVirtualizer, + ]); + + const { headingRows, headingDataCellCodes } = useMemo( + () => + createHeadingRowsAndDataCellCodes({ + table: pxtable, + tableMeta, + tableColumnSize, + columnWindow, + nextKey: createKeyFactory(), + }), + [pxtable, tableMeta, tableColumnSize, columnWindow], + ); + + 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.visibleColumnEnd - columnWindow.visibleColumnStart) + + (columnWindow.startPadding > 0 ? 1 : 0) + + (columnWindow.endPadding > 0 ? 1 : 0); + + 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..79d99de7d --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableMobileVirtualized.tsx @@ -0,0 +1,375 @@ +import { useMemo } from 'react'; +import cl from 'clsx'; + +import { + createHeadingRowsAndDataCellCodes, + createVirtualPaddingCell, + createKeyFactory, + useBodyRowVirtualizationWindow, + useVirtualizedTableBaseProps, + VirtualizedTableLayout, + VirtualizedTableProps, +} from './Table'; +import classes from './Table.module.scss'; +import { resolveDataCell } from './TableCellData'; +import { walkStubTree } from './TableStubTraversal'; +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 = { + visibleColumnStart: number; + visibleColumnEnd: number; + startPadding: number; + endPadding: number; +}; + +/** Builds an accessibility label for time variables and leaves others undefined. */ +function getAriaLabel(variable: Variable, label: string): string | undefined { + return variable.type === VartypeEnum.TIME_VARIABLE + ? `${variable.label} ${label}` + : undefined; +} + +/** Flattens stub dimensions into mobile row entries with header/data row metadata. */ +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, + }); + }; + + walkStubTree({ + pxtable, + createPathItem: ({ variable, value }) => { + uniqueIdCounter.idCounter++; + + return { + varPos: pxtable.data.variableOrder.indexOf(variable.id), + valCode: value.code, + htmlId: '', + valLabel: value.label, + }; + }, + onVisit: ({ level, variable, value, valueIndex, isLeaf, path }) => { + for (let idx = 0; idx <= level; idx++) { + stubDataCellCodes[idx] = path[idx]; + } + + if (!isLeaf) { + if (level === stubLength - 3) { + pushRepeatedHeaders(level); + } else if (level === stubLength - 2) { + pushSecondLastHeader( + level, + variable, + value.code, + value.label, + valueIndex, + ); + } + + return; + } + + const cellId = createCellId(variable.id, value.code); + stubDataCellCodes[level].htmlId = cellId; + const lastValueOfLastStub = valueIndex === variable.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-${level}`]), + ariaLabel: getAriaLabel(variable, value.label), + }, + isDataRow: true, + stubCellCodes: stubDataCellCodes.map((code) => ({ ...code })), + }); + }, + }); + + return rows; +} + +/** Renders mobile body rows for the current row and column render windows. */ +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.visibleColumnStart; + colIndex < columnWindow.visibleColumnEnd; + colIndex++ + ) { + const dataValue = resolveDataCell( + stubCodes.concat(headingDataCellCodes[colIndex]), + cube, + ); + 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.startPadding > 0) { + cells.push(createVirtualPaddingCell(columnWindow.startPadding, nextKey)); + } + + if (row.isDataRow) { + const stubCodes = row.stubCellCodes ?? []; + cells.push(...createDataCells(stubCodes)); + } + + if (columnWindow.endPadding > 0) { + cells.push(createVirtualPaddingCell(columnWindow.endPadding, nextKey)); + } + + renderedRows.push( + + {cells} + , + ); + } + + return renderedRows; +} + +/** Renders the mobile table variant with row virtualization enabled. */ +export function MobileVirtualizedTable({ + pxtable, + getVerticalScrollElement, + className = '', +}: VirtualizedTableProps) { + const { + tableMeta, + tableColumnSize, + scrollContainerRef, + verticalScrollElement, + tableScrollMargin, + } = useVirtualizedTableBaseProps({ + pxtable, + getVerticalScrollElement, + className, + }); + + const shouldVirtualizeColumns = false; + + const columnWindow = useMemo( + () => ({ + visibleColumnStart: 0, + visibleColumnEnd: tableColumnSize, + startPadding: 0, + endPadding: 0, + }), + [tableColumnSize], + ); + + const { headingRows, headingDataCellCodes } = useMemo( + () => + createHeadingRowsAndDataCellCodes({ + table: pxtable, + tableMeta, + tableColumnSize, + columnWindow, + nextKey: createKeyFactory(), + }), + [pxtable, tableMeta, tableColumnSize, 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/TableStubTraversal.ts b/packages/pxweb2-ui/src/lib/components/Table/TableStubTraversal.ts new file mode 100644 index 000000000..68f259304 --- /dev/null +++ b/packages/pxweb2-ui/src/lib/components/Table/TableStubTraversal.ts @@ -0,0 +1,73 @@ +import { PxTable } from '../../shared-types/pxTable'; +import { Variable } from '../../shared-types/variable'; + +export type StubValue = Variable['values'][number]; + +export type StubVisitArgs = { + level: number; + variable: Variable; + value: StubValue; + valueIndex: number; + isLeaf: boolean; + path: TPathItem[]; +}; + +/** Walks stub dimensions depth-first and emits a typed path for each visited node. */ +export function walkStubTree({ + pxtable, + createPathItem, + onVisit, +}: { + pxtable: PxTable; + createPathItem: (args: { + level: number; + variable: Variable; + value: StubValue; + valueIndex: number; + path: TPathItem[]; + }) => TPathItem; + onVisit: (args: StubVisitArgs) => void; +}): void { + if (pxtable.stub.length === 0) { + return; + } + + const lastLevel = pxtable.stub.length - 1; + + const walk = (level: number, path: TPathItem[]) => { + const variable = pxtable.stub[level]; + + for ( + let valueIndex = 0; + valueIndex < variable.values.length; + valueIndex++ + ) { + const value = variable.values[valueIndex]; + const nextPath = path.slice(0, level); + nextPath[level] = createPathItem({ + level, + variable, + value, + valueIndex, + path, + }); + + const isLeaf = level === lastLevel; + + onVisit({ + level, + variable, + value, + valueIndex, + isLeaf, + path: nextPath, + }); + + if (!isLeaf) { + walk(level + 1, nextPath); + } + } + }; + + walk(0, []); +} diff --git a/packages/pxweb2-ui/src/lib/components/Table/columnRowMeta.ts b/packages/pxweb2-ui/src/lib/components/Table/columnRowMeta.ts index 7367c7da1..9ee8d537d 100644 --- a/packages/pxweb2-ui/src/lib/components/Table/columnRowMeta.ts +++ b/packages/pxweb2-ui/src/lib/components/Table/columnRowMeta.ts @@ -20,6 +20,10 @@ export type columnRowMeta = { * The number of rows that contain headers */ rowOffset: number; + /** + * The longest value label for each variable, keyed by variable id. + */ + longestValueTextByVariableId: Record; }; /** @@ -29,6 +33,7 @@ export type columnRowMeta = { * - The number of rows in the table * - The number of columns that contain headers * - The number of rows that contain headers + * - The longest value label for each variable, keyed by variable id * * @param pxtable The PxTable object for which to calculate the metadata. * @returns The calculated columnRowMeta object containing the table metadata. @@ -38,6 +43,7 @@ export function calculateRowAndColumnMeta(pxtable: PxTable): columnRowMeta { let rowCount = 1; const columnOffset = 1; const rowOffset = pxtable.heading.length; + const longestValueTextByVariableId: Record = {}; for (let i = 0; i < pxtable.heading.length; i++) { columnCount *= pxtable.heading[i].values.length; @@ -47,6 +53,21 @@ export function calculateRowAndColumnMeta(pxtable: PxTable): columnRowMeta { rowCount *= pxtable.stub[i].values.length; } + const allVariables = [...pxtable.heading, ...pxtable.stub]; + for (const variable of allVariables) { + let longestValueText = ''; + + for (const value of variable.values) { + const valueLabel = value.label; + + if (valueLabel.length > longestValueText.length) { + longestValueText = valueLabel; + } + } + + longestValueTextByVariableId[variable.id] = longestValueText.length; + } + rowCount += pxtable.heading.length; columnCount += columnOffset; @@ -55,5 +76,6 @@ export function calculateRowAndColumnMeta(pxtable: PxTable): columnRowMeta { columns: columnCount, columnOffset: columnOffset, rowOffset: rowOffset, + longestValueTextByVariableId: longestValueTextByVariableId, }; } 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( - -
- , - ); - expect( - screen.getByRole('button', { name: /common.footer.top_button_text/i }), - ).toBeInTheDocument(); + expect(scrollSpy).toHaveBeenCalledWith(0, 0); + rafSpy.mockRestore(); + scrollSpy.mockRestore(); }); - it('shows Top button when enableWindowScroll is true (no containerRef)', () => { + it('shows Top button when enableWindowScroll is true', () => { (useLocaleContent as Mock).mockReturnValue(footerContent); render( @@ -145,7 +134,7 @@ describe('Footer', () => { ).toBeInTheDocument(); }); - it('hides Top button when neither containerRef nor enableWindowScroll', () => { + it('hides Top button when enableWindowScroll is false', () => { (useLocaleContent as Mock).mockReturnValue(footerContent); render( @@ -161,8 +150,20 @@ describe('Footer', () => { it('scrolls window to top when enableWindowScroll is true', () => { (useLocaleContent as Mock).mockReturnValue(footerContent); - const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => { - vi.fn(); + 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 }, }); render( @@ -175,7 +176,9 @@ describe('Footer', () => { }); btn.click(); - expect(scrollSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + expect(scrollSpy).toHaveBeenCalled(); + expect(scrollSpy).toHaveBeenLastCalledWith(0, 0); + rafSpy.mockRestore(); scrollSpy.mockRestore(); }); }); diff --git a/packages/pxweb2/src/app/components/Footer/Footer.tsx b/packages/pxweb2/src/app/components/Footer/Footer.tsx index 059d1b2b0..c29795a09 100644 --- a/packages/pxweb2/src/app/components/Footer/Footer.tsx +++ b/packages/pxweb2/src/app/components/Footer/Footer.tsx @@ -21,32 +21,43 @@ function useSafeLocation(): { pathname: string } { } type FooterProps = { - containerRef?: React.RefObject; variant?: 'generic' | 'tableview'; enableWindowScroll?: boolean; }; -export function scrollToTop(ref?: React.RefObject) { - if (ref?.current) { - const container = ref.current; - const start = container.scrollTop; - const duration = 200; // ms, decrease for even faster scroll +export function scrollToTop() { + const duration = 200; // ms, decrease for even faster scroll + + const animateToTop = ( + start: number, + setPosition: (value: number) => void, + ) => { const startTime = performance.now(); function animateScroll(time: number) { const elapsed = time - startTime; const progress = Math.min(elapsed / duration, 1); - container.scrollTop = start * (1 - progress); + setPosition(start * (1 - progress)); if (progress < 1) { requestAnimationFrame(animateScroll); } } + requestAnimationFrame(animateScroll); + }; + + const root = document.scrollingElement as HTMLElement | null; + const start = root?.scrollTop ?? window.scrollY; + if (start <= 0) { + return; } + + animateToTop(start, (value) => { + window.scrollTo(0, value); + }); } export const Footer: React.FC = ({ - containerRef, variant = 'generic', enableWindowScroll = false, }) => { @@ -60,14 +71,8 @@ export const Footer: React.FC = ({ const scrollContainerRef = useRef(null); const location = useSafeLocation(); - const canShowTopButton = !!containerRef || enableWindowScroll; - const handleScrollTop = () => { - if (containerRef?.current) { - scrollToTop(containerRef); - } else if (enableWindowScroll) { - window.scrollTo({ top: 0, behavior: 'smooth' }); - } + scrollToTop(); }; return ( @@ -156,7 +161,7 @@ export const Footer: React.FC = ({ {t('common.footer.copyright')}
- {canShowTopButton && ( + {enableWindowScroll && (