From 2664474ddb408b9353272e1b5364b8ccf33dfb5d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 18 Oct 2026 12:40:51 -0400 Subject: [PATCH] Version 10 --- .babelrc.js | 76 - .circleci/config.yml | 119 - .eslintignore | 10 - .eslintrc | 8 - .flowconfig | 20 - .github/PULL_REQUEST_TEMPLATE.md | 6 +- .github/workflows/eslint.yml | 18 + .github/workflows/playwright.yml | 42 + .github/workflows/prettier.yml | 18 + .github/workflows/typescript.yml | 20 + .github/workflows/vitest.yml | 20 + .gitignore | 36 +- .nvmrc | 1 - .prettierignore | 11 + .prettierrc | 7 - .watchmanconfig | 0 CHANGELOG.md | 314 +- CONTRIBUTING.md | 12 +- README.md | 117 +- docs/ArrowKeyStepper.md | 65 - docs/AutoSizer.md | 65 - docs/CellMeasurer.md | 256 - docs/Collection.md | 98 - docs/Column.md | 82 - docs/ColumnSizer.md | 59 - docs/Grid.md | 277 - docs/InfiniteLoader.md | 194 - docs/List.md | 161 - docs/Masonry.md | 299 - docs/MultiGrid.md | 64 - docs/README.md | 31 - docs/ScrollSync.md | 57 - docs/SortDirection.md | 13 - docs/Table.md | 194 - docs/WindowScroller.md | 99 - docs/creatingAnInfiniteLoadingList.md | 61 - docs/customizingStyles.md | 31 - docs/multiColumnSortTable.md | 51 - docs/overscanUsage.md | 7 - docs/reverseList.md | 67 - docs/tableWithNaturalSort.md | 68 - docs/upgrades/Version8.md | 131 - docs/usingAutoSizer.md | 85 - eslint.config.js | 30 + index.css | 25 + index.html | 30 +- index.tsx | 11 + integrations/tests/package.json | 17 + integrations/tests/playwright.config.ts | 22 + integrations/tests/src/index.ts | 52 + .../tests/tests/scroll-element-grid.spec.tsx | 161 + .../scroll-element-header-resize.spec.tsx | 128 + .../tests/tests/scroll-element-list.spec.tsx | 220 + .../tests/scroll-element-masonry.spec.tsx | 112 + .../tests/scroll-element-resize.spec.tsx | 64 + .../tests/tests/scroll-element-table.spec.tsx | 105 + integrations/tests/tests/utils.ts | 167 + integrations/vite/index.html | 25 + integrations/vite/package.json | 24 + integrations/vite/src/components/Header.tsx | 24 + .../vite/src/components/ScrollContainer.tsx | 26 + integrations/vite/src/main.tsx | 21 + integrations/vite/src/routes/GridRoute.tsx | 67 + integrations/vite/src/routes/Home.tsx | 25 + integrations/vite/src/routes/ListRoute.tsx | 69 + integrations/vite/src/routes/MasonryRoute.tsx | 94 + integrations/vite/src/routes/TableRoute.tsx | 42 + integrations/vite/src/utils/searchParams.ts | 26 + integrations/vite/src/vite-env.d.ts | 1 + integrations/vite/tsconfig.json | 22 + integrations/vite/tsconfig.tsbuildinfo | 1 + integrations/vite/vite.config.ts | 15 + jest-puppeteer.config.js | 6 - jest.config.js | 17 - .../ArrowKeyStepper/ArrowKeyStepper.test.tsx | 577 + .../ArrowKeyStepper/ArrowKeyStepper.tsx | 276 + .../defaultArrowKeyStepperProps.ts | 21 + lib/components/ArrowKeyStepper/types.ts | 135 + .../CellMeasurer.react18.test.tsx | 93 + .../CellMeasurer/CellMeasurer.test.tsx | 834 ++ lib/components/CellMeasurer/CellMeasurer.tsx | 343 + .../CellMeasurer/CellMeasurerCache.test.ts | 439 + .../CellMeasurer/CellMeasurerCache.ts | 363 + lib/components/CellMeasurer/types.ts | 276 + .../ColumnSizer/ColumnSizer.test.tsx | 292 + lib/components/ColumnSizer/ColumnSizer.tsx | 82 + lib/components/ColumnSizer/types.ts | 66 + lib/components/Grid/Grid.ssr.test.tsx | 27 + lib/components/Grid/Grid.test.tsx | 4054 ++++++ lib/components/Grid/Grid.tsx | 1650 +++ ...accessibilityOverscanIndicesGetter.test.ts | 63 +- .../accessibilityOverscanIndicesGetter.ts | 19 +- lib/components/Grid/constants.ts | 5 + .../Grid/defaultCellRangeRenderer.ts | 93 +- lib/components/Grid/defaultGridProps.ts | 60 + .../Grid/defaultOverscanIndicesGetter.test.ts | 63 +- .../Grid/defaultOverscanIndicesGetter.ts | 19 +- lib/components/Grid/types.ts | 560 + .../utils/CellSizeAndPositionManager.test.ts | 509 + .../Grid/utils/CellSizeAndPositionManager.ts | 93 +- .../ScalingCellSizeAndPositionManager.test.ts | 201 + .../ScalingCellSizeAndPositionManager.ts | 89 +- ...dPositionDataAndUpdateScrollOffset.test.ts | 87 + ...izeAndPositionDataAndUpdateScrollOffset.ts | 28 +- lib/components/Grid/utils/maxElementSize.ts | 7 + .../utils/updateScrollIndexHelper.test.ts | 141 + .../Grid/utils/updateScrollIndexHelper.ts | 55 + .../InfiniteLoader/InfiniteLoader.test.tsx | 822 ++ .../InfiniteLoader/InfiniteLoader.tsx | 187 + .../defaultInfiniteLoaderProps.ts | 10 + lib/components/InfiniteLoader/types.ts | 122 + lib/components/InfiniteLoader/utils.ts | 142 + lib/components/List/List.test.tsx | 1178 ++ lib/components/List/List.tsx | 235 + lib/components/List/defaultListProps.ts | 33 + lib/components/List/types.ts | 331 + lib/components/Masonry/Masonry.test.tsx | 1053 ++ lib/components/Masonry/Masonry.tsx | 621 + lib/components/Masonry/PositionCache.test.ts | 48 + .../components/Masonry/PositionCache.ts | 47 +- lib/components/Masonry/constants.ts | 5 + .../Masonry/createCellPositioner.test.ts | 52 + .../Masonry/createCellPositioner.ts | 42 +- lib/components/Masonry/defaultMasonryProps.ts | 33 + lib/components/Masonry/types.ts | 326 + .../CellMeasurerCacheDecorator.test.ts | 31 + .../MultiGrid/CellMeasurerCacheDecorator.ts | 52 +- lib/components/MultiGrid/MultiGrid.test.tsx | 1325 ++ lib/components/MultiGrid/MultiGrid.tsx | 877 ++ .../MultiGrid/defaultMultiGridProps.ts | 42 + lib/components/MultiGrid/types.ts | 170 + lib/components/ScrollSync/ScrollSync.test.tsx | 518 + lib/components/ScrollSync/ScrollSync.tsx | 174 + lib/components/ScrollSync/types.ts | 90 + lib/components/Table/Column.test.tsx | 122 + lib/components/Table/Column.tsx | 15 + .../components/Table/SortDirection.ts | 15 +- lib/components/Table/SortIndicator.tsx | 26 + lib/components/Table/Table.test.tsx | 2361 ++++ lib/components/Table/Table.tsx | 636 + lib/components/Table/createMultiSort.test.ts | 281 + lib/components/Table/createMultiSort.ts | 98 + .../components/Table/defaultCellDataGetter.ts | 11 +- .../components/Table/defaultCellRenderer.ts | 11 +- lib/components/Table/defaultColumnProps.ts | 35 + .../Table/defaultHeaderRenderer.tsx | 36 + .../Table/defaultHeaderRowRenderer.tsx | 16 + lib/components/Table/defaultRowRenderer.tsx | 66 + lib/components/Table/defaultTableProps.ts | 41 + lib/components/Table/types.ts | 771 ++ lib/index.ts | 110 + {source => lib}/styles.css | 8 - .../TestHelper.js => lib/utils/TestHelper.ts | 6 +- lib/utils/animationFrame.ts | 53 + .../utils/createCallbackMemoizer.test.ts | 116 +- lib/utils/createCallbackMemoizer.ts | 39 + lib/utils/createInnerGridRef.test.ts | 79 + lib/utils/createInnerGridRef.ts | 80 + lib/utils/getUpdatedOffsetForIndex.test.ts | 100 + .../utils/getUpdatedOffsetForIndex.ts | 27 +- .../utils/initCellMetadata.ts | 23 +- .../utils/requestAnimationTimeout.ts | 16 +- lib/utils/scrollElement/dimensions.test.ts | 98 + .../utils/scrollElement/dimensions.ts | 62 +- .../scrollElement.hydration.test.tsx | 91 + .../scrollElement/scrollElement.ssr.test.tsx | 107 + .../scrollElement/scrollListeners.test.ts | 181 + lib/utils/scrollElement/scrollListeners.ts | 126 + lib/utils/scrollElement/types.ts | 49 + .../scrollElement/useMissingHeightWarning.ts | 18 + .../useScrollElementLink.test.tsx | 393 + .../scrollElement/useScrollElementLink.ts | 208 + lib/utils/test/mockResizeObserver.ts | 140 + lib/utils/test/render.ts | 146 + lib/utils/test/scrollElement.ts | 386 + lib/utils/useIsomorphicLayoutEffect.ts | 7 + lib/vendor/binarySearchBounds.ts | 339 + lib/vendor/intervalTree.test.ts | 102 + lib/vendor/intervalTree.ts | 470 + package.json | 218 +- playground/chat-no-resize.html | 45 - playground/chat-no-resize.js | 274 - playground/chat.html | 74 - playground/chat.js | 283 - playground/grid-test.html | 27 - playground/grid-test.js | 46 - playground/grid.html | 50 - playground/grid.js | 64 - playground/helper.js | 50 - playground/hover.html | 43 - playground/hover.js | 60 - playground/render-counters.html | 43 - playground/render-counters.js | 65 - playground/scroll-sync.html | 25 - playground/scroll-sync.js | 59 - playground/table.html | 27 - playground/table.js | 58 - playground/tests.js | 52 - playground/tree.html | 43 - playground/tree.js | 212 - playground/utils.js | 18 - pnpm-lock.yaml | 5647 ++++++++ pnpm-workspace.yaml | 2 + postcss.config.js | 11 +- prettier.config.js | 9 + public/favicon.svg | 15 + public/generated/docs/ArrowKeyStepper.json | 114 + .../docs/ArrowKeyStepperImperativeAPI.json | 20 + public/generated/docs/CellMeasurer.json | 83 + .../generated/docs/CellMeasurerCacheAPI.json | 119 + .../docs/CellMeasurerCacheOptions.json | 81 + public/generated/docs/Column.json | 213 + public/generated/docs/ColumnSizer.json | 64 + public/generated/docs/Grid.json | 401 + public/generated/docs/GridImperativeAPI.json | 101 + public/generated/docs/InfiniteLoader.json | 71 + .../docs/InfiniteLoaderImperativeAPI.json | 20 + public/generated/docs/List.json | 335 + public/generated/docs/ListImperativeAPI.json | 92 + public/generated/docs/Masonry.json | 216 + .../generated/docs/MasonryImperativeAPI.json | 56 + public/generated/docs/MultiGrid.json | 525 + .../docs/MultiGridImperativeAPI.json | 65 + public/generated/docs/ScrollSync.json | 21 + public/generated/docs/Table.json | 528 + public/generated/docs/TableImperativeAPI.json | 101 + .../examples/ArrowKeyStepperControlled.json | 3 + .../examples/ArrowKeyStepperGrid.json | 3 + public/generated/examples/AutoSizerGrid.json | 3 + public/generated/examples/AutoSizerList.json | 3 + public/generated/examples/AutoSizerTable.json | 3 + .../examples/CellMeasurerCacheClear.json | 3 + .../CellMeasurerDynamicHeightGrid.json | 3 + .../CellMeasurerDynamicHeightList.json | 3 + .../CellMeasurerDynamicHeightTableColumn.json | 3 + .../CellMeasurerDynamicWidthGrid.json | 3 + .../CellMeasurerDynamicWidthMultiGrid.json | 3 + .../CellMeasurerFunctionComponent.json | 3 + .../examples/CellMeasurerImages.json | 3 + .../examples/CellMeasurerKeyMapper.json | 3 + .../generated/examples/ColumnSizerGrid.json | 3 + .../examples/CustomizingStylesClassNames.json | 3 + .../examples/CustomizingStylesCssModules.json | 3 + .../CustomizingStylesCssModules.module.json | 3 + .../examples/CustomizingStylesGlobal.json | 3 + .../examples/CustomizingStylesImport.json | 3 + .../CustomizingStylesInlineStyles.json | 3 + public/generated/examples/GridBasic.json | 3 + .../examples/GridCellRangeRenderer.json | 3 + .../generated/examples/GridCellRenderer.json | 3 + public/generated/examples/GridMethodsRef.json | 3 + .../examples/GridOverscanIndicesGetter.json | 3 + .../generated/examples/GridScrollToCell.json | 3 + .../generated/examples/GridVariableSizes.json | 3 + .../examples/InfiniteLoaderBasic.json | 3 + .../examples/InfiniteLoaderGrid.json | 3 + .../examples/InfiniteLoaderList.json | 3 + .../examples/InfiniteLoaderResetCache.json | 3 + .../examples/InfiniteLoadingList.json | 3 + .../examples/InfiniteLoadingListDemo.json | 3 + public/generated/examples/ListBasic.json | 3 + public/generated/examples/ListMethods.json | 3 + public/generated/examples/ListNoRows.json | 3 + .../examples/ListRecomputeRowHeights.json | 3 + .../generated/examples/ListRowRenderer.json | 3 + .../generated/examples/ListScrollToRow.json | 3 + .../examples/ListScrollingPlaceholder.json | 3 + .../examples/ListVariableRowHeights.json | 3 + .../examples/MasonryCellPositioner.json | 3 + .../examples/MasonryCellPositionerReset.json | 3 + .../examples/MasonryCustomPositioner.json | 3 + public/generated/examples/MasonryExample.json | 3 + .../examples/MultiColumnSortTable.json | 3 + .../MultiColumnSortTableCallback.json | 3 + .../MultiColumnSortTableDefaults.json | 3 + .../MultiGridFixedRowsAndColumns.json | 3 + .../generated/examples/NaturalSortTable.json | 3 + public/generated/examples/OverscanDemo.json | 3 + public/generated/examples/ReverseList.json | 3 + .../examples/ScrollSyncSpreadsheet.json | 3 + .../ServerRenderingInitialScroll.json | 3 + .../examples/ServerRenderingSizes.json | 3 + .../examples/SortIndicatorHeader.json | 3 + public/generated/examples/SortTableBasic.json | 3 + public/generated/examples/TableBasic.json | 3 + .../examples/TableColumnRenderers.json | 3 + .../generated/examples/TableFullFeatured.json | 3 + .../examples/TableImperativeMethods.json | 3 + .../generated/examples/TableRowRenderer.json | 3 + .../UsingAutoSizerChildComponent.json | 3 + .../examples/UsingAutoSizerFlexContainer.json | 3 + .../UsingAutoSizerInfiniteLoader.json | 3 + .../examples/UsingAutoSizerWidthOnly.json | 3 + .../examples/WindowScrollingContentAbove.json | 3 + .../examples/WindowScrollingGrid.json | 3 + .../examples/WindowScrollingList.json | 3 + .../examples/WindowScrollingMasonry.json | 3 + .../WindowScrollingMigrationAfter.json | 3 + .../WindowScrollingMigrationBefore.json | 3 + .../WindowScrollingScrollElement.json | 3 + .../examples/WindowScrollingScrollToRow.json | 3 + .../examples/WindowScrollingSmoothScroll.json | 3 + .../examples/WindowScrollingTable.json | 3 + public/generated/site-map.json | 2045 +++ public/robots.txt | 2 + rollup.config.js | 38 - scripts/compile-docs.ts | 135 + scripts/compile-examples.ts | 3 + scripts/compile-search-index.ts | 23 + .../docs-stubs/CellMeasurerCacheOptions.tsx | 13 + .../ArrowKeyStepper.example.css | 32 - .../ArrowKeyStepper.example.js | 208 - .../ArrowKeyStepper/ArrowKeyStepper.jest.js | 269 - source/ArrowKeyStepper/ArrowKeyStepper.js | 194 - source/ArrowKeyStepper/index.js | 6 - source/ArrowKeyStepper/types.js | 6 - source/AutoSizer/AutoSizer.example.css | 24 - source/AutoSizer/AutoSizer.example.js | 103 - source/AutoSizer/AutoSizer.jest.js | 238 - source/AutoSizer/AutoSizer.js | 200 - source/AutoSizer/AutoSizer.ssr.js | 17 - source/AutoSizer/index.js | 4 - .../CellMeasurer.DynamicHeightGrid.example.js | 73 - .../CellMeasurer.DynamicHeightList.example.js | 76 - ...asurer.DynamicHeightTableColumn.example.js | 92 - .../CellMeasurer.DynamicWidthGrid.example.js | 74 - ...lMeasurer.DynamicWidthMultiGrid.example.js | 80 - source/CellMeasurer/CellMeasurer.example.css | 70 - source/CellMeasurer/CellMeasurer.example.js | 139 - source/CellMeasurer/CellMeasurer.jest.js | 331 - source/CellMeasurer/CellMeasurer.js | 183 - source/CellMeasurer/CellMeasurerCache.jest.js | 275 - source/CellMeasurer/CellMeasurerCache.js | 227 - source/CellMeasurer/index.js | 6 - source/CellMeasurer/types.js | 15 - source/Collection/Collection.example.css | 26 - source/Collection/Collection.example.js | 241 - source/Collection/Collection.jest.js | 846 -- source/Collection/Collection.js | 238 - source/Collection/CollectionView.js | 598 - source/Collection/Section.jest.js | 42 - source/Collection/Section.js | 38 - source/Collection/SectionManager.jest.js | 78 - source/Collection/SectionManager.js | 100 - source/Collection/TestData.js | 33 - source/Collection/index.js | 5 - source/Collection/types.js | 27 - .../calculateSizeAndPositionData.jest.js | 31 - .../utils/calculateSizeAndPositionData.js | 48 - source/ColumnSizer/ColumnSizer.example.css | 32 - source/ColumnSizer/ColumnSizer.example.js | 148 - source/ColumnSizer/ColumnSizer.jest.js | 153 - source/ColumnSizer/ColumnSizer.js | 98 - source/ColumnSizer/index.js | 5 - source/Grid/Grid.example.css | 67 - source/Grid/Grid.example.js | 312 - source/Grid/Grid.jest.js | 2760 ---- source/Grid/Grid.js | 1657 --- source/Grid/Grid.ssr.js | 56 - source/Grid/index.js | 18 - source/Grid/types.js | 115 - .../utils/CellSizeAndPositionManager.jest.js | 418 - .../ScalingCellSizeAndPositionManager.jest.js | 200 - ...dPositionDataAndUpdateScrollOffset.jest.js | 77 - source/Grid/utils/maxElementSize.js | 17 - .../utils/updateScrollIndexHelper.jest.js | 194 - source/Grid/utils/updateScrollIndexHelper.js | 98 - .../InfiniteLoader/InfiniteLoader.example.css | 41 - .../InfiniteLoader/InfiniteLoader.example.js | 173 - source/InfiniteLoader/InfiniteLoader.jest.js | 472 - source/InfiniteLoader/InfiniteLoader.js | 274 - source/InfiniteLoader/index.js | 5 - source/List/List.example.css | 67 - source/List/List.example.js | 255 - source/List/List.jest.js | 560 - source/List/List.js | 268 - source/List/index.js | 6 - source/List/types.js | 27 - source/Masonry/Masonry.example.css | 18 - source/Masonry/Masonry.example.js | 301 - source/Masonry/Masonry.jest.js | 472 - source/Masonry/Masonry.js | 490 - source/Masonry/index.js | 6 - source/MultiGrid/MultiGrid.example.css | 7 - source/MultiGrid/MultiGrid.example.js | 142 - source/MultiGrid/MultiGrid.jest.js | 715 -- source/MultiGrid/MultiGrid.js | 820 -- source/MultiGrid/index.js | 5 - source/ScrollSync/ScrollSync.example.css | 48 - source/ScrollSync/ScrollSync.example.js | 271 - source/ScrollSync/ScrollSync.jest.js | 82 - source/ScrollSync/ScrollSync.js | 71 - source/ScrollSync/index.js | 5 - source/Table/Column.jest.js | 112 - source/Table/Column.js | 93 - source/Table/SortIndicator.js | 31 - source/Table/Table.example.css | 40 - source/Table/Table.example.js | 315 - source/Table/Table.jest.js | 1566 --- source/Table/Table.js | 747 -- source/Table/createMultiSort.jest.js | 200 - source/Table/createMultiSort.js | 109 - source/Table/defaultHeaderRenderer.js | 32 - source/Table/defaultHeaderRowRenderer.js | 15 - source/Table/defaultRowRenderer.js | 62 - source/Table/index.js | 25 - source/Table/types.js | 43 - source/TestUtils.js | 33 - source/WindowScroller/WindowScroller.e2e.js | 165 - .../WindowScroller/WindowScroller.example.css | 29 - .../WindowScroller/WindowScroller.example.js | 177 - .../WindowScroller.header-resize.e2e.js | 206 - source/WindowScroller/WindowScroller.jest.js | 476 - source/WindowScroller/WindowScroller.js | 277 - source/WindowScroller/WindowScroller.ssr.js | 18 - source/WindowScroller/index.js | 6 - source/WindowScroller/utils/onScroll.js | 94 - source/demo/Application.css | 119 - source/demo/Application.js | 184 - source/demo/ComponentLink.css | 25 - source/demo/ComponentLink.js | 16 - source/demo/ContentBox.css | 35 - source/demo/ContentBox.js | 47 - source/demo/Icon.css | 3 - source/demo/Icon.js | 60 - source/demo/LabeledInput.css | 51 - source/demo/LabeledInput.js | 46 - source/demo/NavLink.css | 35 - source/demo/NavLink.js | 32 - source/demo/Wizard/Generator.js | 279 - source/demo/Wizard/Wizard.css | 22 - source/demo/Wizard/Wizard.js | 233 - source/demo/Wizard/index.js | 5 - source/demo/favicon.png | Bin 2290 -> 0 bytes source/demo/index.js | 17 - source/demo/utils.js | 226 - source/index.js | 32 - source/jest-setup.js | 5 - source/utils/animationFrame.js | 40 - source/utils/createCallbackMemoizer.js | 32 - source/utils/getUpdatedOffsetForIndex.jest.js | 85 - source/vendor/binarySearchBounds.js | 254 - source/vendor/detectElementResize.js | 247 - source/vendor/intervalTree.js | 408 - src/App.tsx | 264 + src/components/Link.tsx | 13 + src/components/Methods.tsx | 52 + src/constants.ts | 3 + src/routes.ts | 49 + .../cell-measurer/CellMeasurerCacheRoute.tsx | 65 + .../CellMeasurerColumnWidthsRoute.tsx | 33 + .../CellMeasurerLimitationsRoute.tsx | 33 + .../cell-measurer/CellMeasurerPropsRoute.tsx | 34 + .../cell-measurer/CellMeasurerRoute.tsx | 186 + .../examples/CellMeasurerCacheClear.tsx | 65 + .../CellMeasurerDynamicHeightGrid.tsx | 56 + .../CellMeasurerDynamicHeightList.tsx | 66 + .../CellMeasurerDynamicHeightTableColumn.tsx | 66 + .../examples/CellMeasurerDynamicWidthGrid.tsx | 83 + .../CellMeasurerDynamicWidthMultiGrid.tsx | 62 + .../CellMeasurerFunctionComponent.tsx | 122 + .../examples/CellMeasurerImages.tsx | 58 + .../examples/CellMeasurerKeyMapper.ts | 17 + src/routes/cell-measurer/routes.ts | 37 + src/routes/grid/CellRangeRendererRoute.tsx | 109 + src/routes/grid/FixedRowsAndColumnsRoute.tsx | 107 + src/routes/grid/GridMethodsRoute.tsx | 41 + src/routes/grid/GridPropsRoute.tsx | 10 + src/routes/grid/GridRoute.tsx | 89 + src/routes/grid/MultiGridMethodsRoute.tsx | 33 + src/routes/grid/MultiGridPropsRoute.tsx | 45 + src/routes/grid/OverscanRoute.tsx | 208 + src/routes/grid/ScrollToCellRoute.tsx | 68 + src/routes/grid/VariableSizesRoute.tsx | 61 + src/routes/grid/examples/GridBasic.tsx | 54 + .../grid/examples/GridCellRangeRenderer.tsx | 91 + src/routes/grid/examples/GridCellRenderer.tsx | 43 + src/routes/grid/examples/GridMethodsRef.tsx | 73 + .../examples/GridOverscanIndicesGetter.ts | 22 + src/routes/grid/examples/GridScrollToCell.tsx | 118 + .../grid/examples/GridVariableSizes.tsx | 100 + .../examples/MultiGridFixedRowsAndColumns.tsx | 60 + src/routes/grid/examples/OverscanDemo.tsx | 63 + src/routes/grid/routes.ts | 35 + src/routes/guides/CustomizingStylesRoute.tsx | 135 + .../guides/InfiniteLoadingListRoute.tsx | 46 + .../guides/MultiColumnSortTableRoute.tsx | 92 + src/routes/guides/NaturalSortTableRoute.tsx | 47 + src/routes/guides/ReverseListRoute.tsx | 38 + src/routes/guides/ServerRenderingRoute.tsx | 134 + src/routes/guides/SizingComponentsRoute.tsx | 219 + src/routes/guides/examples/AutoSizerGrid.tsx | 44 + src/routes/guides/examples/AutoSizerList.tsx | 42 + src/routes/guides/examples/AutoSizerTable.tsx | 38 + .../examples/CustomizingStylesClassNames.tsx | 61 + .../CustomizingStylesCssModules.module.css | 11 + .../examples/CustomizingStylesCssModules.tsx | 29 + .../examples/CustomizingStylesGlobal.css | 13 + .../examples/CustomizingStylesImport.ts | 6 + .../CustomizingStylesInlineStyles.tsx | 35 + .../guides/examples/InfiniteLoadingList.tsx | 82 + .../examples/InfiniteLoadingListDemo.tsx | 44 + .../guides/examples/MultiColumnSortTable.tsx | 125 + .../examples/MultiColumnSortTableCallback.tsx | 18 + .../examples/MultiColumnSortTableDefaults.tsx | 17 + .../guides/examples/NaturalSortTable.tsx | 80 + src/routes/guides/examples/ReverseList.tsx | 91 + .../examples/ServerRenderingInitialScroll.tsx | 35 + .../guides/examples/ServerRenderingSizes.tsx | 39 + .../examples/UsingAutoSizerChildComponent.tsx | 44 + .../examples/UsingAutoSizerFlexContainer.tsx | 42 + .../examples/UsingAutoSizerInfiniteLoader.tsx | 53 + .../examples/UsingAutoSizerWidthOnly.tsx | 37 + src/routes/hoc/ArrowKeyStepperRoute.tsx | 134 + src/routes/hoc/ColumnSizerRoute.tsx | 132 + src/routes/hoc/InfiniteLoaderRoute.tsx | 135 + src/routes/hoc/ScrollSyncRoute.tsx | 95 + .../examples/ArrowKeyStepperControlled.tsx | 80 + .../hoc/examples/ArrowKeyStepperGrid.tsx | 76 + src/routes/hoc/examples/ColumnSizerGrid.tsx | 66 + .../hoc/examples/InfiniteLoaderBasic.tsx | 68 + .../hoc/examples/InfiniteLoaderGrid.tsx | 66 + .../hoc/examples/InfiniteLoaderList.tsx | 132 + .../hoc/examples/InfiniteLoaderResetCache.tsx | 67 + .../hoc/examples/ScrollSyncSpreadsheet.tsx | 137 + src/routes/list/ListMethodsRoute.tsx | 52 + src/routes/list/ListPropsRoute.tsx | 10 + src/routes/list/ListRenderingRowsRoute.tsx | 44 + src/routes/list/ListRoute.tsx | 72 + src/routes/list/ListScrollToRowRoute.tsx | 53 + .../list/ListVariableRowHeightsRoute.tsx | 33 + src/routes/list/examples/ListBasic.tsx | 57 + src/routes/list/examples/ListMethods.tsx | 82 + src/routes/list/examples/ListNoRows.tsx | 35 + .../list/examples/ListRecomputeRowHeights.tsx | 41 + src/routes/list/examples/ListRowRenderer.tsx | 37 + src/routes/list/examples/ListScrollToRow.tsx | 101 + .../examples/ListScrollingPlaceholder.tsx | 55 + .../list/examples/ListVariableRowHeights.tsx | 66 + src/routes/list/routes.ts | 27 + .../masonry/MasonryCellPositionersRoute.tsx | 53 + src/routes/masonry/MasonryImagesRoute.tsx | 49 + src/routes/masonry/MasonryMeasuringRoute.tsx | 80 + src/routes/masonry/MasonryMethodsRoute.tsx | 65 + src/routes/masonry/MasonryPropsRoute.tsx | 10 + src/routes/masonry/MasonryRoute.tsx | 113 + .../examples/MasonryCellPositioner.tsx | 31 + .../examples/MasonryCellPositionerReset.tsx | 26 + .../examples/MasonryCustomPositioner.tsx | 38 + .../masonry/examples/MasonryExample.tsx | 125 + src/routes/masonry/routes.ts | 29 + src/routes/table/ColumnPropsRoute.tsx | 23 + src/routes/table/SortingRoute.tsx | 107 + src/routes/table/TableCellRenderersRoute.tsx | 56 + src/routes/table/TableMethodsRoute.tsx | 38 + src/routes/table/TablePropsRoute.tsx | 10 + src/routes/table/TableRoute.tsx | 106 + src/routes/table/TableRowRenderersRoute.tsx | 55 + src/routes/table/TableRowStylesRoute.tsx | 136 + .../table/examples/SortIndicatorHeader.tsx | 19 + src/routes/table/examples/SortTableBasic.tsx | 89 + src/routes/table/examples/TableBasic.tsx | 42 + .../table/examples/TableColumnRenderers.tsx | 62 + .../table/examples/TableFullFeatured.tsx | 117 + .../table/examples/TableImperativeMethods.tsx | 102 + .../table/examples/TableRowRenderer.tsx | 78 + src/routes/table/routes.ts | 29 + src/routes/types.ts | 3 + .../ScrollingAnElementRoute.tsx | 41 + .../ScrollingWithThePageRoute.tsx | 257 + .../examples/WindowScrollingContentAbove.tsx | 45 + .../examples/WindowScrollingGrid.tsx | 33 + .../examples/WindowScrollingList.tsx | 42 + .../examples/WindowScrollingMasonry.tsx | 66 + .../WindowScrollingMigrationAfter.tsx | 26 + .../WindowScrollingMigrationBefore.tsx | 47 + .../examples/WindowScrollingScrollElement.tsx | 49 + .../examples/WindowScrollingScrollToRow.tsx | 79 + .../examples/WindowScrollingSmoothScroll.ts | 24 + .../examples/WindowScrollingTable.tsx | 26 + src/routes/window-scrolling/routes.ts | 21 + src/utils/data.ts | 240 + src/vite-env.d.ts | 2 + tsconfig.json | 44 + vercel.json | 8 + vite.config.ts | 105 + vitest.config.ts | 14 + vitest.setup.ts | 14 + webpack.config.demo.js | 45 - webpack.config.dev.js | 43 - yarn.lock | 10712 ---------------- 591 files changed, 50793 insertions(+), 40615 deletions(-) delete mode 100644 .babelrc.js delete mode 100644 .circleci/config.yml delete mode 100644 .eslintignore delete mode 100644 .eslintrc delete mode 100644 .flowconfig create mode 100644 .github/workflows/eslint.yml create mode 100644 .github/workflows/playwright.yml create mode 100644 .github/workflows/prettier.yml create mode 100644 .github/workflows/typescript.yml create mode 100644 .github/workflows/vitest.yml delete mode 100644 .nvmrc create mode 100644 .prettierignore delete mode 100644 .prettierrc delete mode 100644 .watchmanconfig delete mode 100644 docs/ArrowKeyStepper.md delete mode 100644 docs/AutoSizer.md delete mode 100644 docs/CellMeasurer.md delete mode 100644 docs/Collection.md delete mode 100644 docs/Column.md delete mode 100644 docs/ColumnSizer.md delete mode 100644 docs/Grid.md delete mode 100644 docs/InfiniteLoader.md delete mode 100644 docs/List.md delete mode 100644 docs/Masonry.md delete mode 100644 docs/MultiGrid.md delete mode 100644 docs/README.md delete mode 100644 docs/ScrollSync.md delete mode 100644 docs/SortDirection.md delete mode 100644 docs/Table.md delete mode 100644 docs/WindowScroller.md delete mode 100644 docs/creatingAnInfiniteLoadingList.md delete mode 100644 docs/customizingStyles.md delete mode 100644 docs/multiColumnSortTable.md delete mode 100644 docs/overscanUsage.md delete mode 100644 docs/reverseList.md delete mode 100644 docs/tableWithNaturalSort.md delete mode 100644 docs/upgrades/Version8.md delete mode 100644 docs/usingAutoSizer.md create mode 100644 eslint.config.js create mode 100644 index.css create mode 100644 index.tsx create mode 100644 integrations/tests/package.json create mode 100644 integrations/tests/playwright.config.ts create mode 100644 integrations/tests/src/index.ts create mode 100644 integrations/tests/tests/scroll-element-grid.spec.tsx create mode 100644 integrations/tests/tests/scroll-element-header-resize.spec.tsx create mode 100644 integrations/tests/tests/scroll-element-list.spec.tsx create mode 100644 integrations/tests/tests/scroll-element-masonry.spec.tsx create mode 100644 integrations/tests/tests/scroll-element-resize.spec.tsx create mode 100644 integrations/tests/tests/scroll-element-table.spec.tsx create mode 100644 integrations/tests/tests/utils.ts create mode 100644 integrations/vite/index.html create mode 100644 integrations/vite/package.json create mode 100644 integrations/vite/src/components/Header.tsx create mode 100644 integrations/vite/src/components/ScrollContainer.tsx create mode 100644 integrations/vite/src/main.tsx create mode 100644 integrations/vite/src/routes/GridRoute.tsx create mode 100644 integrations/vite/src/routes/Home.tsx create mode 100644 integrations/vite/src/routes/ListRoute.tsx create mode 100644 integrations/vite/src/routes/MasonryRoute.tsx create mode 100644 integrations/vite/src/routes/TableRoute.tsx create mode 100644 integrations/vite/src/utils/searchParams.ts create mode 100644 integrations/vite/src/vite-env.d.ts create mode 100644 integrations/vite/tsconfig.json create mode 100644 integrations/vite/tsconfig.tsbuildinfo create mode 100644 integrations/vite/vite.config.ts delete mode 100644 jest-puppeteer.config.js delete mode 100644 jest.config.js create mode 100644 lib/components/ArrowKeyStepper/ArrowKeyStepper.test.tsx create mode 100644 lib/components/ArrowKeyStepper/ArrowKeyStepper.tsx create mode 100644 lib/components/ArrowKeyStepper/defaultArrowKeyStepperProps.ts create mode 100644 lib/components/ArrowKeyStepper/types.ts create mode 100644 lib/components/CellMeasurer/CellMeasurer.react18.test.tsx create mode 100644 lib/components/CellMeasurer/CellMeasurer.test.tsx create mode 100644 lib/components/CellMeasurer/CellMeasurer.tsx create mode 100644 lib/components/CellMeasurer/CellMeasurerCache.test.ts create mode 100644 lib/components/CellMeasurer/CellMeasurerCache.ts create mode 100644 lib/components/CellMeasurer/types.ts create mode 100644 lib/components/ColumnSizer/ColumnSizer.test.tsx create mode 100644 lib/components/ColumnSizer/ColumnSizer.tsx create mode 100644 lib/components/ColumnSizer/types.ts create mode 100644 lib/components/Grid/Grid.ssr.test.tsx create mode 100644 lib/components/Grid/Grid.test.tsx create mode 100644 lib/components/Grid/Grid.tsx rename source/Grid/accessibilityOverscanIndicesGetter.jest.js => lib/components/Grid/accessibilityOverscanIndicesGetter.test.ts (53%) rename source/Grid/accessibilityOverscanIndicesGetter.js => lib/components/Grid/accessibilityOverscanIndicesGetter.ts (67%) create mode 100644 lib/components/Grid/constants.ts rename source/Grid/defaultCellRangeRenderer.js => lib/components/Grid/defaultCellRangeRenderer.ts (65%) create mode 100644 lib/components/Grid/defaultGridProps.ts rename source/Grid/defaultOverscanIndicesGetter.jest.js => lib/components/Grid/defaultOverscanIndicesGetter.test.ts (54%) rename source/Grid/defaultOverscanIndicesGetter.js => lib/components/Grid/defaultOverscanIndicesGetter.ts (58%) create mode 100644 lib/components/Grid/types.ts create mode 100644 lib/components/Grid/utils/CellSizeAndPositionManager.test.ts rename source/Grid/utils/CellSizeAndPositionManager.js => lib/components/Grid/utils/CellSizeAndPositionManager.ts (80%) create mode 100644 lib/components/Grid/utils/ScalingCellSizeAndPositionManager.test.ts rename source/Grid/utils/ScalingCellSizeAndPositionManager.js => lib/components/Grid/utils/ScalingCellSizeAndPositionManager.ts (76%) create mode 100644 lib/components/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.test.ts rename source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js => lib/components/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.ts (72%) create mode 100644 lib/components/Grid/utils/maxElementSize.ts create mode 100644 lib/components/Grid/utils/updateScrollIndexHelper.test.ts create mode 100644 lib/components/Grid/utils/updateScrollIndexHelper.ts create mode 100644 lib/components/InfiniteLoader/InfiniteLoader.test.tsx create mode 100644 lib/components/InfiniteLoader/InfiniteLoader.tsx create mode 100644 lib/components/InfiniteLoader/defaultInfiniteLoaderProps.ts create mode 100644 lib/components/InfiniteLoader/types.ts create mode 100644 lib/components/InfiniteLoader/utils.ts create mode 100644 lib/components/List/List.test.tsx create mode 100644 lib/components/List/List.tsx create mode 100644 lib/components/List/defaultListProps.ts create mode 100644 lib/components/List/types.ts create mode 100644 lib/components/Masonry/Masonry.test.tsx create mode 100644 lib/components/Masonry/Masonry.tsx create mode 100644 lib/components/Masonry/PositionCache.test.ts rename source/Masonry/PositionCache.js => lib/components/Masonry/PositionCache.ts (57%) create mode 100644 lib/components/Masonry/constants.ts create mode 100644 lib/components/Masonry/createCellPositioner.test.ts rename source/Masonry/createCellPositioner.js => lib/components/Masonry/createCellPositioner.ts (63%) create mode 100644 lib/components/Masonry/defaultMasonryProps.ts create mode 100644 lib/components/Masonry/types.ts create mode 100644 lib/components/MultiGrid/CellMeasurerCacheDecorator.test.ts rename source/MultiGrid/CellMeasurerCacheDecorator.js => lib/components/MultiGrid/CellMeasurerCacheDecorator.ts (57%) create mode 100644 lib/components/MultiGrid/MultiGrid.test.tsx create mode 100644 lib/components/MultiGrid/MultiGrid.tsx create mode 100644 lib/components/MultiGrid/defaultMultiGridProps.ts create mode 100644 lib/components/MultiGrid/types.ts create mode 100644 lib/components/ScrollSync/ScrollSync.test.tsx create mode 100644 lib/components/ScrollSync/ScrollSync.tsx create mode 100644 lib/components/ScrollSync/types.ts create mode 100644 lib/components/Table/Column.test.tsx create mode 100644 lib/components/Table/Column.tsx rename source/Table/SortDirection.js => lib/components/Table/SortDirection.ts (56%) create mode 100644 lib/components/Table/SortIndicator.tsx create mode 100644 lib/components/Table/Table.test.tsx create mode 100644 lib/components/Table/Table.tsx create mode 100644 lib/components/Table/createMultiSort.test.ts create mode 100644 lib/components/Table/createMultiSort.ts rename source/Table/defaultCellDataGetter.js => lib/components/Table/defaultCellDataGetter.ts (62%) rename source/Table/defaultCellRenderer.js => lib/components/Table/defaultCellRenderer.ts (59%) create mode 100644 lib/components/Table/defaultColumnProps.ts create mode 100644 lib/components/Table/defaultHeaderRenderer.tsx create mode 100644 lib/components/Table/defaultHeaderRowRenderer.tsx create mode 100644 lib/components/Table/defaultRowRenderer.tsx create mode 100644 lib/components/Table/defaultTableProps.ts create mode 100644 lib/components/Table/types.ts create mode 100644 lib/index.ts rename {source => lib}/styles.css (91%) rename source/utils/TestHelper.js => lib/utils/TestHelper.ts (74%) create mode 100644 lib/utils/animationFrame.ts rename source/utils/createCallbackMemoizer.jest.js => lib/utils/createCallbackMemoizer.test.ts (66%) create mode 100644 lib/utils/createCallbackMemoizer.ts create mode 100644 lib/utils/createInnerGridRef.test.ts create mode 100644 lib/utils/createInnerGridRef.ts create mode 100644 lib/utils/getUpdatedOffsetForIndex.test.ts rename source/utils/getUpdatedOffsetForIndex.js => lib/utils/getUpdatedOffsetForIndex.ts (62%) rename source/utils/initCellMetadata.js => lib/utils/initCellMetadata.ts (58%) rename source/utils/requestAnimationTimeout.js => lib/utils/requestAnimationTimeout.ts (82%) create mode 100644 lib/utils/scrollElement/dimensions.test.ts rename source/WindowScroller/utils/dimensions.js => lib/utils/scrollElement/dimensions.ts (55%) create mode 100644 lib/utils/scrollElement/scrollElement.hydration.test.tsx create mode 100644 lib/utils/scrollElement/scrollElement.ssr.test.tsx create mode 100644 lib/utils/scrollElement/scrollListeners.test.ts create mode 100644 lib/utils/scrollElement/scrollListeners.ts create mode 100644 lib/utils/scrollElement/types.ts create mode 100644 lib/utils/scrollElement/useMissingHeightWarning.ts create mode 100644 lib/utils/scrollElement/useScrollElementLink.test.tsx create mode 100644 lib/utils/scrollElement/useScrollElementLink.ts create mode 100644 lib/utils/test/mockResizeObserver.ts create mode 100644 lib/utils/test/render.ts create mode 100644 lib/utils/test/scrollElement.ts create mode 100644 lib/utils/useIsomorphicLayoutEffect.ts create mode 100644 lib/vendor/binarySearchBounds.ts create mode 100644 lib/vendor/intervalTree.test.ts create mode 100644 lib/vendor/intervalTree.ts delete mode 100644 playground/chat-no-resize.html delete mode 100644 playground/chat-no-resize.js delete mode 100644 playground/chat.html delete mode 100644 playground/chat.js delete mode 100644 playground/grid-test.html delete mode 100644 playground/grid-test.js delete mode 100644 playground/grid.html delete mode 100644 playground/grid.js delete mode 100644 playground/helper.js delete mode 100644 playground/hover.html delete mode 100644 playground/hover.js delete mode 100644 playground/render-counters.html delete mode 100644 playground/render-counters.js delete mode 100644 playground/scroll-sync.html delete mode 100644 playground/scroll-sync.js delete mode 100644 playground/table.html delete mode 100644 playground/table.js delete mode 100644 playground/tests.js delete mode 100644 playground/tree.html delete mode 100644 playground/tree.js delete mode 100644 playground/utils.js create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 prettier.config.js create mode 100644 public/favicon.svg create mode 100644 public/generated/docs/ArrowKeyStepper.json create mode 100644 public/generated/docs/ArrowKeyStepperImperativeAPI.json create mode 100644 public/generated/docs/CellMeasurer.json create mode 100644 public/generated/docs/CellMeasurerCacheAPI.json create mode 100644 public/generated/docs/CellMeasurerCacheOptions.json create mode 100644 public/generated/docs/Column.json create mode 100644 public/generated/docs/ColumnSizer.json create mode 100644 public/generated/docs/Grid.json create mode 100644 public/generated/docs/GridImperativeAPI.json create mode 100644 public/generated/docs/InfiniteLoader.json create mode 100644 public/generated/docs/InfiniteLoaderImperativeAPI.json create mode 100644 public/generated/docs/List.json create mode 100644 public/generated/docs/ListImperativeAPI.json create mode 100644 public/generated/docs/Masonry.json create mode 100644 public/generated/docs/MasonryImperativeAPI.json create mode 100644 public/generated/docs/MultiGrid.json create mode 100644 public/generated/docs/MultiGridImperativeAPI.json create mode 100644 public/generated/docs/ScrollSync.json create mode 100644 public/generated/docs/Table.json create mode 100644 public/generated/docs/TableImperativeAPI.json create mode 100644 public/generated/examples/ArrowKeyStepperControlled.json create mode 100644 public/generated/examples/ArrowKeyStepperGrid.json create mode 100644 public/generated/examples/AutoSizerGrid.json create mode 100644 public/generated/examples/AutoSizerList.json create mode 100644 public/generated/examples/AutoSizerTable.json create mode 100644 public/generated/examples/CellMeasurerCacheClear.json create mode 100644 public/generated/examples/CellMeasurerDynamicHeightGrid.json create mode 100644 public/generated/examples/CellMeasurerDynamicHeightList.json create mode 100644 public/generated/examples/CellMeasurerDynamicHeightTableColumn.json create mode 100644 public/generated/examples/CellMeasurerDynamicWidthGrid.json create mode 100644 public/generated/examples/CellMeasurerDynamicWidthMultiGrid.json create mode 100644 public/generated/examples/CellMeasurerFunctionComponent.json create mode 100644 public/generated/examples/CellMeasurerImages.json create mode 100644 public/generated/examples/CellMeasurerKeyMapper.json create mode 100644 public/generated/examples/ColumnSizerGrid.json create mode 100644 public/generated/examples/CustomizingStylesClassNames.json create mode 100644 public/generated/examples/CustomizingStylesCssModules.json create mode 100644 public/generated/examples/CustomizingStylesCssModules.module.json create mode 100644 public/generated/examples/CustomizingStylesGlobal.json create mode 100644 public/generated/examples/CustomizingStylesImport.json create mode 100644 public/generated/examples/CustomizingStylesInlineStyles.json create mode 100644 public/generated/examples/GridBasic.json create mode 100644 public/generated/examples/GridCellRangeRenderer.json create mode 100644 public/generated/examples/GridCellRenderer.json create mode 100644 public/generated/examples/GridMethodsRef.json create mode 100644 public/generated/examples/GridOverscanIndicesGetter.json create mode 100644 public/generated/examples/GridScrollToCell.json create mode 100644 public/generated/examples/GridVariableSizes.json create mode 100644 public/generated/examples/InfiniteLoaderBasic.json create mode 100644 public/generated/examples/InfiniteLoaderGrid.json create mode 100644 public/generated/examples/InfiniteLoaderList.json create mode 100644 public/generated/examples/InfiniteLoaderResetCache.json create mode 100644 public/generated/examples/InfiniteLoadingList.json create mode 100644 public/generated/examples/InfiniteLoadingListDemo.json create mode 100644 public/generated/examples/ListBasic.json create mode 100644 public/generated/examples/ListMethods.json create mode 100644 public/generated/examples/ListNoRows.json create mode 100644 public/generated/examples/ListRecomputeRowHeights.json create mode 100644 public/generated/examples/ListRowRenderer.json create mode 100644 public/generated/examples/ListScrollToRow.json create mode 100644 public/generated/examples/ListScrollingPlaceholder.json create mode 100644 public/generated/examples/ListVariableRowHeights.json create mode 100644 public/generated/examples/MasonryCellPositioner.json create mode 100644 public/generated/examples/MasonryCellPositionerReset.json create mode 100644 public/generated/examples/MasonryCustomPositioner.json create mode 100644 public/generated/examples/MasonryExample.json create mode 100644 public/generated/examples/MultiColumnSortTable.json create mode 100644 public/generated/examples/MultiColumnSortTableCallback.json create mode 100644 public/generated/examples/MultiColumnSortTableDefaults.json create mode 100644 public/generated/examples/MultiGridFixedRowsAndColumns.json create mode 100644 public/generated/examples/NaturalSortTable.json create mode 100644 public/generated/examples/OverscanDemo.json create mode 100644 public/generated/examples/ReverseList.json create mode 100644 public/generated/examples/ScrollSyncSpreadsheet.json create mode 100644 public/generated/examples/ServerRenderingInitialScroll.json create mode 100644 public/generated/examples/ServerRenderingSizes.json create mode 100644 public/generated/examples/SortIndicatorHeader.json create mode 100644 public/generated/examples/SortTableBasic.json create mode 100644 public/generated/examples/TableBasic.json create mode 100644 public/generated/examples/TableColumnRenderers.json create mode 100644 public/generated/examples/TableFullFeatured.json create mode 100644 public/generated/examples/TableImperativeMethods.json create mode 100644 public/generated/examples/TableRowRenderer.json create mode 100644 public/generated/examples/UsingAutoSizerChildComponent.json create mode 100644 public/generated/examples/UsingAutoSizerFlexContainer.json create mode 100644 public/generated/examples/UsingAutoSizerInfiniteLoader.json create mode 100644 public/generated/examples/UsingAutoSizerWidthOnly.json create mode 100644 public/generated/examples/WindowScrollingContentAbove.json create mode 100644 public/generated/examples/WindowScrollingGrid.json create mode 100644 public/generated/examples/WindowScrollingList.json create mode 100644 public/generated/examples/WindowScrollingMasonry.json create mode 100644 public/generated/examples/WindowScrollingMigrationAfter.json create mode 100644 public/generated/examples/WindowScrollingMigrationBefore.json create mode 100644 public/generated/examples/WindowScrollingScrollElement.json create mode 100644 public/generated/examples/WindowScrollingScrollToRow.json create mode 100644 public/generated/examples/WindowScrollingSmoothScroll.json create mode 100644 public/generated/examples/WindowScrollingTable.json create mode 100644 public/generated/site-map.json create mode 100644 public/robots.txt delete mode 100644 rollup.config.js create mode 100644 scripts/compile-docs.ts create mode 100644 scripts/compile-examples.ts create mode 100644 scripts/compile-search-index.ts create mode 100644 scripts/docs-stubs/CellMeasurerCacheOptions.tsx delete mode 100644 source/ArrowKeyStepper/ArrowKeyStepper.example.css delete mode 100644 source/ArrowKeyStepper/ArrowKeyStepper.example.js delete mode 100644 source/ArrowKeyStepper/ArrowKeyStepper.jest.js delete mode 100644 source/ArrowKeyStepper/ArrowKeyStepper.js delete mode 100644 source/ArrowKeyStepper/index.js delete mode 100644 source/ArrowKeyStepper/types.js delete mode 100644 source/AutoSizer/AutoSizer.example.css delete mode 100644 source/AutoSizer/AutoSizer.example.js delete mode 100644 source/AutoSizer/AutoSizer.jest.js delete mode 100644 source/AutoSizer/AutoSizer.js delete mode 100644 source/AutoSizer/AutoSizer.ssr.js delete mode 100644 source/AutoSizer/index.js delete mode 100644 source/CellMeasurer/CellMeasurer.DynamicHeightGrid.example.js delete mode 100644 source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js delete mode 100644 source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js delete mode 100644 source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js delete mode 100644 source/CellMeasurer/CellMeasurer.DynamicWidthMultiGrid.example.js delete mode 100644 source/CellMeasurer/CellMeasurer.example.css delete mode 100644 source/CellMeasurer/CellMeasurer.example.js delete mode 100644 source/CellMeasurer/CellMeasurer.jest.js delete mode 100644 source/CellMeasurer/CellMeasurer.js delete mode 100644 source/CellMeasurer/CellMeasurerCache.jest.js delete mode 100644 source/CellMeasurer/CellMeasurerCache.js delete mode 100644 source/CellMeasurer/index.js delete mode 100644 source/CellMeasurer/types.js delete mode 100644 source/Collection/Collection.example.css delete mode 100644 source/Collection/Collection.example.js delete mode 100644 source/Collection/Collection.jest.js delete mode 100644 source/Collection/Collection.js delete mode 100644 source/Collection/CollectionView.js delete mode 100644 source/Collection/Section.jest.js delete mode 100644 source/Collection/Section.js delete mode 100644 source/Collection/SectionManager.jest.js delete mode 100644 source/Collection/SectionManager.js delete mode 100644 source/Collection/TestData.js delete mode 100644 source/Collection/index.js delete mode 100644 source/Collection/types.js delete mode 100644 source/Collection/utils/calculateSizeAndPositionData.jest.js delete mode 100644 source/Collection/utils/calculateSizeAndPositionData.js delete mode 100644 source/ColumnSizer/ColumnSizer.example.css delete mode 100644 source/ColumnSizer/ColumnSizer.example.js delete mode 100644 source/ColumnSizer/ColumnSizer.jest.js delete mode 100644 source/ColumnSizer/ColumnSizer.js delete mode 100644 source/ColumnSizer/index.js delete mode 100644 source/Grid/Grid.example.css delete mode 100644 source/Grid/Grid.example.js delete mode 100644 source/Grid/Grid.jest.js delete mode 100644 source/Grid/Grid.js delete mode 100644 source/Grid/Grid.ssr.js delete mode 100644 source/Grid/index.js delete mode 100644 source/Grid/types.js delete mode 100644 source/Grid/utils/CellSizeAndPositionManager.jest.js delete mode 100644 source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js delete mode 100644 source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js delete mode 100644 source/Grid/utils/maxElementSize.js delete mode 100644 source/Grid/utils/updateScrollIndexHelper.jest.js delete mode 100644 source/Grid/utils/updateScrollIndexHelper.js delete mode 100644 source/InfiniteLoader/InfiniteLoader.example.css delete mode 100644 source/InfiniteLoader/InfiniteLoader.example.js delete mode 100644 source/InfiniteLoader/InfiniteLoader.jest.js delete mode 100644 source/InfiniteLoader/InfiniteLoader.js delete mode 100644 source/InfiniteLoader/index.js delete mode 100644 source/List/List.example.css delete mode 100644 source/List/List.example.js delete mode 100644 source/List/List.jest.js delete mode 100644 source/List/List.js delete mode 100644 source/List/index.js delete mode 100644 source/List/types.js delete mode 100644 source/Masonry/Masonry.example.css delete mode 100644 source/Masonry/Masonry.example.js delete mode 100644 source/Masonry/Masonry.jest.js delete mode 100644 source/Masonry/Masonry.js delete mode 100644 source/Masonry/index.js delete mode 100644 source/MultiGrid/MultiGrid.example.css delete mode 100644 source/MultiGrid/MultiGrid.example.js delete mode 100644 source/MultiGrid/MultiGrid.jest.js delete mode 100644 source/MultiGrid/MultiGrid.js delete mode 100644 source/MultiGrid/index.js delete mode 100644 source/ScrollSync/ScrollSync.example.css delete mode 100644 source/ScrollSync/ScrollSync.example.js delete mode 100644 source/ScrollSync/ScrollSync.jest.js delete mode 100644 source/ScrollSync/ScrollSync.js delete mode 100644 source/ScrollSync/index.js delete mode 100644 source/Table/Column.jest.js delete mode 100644 source/Table/Column.js delete mode 100644 source/Table/SortIndicator.js delete mode 100644 source/Table/Table.example.css delete mode 100644 source/Table/Table.example.js delete mode 100644 source/Table/Table.jest.js delete mode 100644 source/Table/Table.js delete mode 100644 source/Table/createMultiSort.jest.js delete mode 100644 source/Table/createMultiSort.js delete mode 100644 source/Table/defaultHeaderRenderer.js delete mode 100644 source/Table/defaultHeaderRowRenderer.js delete mode 100644 source/Table/defaultRowRenderer.js delete mode 100644 source/Table/index.js delete mode 100644 source/Table/types.js delete mode 100644 source/TestUtils.js delete mode 100644 source/WindowScroller/WindowScroller.e2e.js delete mode 100644 source/WindowScroller/WindowScroller.example.css delete mode 100644 source/WindowScroller/WindowScroller.example.js delete mode 100644 source/WindowScroller/WindowScroller.header-resize.e2e.js delete mode 100644 source/WindowScroller/WindowScroller.jest.js delete mode 100644 source/WindowScroller/WindowScroller.js delete mode 100644 source/WindowScroller/WindowScroller.ssr.js delete mode 100644 source/WindowScroller/index.js delete mode 100644 source/WindowScroller/utils/onScroll.js delete mode 100644 source/demo/Application.css delete mode 100644 source/demo/Application.js delete mode 100644 source/demo/ComponentLink.css delete mode 100644 source/demo/ComponentLink.js delete mode 100644 source/demo/ContentBox.css delete mode 100644 source/demo/ContentBox.js delete mode 100644 source/demo/Icon.css delete mode 100644 source/demo/Icon.js delete mode 100644 source/demo/LabeledInput.css delete mode 100644 source/demo/LabeledInput.js delete mode 100644 source/demo/NavLink.css delete mode 100644 source/demo/NavLink.js delete mode 100644 source/demo/Wizard/Generator.js delete mode 100644 source/demo/Wizard/Wizard.css delete mode 100644 source/demo/Wizard/Wizard.js delete mode 100644 source/demo/Wizard/index.js delete mode 100644 source/demo/favicon.png delete mode 100644 source/demo/index.js delete mode 100644 source/demo/utils.js delete mode 100644 source/index.js delete mode 100644 source/jest-setup.js delete mode 100644 source/utils/animationFrame.js delete mode 100644 source/utils/createCallbackMemoizer.js delete mode 100644 source/utils/getUpdatedOffsetForIndex.jest.js delete mode 100644 source/vendor/binarySearchBounds.js delete mode 100644 source/vendor/detectElementResize.js delete mode 100644 source/vendor/intervalTree.js create mode 100644 src/App.tsx create mode 100644 src/components/Link.tsx create mode 100644 src/components/Methods.tsx create mode 100644 src/constants.ts create mode 100644 src/routes.ts create mode 100644 src/routes/cell-measurer/CellMeasurerCacheRoute.tsx create mode 100644 src/routes/cell-measurer/CellMeasurerColumnWidthsRoute.tsx create mode 100644 src/routes/cell-measurer/CellMeasurerLimitationsRoute.tsx create mode 100644 src/routes/cell-measurer/CellMeasurerPropsRoute.tsx create mode 100644 src/routes/cell-measurer/CellMeasurerRoute.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerCacheClear.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerDynamicHeightGrid.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerDynamicHeightList.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerDynamicHeightTableColumn.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerDynamicWidthGrid.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerDynamicWidthMultiGrid.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerFunctionComponent.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerImages.tsx create mode 100644 src/routes/cell-measurer/examples/CellMeasurerKeyMapper.ts create mode 100644 src/routes/cell-measurer/routes.ts create mode 100644 src/routes/grid/CellRangeRendererRoute.tsx create mode 100644 src/routes/grid/FixedRowsAndColumnsRoute.tsx create mode 100644 src/routes/grid/GridMethodsRoute.tsx create mode 100644 src/routes/grid/GridPropsRoute.tsx create mode 100644 src/routes/grid/GridRoute.tsx create mode 100644 src/routes/grid/MultiGridMethodsRoute.tsx create mode 100644 src/routes/grid/MultiGridPropsRoute.tsx create mode 100644 src/routes/grid/OverscanRoute.tsx create mode 100644 src/routes/grid/ScrollToCellRoute.tsx create mode 100644 src/routes/grid/VariableSizesRoute.tsx create mode 100644 src/routes/grid/examples/GridBasic.tsx create mode 100644 src/routes/grid/examples/GridCellRangeRenderer.tsx create mode 100644 src/routes/grid/examples/GridCellRenderer.tsx create mode 100644 src/routes/grid/examples/GridMethodsRef.tsx create mode 100644 src/routes/grid/examples/GridOverscanIndicesGetter.ts create mode 100644 src/routes/grid/examples/GridScrollToCell.tsx create mode 100644 src/routes/grid/examples/GridVariableSizes.tsx create mode 100644 src/routes/grid/examples/MultiGridFixedRowsAndColumns.tsx create mode 100644 src/routes/grid/examples/OverscanDemo.tsx create mode 100644 src/routes/grid/routes.ts create mode 100644 src/routes/guides/CustomizingStylesRoute.tsx create mode 100644 src/routes/guides/InfiniteLoadingListRoute.tsx create mode 100644 src/routes/guides/MultiColumnSortTableRoute.tsx create mode 100644 src/routes/guides/NaturalSortTableRoute.tsx create mode 100644 src/routes/guides/ReverseListRoute.tsx create mode 100644 src/routes/guides/ServerRenderingRoute.tsx create mode 100644 src/routes/guides/SizingComponentsRoute.tsx create mode 100644 src/routes/guides/examples/AutoSizerGrid.tsx create mode 100644 src/routes/guides/examples/AutoSizerList.tsx create mode 100644 src/routes/guides/examples/AutoSizerTable.tsx create mode 100644 src/routes/guides/examples/CustomizingStylesClassNames.tsx create mode 100644 src/routes/guides/examples/CustomizingStylesCssModules.module.css create mode 100644 src/routes/guides/examples/CustomizingStylesCssModules.tsx create mode 100644 src/routes/guides/examples/CustomizingStylesGlobal.css create mode 100644 src/routes/guides/examples/CustomizingStylesImport.ts create mode 100644 src/routes/guides/examples/CustomizingStylesInlineStyles.tsx create mode 100644 src/routes/guides/examples/InfiniteLoadingList.tsx create mode 100644 src/routes/guides/examples/InfiniteLoadingListDemo.tsx create mode 100644 src/routes/guides/examples/MultiColumnSortTable.tsx create mode 100644 src/routes/guides/examples/MultiColumnSortTableCallback.tsx create mode 100644 src/routes/guides/examples/MultiColumnSortTableDefaults.tsx create mode 100644 src/routes/guides/examples/NaturalSortTable.tsx create mode 100644 src/routes/guides/examples/ReverseList.tsx create mode 100644 src/routes/guides/examples/ServerRenderingInitialScroll.tsx create mode 100644 src/routes/guides/examples/ServerRenderingSizes.tsx create mode 100644 src/routes/guides/examples/UsingAutoSizerChildComponent.tsx create mode 100644 src/routes/guides/examples/UsingAutoSizerFlexContainer.tsx create mode 100644 src/routes/guides/examples/UsingAutoSizerInfiniteLoader.tsx create mode 100644 src/routes/guides/examples/UsingAutoSizerWidthOnly.tsx create mode 100644 src/routes/hoc/ArrowKeyStepperRoute.tsx create mode 100644 src/routes/hoc/ColumnSizerRoute.tsx create mode 100644 src/routes/hoc/InfiniteLoaderRoute.tsx create mode 100644 src/routes/hoc/ScrollSyncRoute.tsx create mode 100644 src/routes/hoc/examples/ArrowKeyStepperControlled.tsx create mode 100644 src/routes/hoc/examples/ArrowKeyStepperGrid.tsx create mode 100644 src/routes/hoc/examples/ColumnSizerGrid.tsx create mode 100644 src/routes/hoc/examples/InfiniteLoaderBasic.tsx create mode 100644 src/routes/hoc/examples/InfiniteLoaderGrid.tsx create mode 100644 src/routes/hoc/examples/InfiniteLoaderList.tsx create mode 100644 src/routes/hoc/examples/InfiniteLoaderResetCache.tsx create mode 100644 src/routes/hoc/examples/ScrollSyncSpreadsheet.tsx create mode 100644 src/routes/list/ListMethodsRoute.tsx create mode 100644 src/routes/list/ListPropsRoute.tsx create mode 100644 src/routes/list/ListRenderingRowsRoute.tsx create mode 100644 src/routes/list/ListRoute.tsx create mode 100644 src/routes/list/ListScrollToRowRoute.tsx create mode 100644 src/routes/list/ListVariableRowHeightsRoute.tsx create mode 100644 src/routes/list/examples/ListBasic.tsx create mode 100644 src/routes/list/examples/ListMethods.tsx create mode 100644 src/routes/list/examples/ListNoRows.tsx create mode 100644 src/routes/list/examples/ListRecomputeRowHeights.tsx create mode 100644 src/routes/list/examples/ListRowRenderer.tsx create mode 100644 src/routes/list/examples/ListScrollToRow.tsx create mode 100644 src/routes/list/examples/ListScrollingPlaceholder.tsx create mode 100644 src/routes/list/examples/ListVariableRowHeights.tsx create mode 100644 src/routes/list/routes.ts create mode 100644 src/routes/masonry/MasonryCellPositionersRoute.tsx create mode 100644 src/routes/masonry/MasonryImagesRoute.tsx create mode 100644 src/routes/masonry/MasonryMeasuringRoute.tsx create mode 100644 src/routes/masonry/MasonryMethodsRoute.tsx create mode 100644 src/routes/masonry/MasonryPropsRoute.tsx create mode 100644 src/routes/masonry/MasonryRoute.tsx create mode 100644 src/routes/masonry/examples/MasonryCellPositioner.tsx create mode 100644 src/routes/masonry/examples/MasonryCellPositionerReset.tsx create mode 100644 src/routes/masonry/examples/MasonryCustomPositioner.tsx create mode 100644 src/routes/masonry/examples/MasonryExample.tsx create mode 100644 src/routes/masonry/routes.ts create mode 100644 src/routes/table/ColumnPropsRoute.tsx create mode 100644 src/routes/table/SortingRoute.tsx create mode 100644 src/routes/table/TableCellRenderersRoute.tsx create mode 100644 src/routes/table/TableMethodsRoute.tsx create mode 100644 src/routes/table/TablePropsRoute.tsx create mode 100644 src/routes/table/TableRoute.tsx create mode 100644 src/routes/table/TableRowRenderersRoute.tsx create mode 100644 src/routes/table/TableRowStylesRoute.tsx create mode 100644 src/routes/table/examples/SortIndicatorHeader.tsx create mode 100644 src/routes/table/examples/SortTableBasic.tsx create mode 100644 src/routes/table/examples/TableBasic.tsx create mode 100644 src/routes/table/examples/TableColumnRenderers.tsx create mode 100644 src/routes/table/examples/TableFullFeatured.tsx create mode 100644 src/routes/table/examples/TableImperativeMethods.tsx create mode 100644 src/routes/table/examples/TableRowRenderer.tsx create mode 100644 src/routes/table/routes.ts create mode 100644 src/routes/types.ts create mode 100644 src/routes/window-scrolling/ScrollingAnElementRoute.tsx create mode 100644 src/routes/window-scrolling/ScrollingWithThePageRoute.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingContentAbove.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingGrid.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingList.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingMasonry.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingMigrationAfter.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingMigrationBefore.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingScrollElement.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingScrollToRow.tsx create mode 100644 src/routes/window-scrolling/examples/WindowScrollingSmoothScroll.ts create mode 100644 src/routes/window-scrolling/examples/WindowScrollingTable.tsx create mode 100644 src/routes/window-scrolling/routes.ts create mode 100644 src/utils/data.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.json create mode 100644 vercel.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts delete mode 100644 webpack.config.demo.js delete mode 100644 webpack.config.dev.js delete mode 100644 yarn.lock diff --git a/.babelrc.js b/.babelrc.js deleted file mode 100644 index 75c4651d4..000000000 --- a/.babelrc.js +++ /dev/null @@ -1,76 +0,0 @@ -const env = process.env.NODE_ENV; - -if (env === 'commonjs' || env === 'es') { - module.exports = { - ignore: [ - '*.jest.js', - '*.e2e.js', - '*.ssr.js', - '*.example.js', - 'source/demo', - 'source/jest-*.js', - 'source/TestUtils.js', - ], - plugins: [ - '@babel/plugin-transform-runtime', - '@babel/plugin-proposal-class-properties', - '@babel/plugin-transform-flow-comments', - ['flow-react-proptypes', {deadCode: true, useESModules: true}], - ['transform-react-remove-prop-types', {mode: 'wrap'}], - ], - presets: [ - ['@babel/preset-env', {modules: false}], - '@babel/preset-react', - '@babel/preset-flow', - ], - }; - - if (env === 'commonjs') { - module.exports.plugins.push('@babel/plugin-transform-modules-commonjs'); - } -} - -if (env === 'rollup') { - module.exports = { - comments: false, - plugins: [ - '@babel/plugin-external-helpers', - '@babel/plugin-proposal-class-properties', - ], - presets: [ - ['@babel/preset-env', {modules: false}], - '@babel/preset-react', - '@babel/preset-flow', - ], - }; -} - -if (env === 'development') { - module.exports = { - plugins: ['@babel/plugin-proposal-class-properties'], - presets: ['@babel/preset-react', '@babel/preset-flow'], - }; -} - -if (env === 'production') { - module.exports = { - comments: false, - plugins: [ - '@babel/plugin-transform-runtime', - '@babel/plugin-proposal-class-properties', - ], - presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-flow'], - }; -} - -if (env === 'test') { - module.exports = { - comments: false, - - plugins: [ - '@babel/plugin-transform-modules-commonjs', - '@babel/plugin-proposal-class-properties', - ], - presets: ['@babel/preset-react', '@babel/preset-flow'], - }; -} diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index af3d0ca18..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,119 +0,0 @@ -version: 2.1 - -defaults: &defaults - working_directory: ~/repo - docker: - - image: circleci/node:10.16.3-stretch-browsers - -jobs: - dependencies: - <<: *defaults - steps: - - checkout - - restore_cache: - key: npm-cache-{{ checksum "yarn.lock" }} - - run: - name: Install NPM Dependencies - command: yarn --frozen-lockfile - - save_cache: - key: npm-cache-{{ checksum "yarn.lock" }} - paths: - - ~/repo/node_modules - - lint: - <<: *defaults - steps: - - checkout - - restore_cache: - key: npm-cache-{{ checksum "yarn.lock" }} - - run: - name: ESLint Code Analysis - command: yarn lint - - typecheck: - <<: *defaults - steps: - - checkout - - restore_cache: - key: npm-cache-{{ checksum "yarn.lock" }} - - run: - name: Typechecking with flow - command: yarn typecheck - - test: - <<: *defaults - steps: - - checkout - - attach_workspace: - at: ~/repo - - restore_cache: - key: npm-cache-{{ checksum "yarn.lock" }} - - run: - name: Run tests - command: yarn test - - coverage: - <<: *defaults - steps: - - checkout - - attach_workspace: - at: ~/repo - - restore_cache: - key: npm-cache-{{ checksum "yarn.lock" }} - - run: - name: Run tests and upload coverage results - command: yarn test:coverage - - prettier: - <<: *defaults - steps: - - checkout - - restore_cache: - key: npm-cache-{{ checksum "yarn.lock" }} - - run: - name: Check prettier diff - command: yarn prettier:diff - - build: - <<: *defaults - steps: - - checkout - - restore_cache: - key: npm-cache-{{ checksum "yarn.lock" }} - - run: - name: Build all distributions formats - command: yarn build - - persist_to_workspace: - root: . - paths: - - dist - -workflows: - version: 2 - build_and_test: - jobs: - - dependencies - - lint: - requires: - - dependencies - - typecheck: - requires: - - dependencies - - prettier: - requires: - - dependencies - - build: - requires: - - dependencies - - test: - requires: - - build - filters: - branches: - ignore: gh-pages - - coverage: - requires: - - build - filters: - branches: - ignore: gh-pages diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index b16304917..000000000 --- a/.eslintignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules/* - -# Website dist -build/* - -# NPM dist -dist/* - -# Vendor files -source/vendor/* \ No newline at end of file diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index ad28271c9..000000000 --- a/.eslintrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "parser": "babel-eslint", - "plugins": ["react"], - "extends": ["fbjs", "eslint:recommended", "plugin:prettier/recommended"], - "rules": { - "no-console": 0 - } -} diff --git a/.flowconfig b/.flowconfig deleted file mode 100644 index 49d1bb13a..000000000 --- a/.flowconfig +++ /dev/null @@ -1,20 +0,0 @@ -[ignore] -.*/Collection/.* -.*/ColumnSizer/.* -.*/InfiniteLoader/.* -.*/Masonry/.* -.*/MultiGrid/.* -.*/ScrollSync/.* -.*/Table/.* - -[untyped] -.*/node_modules/babel-plugin-transform-react-remove-prop-types/.* -.*/node_modules/graphql/.* -.*/node_modules/immutable/.* - -[include] - -[libs] - -[options] -munge_underscores=true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 798e73834..240768e81 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,12 +2,12 @@ Thanks for contributing to react-virtualized! **Before submitting a pull request,** please complete the following checklist: -- [ ] The existing test suites (`npm test`) all pass +- [ ] The existing test suites (`pnpm test`) all pass - [ ] For any new features or bug fixes, both positive and negative test cases have been added - [ ] For any new features, documentation has been added - [ ] For any documentation changes, the text has been proofread and is clear to both experienced users and beginners. -- [ ] Format your code with [prettier](https://github.com/prettier/prettier) (`yarn run prettier`). -- [ ] Run the [Flow](https://flowtype.org/) typechecks (`yarn run typecheck`). +- [ ] Format your code with [prettier](https://github.com/prettier/prettier) (`pnpm prettier`). +- [ ] Run the [TypeScript](https://www.typescriptlang.org/) typechecks (`pnpm tsc`) and lint (`pnpm lint`). Here is a short checklist of additional things to keep in mind before submitting: diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 000000000..78e4a5d36 --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,18 @@ +name: "ESLint" +on: + push: + branches: [master] + pull_request: +jobs: + eslint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + - uses: pnpm/action-setup@v2 + with: + version: 10 + - name: Install dependencies + run: pnpm install --frozen-lockfile --recursive + - name: Run ESLint + run: pnpm lint diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..02bc9cdac --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,42 @@ +name: "Playwright Tests" +on: + push: + branches: [master] + pull_request: +jobs: + e2e-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + - uses: pnpm/action-setup@v2 + with: + version: 10 + - name: Install dependencies + run: pnpm install --frozen-lockfile --recursive + + - name: Get Playwright version + id: playwright-version + run: echo "version=$(pnpm -C integrations/tests exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browser binaries + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright # Default Linux path + key: ${{ runner.os }}-playwright-chromium-${{ steps.playwright-version.outputs.version }} + + - name: Install Playwright browsers and dependencies (on cache miss) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm e2e:install + + - name: Build library + run: pnpm prerelease + - name: Run Playwright tests + run: pnpm dev:integrations & pnpm e2e:test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: integrations/tests/test-results/ + retention-days: 14 diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml new file mode 100644 index 000000000..e77edce6e --- /dev/null +++ b/.github/workflows/prettier.yml @@ -0,0 +1,18 @@ +name: "Prettier" +on: + push: + branches: [master] + pull_request: +jobs: + prettier: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + - uses: pnpm/action-setup@v2 + with: + version: 10 + - name: Install dependencies + run: pnpm install --frozen-lockfile --recursive + - name: Run Prettier + run: pnpm run prettier:ci diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml new file mode 100644 index 000000000..25c247fec --- /dev/null +++ b/.github/workflows/typescript.yml @@ -0,0 +1,20 @@ +name: "TypeScript" +on: + push: + branches: [master] + pull_request: +jobs: + typescript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + - uses: pnpm/action-setup@v2 + with: + version: 10 + - name: Install dependencies + run: pnpm install --frozen-lockfile --recursive + - name: Build NPM package + run: pnpm build + - name: Run TypeScript + run: pnpm tsc diff --git a/.github/workflows/vitest.yml b/.github/workflows/vitest.yml new file mode 100644 index 000000000..af0e12fef --- /dev/null +++ b/.github/workflows/vitest.yml @@ -0,0 +1,20 @@ +name: "Vitest" +on: + push: + branches: [master] + pull_request: +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + - uses: pnpm/action-setup@v2 + with: + version: 10 + - name: Install dependencies + run: pnpm install --frozen-lockfile --recursive + - name: Build NPM packages + run: pnpm run build + - name: Run tests + run: pnpm run test:ci diff --git a/.gitignore b/.gitignore index d4821a555..72b3eaa7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,31 @@ -.DS_Store -build -coverage -dist +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + node_modules -npm-debug.log -styles.css -.vscode +coverage +/build +/dist +/docs +*.local + +integrations/*/dist +integrations/tests/test-results + +# Editor directories and files +.vscode/* +!.vscode/extensions.json .idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +tmp.* diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 70047db82..000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v10.16.3 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..c0c536bb5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +/codemods +/dist +/docs +/generated +/integrations/*/dist +/integrations/tests/test-results +/public + +CHANGELOG.md +README.md +pnpm-lock.yaml diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index fb944c939..000000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "bracketSpacing": false, - "jsxBracketSameLine": true, - "printWidth": 80, - "singleQuote": true, - "trailingComma": "all" -} \ No newline at end of file diff --git a/.watchmanconfig b/.watchmanconfig deleted file mode 100644 index e69de29bb..000000000 diff --git a/CHANGELOG.md b/CHANGELOG.md index 082c3cc12..a72fe27e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,176 @@ ##### NEXT -- Update peer dependencies to allow React 17 ([levenleven](https://github.com/levenleven) - [#1625](https://github.com/bvaughn/react-virtualized/pull/1625)) -- Use DOM API instead of creating Trusted Types policy to append a markup ([shhnjk](https://github.com/shhnjk) - [#1627](https://github.com/bvaughn/react-virtualized/pull/1627)) -- Fix bug in WindowScroller::updatePosition ([yamadapc](https://github.com/yamadapc) - [#1642](https://github.com/bvaughn/react-virtualized/pull/1642), [#1648](https://github.com/bvaughn/react-virtualized/pull/1648)) -- Fix babel tranform es error ([fupengl](https://github.com/fupengl) - [#1651](https://github.com/bvaughn/react-virtualized/pull/1651)) -- Fix issue with unused import being emitted ([mewhhaha](https://github.com/mewhhaha) - [#1635](https://github.com/bvaughn/react-virtualized/pull/1635)) -- Fix grid roles for accessbility ([asnewman](https://github.com/asnewman) - [#1624](https://github.com/bvaughn/react-virtualized/pull/1624)) +Version 10 is a modernization of react-virtualized. The source has been converted to TypeScript, every component has been rewritten as a function component with hooks, and React 18 or newer is required. Along the way, several long-standing bugs that were inherent in the 9.x architecture have been fixed (see "Bug fixes" below), which required some breaking API changes. The migration guide below covers each of them. + +###### Migrating from 9.x + +**1. Update React.** React 18 or newer is required (`react` and `react-dom` `^18.0.0 || ^19.0.0`). + +**2. Import from the package root.** Deep imports (`react-virtualized/dist/commonjs/List`, `react-virtualized/dist/es/List`) and the UMD build are gone; bundlers tree-shake unused components. + +```js +// Before +import List from 'react-virtualized/dist/commonjs/List'; +// After +import {List} from 'react-virtualized'; +``` + +**3. Replace `AutoSizer`** with the [`react-virtualized-auto-sizer`](https://www.npmjs.com/package/react-virtualized-auto-sizer) package (2.x). Width and height are `undefined` until measured, so `disableHeight`/`disableWidth`/`defaultHeight`/`defaultWidth` are no longer needed: + +```jsx +// Before +import {AutoSizer, List} from 'react-virtualized'; +{({height, width}) => } + +// After +import {AutoSizer} from 'react-virtualized-auto-sizer'; +import {List} from 'react-virtualized'; + + height !== undefined && width !== undefined ? ( + + ) : null + } +/> +``` + +**4. Replace controlled scroll props with ref methods.** `scrollToIndex`, `scrollToRow`, `scrollToColumn`, `scrollToAlignment`, `scrollTop`, and `scrollLeft` are no longer props: + +```jsx +// Before + + +// After +const listRef = useRef(null); // useRef(null) + +listRef.current.scrollToRow({index, align: 'center'}); +``` + +Use `scrollToCell({columnIndex, rowIndex, align})` for `Grid`/`MultiGrid` and `scrollToPosition({scrollLeft, scrollTop})` for offsets. These methods are one-time scrolls: unlike the props, the target isn't re-applied when sizes or counts change afterwards. To scroll to a row that is being added, call `scrollToRow` once it has rendered (e.g. in an effect); rows not yet measured by `CellMeasurer` are positioned using their estimated size. `List`/`Table` `scrollToRow(index)` and `scrollToPosition(scrollTop)` now take objects: `scrollToRow({index})`, `scrollToPosition({scrollTop})`. To start at a scroll position (e.g. one restored from the URL), pass `initialScrollTop`/`initialScrollLeft` instead of `scrollTop`/`scrollLeft`; they are only read when the component mounts (and also work with server rendering). + +**5. Use refs as imperative handles.** Components are function components, so refs no longer point to component instances. Type them with the exported `*ImperativeAPI` types (e.g. `useRef(null)`); internals such as `list.Grid` are no longer reachable. The documented `grid.forceUpdate()` pattern still works through the handle: `gridRef.current.forceUpdate()` (or `forceUpdateGrid()` for `List`/`Table`). Static `defaultProps` are replaced by exported objects such as `defaultGridProps` and `defaultColumnProps`. + +**6. Update `ScrollSync` and `ArrowKeyStepper` children.** Instead of passing `scrollLeft`/`scrollTop` (or `scrollToColumn`/`scrollToRow`) to the `Grid`, attach the `registerChild` ref callback: + +```jsx + + {({onScroll, registerChild}) => ( + <> + + + + )} + + + + {({onSectionRendered, registerChild, scrollToColumn, scrollToRow}) => ( + // scrollToColumn/scrollToRow are the active cell (e.g. to highlight it) + + )} + +``` + +**7. Replace `WindowScroller` with the `scrollElement` prop.** `WindowScroller` has been removed. Instead, pass `scrollElement="window"` (or a scrollable element) to a `Grid`, `List`, `Masonry` or `Table`. The component then renders at its full height, renders only the rows visible within the scroll element's viewport, and its `scrollToRow`/`scrollToCell`/`scrollToPosition` methods scroll the scroll element. `height` is optional: the viewport height of the scroll element is used. + +```jsx +// Before + + {({height, isScrolling, onChildScroll, registerChild, scrollTop}) => ( +
+ +
+ )} +
+ +// After + +``` + +- `registerChild` and the wrapper element: not needed; use a regular `ref` to call the component's methods. +- `height`/`width` render params: the viewport height is measured by the component; use `AutoSizer` (`renderProp={({width}) => …}`) for the width. +- `updatePosition()` (and `updateScrollTopOnUpdatePosition`): not needed; the component's position within the scroll element is measured again on every scroll, on resize, and whenever it re-renders. If content above it changes size without it re-rendering, call `forceUpdateGrid()` (`List`/`Table`) or `forceUpdate()` (`Grid`/`Masonry`). +- `scrollElement={element}`: pass the element itself (e.g. stored with a `useState` ref callback); `null` until it is available (the component then renders its top rows). +- `onScroll`: use the component's own `onScroll`. `onResize`: listen to the window's `resize` event (or observe the scroll element). +- `serverHeight`/`serverWidth`: pass `height` (used as the viewport height until the scroll element has been measured, e.g. on the server) and `width`. +- `scrollingResetTimeInterval`: use the prop of the `Grid`, `List`, `Masonry` or `Table`. + +**8. Replace `Column` subclasses with wrapper components.** `Table` applies `defaultColumnProps` to any child element, so `const MyColumn = (props) => ` works as a column. + +**9. Make sure `CellMeasurer` children can be measured.** The child must be a DOM element, or a component that passes its `ref` to one (a regular prop in React 19, `forwardRef` in React 18). Otherwise use the `registerChild` render prop. A DEV warning is logged when there is nothing to measure. + +**10. Remove `Collection`.** It has been removed; there is no direct replacement. + +###### Breaking changes + +- React 18 or newer is required (`react` and `react-dom` peer dependencies are `^18.0.0 || ^19.0.0`). +- The package ships a single ES module (`dist/react-virtualized.js`) and a single CommonJS module (`dist/react-virtualized.cjs`), with an `exports` map. Deep imports and the UMD build (`dist/umd/react-virtualized.js`) are no longer supported. +- Components no longer define `propTypes`; prop validation is done by TypeScript. +- `AutoSizer` has been removed; use [`react-virtualized-auto-sizer`](https://www.npmjs.com/package/react-virtualized-auto-sizer) 2.x (`renderProp`/`ChildComponent` replace the `children` function). +- `Collection` (and its `ReactVirtualized__Collection*` styles) has been removed. +- All components are function components wrapped in `memo`; refs expose imperative handles (`*ImperativeAPI` types) instead of component instances, and static `defaultProps` are replaced by exported `default*Props` objects. Subclassing components is no longer supported. +- Controlled scroll props have been removed: `scrollToRow`, `scrollToColumn`, and `scrollToAlignment` from `Grid`; `scrollToIndex` and `scrollToAlignment` from `List` and `Table`; `scrollLeft`, `scrollTop`, `scrollToRow`, `scrollToColumn`, and `scrollToAlignment` from `MultiGrid`. To render (or server-render) at an initial scroll position, use the new `initialScrollTop`/`initialScrollLeft` props. +- `List`/`Table`: `scrollToRow(index)` is now `scrollToRow({ index, align })` and `scrollToPosition(scrollTop)` is now `scrollToPosition({ scrollTop })`. `scrollToPosition` ignores unchanged offsets and clamps to the scrollable range. +- `Grid`'s `getOffsetForCell()` and `List`/`Table`'s `getOffsetForRow()` default to the last cell (or row) and `"auto"` alignment; they used to default to the `scrollToColumn`/`scrollToRow` props, which no longer exist. +- `List`'s props no longer include the `Grid` props that don't apply to a single-column list (`estimatedColumnSize`, `overscanColumnCount`). +- The `parent` passed to `List`'s `rowRenderer`, `Table`'s `cellRenderer`, `Masonry`'s `cellRenderer`, and every `MultiGrid` quadrant's `cellRenderer` is now that component's imperative handle. +- `WindowScroller` has been removed, along with `defaultWindowScrollerProps`, `IS_SCROLLING_TIMEOUT` and the `WindowScroller*` types. `Grid`, `List`, `Masonry` and `Table` take a `scrollElement` prop instead (see the migration guide). `updatePosition()` is replaced by automatic re-measuring: a component's position within its scroll element is measured on every scroll, on resize, and after every render. +- The `autoHeight`, `autoWidth`, `scrollTop`, `scrollLeft` and `isScrolling` props have been removed from `Grid`, `List`, `Table`, `Masonry` and `MultiGrid`. They only existed to connect a component to `WindowScroller`; use the `scrollElement` prop instead. Horizontal window scrolling (`autoWidth` with `scrollLeft`) is no longer supported (a `Grid` with a `scrollElement` still scrolls horizontally itself). +- With a `scrollElement`, `height` is optional (the scroll element's viewport height is used; `height` is only a fallback until it has been measured, e.g. during server rendering), and `initialScrollTop` is ignored. Without either, `Grid`, `List`, `Table` and `Masonry` log a DEV warning (and render nothing). +- `ArrowKeyStepper` and `ScrollSync` drive their children through `registerChild` instead of controlled scroll props. `ArrowKeyStepper` scrolls the active cell into view when it changes (rather than re-applying it on every `Grid` update). +- `CellMeasurer` children must accept a `ref` (or use `registerChild`); cells with no DOM node to measure keep the cache's default size instead of being cached as 0×0. +- `InfiniteLoader` re-renders its registered child with the first method it implements: `recomputeGridSize`, `recomputeRowHeights`, `forceUpdateGrid`, then `forceUpdate`. Custom function-component children must expose one of these through their imperative handle. +- Size changes of a `scrollElement` element are detected with `ResizeObserver` (window scrolling used to inject resize-trigger elements and a ` + + +
+ + + diff --git a/integrations/vite/package.json b/integrations/vite/package.json new file mode 100644 index 000000000..d604870de --- /dev/null +++ b/integrations/vite/package.json @@ -0,0 +1,24 @@ +{ + "name": "vite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port 3012 --strictPort", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-router": "^7", + "react-virtualized": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^3.10.2", + "typescript": "~5.8.3", + "vite": "^7.0.4" + } +} diff --git a/integrations/vite/src/components/Header.tsx b/integrations/vite/src/components/Header.tsx new file mode 100644 index 000000000..f3115c5f7 --- /dev/null +++ b/integrations/vite/src/components/Header.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +// A header above the virtualized component. +// Its height is state of its own (changed through window.setHeaderHeight), +// so that changing it does not re-render the virtualized component. +export function Header({ initialHeight }: { initialHeight: number }) { + const [height, setHeight] = useState(initialHeight); + + useEffect(() => { + window.setHeaderHeight = setHeight; + return () => { + window.setHeaderHeight = null; + }; + }, []); + + return ( +
+ Header +
+ ); +} diff --git a/integrations/vite/src/components/ScrollContainer.tsx b/integrations/vite/src/components/ScrollContainer.tsx new file mode 100644 index 000000000..cb09d82d8 --- /dev/null +++ b/integrations/vite/src/components/ScrollContainer.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import { CONTAINER_HEIGHT, CONTAINER_OFFSET } from "../../../tests"; + +// A scrollable element (to be used as the scrollElement of the component it contains), offset from the top of the page +export function ScrollContainer({ + children, + ref +}: { + children: ReactNode; + ref: (element: HTMLDivElement | null) => void; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/integrations/vite/src/main.tsx b/integrations/vite/src/main.tsx new file mode 100644 index 000000000..2f82d36df --- /dev/null +++ b/integrations/vite/src/main.tsx @@ -0,0 +1,21 @@ +import { createRoot } from "react-dom/client"; +import { BrowserRouter, Route, Routes } from "react-router"; +import { GridRoute } from "./routes/GridRoute"; +import { HomeRoute } from "./routes/Home"; +import { ListRoute } from "./routes/ListRoute"; +import { MasonryRoute } from "./routes/MasonryRoute"; +import { TableRoute } from "./routes/TableRoute"; + +// Note that StrictMode is intentionally not used; +// it would double-invoke mount effects in development mode (linking and unlinking each scroll element twice). +createRoot(document.getElementById("root")!).render( + + + } /> + } /> + } /> + } /> + } /> + + +); diff --git a/integrations/vite/src/routes/GridRoute.tsx b/integrations/vite/src/routes/GridRoute.tsx new file mode 100644 index 000000000..13d2b5bcc --- /dev/null +++ b/integrations/vite/src/routes/GridRoute.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { Grid, type CellRendererParams } from "react-virtualized"; +import { + COLUMN_COUNT, + COLUMN_WIDTH, + ROW_COUNT, + ROW_HEIGHT, + WIDTH +} from "../../../tests"; +import { Header } from "../components/Header"; +import { ScrollContainer } from "../components/ScrollContainer"; +import { useRouteParams } from "../utils/searchParams"; + +function cellRenderer({ + columnIndex, + key, + rowIndex, + style +}: CellRendererParams) { + return ( +
+ {rowIndex}:{columnIndex} +
+ ); +} + +function setGridRef(grid: Window["grid"]) { + window.grid = grid; +} + +// A Grid (wider than its width, so it scrolls horizontally itself) below a header, +// scrolled vertically by a scrollable container (or the window) +export function GridRoute() { + const { headerHeight, overscanRowCount, scrollElementType } = + useRouteParams(); + + const [container, setContainer] = useState(null); + + const content = ( + <> +
+ + + ); + + return scrollElementType === "container" ? ( + {content} + ) : ( + content + ); +} diff --git a/integrations/vite/src/routes/Home.tsx b/integrations/vite/src/routes/Home.tsx new file mode 100644 index 000000000..864e7a34f --- /dev/null +++ b/integrations/vite/src/routes/Home.tsx @@ -0,0 +1,25 @@ +import { Link } from "react-router"; + +export function HomeRoute() { + return ( +
+ + List: scrolled by the window + + + List: scrolled by a container + + + List: scrollElement initially null (call window.linkScrollElement()) + + + List: header resize (call window.setHeaderHeight(height)) + + + Grid: scrolled by a container + + Table: scrolled by the window + Masonry: scrolled by the window +
+ ); +} diff --git a/integrations/vite/src/routes/ListRoute.tsx b/integrations/vite/src/routes/ListRoute.tsx new file mode 100644 index 000000000..a79f302a0 --- /dev/null +++ b/integrations/vite/src/routes/ListRoute.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from "react"; +import { List, type RowRendererParams } from "react-virtualized"; +import { ROW_COUNT, ROW_HEIGHT, WIDTH } from "../../../tests"; +import { Header } from "../components/Header"; +import { ScrollContainer } from "../components/ScrollContainer"; +import { useRouteParams } from "../utils/searchParams"; + +// Defined outside of the component so that the List's props are stable +// (it does not re-render unless its scrollElement changes) +function rowRenderer({ index, key, style }: RowRendererParams) { + return ( +
+ Row {index} +
+ ); +} + +function setListRef(list: Window["list"]) { + window.list = list; +} + +// A List below a header, scrolled by the window, a scrollable container, or (initially null) a deferred scroll element +export function ListRoute() { + const { headerHeight, overscanRowCount, scrollElementType } = + useRouteParams(); + + const [container, setContainer] = useState(null); + const [deferredLinked, setDeferredLinked] = useState(false); + + useEffect(() => { + window.linkScrollElement = () => setDeferredLinked(true); + return () => { + window.linkScrollElement = null; + }; + }, []); + + const scrollElement = + scrollElementType === "container" + ? container + : scrollElementType === "deferred" + ? deferredLinked + ? "window" + : null + : "window"; + + const content = ( + <> +
+ + + ); + + return scrollElementType === "container" ? ( + {content} + ) : ( + content + ); +} diff --git a/integrations/vite/src/routes/MasonryRoute.tsx b/integrations/vite/src/routes/MasonryRoute.tsx new file mode 100644 index 000000000..ef4771f88 --- /dev/null +++ b/integrations/vite/src/routes/MasonryRoute.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { + CellMeasurer, + CellMeasurerCache, + createMasonryCellPositioner, + Masonry, + type MasonryProps +} from "react-virtualized"; +import { + getMasonryCellHeight, + MASONRY_CELL_COUNT, + MASONRY_COLUMN_COUNT, + MASONRY_COLUMN_WIDTH, + MASONRY_SPACER +} from "../../../tests"; +import { Header } from "../components/Header"; +import { useRouteParams } from "../utils/searchParams"; + +type MasonryCellRendererParams = Parameters[0]; + +function setMasonryRef(masonry: Window["masonry"]) { + window.masonry = masonry; +} + +// A Masonry below a header, scrolled by the window +export function MasonryRoute() { + const { headerHeight } = useRouteParams(); + + const [cache] = useState( + () => + new CellMeasurerCache({ + defaultHeight: 150, + defaultWidth: MASONRY_COLUMN_WIDTH, + fixedWidth: true + }) + ); + + const [cellPositioner] = useState(() => + createMasonryCellPositioner({ + cellMeasurerCache: cache, + columnCount: MASONRY_COLUMN_COUNT, + columnWidth: MASONRY_COLUMN_WIDTH, + spacer: MASONRY_SPACER + }) + ); + + const [cellRenderer] = useState( + () => + function cellRenderer({ + index, + key, + parent, + style + }: MasonryCellRendererParams) { + return ( + +
+ {/* The measured cell's own height is reset while CellMeasurer measures it; so the content determines its height */} +
+ Cell {index} +
+
+
+ ); + } + ); + + return ( + <> +
+ + + ); +} diff --git a/integrations/vite/src/routes/TableRoute.tsx b/integrations/vite/src/routes/TableRoute.tsx new file mode 100644 index 000000000..35fad52e7 --- /dev/null +++ b/integrations/vite/src/routes/TableRoute.tsx @@ -0,0 +1,42 @@ +import { Column, Table } from "react-virtualized"; +import { + ROW_COUNT, + ROW_HEIGHT, + TABLE_HEADER_HEIGHT, + WIDTH +} from "../../../tests"; +import { Header } from "../components/Header"; +import { useRouteParams } from "../utils/searchParams"; + +function rowGetter({ index }: { index: number }) { + return { index, name: `Row ${index}` }; +} + +function setTableRef(table: Window["table"]) { + window.table = table; +} + +// A Table (whose header row scrolls along with its rows) below a header, scrolled by the window +export function TableRoute() { + const { headerHeight, overscanRowCount } = useRouteParams(); + + return ( + <> +
+ + + +
+ + ); +} diff --git a/integrations/vite/src/utils/searchParams.ts b/integrations/vite/src/utils/searchParams.ts new file mode 100644 index 000000000..31888fd3c --- /dev/null +++ b/integrations/vite/src/utils/searchParams.ts @@ -0,0 +1,26 @@ +import { useSearchParams } from "react-router"; +import { HEADER_HEIGHT } from "../../../tests"; + +export type ScrollElementType = "container" | "deferred" | "window"; + +// Search params shared by the routes: +// - scrollElement: "window" (default), "container" (a scrollable element, stored via a state ref callback), +// or "deferred" (initially null, set to "window" by window.linkScrollElement()) +// - headerHeight: initial height of the header above the component +// - overscan: overscanRowCount (the component's default if not specified) +export function useRouteParams() { + const [searchParams] = useSearchParams(); + + const scrollElement = searchParams.get("scrollElement"); + const headerHeight = searchParams.get("headerHeight"); + const overscan = searchParams.get("overscan"); + + return { + headerHeight: headerHeight !== null ? Number(headerHeight) : HEADER_HEIGHT, + overscanRowCount: overscan !== null ? Number(overscan) : undefined, + scrollElementType: (scrollElement === "container" || + scrollElement === "deferred" + ? scrollElement + : "window") as ScrollElementType + }; +} diff --git a/integrations/vite/src/vite-env.d.ts b/integrations/vite/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/integrations/vite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/integrations/vite/tsconfig.json b/integrations/vite/tsconfig.json new file mode 100644 index 000000000..fcbd955ed --- /dev/null +++ b/integrations/vite/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/integrations/vite/tsconfig.tsbuildinfo b/integrations/vite/tsconfig.tsbuildinfo new file mode 100644 index 000000000..cac56ddb0 --- /dev/null +++ b/integrations/vite/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/routes/home.tsx","./src/routes/windowscrollercontainerresize.tsx","./src/routes/windowscrollerheaderresize.tsx","./src/routes/windowscrollerscroll.tsx"],"version":"5.8.3"} \ No newline at end of file diff --git a/integrations/vite/vite.config.ts b/integrations/vite/vite.config.ts new file mode 100644 index 000000000..9fe73326c --- /dev/null +++ b/integrations/vite/vite.config.ts @@ -0,0 +1,15 @@ +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + resolve: { + // react-virtualized is linked from the workspace root; + // make sure it shares this app's copy of React + dedupe: ["react", "react-dom"] + }, + server: { + cors: true + } +}); diff --git a/jest-puppeteer.config.js b/jest-puppeteer.config.js deleted file mode 100644 index faea0850a..000000000 --- a/jest-puppeteer.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - launch: { - headless: process.env.HEADLESS !== 'false', - devtools: process.env.DEVTOOLS === 'true', - }, -}; diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index 6fb378661..000000000 --- a/jest.config.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - globalSetup: 'jest-environment-puppeteer/setup', - globalTeardown: 'jest-environment-puppeteer/teardown', - setupFiles: ['./source/jest-setup.js'], - roots: ['./source'], - coverageReporters: ['lcov'], - collectCoverageFrom: [ - 'source/**/*.js', - '!source/vendor/**', - '!source/demo/**', - '!source/jest-*.js', - '!source/TestUtils.js', - '!**/*.example.js', - ], - testRegex: '.(jest|e2e|ssr).js$', - verbose: true, -}; diff --git a/lib/components/ArrowKeyStepper/ArrowKeyStepper.test.tsx b/lib/components/ArrowKeyStepper/ArrowKeyStepper.test.tsx new file mode 100644 index 000000000..b081bc819 --- /dev/null +++ b/lib/components/ArrowKeyStepper/ArrowKeyStepper.test.tsx @@ -0,0 +1,577 @@ +import { fireEvent } from "@testing-library/react"; +import { act, StrictMode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { findDOMNode, render } from "../../utils/test/render"; +import { ArrowKeyStepper } from "./ArrowKeyStepper"; +import { defaultArrowKeyStepperProps } from "./defaultArrowKeyStepperProps"; +import type { + ArrowKeyStepperChild, + ArrowKeyStepperChildProps, + ArrowKeyStepperImperativeAPI, + ArrowKeyStepperProps, + ArrowKeyStepperRenderedSection +} from "./types"; + +function renderTextContent(scrollToColumn: number, scrollToRow: number) { + return `scrollToColumn:${scrollToColumn}, scrollToRow:${scrollToRow}`; +} + +function ChildComponent({ + scrollToColumn, + scrollToRow +}: Pick) { + return
{renderTextContent(scrollToColumn, scrollToRow)}
; +} + +describe("ArrowKeyStepper", () => { + function renderHelper( + props: Partial & { node?: HTMLElement } = {} + ) { + let onSectionRenderedCallback: + | ((params: ArrowKeyStepperRenderedSection) => void) + | undefined; + + const component = render( + + {({ onSectionRendered, scrollToColumn, scrollToRow }) => { + onSectionRenderedCallback = onSectionRendered; + + return ( + + ); + }} + + ); + const node = findDOMNode(component) as HTMLElement; + + return { + component, + node, + onSectionRendered: onSectionRenderedCallback! + }; + } + + function assertCurrentScrollTo( + node: HTMLElement, + scrollToColumn: number, + scrollToRow: number + ) { + expect(node.textContent).toEqual( + renderTextContent(scrollToColumn, scrollToRow) + ); + } + + it("should use a custom :className if one is specified", () => { + const { node } = renderHelper({ className: "foo" }); + expect(node.className).toEqual("foo"); + }); + + it("should update :scrollToColumn and :scrollToRow in response to arrow keys", () => { + const { node } = renderHelper(); + assertCurrentScrollTo(node, 0, 0); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 0, 1); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 1, 1); + fireEvent.keyDown(node, { key: "ArrowUp" }); + assertCurrentScrollTo(node, 1, 0); + fireEvent.keyDown(node, { key: "ArrowLeft" }); + assertCurrentScrollTo(node, 0, 0); + }); + + it("should not scroll past the row and column boundaries provided", () => { + const { node } = renderHelper({ + columnCount: 2, + rowCount: 2 + }); + fireEvent.keyDown(node, { key: "ArrowDown" }); + fireEvent.keyDown(node, { key: "ArrowDown" }); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 0, 1); + fireEvent.keyDown(node, { key: "ArrowUp" }); + fireEvent.keyDown(node, { key: "ArrowUp" }); + fireEvent.keyDown(node, { key: "ArrowUp" }); + assertCurrentScrollTo(node, 0, 0); + fireEvent.keyDown(node, { key: "ArrowRight" }); + fireEvent.keyDown(node, { key: "ArrowRight" }); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 1, 0); + fireEvent.keyDown(node, { key: "ArrowLeft" }); + fireEvent.keyDown(node, { key: "ArrowLeft" }); + fireEvent.keyDown(node, { key: "ArrowLeft" }); + assertCurrentScrollTo(node, 0, 0); + }); + + it("should accept initial :scrollToColumn and :scrollToRow values via props", () => { + const { node } = renderHelper({ + mode: "cells", + scrollToColumn: 2, + scrollToRow: 4 + }); + assertCurrentScrollTo(node, 2, 4); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 2, 5); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 3, 5); + }); + + it("should accept updated :scrollToColumn and :scrollToRow values via props", () => { + const { node } = renderHelper({ + mode: "cells", + scrollToColumn: 2, + scrollToRow: 4 + }); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 2, 5); + renderHelper({ + mode: "cells", + scrollToColumn: 1, + scrollToRow: 1 + }); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 2, 1); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 2, 2); + }); + + it("should accept updated :scrollToColumn and :scrollToRow values via setScrollIndexes()", () => { + const { component, node } = renderHelper({ + mode: "cells", + scrollToColumn: 2, + scrollToRow: 4 + }); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 2, 5); + act(() => { + component.setScrollIndexes({ + scrollToColumn: 1, + scrollToRow: 1 + }); + }); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 2, 1); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 2, 2); + }); + + it("should not update :scrollToColumn or :scrollToRow when :disabled", () => { + const { node } = renderHelper({ + disabled: true + }); + assertCurrentScrollTo(node, 0, 0); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 0, 0); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 0, 0); + }); + + it("should call :onScrollToChange for key down", () => { + [true, false].forEach(() => { + const onScrollToChange = vi.fn(); + const { node } = renderHelper({ + isControlled: true, + onScrollToChange + }); + + expect(onScrollToChange.mock.calls).toHaveLength(0); + + fireEvent.keyDown(node, { key: "ArrowDown" }); + + expect(onScrollToChange.mock.calls).toHaveLength(1); + + const { scrollToColumn, scrollToRow } = onScrollToChange.mock.calls[0][0]; + expect(scrollToColumn).toEqual(0); + expect(scrollToRow).toEqual(1); + }); + }); + + it("should not call :onScrollToChange for prop update", () => { + let numCalls = 0; + const onScrollToChange = () => { + numCalls++; + }; + const { node } = renderHelper({ + onScrollToChange, + scrollToColumn: 0, + scrollToRow: 0 + }); + + renderHelper({ + isControlled: true, + onScrollToChange, + node, + scrollToColumn: 0, + scrollToRow: 1 + }); + expect(numCalls).toEqual(0); + }); + + describe('mode === "edges"', () => { + it("should update :scrollToColumn and :scrollToRow relative to the most recent :onSectionRendered event", () => { + const { node, onSectionRendered } = renderHelper(); + act(() => { + onSectionRendered({ + // Simulate a scroll + columnStartIndex: 0, + columnStopIndex: 4, + rowStartIndex: 4, + rowStopIndex: 6 + }); + }); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 0, 7); + + act(() => { + onSectionRendered({ + // Simulate a scroll + columnStartIndex: 5, + columnStopIndex: 10, + rowStartIndex: 2, + rowStopIndex: 4 + }); + }); + fireEvent.keyDown(node, { key: "ArrowUp" }); + assertCurrentScrollTo(node, 0, 1); + + act(() => { + onSectionRendered({ + // Simulate a scroll + columnStartIndex: 4, + columnStopIndex: 8, + rowStartIndex: 5, + rowStopIndex: 10 + }); + }); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 9, 1); + + act(() => { + onSectionRendered({ + // Simulate a scroll + columnStartIndex: 2, + columnStopIndex: 4, + rowStartIndex: 2, + rowStopIndex: 4 + }); + }); + fireEvent.keyDown(node, { key: "ArrowLeft" }); + assertCurrentScrollTo(node, 1, 1); + }); + }); + + describe('mode === "cells"', () => { + it("should update :scrollToColumn and :scrollToRow relative to the most recent :onSectionRendered event", () => { + const { node, onSectionRendered } = renderHelper({ + mode: "cells", + scrollToColumn: 5, + scrollToRow: 5 + }); + + act(() => { + onSectionRendered({ + // Simulate a scroll + columnStartIndex: 10, + columnStopIndex: 10, + rowStartIndex: 15, + rowStopIndex: 15 + }); + }); + fireEvent.keyDown(node, { key: "ArrowUp" }); + assertCurrentScrollTo(node, 5, 4); + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 5, 5); + + act(() => { + onSectionRendered({ + // Simulate a scroll + columnStartIndex: 10, + columnStopIndex: 10, + rowStartIndex: 15, + rowStopIndex: 15 + }); + }); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 6, 5); + fireEvent.keyDown(node, { key: "ArrowLeft" }); + assertCurrentScrollTo(node, 5, 5); + }); + }); + describe("registerChild", () => { + function renderWithChild( + props: Partial = {}, + child: ArrowKeyStepperChild = { scrollToCell: vi.fn() } + ) { + let registerChildCallback: + | ArrowKeyStepperChildProps["registerChild"] + | undefined; + let onSectionRenderedCallback: + | ((params: ArrowKeyStepperRenderedSection) => void) + | undefined; + + const component = render( + + {({ + onSectionRendered, + registerChild, + scrollToColumn, + scrollToRow + }) => { + registerChildCallback = registerChild; + onSectionRenderedCallback = onSectionRendered; + + return ( + + ); + }} + + ); + + return { + child, + component, + node: findDOMNode(component) as HTMLElement, + onSectionRendered: onSectionRenderedCallback!, + registerChild: registerChildCallback! + }; + } + + function RegisteredChild({ + child, + registerChild, + scrollToColumn, + scrollToRow + }: Pick< + ArrowKeyStepperChildProps, + "registerChild" | "scrollToColumn" | "scrollToRow" + > & { child: ArrowKeyStepperChild }) { + return ( +
{ + registerChild(element ? child : null); + }} + > + {renderTextContent(scrollToColumn, scrollToRow)} +
+ ); + } + + it("should scroll the registered child to the initial active cell", () => { + const { child } = renderWithChild({ scrollToColumn: 2, scrollToRow: 4 }); + expect(child.scrollToCell).toHaveBeenCalledTimes(1); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 2, + rowIndex: 4 + }); + }); + + it("should scroll the registered child when arrow keys change the active cell", () => { + const { child, node } = renderWithChild(); + vi.mocked(child.scrollToCell).mockClear(); + + fireEvent.keyDown(node, { key: "ArrowDown" }); + expect(child.scrollToCell).toHaveBeenCalledTimes(1); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 0, + rowIndex: 1 + }); + + fireEvent.keyDown(node, { key: "ArrowRight" }); + expect(child.scrollToCell).toHaveBeenCalledTimes(2); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 1, + rowIndex: 1 + }); + + // No change at the boundary, so no scroll + fireEvent.keyDown(node, { key: "ArrowUp" }); + fireEvent.keyDown(node, { key: "ArrowUp" }); + expect(child.scrollToCell).toHaveBeenCalledTimes(3); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 1, + rowIndex: 0 + }); + }); + + it("should scroll the registered child in edges mode", () => { + const { child, node, onSectionRendered } = renderWithChild({ + mode: "edges" + }); + act(() => { + onSectionRendered({ + columnStartIndex: 0, + columnStopIndex: 4, + rowStartIndex: 0, + rowStopIndex: 5 + }); + }); + fireEvent.keyDown(node, { key: "ArrowDown" }); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 0, + rowIndex: 6 + }); + }); + + it("should scroll the registered child when setScrollIndexes() is called", () => { + const { child, component } = renderWithChild(); + act(() => { + component.setScrollIndexes({ scrollToColumn: 3, scrollToRow: 7 }); + }); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 3, + rowIndex: 7 + }); + }); + + it("should scroll the registered child when controlled props change", () => { + const onScrollToChange = vi.fn(); + const { child, node } = renderWithChild({ + isControlled: true, + onScrollToChange, + scrollToColumn: 1, + scrollToRow: 1 + }); + vi.mocked(child.scrollToCell).mockClear(); + + // A controlled stepper only reports the change + fireEvent.keyDown(node, { key: "ArrowDown" }); + expect(onScrollToChange).toHaveBeenLastCalledWith({ + scrollToColumn: 1, + scrollToRow: 2 + }); + expect(child.scrollToCell).not.toHaveBeenCalled(); + assertCurrentScrollTo(node, 1, 1); + + renderWithChild( + { + isControlled: true, + onScrollToChange, + scrollToColumn: 1, + scrollToRow: 2 + }, + child + ); + expect(child.scrollToCell).toHaveBeenCalledTimes(1); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 1, + rowIndex: 2 + }); + assertCurrentScrollTo(node, 1, 2); + }); + + it("should scroll a newly registered child to the active cell", () => { + const { child, node } = renderWithChild(); + fireEvent.keyDown(node, { key: "ArrowDown" }); + fireEvent.keyDown(node, { key: "ArrowRight" }); + + const nextChild: ArrowKeyStepperChild = { scrollToCell: vi.fn() }; + renderWithChild({}, nextChild); + expect(nextChild.scrollToCell).toHaveBeenCalledTimes(1); + expect(nextChild.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 1, + rowIndex: 1 + }); + expect(child.scrollToCell).toHaveBeenCalledTimes(3); + }); + + it("should not scroll a child after it has been unregistered", () => { + const child: ArrowKeyStepperChild = { scrollToCell: vi.fn() }; + const { node } = renderWithChild({}, child); + vi.mocked(child.scrollToCell).mockClear(); + + // Unmounting the child calls registerChild(null) + const component = render( + + {({ scrollToColumn, scrollToRow }) => ( +
{renderTextContent(scrollToColumn, scrollToRow)}
+ )} +
+ ); + expect(findDOMNode(component)).toBe(node); + + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 0, 1); + expect(child.scrollToCell).not.toHaveBeenCalled(); + }); + + it("should pass a stable registerChild and onSectionRendered", () => { + const first = renderWithChild(); + fireEvent.keyDown(first.node, { key: "ArrowDown" }); + const second = renderWithChild(); + expect(second.registerChild).toBe(first.registerChild); + expect(second.onSectionRendered).toBe(first.onSectionRendered); + }); + }); + + it("should work in StrictMode", () => { + const child: ArrowKeyStepperChild = { scrollToCell: vi.fn() }; + let component: ArrowKeyStepperImperativeAPI | null = null; + + render( + + { + component = value; + }} + rowCount={10} + scrollToColumn={2} + scrollToRow={3} + > + {({ registerChild, scrollToColumn, scrollToRow }) => ( +
{ + registerChild(element ? child : null); + }} + > + {renderTextContent(scrollToColumn, scrollToRow)} +
+ )} +
+
+ ); + + const node = document.body.querySelector("div > div > div")! + .parentElement as HTMLElement; + assertCurrentScrollTo(node, 2, 3); + expect(child.scrollToCell).toHaveBeenCalledTimes(1); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 2, + rowIndex: 3 + }); + + fireEvent.keyDown(node, { key: "ArrowDown" }); + assertCurrentScrollTo(node, 2, 4); + expect(child.scrollToCell).toHaveBeenCalledTimes(2); + expect(child.scrollToCell).toHaveBeenLastCalledWith({ + columnIndex: 2, + rowIndex: 4 + }); + + act(() => { + component!.setScrollIndexes({ scrollToColumn: 5, scrollToRow: 5 }); + }); + assertCurrentScrollTo(node, 5, 5); + fireEvent.keyDown(node, { key: "ArrowRight" }); + assertCurrentScrollTo(node, 6, 5); + }); + + it("should export its default prop values", () => { + expect(defaultArrowKeyStepperProps).toEqual({ + disabled: false, + isControlled: false, + mode: "edges", + scrollToColumn: 0, + scrollToRow: 0 + }); + expect(Object.isFrozen(defaultArrowKeyStepperProps)).toBe(true); + }); +}); diff --git a/lib/components/ArrowKeyStepper/ArrowKeyStepper.tsx b/lib/components/ArrowKeyStepper/ArrowKeyStepper.tsx new file mode 100644 index 000000000..af72d79f3 --- /dev/null +++ b/lib/components/ArrowKeyStepper/ArrowKeyStepper.tsx @@ -0,0 +1,276 @@ +import { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useRef, + useState, + type KeyboardEvent +} from "react"; +import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect"; +import { defaultArrowKeyStepperProps } from "./defaultArrowKeyStepperProps"; +import type { + ArrowKeyStepperChild, + ArrowKeyStepperImperativeAPI, + ArrowKeyStepperProps, + ArrowKeyStepperRenderedSection, + ScrollIndices +} from "./types"; + +type ArrowKeyStepperState = ScrollIndices & { + prevScrollToColumn: number; + prevScrollToRow: number; +}; + +const initialState: ArrowKeyStepperState = { + scrollToColumn: 0, + scrollToRow: 0, + prevScrollToColumn: 0, + prevScrollToRow: 0 +}; + +// Equivalent of the 9.x getDerivedStateFromProps(): +// an uncontrolled stepper resets its active cell whenever the scrollToColumn/scrollToRow props change. +function deriveState( + isControlled: boolean, + scrollToColumn: number, + scrollToRow: number, + prevState: ArrowKeyStepperState +): ArrowKeyStepperState { + if ( + !isControlled && + (scrollToColumn !== prevState.prevScrollToColumn || + scrollToRow !== prevState.prevScrollToRow) + ) { + return { + scrollToColumn, + scrollToRow, + prevScrollToColumn: scrollToColumn, + prevScrollToRow: scrollToRow + }; + } + + return prevState; +} + +/** + * This HOC decorates a virtualized component and responds to arrow-key events by scrolling one row or column at a time. + * This provides a snap-to behavior rather than the default browser scrolling behavior. + * + * Note that unlike the other HOCs in react-virtualized, `ArrowKeyStepper` adds a `
` element around its children in order to attach a key-down event handler. + * The appearance of this wrapper element can be customized using the `className` property. + */ +export const ArrowKeyStepper = /* @__PURE__ */ memo( + /* @__PURE__ */ forwardRef< + ArrowKeyStepperImperativeAPI, + ArrowKeyStepperProps + >(function ArrowKeyStepper( + { + children, + className, + columnCount, + disabled = defaultArrowKeyStepperProps.disabled, + isControlled = defaultArrowKeyStepperProps.isControlled, + mode = defaultArrowKeyStepperProps.mode, + onScrollToChange, + rowCount, + scrollToColumn: + scrollToColumnProp = defaultArrowKeyStepperProps.scrollToColumn, + scrollToRow: scrollToRowProp = defaultArrowKeyStepperProps.scrollToRow + }, + ref + ) { + const [storedState, setState] = useState(() => + deriveState( + isControlled, + scrollToColumnProp, + scrollToRowProp, + initialState + ) + ); + + // Apply prop changes during render (the documented replacement for getDerivedStateFromProps) + const state = deriveState( + isControlled, + scrollToColumnProp, + scrollToRowProp, + storedState + ); + if (state !== storedState) { + setState(state); + } + + const scrollToColumn = isControlled + ? scrollToColumnProp + : state.scrollToColumn; + const scrollToRow = isControlled ? scrollToRowProp : state.scrollToRow; + + // Range of cells most recently rendered by the child (see onSectionRendered) + const renderedSectionRef = useRef({ + columnStartIndex: 0, + columnStopIndex: 0, + rowStartIndex: 0, + rowStopIndex: 0 + }); + + const childRef = useRef(null); + + // Active cell as of the most recent commit; initialized for the first commit + // (child refs are attached before this component's layout effects run) + const activeCellRef = useRef({ + scrollToColumn, + scrollToRow + }); + + // The child and cell most recently scrolled into view; avoids redundant scrollToCell() calls + // (e.g. when the same child is re-registered in StrictMode) + const lastScrolledRef = useRef<{ + child: ArrowKeyStepperChild; + scrollToColumn: number; + scrollToRow: number; + } | null>(null); + + const scrollChildToActiveCell = useCallback(() => { + const child = childRef.current; + const { scrollToColumn, scrollToRow } = activeCellRef.current; + const lastScrolled = lastScrolledRef.current; + + if ( + child === null || + (lastScrolled !== null && + lastScrolled.child === child && + lastScrolled.scrollToColumn === scrollToColumn && + lastScrolled.scrollToRow === scrollToRow) + ) { + return; + } + + lastScrolledRef.current = { child, scrollToColumn, scrollToRow }; + + child.scrollToCell({ + columnIndex: scrollToColumn, + rowIndex: scrollToRow + }); + }, []); + + const registerChild = useCallback( + (child: ArrowKeyStepperChild | null) => { + childRef.current = child; + if (child !== null) { + scrollChildToActiveCell(); + } + }, + [scrollChildToActiveCell] + ); + + useIsomorphicLayoutEffect(() => { + activeCellRef.current = { scrollToColumn, scrollToRow }; + scrollChildToActiveCell(); + }, [scrollChildToActiveCell, scrollToColumn, scrollToRow]); + + const onSectionRendered = useCallback( + ({ + columnStartIndex, + columnStopIndex, + rowStartIndex, + rowStopIndex + }: ArrowKeyStepperRenderedSection) => { + renderedSectionRef.current = { + columnStartIndex, + columnStopIndex, + rowStartIndex, + rowStopIndex + }; + }, + [] + ); + + useImperativeHandle( + ref, + () => ({ + setScrollIndexes({ scrollToColumn, scrollToRow }: ScrollIndices) { + setState((prevState) => ({ + ...prevState, + scrollToColumn, + scrollToRow + })); + } + }), + [] + ); + + const onKeyDown = (event: KeyboardEvent) => { + if (disabled) { + return; + } + + const { columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex } = + renderedSectionRef.current; + + let nextScrollToColumn = scrollToColumn; + let nextScrollToRow = scrollToRow; + + // The above cases all prevent default event event behavior. + // This is to keep the grid from scrolling after the snap-to update. + switch (event.key) { + case "ArrowDown": + nextScrollToRow = + mode === "cells" + ? Math.min(scrollToRow + 1, rowCount - 1) + : Math.min(rowStopIndex + 1, rowCount - 1); + break; + case "ArrowLeft": + nextScrollToColumn = + mode === "cells" + ? Math.max(scrollToColumn - 1, 0) + : Math.max(columnStartIndex - 1, 0); + break; + case "ArrowRight": + nextScrollToColumn = + mode === "cells" + ? Math.min(scrollToColumn + 1, columnCount - 1) + : Math.min(columnStopIndex + 1, columnCount - 1); + break; + case "ArrowUp": + nextScrollToRow = + mode === "cells" + ? Math.max(scrollToRow - 1, 0) + : Math.max(rowStartIndex - 1, 0); + break; + } + + if ( + nextScrollToColumn !== scrollToColumn || + nextScrollToRow !== scrollToRow + ) { + event.preventDefault(); + + if (typeof onScrollToChange === "function") { + onScrollToChange({ + scrollToColumn: nextScrollToColumn, + scrollToRow: nextScrollToRow + }); + } + + if (!isControlled) { + setState((prevState) => ({ + ...prevState, + scrollToColumn: nextScrollToColumn, + scrollToRow: nextScrollToRow + })); + } + } + }; + + return ( +
+ {children({ + onSectionRendered, + registerChild, + scrollToColumn, + scrollToRow + })} +
+ ); + }) +); diff --git a/lib/components/ArrowKeyStepper/defaultArrowKeyStepperProps.ts b/lib/components/ArrowKeyStepper/defaultArrowKeyStepperProps.ts new file mode 100644 index 000000000..a3b1d5b57 --- /dev/null +++ b/lib/components/ArrowKeyStepper/defaultArrowKeyStepperProps.ts @@ -0,0 +1,21 @@ +import type { ArrowKeyStepperProps } from "./types"; + +type DefaultedPropKeys = + | "disabled" + | "isControlled" + | "mode" + | "scrollToColumn" + | "scrollToRow"; + +/** + * Default values for `ArrowKeyStepper`'s optional props. + */ +export const defaultArrowKeyStepperProps: Readonly< + Required> +> = /* @__PURE__ */ Object.freeze({ + disabled: false, + isControlled: false, + mode: "edges", + scrollToColumn: 0, + scrollToRow: 0 +}); diff --git a/lib/components/ArrowKeyStepper/types.ts b/lib/components/ArrowKeyStepper/types.ts new file mode 100644 index 000000000..c1792776b --- /dev/null +++ b/lib/components/ArrowKeyStepper/types.ts @@ -0,0 +1,135 @@ +import type { ReactNode } from "react"; +import type { RenderedSection } from "../Grid/types"; + +/** + * Scroll-to indices managed by `ArrowKeyStepper`. + */ +export type ScrollIndices = { + /** Index of the column that should be scrolled into view. */ + scrollToColumn: number; + + /** Index of the row that should be scrolled into view. */ + scrollToRow: number; +}; + +/** + * Range of cells currently visible in the decorated component. + * This is a subset of the parameters passed to `Grid`'s `onSectionRendered` callback. + */ +export type ArrowKeyStepperRenderedSection = Pick< + RenderedSection, + "columnStartIndex" | "columnStopIndex" | "rowStartIndex" | "rowStopIndex" +>; + +/** + * Imperative API of the component decorated by `ArrowKeyStepper` (e.g. a `Grid` ref), + * registered through the `registerChild` parameter. + */ +export interface ArrowKeyStepperChild { + /** Ensure the specified column and row are visible. */ + scrollToCell(params: { columnIndex?: number; rowIndex?: number }): void; +} + +/** + * Parameters passed to the `ArrowKeyStepper` children render function. + */ +export type ArrowKeyStepperChildProps = { + /** + * Pass-through callback to be attached to the child component (e.g. `Grid`'s `onSectionRendered` prop); + * informs the key-stepper which range of cells are currently visible. + */ + onSectionRendered: (params: ArrowKeyStepperRenderedSection) => void; + + /** + * Stable ref callback to be attached to the decorated component (e.g. ``). + * Whenever the active cell (`scrollToColumn`/`scrollToRow`) changes, `ArrowKeyStepper` scrolls it into view + * by calling `scrollToCell({ columnIndex, rowIndex })` on the registered component. + * The active cell is also scrolled into view when a component is first registered. + */ + registerChild: (child: ArrowKeyStepperChild | null) => void; + + /** + * Column index of the active cell. + * It is scrolled into view automatically (see `registerChild`); it can also be used to e.g. highlight the active cell. + */ + scrollToColumn: number; + + /** + * Row index of the active cell. + * It is scrolled into view automatically (see `registerChild`); it can also be used to e.g. highlight the active cell. + */ + scrollToRow: number; +}; + +export type ArrowKeyStepperProps = { + /** + * Function responsible for rendering children. + * It is passed `onSectionRendered` and `registerChild` parameters, which should be attached to the decorated + * virtualized component (e.g. ``), + * and the `scrollToColumn` and `scrollToRow` indices of the active cell (e.g. to highlight it). + * The active cell is scrolled into view through the registered component's `scrollToCell` method. + */ + children: (params: ArrowKeyStepperChildProps) => ReactNode; + + /** CSS class name to attach to the wrapper `
`. */ + className?: string; + + /** Number of columns in grid; for `Table` and `List` this property should always be `1`. */ + columnCount: number; + + /** + * Disables all scrolling using arrow-keys. + * + * @default false + */ + disabled?: boolean; + + /** + * This component is "controlled"; it will not update `scrollToColumn` or `scrollToRow` itself. + * This property should be used with `onScrollToChange`. + * + * @default false + */ + isControlled?: boolean; + + /** + * Controls behavior of stepper when arrow key direction changes. + * "cells" means that the index will only increment or decrement by 1; + * "edges" means that the opposite side of the grid will be incremented. + * + * @default "edges" + */ + mode?: "cells" | "edges"; + + /** Called when arrow key navigation should update the current scroll-to values. */ + onScrollToChange?: (params: ScrollIndices) => void; + + /** Number of rows in grid. */ + rowCount: number; + + /** + * Default/initial `scrollToColumn` value. + * When `isControlled` is true, this is the current `scrollToColumn` value. + * + * @default 0 + */ + scrollToColumn?: number; + + /** + * Default/initial `scrollToRow` value. + * When `isControlled` is true, this is the current `scrollToRow` value. + * + * @default 0 + */ + scrollToRow?: number; +}; + +/** + * Public methods available on an `ArrowKeyStepper` ref. + */ +export interface ArrowKeyStepperImperativeAPI { + /** + * Override the local state of the component with new values for `scrollToRow` and `scrollToColumn`. + */ + setScrollIndexes(params: ScrollIndices): void; +} diff --git a/lib/components/CellMeasurer/CellMeasurer.react18.test.tsx b/lib/components/CellMeasurer/CellMeasurer.react18.test.tsx new file mode 100644 index 000000000..a36fe74a9 --- /dev/null +++ b/lib/components/CellMeasurer/CellMeasurer.react18.test.tsx @@ -0,0 +1,93 @@ +import { forwardRef, memo, type Ref } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "../../utils/test/render"; +import { CellMeasurer } from "./CellMeasurer"; +import { CellMeasurerCache } from "./CellMeasurerCache"; + +// Simulates React 18 (where refs are not passed to function components as props) +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, default: actual, version: "18.3.1" }; +}); + +function mockSizesFromDataAttributes() { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + return Number(this.getAttribute("data-height") ?? 0); + } + }); +} + +describe("CellMeasurer (React 18)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should not attach a ref to a plain function component (and warn that it can't be measured)", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "error"); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const Row = vi.fn(function Row(props: { ref?: Ref }) { + return
; + }); + const MemoRow = memo(function MemoRow() { + return
; + }); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + render( + + + + ); + render( + + + + ); + + // The ref is not passed along (React 18 would log an error and ignore it) + expect(Row.mock.calls[0][0].ref).toBeUndefined(); + expect(cache.has(0, 0)).toBe(false); + expect(cache.has(1, 0)).toBe(false); + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("forwardRef") + ); + }); + + it("should measure a forwardRef function component", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "warn"); + + const Row = forwardRef(function Row(_, ref) { + return
; + }); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + render( + + + + ); + + expect(cache.getHeight(0, 0)).toBe(50); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("should measure a DOM element child", () => { + mockSizesFromDataAttributes(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + render( + +
+ + ); + + expect(cache.getHeight(0, 0)).toBe(20); + }); +}); diff --git a/lib/components/CellMeasurer/CellMeasurer.test.tsx b/lib/components/CellMeasurer/CellMeasurer.test.tsx new file mode 100644 index 000000000..e32132302 --- /dev/null +++ b/lib/components/CellMeasurer/CellMeasurer.test.tsx @@ -0,0 +1,834 @@ +import { + act, + Component, + createRef, + forwardRef, + StrictMode, + type ReactNode, + type Ref +} from "react"; +import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; +import { render } from "../../utils/test/render"; +import { CellMeasurer } from "./CellMeasurer"; +import { + CellMeasurerCache, + DEFAULT_HEIGHT, + DEFAULT_WIDTH +} from "./CellMeasurerCache"; +import type { + CellMeasurerChildFunction, + CellMeasurerChildParams, + CellMeasurerProps +} from "./types"; + +// Accounts for the fact that JSDom doesn't support measurements. +function mockClientWidthAndHeight( + { height, width }: { height: number; width: number }, + object: object = HTMLElement.prototype +) { + const heightFn = vi.fn().mockReturnValue(height); + const widthFn = vi.fn().mockReturnValue(width); + + Object.defineProperty(object, "offsetHeight", { + configurable: true, + get: heightFn + }); + + Object.defineProperty(object, "offsetWidth", { + configurable: true, + get: widthFn + }); + + return { + heightFn, + widthFn + }; +} + +// Elements report the size specified by their data-height and data-width attributes +function mockSizesFromDataAttributes() { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + return Number(this.getAttribute("data-height") ?? 0); + } + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get(this: HTMLElement) { + return Number(this.getAttribute("data-width") ?? 0); + } + }); +} + +function createParent({ + cache, + invalidateCellSizeAfterRender = vi.fn(), + recomputeGridSize = vi.fn() +}: { + cache?: CellMeasurerCache; + invalidateCellSizeAfterRender?: Mock; + recomputeGridSize?: Mock; +} = {}) { + return { + invalidateCellSizeAfterRender, + recomputeGridSize, + props: { + deferredMeasurementCache: cache + } + }; +} + +function renderHelper({ + cache = new CellMeasurerCache({ + fixedWidth: true + }), + children =
, + parent, + strictMode = false +}: { + cache?: CellMeasurerCache; + children?: ReactNode | CellMeasurerChildFunction; + parent?: ReturnType; + strictMode?: boolean; +} = {}) { + const measurer = ( + + {children} + + ); + + render(strictMode ? {measurer} : measurer); +} + +// Captures the params passed to a function child +function createChildFunction(renderChild: CellMeasurerChildFunction) { + const calls: CellMeasurerChildParams[] = []; + const children: CellMeasurerChildFunction = (params) => { + calls.push(params); + return renderChild(params); + }; + return { calls, children }; +} + +describe("CellMeasurer", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should measure content that is not already in the cache on mount", () => { + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + const { heightFn, widthFn } = mockClientWidthAndHeight({ + height: 20, + width: 100 + }); + + expect(heightFn).toHaveBeenCalledTimes(0); + expect(widthFn).toHaveBeenCalledTimes(0); + expect(cache.has(0, 0)).toBe(false); + + renderHelper({ cache, parent }); + + expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled(); + expect(heightFn).toHaveBeenCalledTimes(1); + expect(widthFn).toHaveBeenCalledTimes(1); + expect(cache.has(0, 0)).toBe(true); + expect(cache.getWidth(0, 0)).toBe(100); + expect(cache.getHeight(0, 0)).toBe(20); + }); + + it("should not measure content that is already in the cache on mount", () => { + const cache = new CellMeasurerCache({ fixedWidth: true }); + cache.set(0, 0, 100, 20); + + const parent = createParent({ cache }); + + const { heightFn, widthFn } = mockClientWidthAndHeight({ + height: 20, + width: 100 + }); + + expect(cache.has(0, 0)).toBe(true); + + renderHelper({ cache, parent }); + + expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled(); + expect(heightFn).toHaveBeenCalledTimes(0); + expect(widthFn).toHaveBeenCalledTimes(0); + }); + + it("should measure content that is not already in the cache on update", () => { + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + renderHelper({ cache, parent }); + + cache.clear(0, 0); + parent.invalidateCellSizeAfterRender.mockReset(); + + expect(cache.has(0, 0)).toBe(false); + expect(cache.getWidth(0, 0)).toBe(DEFAULT_WIDTH); + expect(cache.getHeight(0, 0)).toBe(DEFAULT_HEIGHT); + + const { heightFn, widthFn } = mockClientWidthAndHeight({ + height: 20, + width: 100 + }); + + // A new child element (as a cell renderer would create) re-renders the memoized CellMeasurer + renderHelper({ cache, children:
, parent }); + + expect(cache.has(0, 0)).toBe(true); + + expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled(); + expect(heightFn).toHaveBeenCalledTimes(1); + expect(widthFn).toHaveBeenCalledTimes(1); + expect(cache.getWidth(0, 0)).toBe(100); + expect(cache.getHeight(0, 0)).toBe(20); + }); + + it("should not measure content that is already in the cache on update", () => { + const cache = new CellMeasurerCache({ fixedWidth: true }); + cache.set(0, 0, 100, 20); + + const parent = createParent({ cache }); + + expect(cache.has(0, 0)).toBe(true); + + const { heightFn, widthFn } = mockClientWidthAndHeight({ + height: 20, + width: 100 + }); + + renderHelper({ cache, parent }); + renderHelper({ cache, children:
, parent }); + + expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled(); + expect(heightFn).toHaveBeenCalledTimes(0); + expect(widthFn).toHaveBeenCalledTimes(0); + }); + + it("registerChild() should measure content that is not already in the cache", () => { + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + const element = document.createElement("div"); + const { heightFn, widthFn } = mockClientWidthAndHeight( + { + height: 20, + width: 100 + }, + element + ); + + expect(heightFn).toHaveBeenCalledTimes(0); + expect(widthFn).toHaveBeenCalledTimes(0); + expect(cache.has(0, 0)).toBe(false); + + renderHelper({ + cache, + parent, + children({ registerChild }) { + registerChild(element); + return null; + } + }); + + expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled(); + expect(heightFn).toHaveBeenCalledTimes(1); + expect(widthFn).toHaveBeenCalledTimes(1); + expect(cache.has(0, 0)).toBe(true); + expect(cache.getWidth(0, 0)).toBe(100); + expect(cache.getHeight(0, 0)).toBe(20); + }); + + it("registerChild() should not measure content that is already in the cache", () => { + const cache = new CellMeasurerCache({ fixedWidth: true }); + cache.set(0, 0, 100, 20); + + const parent = createParent({ cache }); + + const element = document.createElement("div"); + const { heightFn, widthFn } = mockClientWidthAndHeight( + { + height: 20, + width: 100 + }, + element + ); + + expect(cache.has(0, 0)).toBe(true); + + renderHelper({ + cache, + parent, + children({ registerChild }) { + registerChild(element); + return null; + } + }); + + expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled(); + expect(heightFn).toHaveBeenCalledTimes(0); + expect(widthFn).toHaveBeenCalledTimes(0); + }); + + it("registerChild() should be usable as a ref", () => { + mockSizesFromDataAttributes(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + renderHelper({ + cache, + parent, + children: ({ registerChild }) => ( +
+ ) + }); + + expect(cache.getHeight(0, 0)).toBe(25); + expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalledTimes(1); + }); + + it("should pass a :measure param to a function child", () => { + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + + const children = vi.fn().mockReturnValue(
); + + renderHelper({ cache, children }); + + expect(children).toHaveBeenCalled(); + + const params = children.mock.calls[0][0]; + + expect(typeof params.measure === "function").toBe(true); + }); + + it("should pass stable :measure and :registerChild params to a function child", () => { + const cache = new CellMeasurerCache({ fixedWidth: true }); + + const { calls, children } = createChildFunction(() =>
); + + renderHelper({ cache, children }); + renderHelper({ cache, children: (params) => children(params) }); + + expect(calls).toHaveLength(2); + expect(calls[0].measure).toBe(calls[1].measure); + expect(calls[0].registerChild).toBe(calls[1].registerChild); + }); + + it("measure() should re-measure the cell and recompute the parent's size if it changed", () => { + mockSizesFromDataAttributes(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + let height = 20; + const { calls, children } = createChildFunction(() => ( +
+ )); + renderHelper({ cache, children, parent }); + + expect(cache.getHeight(0, 0)).toBe(20); + + // Unchanged size + act(() => calls[0].measure()); + expect(parent.recomputeGridSize).not.toHaveBeenCalled(); + + // Changed size + height = 40; + renderHelper({ cache, children: (params) => children(params), parent }); + act(() => calls[0].measure()); + expect(cache.getHeight(0, 0)).toBe(40); + expect(parent.recomputeGridSize).toHaveBeenCalledTimes(1); + expect(parent.recomputeGridSize).toHaveBeenCalledWith({ + columnIndex: 0, + rowIndex: 0 + }); + }); + + it("should use :index as the row index if :rowIndex is not specified", () => { + mockSizesFromDataAttributes(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + + render( + +
+ + ); + + expect(cache.has(3, 0)).toBe(true); + expect(cache.getHeight(3, 0)).toBe(45); + }); + + it("should still update cache without a parent Grid", () => { + vi.spyOn(console, "warn"); + + mockClientWidthAndHeight({ height: 20, width: 100 }); + + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + + renderHelper({ cache }); // No parent Grid + + expect(cache.has(0, 0)).toBe(true); + + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("should measure under StrictMode", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "error"); + vi.spyOn(console, "warn"); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + const { calls, children } = createChildFunction(() => ( +
+ )); + renderHelper({ cache, children, parent, strictMode: true }); + + expect(cache.getHeight(0, 0)).toBe(20); + // Measured once; the second (StrictMode) effect run finds the cell in the cache + expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalledTimes(1); + + // StrictMode double-invokes the render function, but the params are stable + expect(calls.length).toBeGreaterThan(1); + expect(new Set(calls.map((call) => call.measure)).size).toBe(1); + expect(new Set(calls.map((call) => call.registerChild)).size).toBe(1); + + // The ref is re-attached after StrictMode's simulated unmount, so measure() still works + act(() => calls[0].measure()); + expect(parent.recomputeGridSize).not.toHaveBeenCalled(); + + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + // See issue #593 + it('should explicitly set width/height style to "auto" before re-measuring', () => { + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + const parent = createParent({ cache }); + const child = vi + .fn() + .mockReturnValue(
); + + render( + + {child} + + ); + + const node = document.querySelector("div div") as HTMLElement; + + const styleHeights: unknown[] = [30]; + const styleWidths: unknown[] = [100]; + Object.defineProperties(node.style, { + height: { + get: () => styleHeights[styleHeights.length - 1], + set: (value: unknown) => styleHeights.push(value) + }, + width: { + get: () => styleWidths[styleWidths.length - 1], + set: (value: unknown) => styleWidths.push(value) + } + }); + + mockClientWidthAndHeight({ height: 30, width: 100 }); + + act(() => child.mock.calls[0][0].measure()); + + expect(styleHeights).toEqual([30, "auto", 30]); + expect(styleWidths).toEqual([100, 100]); + }); + + // See issue #660 + it('should reset width/height style values after measuring with style "auto"', () => { + const cache = new CellMeasurerCache({ + fixedHeight: true + }); + const parent = createParent({ cache }); + const child = vi + .fn() + .mockReturnValue(
); + + render( + + {child} + + ); + + const node = document.querySelector("div div") as HTMLElement; + + (node.style as unknown as Record).width = 200; + (node.style as unknown as Record).height = 60; + + act(() => child.mock.calls[0][0].measure()); + + expect(node.style.height).toBe("30px"); + expect(node.style.width).toBe("100px"); + }); + + it("should be flagged for Grid's missing style warning", () => { + expect(CellMeasurer.__internalCellMeasurerFlag).toBe(true); + }); + + // Bug #1 + describe("function component children", () => { + it("should measure a function component that passes its ref prop to a DOM element (React 19)", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "error"); + + function Row({ ref }: { ref?: Ref }) { + return
; + } + + const cache = new CellMeasurerCache({ fixedWidth: true }); + renderHelper({ cache, children: }); + + expect(cache.getHeight(0, 0)).toBe(50); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("should measure a forwardRef function component", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "error"); + + const Row = forwardRef(function Row(_, ref) { + return
; + }); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + renderHelper({ cache, children: }); + + expect(cache.getHeight(0, 0)).toBe(50); + expect(console.error).not.toHaveBeenCalled(); + }); + + it("should warn (and not cache 0x0) for a function component that doesn't attach its ref", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + function Row() { + return
; + } + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + renderHelper({ cache, children: , parent }); + + expect(cache.has(0, 0)).toBe(false); + expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("registerChild") + ); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("forwardRef") + ); + + // Warns once per cache + renderHelper({ cache, children: , parent }); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it("should measure a function component that uses registerChild", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "warn"); + + function Row({ + registerChild + }: { + registerChild: CellMeasurerChildParams["registerChild"]; + }) { + return
; + } + + const cache = new CellMeasurerCache({ fixedWidth: true }); + renderHelper({ + cache, + children: ({ registerChild }) => + }); + + expect(cache.getHeight(0, 0)).toBe(50); + expect(console.warn).not.toHaveBeenCalled(); + }); + }); + + // Bug #2 + describe("registerChild precedence", () => { + it("should measure the registered element rather than the outer element", () => { + mockSizesFromDataAttributes(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + const { calls, children } = createChildFunction(({ registerChild }) => ( +
+
+
+ )); + renderHelper({ cache, children, parent }); + + expect(cache.getHeight(0, 0)).toBe(30); + + // Later measurements also use the registered element + act(() => calls[0].measure()); + expect(cache.getHeight(0, 0)).toBe(30); + expect(parent.recomputeGridSize).not.toHaveBeenCalled(); + + // Even after re-rendering + cache.clear(0, 0); + renderHelper({ cache, children: (params) => children(params), parent }); + expect(cache.getHeight(0, 0)).toBe(30); + act(() => calls[0].measure()); + expect(cache.getHeight(0, 0)).toBe(30); + }); + + it("should measure the registered element when the child is a class component", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "warn"); + + class Wrapper extends Component<{ + registerChild: CellMeasurerChildParams["registerChild"]; + }> { + render() { + return ( +
+ ); + } + } + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + const { calls, children } = createChildFunction(({ registerChild }) => ( + + )); + renderHelper({ cache, children, parent }); + + expect(cache.getHeight(0, 0)).toBe(30); + + act(() => calls[0].measure()); + expect(cache.getHeight(0, 0)).toBe(30); + expect(parent.recomputeGridSize).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + }); + }); + + // Bug #3 + describe("children that are not a single element", () => { + it.each([ + ["undefined", undefined, ""], + ["false", false, ""], + ["a string", "text", "text"], + ["a number", 123, "123"], + ["an array", [a, b], "ab"], + [ + "a fragment", + <> + a + b + , + "ab" + ] + ])( + "should render %s returned by a function child as-is and warn", + (_, value, textContent) => { + vi.spyOn(console, "error"); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + const { calls, children } = createChildFunction( + () => value as ReactNode + ); + renderHelper({ cache, children, parent }); + + expect(document.body.textContent).toBe(textContent); + expect(cache.has(0, 0)).toBe(false); + expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled(); + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining("could not find a DOM element to measure") + ); + + // measure() doesn't throw or write to the cache either + act(() => calls[0].measure()); + expect(cache.has(0, 0)).toBe(false); + expect(parent.recomputeGridSize).not.toHaveBeenCalled(); + } + ); + + it("should render a string child as-is", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + + renderHelper({ children: "text" }); + + expect(document.body.textContent).toBe("text"); + }); + + it("should measure an element nested in a fragment with registerChild (without warning)", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "error"); + vi.spyOn(console, "warn"); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + renderHelper({ + cache, + children: ({ registerChild }) => ( + <> +
+ + ) + }); + + expect(cache.getHeight(0, 0)).toBe(30); + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + }); + }); + + // Bug #17 + it("should not write to the cache when measure() is called after unmounting", () => { + mockSizesFromDataAttributes(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + const { calls, children } = createChildFunction(() => ( +
+ )); + renderHelper({ cache, children, parent }); + + expect(cache.getHeight(0, 0)).toBe(20); + + render.unmount(); + + act(() => calls[0].measure()); + + expect(cache.getHeight(0, 0)).toBe(20); + expect(parent.recomputeGridSize).not.toHaveBeenCalled(); + }); + + it("should not write to the cache when measure() is called after the registered element unmounted", () => { + mockSizesFromDataAttributes(); + + const element = document.createElement("div"); + element.setAttribute("data-height", "20"); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + const parent = createParent({ cache }); + + const { calls, children } = createChildFunction(({ registerChild }) => { + registerChild(element); + return null; + }); + renderHelper({ cache, children, parent }); + expect(cache.getHeight(0, 0)).toBe(20); + + render.unmount(); + act(() => calls[0].measure()); + + expect(cache.getHeight(0, 0)).toBe(20); + expect(parent.recomputeGridSize).not.toHaveBeenCalled(); + }); + + // Bug #31 + describe("child refs", () => { + it("should forward the element to the child's own object ref (without logging an error)", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "error"); + + const ref = createRef(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + renderHelper({ + cache, + children:
+ }); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(cache.getHeight(0, 0)).toBe(20); + + render.unmount(); + expect(ref.current).toBeNull(); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it("should forward the element to the child's own callback ref (without logging an error)", () => { + mockSizesFromDataAttributes(); + vi.spyOn(console, "error"); + + const ref = vi.fn(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + renderHelper({ + cache, + children:
+ }); + + expect(ref).toHaveBeenCalledTimes(1); + expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLDivElement); + expect(cache.getHeight(0, 0)).toBe(20); + + render.unmount(); + expect(ref).toHaveBeenCalledTimes(2); + expect(ref.mock.calls[1][0]).toBeNull(); + + expect(console.error).not.toHaveBeenCalled(); + }); + + it("should call the cleanup function returned by the child's callback ref", () => { + const cleanup = vi.fn(); + const ref = vi.fn(() => cleanup); + + renderHelper({ children:
}); + + expect(ref).toHaveBeenCalledTimes(1); + expect(cleanup).not.toHaveBeenCalled(); + + render.unmount(); + + expect(ref).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + it("should forward the element to a forwardRef child's ref", () => { + mockSizesFromDataAttributes(); + + const Row = forwardRef(function Row(_, ref) { + return
; + }); + const ref = createRef(); + + const cache = new CellMeasurerCache({ fixedWidth: true }); + renderHelper({ cache, children: }); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(cache.getHeight(0, 0)).toBe(50); + }); + }); +}); diff --git a/lib/components/CellMeasurer/CellMeasurer.tsx b/lib/components/CellMeasurer/CellMeasurer.tsx new file mode 100644 index 000000000..a4eff1a8f --- /dev/null +++ b/lib/components/CellMeasurer/CellMeasurer.tsx @@ -0,0 +1,343 @@ +import { + cloneElement, + Fragment, + isValidElement, + memo, + useCallback, + useInsertionEffect, + useRef, + useState, + version, + type ReactElement, + type ReactNode, + type Ref +} from "react"; +import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect"; +import type { CellMeasureCache, CellMeasurerProps } from "./types"; + +/** + * Wraps a cell and measures its rendered content. + * Measurements are stored in a per-cell cache. + * Cached-content is not be re-measured. + * + * The child must be a DOM element (e.g. `
`) or a component that passes its `ref` along to one; + * otherwise use a function child and pass `registerChild` as the `ref` of the element to be measured. + */ +export const CellMeasurer = /* @__PURE__ */ Object.assign( + /* @__PURE__ */ memo(function CellMeasurer(props: CellMeasurerProps) { + const { children } = props; + + // Mutable values shared by the stable callbacks below + const [internals] = useState(createInternals); + + // Latest props, for the stable callbacks (updated before child refs and layout effects run) + const latestPropsRef = useRef(props); + useInsertionEffect(() => { + latestPropsRef.current = props; + }); + + const maybeMeasureCell = useCallback(() => { + const { cache, columnIndex = 0, index, parent } = latestPropsRef.current; + const rowIndex = latestPropsRef.current.rowIndex ?? index ?? 0; + + if (!cache.has(rowIndex, columnIndex)) { + const measurements = getCellMeasurements( + cache, + getMeasurableNode(internals) + ); + if (measurements === null) { + // Leave the cache alone (the cell keeps its default size) rather than storing 0x0 + if (process.env.NODE_ENV !== "production") { + warnAboutMissingNode(cache, internals); + } + return; + } + + cache.set( + rowIndex, + columnIndex, + measurements.width, + measurements.height + ); + + // If size has changed, let Grid know to re-render. + if ( + parent && + typeof parent.invalidateCellSizeAfterRender === "function" + ) { + parent.invalidateCellSizeAfterRender({ + columnIndex, + rowIndex + }); + } + } + }, [internals]); + + const measure = useCallback(() => { + const { cache, columnIndex = 0, index, parent } = latestPropsRef.current; + const rowIndex = latestPropsRef.current.rowIndex ?? index ?? 0; + + if (!internals.isMounted) { + // E.g. called from a timeout or a load event after the cell scrolled out of view; + // there is nothing to measure (and storing 0x0 would collapse the cell once it's rendered again) + return; + } + + const measurements = getCellMeasurements( + cache, + getMeasurableNode(internals) + ); + if (measurements === null) { + if (process.env.NODE_ENV !== "production") { + warnAboutMissingNode(cache, internals); + } + return; + } + + const { height, width } = measurements; + if ( + height !== cache.getHeight(rowIndex, columnIndex) || + width !== cache.getWidth(rowIndex, columnIndex) + ) { + cache.set(rowIndex, columnIndex, width, height); + + if (parent && typeof parent.recomputeGridSize === "function") { + parent.recomputeGridSize({ + columnIndex, + rowIndex + }); + } + } + }, [internals]); + + const registerChild = useCallback( + (element: Element | null | undefined) => { + if (element && !(element instanceof Element)) { + console.warn( + "CellMeasurer registerChild expects to be passed Element or null" + ); + } + + internals.registeredNode = element ?? null; + if (element) { + maybeMeasureCell(); + } + }, + [internals, maybeMeasureCell] + ); + + useIsomorphicLayoutEffect(() => { + internals.isMounted = true; + return () => { + internals.isMounted = false; + }; + }, [internals]); + + // Equivalent of componentDidMount() + componentDidUpdate() + useIsomorphicLayoutEffect(() => { + maybeMeasureCell(); + }); + + const resolvedChildren: ReactNode = + typeof children === "function" + ? children({ measure, registerChild }) + : children; + + const element = getMeasurableElement(resolvedChildren); + const hasMeasurableElement = element !== null; + useInsertionEffect(() => { + internals.hasMeasurableElement = hasMeasurableElement; + }); + + // The child's own ref (if any) is composed with the ref CellMeasurer attaches to it + const childRef = element !== null ? getElementRef(element) : null; + const composedRef = useCallback( + (node: unknown) => { + if (node === null) { + if (typeof internals.childRefCleanup === "function") { + internals.childRefCleanup(); + } else { + assignRef(childRef, null); + } + internals.childRefCleanup = undefined; + internals.clonedNode = null; + } else { + internals.childRefCleanup = assignRef(childRef, node); + internals.clonedNode = node; + } + }, + [childRef, internals] + ); + + if (element === null) { + // Strings, arrays, fragments etc. can't be given a ref; render them as-is + return resolvedChildren === undefined ? null : resolvedChildren; + } + + return cloneElement(element, { ref: composedRef } as Partial); + }), + { + // Lets Grid's DEV warning about a missing "style" check the CellMeasurer's child instead (see issue #611) + __internalCellMeasurerFlag: process.env.NODE_ENV !== "production" + } +); + +type Internals = { + childRefCleanup: (() => void) | undefined | void; + clonedNode: unknown; + hasMeasurableElement: boolean; + isMounted: boolean; + registeredNode: unknown; +}; + +function createInternals(): Internals { + return { + childRefCleanup: undefined, + clonedNode: null, + hasMeasurableElement: false, + isMounted: false, + registeredNode: null + }; +} + +function isHTMLElement(node: unknown): node is HTMLElement { + if (node == null || typeof node !== "object") { + return false; + } + + // Supports nodes from other frames (which have their own HTMLElement class) + const view = (node as Partial).ownerDocument?.defaultView; + return view != null && node instanceof view.HTMLElement; +} + +// An element registered with registerChild takes precedence over the element CellMeasurer attached its ref to +function getMeasurableNode(internals: Internals): HTMLElement | null { + if (isHTMLElement(internals.registeredNode)) { + return internals.registeredNode; + } else if (isHTMLElement(internals.clonedNode)) { + return internals.clonedNode; + } + return null; +} + +function getCellMeasurements( + cache: CellMeasureCache, + node: HTMLElement | null +): { height: number; width: number } | null { + // TODO Check for a bad combination of fixedWidth and missing numeric width or vice versa with height + + if (node === null) { + return null; + } + + const styleWidth = node.style.width; + const styleHeight = node.style.height; + + // If we are re-measuring a cell that has already been measured, + // It will have a hard-coded width/height from the previous measurement. + // The fact that we are measuring indicates this measurement is probably stale, + // So explicitly clear it out (eg set to "auto") so we can recalculate. + // See issue #593 for more info. + // Even if we are measuring initially- if we're inside of a MultiGrid component, + // Explicitly clear width/height before measuring to avoid being tainted by another Grid. + // eg top/left Grid renders before bottom/right Grid + // Since the CellMeasurerCache is shared between them this taints derived cell size values. + if (!cache.hasFixedWidth()) { + node.style.width = "auto"; + } + if (!cache.hasFixedHeight()) { + node.style.height = "auto"; + } + + const height = Math.ceil(node.offsetHeight); + const width = Math.ceil(node.offsetWidth); + + // Reset after measuring to avoid breaking styles; see #660 + if (styleWidth) { + node.style.width = styleWidth; + } + if (styleHeight) { + node.style.height = styleHeight; + } + + return { height, width }; +} + +// Returns the child element CellMeasurer can attach a ref to (or null for strings, arrays, fragments, etc.) +function getMeasurableElement(children: ReactNode): ReactElement | null { + if (!isValidElement(children) || children.type === Fragment) { + return null; + } + + // React 18 only passes refs to class components, host components and forwardRef() components; + // attaching one to a plain function component logs an error (and the ref is never set). + // React 19 passes the ref as a regular prop, so a function component can pass it along to a DOM element. + if (!isReact19OrNewer() && isPlainFunctionComponent(children.type)) { + return null; + } + + return children; +} + +const MEMO_TYPE = /* @__PURE__ */ Symbol.for("react.memo"); + +function isPlainFunctionComponent(type: unknown): boolean { + if (typeof type === "function") { + return !(type.prototype && type.prototype.isReactComponent); + } else if ( + type != null && + typeof type === "object" && + (type as { $$typeof?: symbol }).$$typeof === MEMO_TYPE + ) { + return isPlainFunctionComponent((type as { type: unknown }).type); + } + return false; +} + +function isReact19OrNewer(): boolean { + return parseInt(version, 10) >= 19; +} + +// React 19 stores an element's ref in props.ref (reading element.ref logs a deprecation error); +// React 18 stores it in element.ref (and never in props). +// React 19 elements without a ref define element.ref as null (without logging an error). +function getElementRef(element: ReactElement): Ref | null { + const props = element.props as { ref?: Ref } | null; + if (props != null && "ref" in props) { + return props.ref ?? null; + } + return (element as { ref?: Ref | null }).ref ?? null; +} + +function assignRef( + ref: Ref | null, + value: unknown +): (() => void) | undefined | void { + if (typeof ref === "function") { + return ref(value); + } else if (ref) { + (ref as { current: unknown }).current = value; + } +} + +// Warn at most once per cache, to avoid flooding the console (one message per rendered cell) +const warnedCaches = /* @__PURE__ */ new WeakSet(); + +function warnAboutMissingNode(cache: CellMeasureCache, internals: Internals) { + if (warnedCaches.has(cache)) { + return; + } + warnedCaches.add(cache); + + console.warn( + internals.hasMeasurableElement + ? "CellMeasurer could not find a DOM element to measure. " + + "Its child should be a DOM element (e.g.
) or a component that passes its ref along to one " + + "(in React 18, function components must use forwardRef). " + + "Otherwise, use a function child and pass registerChild as the ref of the element to be measured." + : "CellMeasurer could not find a DOM element to measure. " + + "Its child should be a single DOM element (e.g.
), not text, an array or a fragment " + + "(or, in React 18, a function component that doesn't use forwardRef). " + + "Otherwise, use a function child and pass registerChild as the ref of the element to be measured." + ); +} diff --git a/lib/components/CellMeasurer/CellMeasurerCache.test.ts b/lib/components/CellMeasurer/CellMeasurerCache.test.ts new file mode 100644 index 000000000..7bd7ec734 --- /dev/null +++ b/lib/components/CellMeasurer/CellMeasurerCache.test.ts @@ -0,0 +1,439 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, vi } from "vitest"; +import { + CellMeasurerCache, + DEFAULT_HEIGHT, + DEFAULT_WIDTH +} from "./CellMeasurerCache"; +import type { CellMeasurerCacheParams } from "./types"; + +// The derived sizes computed without the #1747 optimization: every measured cell of the column (or row) is checked on every update +class ReferenceCellMeasurerCache extends CellMeasurerCache { + override _updateCachedColumnAndRowSizes( + rowIndex: number, + columnIndex: number + ) { + if (!this._hasFixedWidth) { + const key = this._keyMapper(0, columnIndex); + delete this._columnWidthCache[key]; + for (let i = 0; i < this._rowCount; i++) { + if (this.has(i, columnIndex)) { + this._columnWidthCache[key] = Math.max( + this._columnWidthCache[key] ?? 0, + this.getWidth(i, columnIndex) + ); + } + } + } + if (!this._hasFixedHeight) { + const key = this._keyMapper(rowIndex, 0); + delete this._rowHeightCache[key]; + for (let i = 0; i < this._columnCount; i++) { + if (this.has(rowIndex, i)) { + this._rowHeightCache[key] = Math.max( + this._rowHeightCache[key] ?? 0, + this.getHeight(rowIndex, i) + ); + } + } + } + } +} + +// Deterministic pseudo-random numbers (so failures are reproducible) +function createRandom(seed: number) { + return () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; +} + +describe("CellMeasurerCache", () => { + it("should override defaultHeight/defaultWidth if minHeight/minWidth are greater", () => { + const cache = new CellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 100, + fixedHeight: true, + fixedWidth: true, + minHeight: 30, + minWidth: 150 + }); + cache.set(0, 0, 50, 10); + expect(cache.getHeight(0, 0)).toBe(30); + expect(cache.getWidth(0, 0)).toBe(150); + expect(cache.rowHeight({ index: 0 })).toBe(30); + expect(cache.columnWidth({ index: 0 })).toBe(150); + }); + + it("should correctly report cache status", () => { + const cache = new CellMeasurerCache({ + fixedHeight: true, + fixedWidth: true + }); + expect(cache.has(0, 0)).toBe(false); + }); + + it("should cache cells", () => { + const cache = new CellMeasurerCache({ + fixedHeight: true, + fixedWidth: true + }); + cache.set(0, 0, 100, 20); + expect(cache.has(0, 0)).toBe(true); + }); + + it("should return the correct default sizes for uncached cells if specified", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); // Ignore warning about variable width and height + + const cache = new CellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 100, + minHeight: 15, + minWidth: 80 + }); + expect(cache.getWidth(0, 0)).toBe(100); + expect(cache.getHeight(0, 0)).toBe(20); + cache.set(0, 0, 70, 10); + expect(cache.getWidth(0, 0)).toBe(80); + expect(cache.getHeight(0, 0)).toBe(15); + }); + + it("should clear a single cached cell", () => { + const cache = new CellMeasurerCache({ + fixedHeight: true, + fixedWidth: true + }); + cache.set(0, 0, 100, 20); + cache.set(1, 0, 100, 20); + expect(cache.has(0, 0)).toBe(true); + expect(cache.has(1, 0)).toBe(true); + cache.clear(0, 0); + expect(cache.has(0, 0)).toBe(false); + expect(cache.has(1, 0)).toBe(true); + }); + + it("should clear a single cached row cell in column 0 when columnIndex param is absent", () => { + const cache = new CellMeasurerCache({ + fixedHeight: true, + fixedWidth: true + }); + cache.set(0, 0, 100, 20); + cache.set(1, 0, 100, 20); + expect(cache.has(0, 0)).toBe(true); + expect(cache.has(1, 0)).toBe(true); + cache.clear(0); + expect(cache.has(0, 0)).toBe(false); + expect(cache.has(1, 0)).toBe(true); + }); + + it("should clear all cached cells", () => { + const cache = new CellMeasurerCache({ + fixedHeight: true, + fixedWidth: true + }); + cache.set(0, 0, 100, 20); + cache.set(1, 0, 100, 20); + expect(cache.has(0, 0)).toBe(true); + expect(cache.has(1, 0)).toBe(true); + cache.clearAll(); + expect(cache.has(0, 0)).toBe(false); + expect(cache.has(1, 0)).toBe(false); + }); + + it("should clear row and column counts when clearing all cells", () => { + const cache = new CellMeasurerCache({ + fixedHeight: true, + fixedWidth: true + }); + cache.set(0, 0, 100, 20); + cache.set(1, 0, 100, 20); + expect(cache._rowCount).toBe(2); + expect(cache._columnCount).toBe(1); + cache.clearAll(); + expect(cache._rowCount).toBe(0); + expect(cache._columnCount).toBe(0); + }); + + it("should support a custom :keyMapper", () => { + const keyMapper = vi.fn(); + keyMapper.mockReturnValue("a"); + + vi.spyOn(console, "warn").mockImplementation(() => {}); // Ignore warning about variable width and height + + const cache = new CellMeasurerCache({ + defaultHeight: 30, + defaultWidth: 50, + keyMapper + }); + cache.set(0, 0, 100, 20); + expect(cache.has(0, 0)).toBe(true); + + // Changing the returned key should cause cache misses + keyMapper.mockReset(); + keyMapper.mockReturnValue("b"); + expect(cache.has(0, 0)).toBe(false); + expect(cache.columnWidth(0 as any)).toBe(50); + expect(cache.rowHeight(0 as any)).toBe(30); + expect(keyMapper.mock.calls).toHaveLength(3); + + // Restoring it should fix + keyMapper.mockReset(); + keyMapper.mockReturnValue("a"); + expect(cache.has(0, 0)).toBe(true); + expect(cache.columnWidth(0 as any)).toBe(100); + expect(cache.rowHeight(0 as any)).toBe(20); + expect(keyMapper.mock.calls).toHaveLength(3); + }); + + it("should provide a Grid-compatible :columnWidth method", () => { + const cache = new CellMeasurerCache({ + fixedHeight: true + }); + expect(cache.columnWidth({ index: 0 })).toBe(DEFAULT_WIDTH); + cache.set(0, 0, 100, 50); + expect(cache.columnWidth({ index: 0 })).toBe(100); + expect(cache.columnWidth({ index: 1 })).toBe(DEFAULT_WIDTH); + cache.set(1, 0, 75, 50); + expect(cache.columnWidth({ index: 0 })).toBe(100); + cache.set(2, 0, 125, 50); + expect(cache.columnWidth({ index: 0 })).toBe(125); + }); + + it("should provide a Grid-compatible :rowHeight method", () => { + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + expect(cache.rowHeight({ index: 0 })).toBe(DEFAULT_HEIGHT); + cache.set(0, 0, 100, 50); + expect(cache.rowHeight({ index: 0 })).toBe(50); + expect(cache.rowHeight({ index: 1 })).toBe(DEFAULT_HEIGHT); + cache.set(0, 1, 100, 25); + expect(cache.rowHeight({ index: 0 })).toBe(50); + cache.set(0, 2, 100, 75); + expect(cache.rowHeight({ index: 0 })).toBe(75); + }); + + it("should return the :defaultWidth for :columnWidth if not measured", () => { + const cache = new CellMeasurerCache({ + defaultWidth: 25, + fixedHeight: true, + fixedWidth: true + }); + expect(cache.columnWidth({ index: 0 })).toBe(25); + }); + + it("should return the :defaultHeight for :rowHeight if not measured", () => { + const cache = new CellMeasurerCache({ + defaultHeight: 25, + fixedHeight: true, + fixedWidth: true + }); + expect(cache.rowHeight({ index: 0 })).toBe(25); + }); + + it("should recalculate cached :columnWidth when cells are cleared", () => { + const cache = new CellMeasurerCache({ + fixedHeight: true + }); + expect(cache.columnWidth({ index: 0 })).toBe(DEFAULT_WIDTH); + cache.set(0, 0, 125, 50); + expect(cache.columnWidth({ index: 0 })).toBe(125); + cache.set(1, 0, 150, 50); + expect(cache.columnWidth({ index: 0 })).toBe(150); + cache.clear(1, 0); + expect(cache.columnWidth({ index: 0 })).toBe(125); + cache.clear(0, 0); + expect(cache.columnWidth({ index: 0 })).toBe(DEFAULT_WIDTH); + cache.set(0, 0, 125, 50); + expect(cache.columnWidth({ index: 0 })).toBe(125); + cache.clearAll(); + expect(cache.columnWidth({ index: 0 })).toBe(DEFAULT_WIDTH); + }); + + it("should recalculate cached :rowHeight when cells are cleared", () => { + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + expect(cache.rowHeight({ index: 0 })).toBe(DEFAULT_HEIGHT); + cache.set(0, 0, 125, 50); + expect(cache.rowHeight({ index: 0 })).toBe(50); + cache.set(0, 1, 150, 75); + expect(cache.rowHeight({ index: 0 })).toBe(75); + cache.clear(0, 1); + expect(cache.rowHeight({ index: 0 })).toBe(50); + cache.clear(0, 0); + expect(cache.rowHeight({ index: 0 })).toBe(DEFAULT_HEIGHT); + cache.set(0, 0, 125, 50); + expect(cache.rowHeight({ index: 0 })).toBe(50); + cache.clearAll(); + expect(cache.rowHeight({ index: 0 })).toBe(DEFAULT_HEIGHT); + }); + + describe("DEV mode", () => { + it("should warn about dynamic width and height configurations", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const cache = new CellMeasurerCache({ + fixedHeight: false, + fixedWidth: false + }); + + expect(cache.hasFixedHeight()).toBe(false); + expect(cache.hasFixedWidth()).toBe(false); + expect(console.warn).toHaveBeenCalledWith( + "CellMeasurerCache should only measure a cell's width or height. " + + "You have configured CellMeasurerCache to measure both. " + + "This will result in poor performance." + ); + }); + + it("should warn about dynamic width with a defaultWidth of 0", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const cache = new CellMeasurerCache({ + defaultWidth: 0, + fixedHeight: true + }); + + expect(cache.getWidth(0, 0)).toBe(0); + expect(console.warn).toHaveBeenCalledWith( + "Fixed width CellMeasurerCache should specify a :defaultWidth greater than 0. " + + "Failing to do so will lead to unnecessary layout and poor performance." + ); + }); + + it("should warn about dynamic height with a defaultHeight of 0", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const cache = new CellMeasurerCache({ + defaultHeight: 0, + fixedWidth: true + }); + + expect(cache.getHeight(0, 0)).toBe(0); + expect(console.warn).toHaveBeenCalledWith( + "Fixed height CellMeasurerCache should specify a :defaultHeight greater than 0. " + + "Failing to do so will lead to unnecessary layout and poor performance." + ); + }); + }); + + // #1747 + describe("derived column widths and row heights", () => { + const configurations: [string, CellMeasurerCacheParams][] = [ + ["default", {}], + ["minimum sizes", { minHeight: 15, minWidth: 25 }], + ["fixed width", { fixedWidth: true }], + ["fixed height", { fixedHeight: true }], + // Measured sizes are smaller than the default sizes (which unmeasured cells shouldn't contribute) + ["large defaults", { defaultHeight: 200, defaultWidth: 300 }], + [ + "row key mapper", + { fixedWidth: true, keyMapper: (rowIndex) => `id-${rowIndex % 37}` } + ] + ]; + + configurations.forEach(([name, params]) => { + it.each([1, 2, 3])( + `should match the full recomputation (${name}, seed %i)`, + (seed) => { + const random = createRandom(name.length * 7919 + seed); + const cache = new CellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 40, + ...params + }); + const reference = new ReferenceCellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 40, + ...params + }); + const size = (max: number) => Math.floor(random() * max); + + for (let step = 0; step < 800; step++) { + const rowIndex = size(60); + const columnIndex = size(12); + const operation = random(); + if (operation < 0.8) { + // Sizes both grow and shrink (a re-measured cell may get smaller) + const width = 5 + size(100); + const height = 5 + size(60); + cache.set(rowIndex, columnIndex, width, height); + reference.set(rowIndex, columnIndex, width, height); + } else if (operation < 0.98) { + cache.clear(rowIndex, columnIndex); + reference.clear(rowIndex, columnIndex); + } else { + cache.clearAll(); + reference.clearAll(); + } + + for (let index = 0; index < 60; index++) { + expect(cache.rowHeight({ index })).toEqual( + reference.rowHeight({ index }) + ); + } + for (let index = 0; index < 12; index++) { + expect(cache.columnWidth({ index })).toEqual( + reference.columnWidth({ index }) + ); + } + } + } + ); + }); + + it("should not check every cell of a column when a cell is measured", () => { + const cache = new CellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 40 + }); + // Measure the last row of a 100,000-row grid first (e.g. after scrolling to the bottom) + cache.set(99_999, 0, 50, 20); + const getWidth = vi.spyOn(cache, "getWidth"); + for (let rowIndex = 99_990; rowIndex < 99_999; rowIndex++) { + cache.set(rowIndex, 0, 60, 20); + } + // A few calls per measured cell (previously 100,000 per cell) + expect(getWidth.mock.calls.length).toBeLessThan(100); + expect(cache.columnWidth({ index: 0 })).toEqual(60); + }); + + it("should not check every cell of a column when cells smaller than the default size are measured", () => { + const cache = new CellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 100 + }); + cache.set(99_999, 0, 50, 20); + const getWidth = vi.spyOn(cache, "getWidth"); + for (let rowIndex = 99_990; rowIndex < 99_999; rowIndex++) { + cache.set(rowIndex, 0, 60, 20); + } + expect(getWidth.mock.calls.length).toBeLessThan(100); + expect(cache.columnWidth({ index: 0 })).toEqual(60); + }); + + it("should not use the default size as a minimum when other cells of the column (or row) haven't been measured", () => { + const cache = new CellMeasurerCache({ + defaultHeight: 50, + defaultWidth: 100 + }); + expect(cache.columnWidth({ index: 2 })).toEqual(100); + expect(cache.rowHeight({ index: 5 })).toEqual(50); + + // e.g. a Grid first rendered scrolled down and to the right + cache.set(5, 2, 60, 20); + cache.set(6, 2, 40, 30); + expect(cache.columnWidth({ index: 2 })).toEqual(60); + expect(cache.rowHeight({ index: 5 })).toEqual(20); + expect(cache.rowHeight({ index: 6 })).toEqual(30); + + // The default size is used again once none of the cells are measured + cache.clear(5, 2); + expect(cache.columnWidth({ index: 2 })).toEqual(40); + expect(cache.rowHeight({ index: 5 })).toEqual(50); + cache.clear(6, 2); + expect(cache.columnWidth({ index: 2 })).toEqual(100); + }); + }); +}); diff --git a/lib/components/CellMeasurer/CellMeasurerCache.ts b/lib/components/CellMeasurer/CellMeasurerCache.ts new file mode 100644 index 000000000..19919c6c5 --- /dev/null +++ b/lib/components/CellMeasurer/CellMeasurerCache.ts @@ -0,0 +1,363 @@ +import type { + CellMeasureCache, + CellMeasurerCacheAPI, + CellMeasurerCacheParams +} from "./types"; + +export const DEFAULT_HEIGHT = 30; +export const DEFAULT_WIDTH = 100; + +// Keys are produced by the (possibly user-supplied) key mapper and are coerced to strings when used as object keys. +type KeyMapper = (rowIndex: number, columnIndex: number) => string; + +type Cache = Record; + +type IndexParam = { + index: number; +}; + +/** + * Caches measurements for a given cell. + */ +export class CellMeasurerCache + implements CellMeasureCache, CellMeasurerCacheAPI +{ + _cellHeightCache: Cache = {}; + _cellWidthCache: Cache = {}; + _columnWidthCache: Cache = {}; + _rowHeightCache: Cache = {}; + // The number of rows (or columns) that each derived column width (or row height) was computed over, + // so that it can be updated incrementally when a cell is measured (#1747) + _columnWidthRowCount: Cache = {}; + _rowHeightColumnCount: Cache = {}; + _defaultHeight: number; + _defaultWidth: number; + _minHeight: number; + _minWidth: number; + _keyMapper: KeyMapper; + _hasFixedHeight: boolean; + _hasFixedWidth: boolean; + _columnCount = 0; + _rowCount = 0; + + constructor(params: CellMeasurerCacheParams = {}) { + const { + defaultHeight, + defaultWidth, + fixedHeight, + fixedWidth, + keyMapper, + minHeight, + minWidth + } = params; + + this._hasFixedHeight = fixedHeight === true; + this._hasFixedWidth = fixedWidth === true; + this._minHeight = minHeight || 0; + this._minWidth = minWidth || 0; + this._keyMapper = (keyMapper || defaultKeyMapper) as KeyMapper; + + this._defaultHeight = Math.max( + this._minHeight, + typeof defaultHeight === "number" ? defaultHeight : DEFAULT_HEIGHT + ); + this._defaultWidth = Math.max( + this._minWidth, + typeof defaultWidth === "number" ? defaultWidth : DEFAULT_WIDTH + ); + + if (process.env.NODE_ENV !== "production") { + if (this._hasFixedHeight === false && this._hasFixedWidth === false) { + console.warn( + "CellMeasurerCache should only measure a cell's width or height. " + + "You have configured CellMeasurerCache to measure both. " + + "This will result in poor performance." + ); + } + + if (this._hasFixedHeight === false && this._defaultHeight === 0) { + console.warn( + "Fixed height CellMeasurerCache should specify a :defaultHeight greater than 0. " + + "Failing to do so will lead to unnecessary layout and poor performance." + ); + } + + if (this._hasFixedWidth === false && this._defaultWidth === 0) { + console.warn( + "Fixed width CellMeasurerCache should specify a :defaultWidth greater than 0. " + + "Failing to do so will lead to unnecessary layout and poor performance." + ); + } + } + } + + /** + * Reset cached measurements for a specific cell. + * + * This should be called when a cell needs to be re-measured to handle dynamic content + * (eg. replacing a loading indicator with loaded content or reacting to state-changes for stateful cells). + */ + clear(rowIndex: number, columnIndex: number = 0) { + const key = this._keyMapper(rowIndex, columnIndex); + + delete this._cellHeightCache[key]; + delete this._cellWidthCache[key]; + + this._updateCachedColumnAndRowSizes(rowIndex, columnIndex); + } + + /** + * Reset cached measurements for all cells. + * + * This method should be called when a `Grid`, `List` or `Table` needs to reflow content due to a resizing event + * for a responsive layout (eg. a window width resize may have an impact on the height of cells). + */ + clearAll() { + this._cellHeightCache = {}; + this._cellWidthCache = {}; + this._columnWidthCache = {}; + this._rowHeightCache = {}; + this._columnWidthRowCount = {}; + this._rowHeightColumnCount = {}; + this._rowCount = 0; + this._columnCount = 0; + } + + /** + * Returns the derived width of a column (the widest measured cell in that column) or the default width. + * Compatible with the `columnWidth` prop of `Grid`. + */ + columnWidth = ({ index }: IndexParam) => { + const key = this._keyMapper(0, index); + + return this._columnWidthCache[key] !== undefined + ? this._columnWidthCache[key] + : this._defaultWidth; + }; + + /** Height reported for unmeasured cells. */ + get defaultHeight(): number { + return this._defaultHeight; + } + + /** Width reported for unmeasured cells. */ + get defaultWidth(): number { + return this._defaultWidth; + } + + /** Returns true if cells have a fixed height (and a dynamic, measured width). */ + hasFixedHeight(): boolean { + return this._hasFixedHeight; + } + + /** Returns true if cells have a fixed width (and a dynamic, measured height). */ + hasFixedWidth(): boolean { + return this._hasFixedWidth; + } + + /** Returns the (measured or default) height of the specified cell. */ + getHeight(rowIndex: number, columnIndex: number = 0): number { + if (this._hasFixedHeight) { + return this._defaultHeight; + } else { + const key = this._keyMapper(rowIndex, columnIndex); + + return this._cellHeightCache[key] !== undefined + ? Math.max(this._minHeight, this._cellHeightCache[key]) + : this._defaultHeight; + } + } + + /** Returns the (measured or default) width of the specified cell. */ + getWidth(rowIndex: number, columnIndex: number = 0): number { + if (this._hasFixedWidth) { + return this._defaultWidth; + } else { + const key = this._keyMapper(rowIndex, columnIndex); + + return this._cellWidthCache[key] !== undefined + ? Math.max(this._minWidth, this._cellWidthCache[key]) + : this._defaultWidth; + } + } + + /** Returns true if the specified cell has already been measured. */ + has(rowIndex: number, columnIndex: number = 0): boolean { + const key = this._keyMapper(rowIndex, columnIndex); + + return this._cellHeightCache[key] !== undefined; + } + + /** + * Returns the derived height of a row (the tallest measured cell in that row) or the default height. + * Compatible with the `rowHeight` prop of `Grid`. + */ + rowHeight = ({ index }: IndexParam) => { + const key = this._keyMapper(index, 0); + + return this._rowHeightCache[key] !== undefined + ? this._rowHeightCache[key] + : this._defaultHeight; + }; + + /** Stores the measured size of the specified cell. */ + set( + rowIndex: number, + columnIndex: number, + width: number, + height: number + ): void { + const key = this._keyMapper(rowIndex, columnIndex); + const previousCachedWidth = this._cellWidthCache[key]; + const previousCachedHeight = this._cellHeightCache[key]; + + if (columnIndex >= this._columnCount) { + this._columnCount = columnIndex + 1; + } + if (rowIndex >= this._rowCount) { + this._rowCount = rowIndex + 1; + } + + // Size is cached per cell so we don't have to re-measure if cells are re-ordered. + this._cellHeightCache[key] = height; + this._cellWidthCache[key] = width; + + // The derived sizes are updated incrementally when possible, rather than recomputed from every cell in the column/row (#1747). + this._updateCachedColumnAndRowSizes(rowIndex, columnIndex, { + height: this._canUpdateIncrementally( + this._rowHeightCache[this._keyMapper(rowIndex, 0)], + this._rowHeightColumnCount[this._keyMapper(rowIndex, 0)], + columnIndex, + this.getHeight(rowIndex, columnIndex), + previousCachedHeight === undefined + ? undefined + : Math.max(this._minHeight, previousCachedHeight) + ), + width: this._canUpdateIncrementally( + this._columnWidthCache[this._keyMapper(0, columnIndex)], + this._columnWidthRowCount[this._keyMapper(0, columnIndex)], + rowIndex, + this.getWidth(rowIndex, columnIndex), + previousCachedWidth === undefined + ? undefined + : Math.max(this._minWidth, previousCachedWidth) + ) + }); + } + + /** + * Whether a derived column width (or row height) can be updated incrementally after a cell is measured: + * i.e. whether the previous derived size is still a lower bound of the new one. + * The maximum can only decrease if the cell's previous size was the maximum and it got smaller. + * + * @param derivedSize The current derived size (the maximum of the measured cells counted so far, if any) + * @param countedCells The number of cells (rows of a column, or columns of a row) that the derived size was computed over + * @param cellIndex The index of the measured cell within the column (or row) + * @param size The cell's new (effective) size + * @param previousSize The cell's previous (effective) size, or undefined if it wasn't measured (so it wasn't part of the maximum) + */ + _canUpdateIncrementally( + derivedSize: number | undefined, + countedCells: number | undefined, + cellIndex: number, + size: number, + previousSize: number | undefined + ): boolean { + if (countedCells === undefined) { + return false; + } + + // With the default keyMapper, a cell beyond the counted range wasn't part of the maximum. + // (Other keyMappers may map it to the key of a counted cell, so it is treated as counted.) + if (this._keyMapper === defaultKeyMapper && cellIndex >= countedCells) { + return true; + } + + return ( + previousSize === undefined || + size >= previousSize || + (derivedSize !== undefined && previousSize < derivedSize) + ); + } + + _updateCachedColumnAndRowSizes( + rowIndex: number, + columnIndex: number, + incremental: { height: boolean; width: boolean } = { + height: false, + width: false + } + ) { + // :columnWidth and :rowHeight are derived from the measured cells in a column/row (the default size is only used if none are). + // Unmeasured cells aren't counted, so a column measured past its first rows can be narrower than the default width. + // Pre-cache these derived values for faster lookup later. + // Reads are expected to occur more frequently than writes in this case. + // Only update non-fixed dimensions though to avoid doing unnecessary work. + if (!this._hasFixedWidth) { + const columnKey = this._keyMapper(0, columnIndex); + const cachedWidth = this._columnWidthCache[columnKey]; + const countedRows = this._columnWidthRowCount[columnKey]; + + // Incrementally: the previous width, the measured cell, and any rows added since it was computed + let columnWidth: number | undefined; + let firstRow: number; + if (incremental.width && countedRows !== undefined) { + columnWidth = Math.max( + cachedWidth ?? 0, + this.getWidth(rowIndex, columnIndex) + ); + firstRow = countedRows; + } else { + columnWidth = undefined; + firstRow = 0; + } + for (let i = firstRow; i < this._rowCount; i++) { + if (this.has(i, columnIndex)) { + columnWidth = Math.max( + columnWidth ?? 0, + this.getWidth(i, columnIndex) + ); + } + } + if (columnWidth === undefined) { + delete this._columnWidthCache[columnKey]; + } else { + this._columnWidthCache[columnKey] = columnWidth; + } + this._columnWidthRowCount[columnKey] = this._rowCount; + } + if (!this._hasFixedHeight) { + const rowKey = this._keyMapper(rowIndex, 0); + const cachedHeight = this._rowHeightCache[rowKey]; + const countedColumns = this._rowHeightColumnCount[rowKey]; + + let rowHeight: number | undefined; + let firstColumn: number; + if (incremental.height && countedColumns !== undefined) { + rowHeight = Math.max( + cachedHeight ?? 0, + this.getHeight(rowIndex, columnIndex) + ); + firstColumn = countedColumns; + } else { + rowHeight = undefined; + firstColumn = 0; + } + for (let i = firstColumn; i < this._columnCount; i++) { + if (this.has(rowIndex, i)) { + rowHeight = Math.max(rowHeight ?? 0, this.getHeight(rowIndex, i)); + } + } + if (rowHeight === undefined) { + delete this._rowHeightCache[rowKey]; + } else { + this._rowHeightCache[rowKey] = rowHeight; + } + this._rowHeightColumnCount[rowKey] = this._columnCount; + } + } +} + +function defaultKeyMapper(rowIndex: number, columnIndex: number) { + return `${rowIndex}-${columnIndex}`; +} diff --git a/lib/components/CellMeasurer/types.ts b/lib/components/CellMeasurer/types.ts new file mode 100644 index 000000000..aeff51cf6 --- /dev/null +++ b/lib/components/CellMeasurer/types.ts @@ -0,0 +1,276 @@ +import type { ReactNode } from "react"; + +/** + * Interface for a cache of cell measurements shared between `CellMeasurer` and its parent + * (e.g. `Grid`, `List`, `Table`, `MultiGrid` or `Masonry`). + * `CellMeasurerCache` is the default implementation. + */ +export interface CellMeasureCache { + /** Returns true if cells have a fixed width (and a dynamic, measured height). */ + hasFixedWidth(): boolean; + + /** Returns true if cells have a fixed height (and a dynamic, measured width). */ + hasFixedHeight(): boolean; + + /** Returns true if the specified cell has already been measured. */ + has(rowIndex: number, columnIndex: number): boolean; + + /** Stores the measured size of the specified cell. */ + set( + rowIndex: number, + columnIndex: number, + width: number, + height: number + ): void; + + /** Returns the (measured or default) height of the specified cell. */ + getHeight(rowIndex: number, columnIndex?: number): number; + + /** Returns the (measured or default) width of the specified cell. */ + getWidth(rowIndex: number, columnIndex?: number): number; +} + +/** + * Identifies a cell within a parent `Grid`. + */ +export type CellMeasurerCellPosition = { + columnIndex: number; + rowIndex: number; +}; + +/** + * Structural type of the parent component (e.g. a `Grid`, `List`, `Table`, `MultiGrid` or `Masonry` instance) + * that `CellMeasurer` notifies when a cell's measured size changes. + */ +export type CellMeasurerParent = { + /** Called after a cell has been measured for the first time (while rendering). */ + invalidateCellSizeAfterRender?: (cell: CellMeasurerCellPosition) => void; + + /** Called when a cell has been re-measured (via `measure`) and its size has changed. */ + recomputeGridSize?: (cell: CellMeasurerCellPosition) => void; +}; + +/** + * Parameters passed to a `CellMeasurer` function child. + */ +export type CellMeasurerChildParams = { + /** + * Perform the cell measurements (e.g. after an image within the cell has loaded). + * Calling this after the `CellMeasurer` has unmounted (e.g. from a timer or a load event) does nothing. + * + * This function is stable (it's the same function for the lifetime of the `CellMeasurer`). + */ + measure: () => void; + + /** + * Specify the DOM element to be measured; can be used as a `ref` (e.g. `
`). + * By default the element returned by the function child is measured; + * an element registered with `registerChild` takes precedence over it. + * Use this when the element to be measured is nested within the returned element, + * or when the returned element is a component that doesn't pass its `ref` along to a DOM element. + * + * This function is stable (it's the same function for the lifetime of the `CellMeasurer`). + */ + registerChild: (element: Element | null | undefined) => void; +}; + +/** + * Function child of `CellMeasurer`. + */ +export type CellMeasurerChildFunction = ( + params: CellMeasurerChildParams +) => ReactNode; + +export type CellMeasurerProps = { + /** + * Cache to be shared between `CellMeasurer` and its parent `Grid`. + * Typically an instance of `CellMeasurerCache`. + */ + cache: CellMeasureCache; + + /** + * Either a React element as a child (eg `
`) or a function + * (eg. `({ measure, registerChild }) =>
`). + * + * `CellMeasurer` attaches a `ref` to the child element to find the DOM element to measure + * (the child's own `ref`, if any, still receives the element). + * So the child must be a DOM element (e.g. `
`) or a component that passes its `ref` along to one: + * in React 19, a function component can pass its `ref` prop to a DOM element; + * in React 18, function components must use `forwardRef` for this. + * + * The function receives a `measure` callback (to re-measure the cell, e.g. after an image has loaded) + * and a `registerChild` callback (to specify the DOM element to be measured; can be used as a `ref`). + * Use `registerChild` if the child doesn't pass its `ref` along to a DOM element, + * or to measure an element nested within the child. + * + * Children that can't be measured (e.g. text, an array or a fragment, without `registerChild`) are rendered as-is, + * and a warning is logged in development mode. + */ + children: ReactNode | CellMeasurerChildFunction; + + /** + * Index of column being measured (within the parent `Grid`) or 0 (if used within a `List` or `Table`). + * + * @default 0 + */ + columnIndex?: number; + + /** + * Index of row being measured; alias of `rowIndex` (as passed to a `List` row renderer). + * Only used if `rowIndex` is not specified. + */ + index?: number; + + /** + * Reference to the parent `Grid` (or `List`, `Table`, `MultiGrid`, `Masonry`); + * this value is passed by `Grid` to the `cellRenderer` and should be passed along as-is. + */ + parent: CellMeasurerParent; + + /** + * Index of row being measured (within the parent `Grid`). + * Defaults to `index` (or 0 if neither is specified). + */ + rowIndex?: number; +}; + +export type CellMeasurerCacheParams = { + /** + * Unmeasured cells will initially report this height + * (or `minHeight`, if that is greater). + * + * @default 30 + */ + defaultHeight?: number; + + /** + * Unmeasured cells will initially report this width + * (or `minWidth`, if that is greater). + * + * @default 100 + */ + defaultWidth?: number; + + /** + * Rendered cells will have a fixed height and a dynamic (measured) width. + * + * @default false + */ + fixedHeight?: boolean; + + /** + * Rendered cells will have a fixed width and a dynamic (measured) height. + * + * @default false + */ + fixedWidth?: boolean; + + /** + * Derived row height (of multiple cells) should not be less than this value. + * + * @default 0 + */ + minHeight?: number; + + /** + * Derived column width (of multiple cells) should not be less than this value. + * + * @default 0 + */ + minWidth?: number; + + /** + * Maps a cell's row and column index to a cache key, e.g. an item ID. + * This prevents the cached measurements from being invalidated when the parent collection is modified + * (e.g. items are inserted or reordered). + * Defaults to a mapper that returns `` `${rowIndex}-${columnIndex}` ``. + */ + keyMapper?: (rowIndex: number, columnIndex: number) => unknown; +}; + +/** + * Public API of a `CellMeasurerCache` instance. + */ +export interface CellMeasurerCacheAPI { + /** + * Reset cached measurements for a specific cell. + * + * This should be called when a cell needs to be re-measured to handle dynamic content + * (e.g. replacing a loading indicator with loaded content, or reacting to state changes for stateful cells). + * `columnIndex` defaults to `0`. + */ + clear(rowIndex: number, columnIndex?: number): void; + + /** + * Reset cached measurements for all cells. + * + * This should be called when a `Grid`, `List` or `Table` needs to reflow its content due to a resize in a responsive layout + * (e.g. a change in window width may impact the height of cells). + */ + clearAll(): void; + + /** + * Returns the derived width of a column (its widest measured cell) or the default width. + * Pass this as the `columnWidth` prop of a `Grid` (or `MultiGrid`); + * it's bound to the cache, so it can be passed as-is. + */ + columnWidth(params: { index: number }): number; + + /** + * Height reported for unmeasured cells (read-only). + */ + readonly defaultHeight: number; + + /** + * Width reported for unmeasured cells (read-only). + */ + readonly defaultWidth: number; + + /** + * Returns the (measured or default) height of the specified cell. + * `columnIndex` defaults to `0`. + */ + getHeight(rowIndex: number, columnIndex?: number): number; + + /** + * Returns the (measured or default) width of the specified cell. + * `columnIndex` defaults to `0`. + */ + getWidth(rowIndex: number, columnIndex?: number): number; + + /** + * Returns true if the specified cell has already been measured. + * `columnIndex` defaults to `0`. + */ + has(rowIndex: number, columnIndex?: number): boolean; + + /** + * Returns true if cells have a fixed height (and a dynamic, measured width); + * see the `fixedHeight` option. + */ + hasFixedHeight(): boolean; + + /** + * Returns true if cells have a fixed width (and a dynamic, measured height); + * see the `fixedWidth` option. + */ + hasFixedWidth(): boolean; + + /** + * Returns the derived height of a row (its tallest measured cell) or the default height. + * Pass this as the `rowHeight` prop of a `Grid`, `List` or `Table`; + * it's bound to the cache, so it can be passed as-is. + */ + rowHeight(params: { index: number }): number; + + /** + * Stores the measured size of the specified cell. + * This is called by `CellMeasurer` and doesn't usually need to be called directly. + */ + set( + rowIndex: number, + columnIndex: number, + width: number, + height: number + ): void; +} diff --git a/lib/components/ColumnSizer/ColumnSizer.test.tsx b/lib/components/ColumnSizer/ColumnSizer.test.tsx new file mode 100644 index 000000000..ef8518f1e --- /dev/null +++ b/lib/components/ColumnSizer/ColumnSizer.test.tsx @@ -0,0 +1,292 @@ +import { StrictMode, useCallback, type Ref } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "../../utils/test/render"; +import { Grid } from "../Grid/Grid"; +import type { CellRendererParams } from "../Grid/types"; +import { ColumnSizer } from "./ColumnSizer"; +import type { ColumnSizerChildProps, ColumnSizerProps } from "./types"; + +describe("ColumnSizer", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function getMarkup({ + columnMinWidth = undefined, + columnMaxWidth = undefined, + columnCount = 10, + width = 200 + }: { + columnMinWidth?: number; + columnMaxWidth?: number; + columnCount?: number; + width?: number; + } = {}) { + function cellRenderer({ + columnIndex, + key, + rowIndex, + style + }: CellRendererParams) { + return ( +
+ {`row:${rowIndex}, column:${columnIndex}`} +
+ ); + } + + return ( + + {({ adjustedWidth, columnWidth, registerChild }) => ( +
+ +
+ {`adjustedWidth:${adjustedWidth} columnWidth:${columnWidth}`} +
+
+ )} +
+ ); + } + + // ColumnSizer has no imperative handle, so the value returned by render() is ignored + // and the rendered output is queried from the DOM instead. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + function getDebugText(_: unknown) { + return document.querySelector(".debug")!.textContent; + } + + it("should distribute column widths evenly if no min/max boundaries have been set", () => { + expect(getDebugText(render(getMarkup()))).toContain("columnWidth:20"); + }); + + it("should respect :columnMaxWidth if specified", () => { + expect( + getDebugText( + render( + getMarkup({ + columnMaxWidth: 10 + }) + ) + ) + ).toContain("columnWidth:10"); + }); + + it("should respect :columnMinWidth if specified", () => { + expect( + getDebugText( + render( + getMarkup({ + columnMinWidth: 30 + }) + ) + ) + ).toContain("columnWidth:30"); + }); + + describe("recomputeGridSize", () => { + function helper( + updatedProps: Parameters[0], + expectedTextContent: string + ) { + expect(getDebugText(render(getMarkup()))).toContain("columnWidth:20"); + + expect(getDebugText(render(getMarkup(updatedProps)))).toContain( + expectedTextContent + ); + } + + it("should recompute metadata sizes if :columnMinWidth changes", () => { + helper({ columnMinWidth: 30 }, "columnWidth:30"); + }); + + it("should recompute metadata sizes if :columnMaxWidth changes", () => { + helper({ columnMaxWidth: 15 }, "columnWidth:15"); + }); + + it("should recompute metadata sizes if :width changes", () => { + helper({ width: 300 }, "columnWidth:30"); + }); + + it("should recompute metadata sizes if :columnCount changes", () => { + helper({ columnCount: 2 }, "columnWidth:100"); + }); + }); + + it("should pass the :width as :adjustedWidth if columns require more than the :width to be displayed", () => { + expect( + getDebugText( + render( + getMarkup({ + columnMinWidth: 30 + }) + ) + ) + ).toContain("adjustedWidth:200"); + }); + + it("should pass an :adjustedWidth if columns require less than the :width to be displayed", () => { + expect( + getDebugText( + render( + getMarkup({ + columnMaxWidth: 10 + }) + ) + ) + ).toContain("adjustedWidth:100"); + }); + + it("should error if the registered child is not a Grid or a MultiGrid", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + expect(() => { + render( + + {({ registerChild }) => ( +
} /> + )} + + ); + }).toThrow(); + }); + + describe("registered child", () => { + function createChild() { + return { recomputeGridSize: vi.fn() }; + } + + function renderWithChild( + child: ReturnType, + props: Partial> = {}, + onRender?: (params: ColumnSizerChildProps) => void, + strict = false + ) { + const markup = ( + + {(params) => { + onRender?.(params); + return ( + + ); + }} + + ); + render(strict ? {markup} : markup); + } + + function Registrar({ + child, + registerChild + }: { + child: ReturnType; + registerChild: ColumnSizerChildProps["registerChild"]; + }) { + const ref = useCallback( + (element: HTMLDivElement | null) => { + registerChild(element ? child : null); + }, + [child, registerChild] + ); + return
; + } + + it("should call recomputeGridSize when the child is registered", () => { + const child = createChild(); + renderWithChild(child); + expect(child.recomputeGridSize).toHaveBeenCalledTimes(1); + }); + + it("should call recomputeGridSize only when sizing props change", () => { + const child = createChild(); + renderWithChild(child); + child.recomputeGridSize.mockClear(); + + renderWithChild(child, { width: 300 }); + expect(child.recomputeGridSize).toHaveBeenCalledTimes(1); + + renderWithChild(child, { width: 300, columnCount: 5 }); + expect(child.recomputeGridSize).toHaveBeenCalledTimes(2); + + renderWithChild(child, { + width: 300, + columnCount: 5, + columnMinWidth: 10 + }); + expect(child.recomputeGridSize).toHaveBeenCalledTimes(3); + + renderWithChild(child, { + width: 300, + columnCount: 5, + columnMinWidth: 10, + columnMaxWidth: 50 + }); + expect(child.recomputeGridSize).toHaveBeenCalledTimes(4); + }); + + it("should pass a stable registerChild function", () => { + const child = createChild(); + const registerChildren = new Set(); + const onRender = (params: ColumnSizerChildProps) => { + registerChildren.add(params.registerChild); + }; + + renderWithChild(child, {}, onRender); + renderWithChild(child, { width: 300 }, onRender); + expect(registerChildren.size).toBe(1); + }); + + it("should pass a deprecated getColumnWidth function", () => { + const child = createChild(); + let getColumnWidth: (() => number) | undefined; + renderWithChild(child, {}, (params) => { + getColumnWidth = params.getColumnWidth; + }); + expect(getColumnWidth!()).toBe(20); + }); + + it("should work in StrictMode", () => { + const child = createChild(); + renderWithChild(child, {}, undefined, true); + + // StrictMode (re-)attaches refs on mount, so the child may be registered more than once + expect(child.recomputeGridSize).toHaveBeenCalled(); + child.recomputeGridSize.mockClear(); + + // Re-rendering with the same props should not recompute + renderWithChild(child, { columnCount: 10 }, undefined, true); + expect(child.recomputeGridSize).not.toHaveBeenCalled(); + + renderWithChild(child, { width: 300 }, undefined, true); + expect(child.recomputeGridSize).toHaveBeenCalledTimes(1); + }); + + it("should work with a Grid in StrictMode", () => { + render({getMarkup()}); + expect(getDebugText(null)).toContain("columnWidth:20"); + + render({getMarkup({ width: 300 })}); + expect(getDebugText(null)).toContain("columnWidth:30"); + expect(document.querySelectorAll(".gridItem")).toHaveLength(10); + }); + }); +}); diff --git a/lib/components/ColumnSizer/ColumnSizer.tsx b/lib/components/ColumnSizer/ColumnSizer.tsx new file mode 100644 index 000000000..1ae9e4b85 --- /dev/null +++ b/lib/components/ColumnSizer/ColumnSizer.tsx @@ -0,0 +1,82 @@ +import { memo, useCallback, useRef } from "react"; +import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect"; +import type { ColumnSizerProps, ColumnSizerRegisteredChild } from "./types"; + +/** + * High-order component that auto-calculates column-widths for `Grid` cells. + */ +export const ColumnSizer = /* @__PURE__ */ memo(function ColumnSizer({ + children, + columnMaxWidth, + columnMinWidth, + columnCount, + width +}: ColumnSizerProps) { + const registeredChildRef = useRef(null); + + const registerChild = useCallback( + (child: ColumnSizerRegisteredChild | null) => { + if (child && typeof child.recomputeGridSize !== "function") { + throw Error( + "Unexpected child type registered; only Grid/MultiGrid children are supported." + ); + } + + registeredChildRef.current = child; + + if (child) { + child.recomputeGridSize(); + } + }, + [] + ); + + // Recompute the registered child's sizes when the sizing inputs change (but not on mount; + // registering the child already does that). Comparing against the previously committed values + // (rather than skipping the first effect run) keeps this correct when StrictMode re-runs effects. + const committedInputsRef = useRef<{ + columnCount: number; + columnMaxWidth: number | undefined; + columnMinWidth: number | undefined; + width: number; + } | null>(null); + useIsomorphicLayoutEffect(() => { + const prev = committedInputsRef.current; + committedInputsRef.current = { + columnCount, + columnMaxWidth, + columnMinWidth, + width + }; + + if ( + prev !== null && + (columnMaxWidth !== prev.columnMaxWidth || + columnMinWidth !== prev.columnMinWidth || + columnCount !== prev.columnCount || + width !== prev.width) + ) { + registeredChildRef.current?.recomputeGridSize(); + } + }, [columnCount, columnMaxWidth, columnMinWidth, width]); + + const safeColumnMinWidth = columnMinWidth || 1; + + const safeColumnMaxWidth = columnMaxWidth + ? Math.min(columnMaxWidth, width) + : width; + + let columnWidth = width / columnCount; + columnWidth = Math.max(safeColumnMinWidth, columnWidth); + columnWidth = Math.min(safeColumnMaxWidth, columnWidth); + columnWidth = Math.floor(columnWidth); + + const adjustedWidth = Math.min(width, columnWidth * columnCount); + + return children({ + adjustedWidth, + columnWidth, + getColumnWidth: () => columnWidth, + registerChild + }); +}); diff --git a/lib/components/ColumnSizer/types.ts b/lib/components/ColumnSizer/types.ts new file mode 100644 index 000000000..4cbbbaecc --- /dev/null +++ b/lib/components/ColumnSizer/types.ts @@ -0,0 +1,66 @@ +import type { ReactNode } from "react"; + +/** + * A child that can be registered with `ColumnSizer` (e.g. a `Grid` or `MultiGrid`, or its imperative handle). + * It must implement a `recomputeGridSize` method. + */ +export type ColumnSizerRegisteredChild = { + /** Recomputes column sizes; called by `ColumnSizer` whenever its sizing props change. */ + recomputeGridSize: () => void; +}; + +/** + * Parameters passed to the `ColumnSizer` children render function. + */ +export type ColumnSizerChildProps = { + /** + * This number reflects the lesser of the overall `Grid` width or the width of all columns. + * Use this to make your `Grid` shrink to fit sparse content. + */ + adjustedWidth: number; + + /** This value should be passed to the `Grid`'s `columnWidth` property. */ + columnWidth: number; + + /** + * This value can be passed to the `Grid`'s `columnWidth` property, + * but it's recommended that you use the `columnWidth` property instead. + * + * @deprecated This is a legacy property; use `columnWidth` instead. + */ + getColumnWidth: () => number; + + /** + * This function should be set as the child's `ref` property. + * It enables column sizes to be recomputed when the `ColumnSizer` props change. + * Only `Grid`/`MultiGrid` children (components with a `recomputeGridSize` method) are supported; + * registering any other type of component throws an error. + * + * This function is stable; it does not change between renders. + */ + registerChild: (child: ColumnSizerRegisteredChild | null) => void; +}; + +export type ColumnSizerProps = { + /** + * Function responsible for rendering a virtualized `Grid`. + * It is passed `{ adjustedWidth, columnWidth, getColumnWidth, registerChild }`. + * + * The `columnWidth` value should be passed to the `Grid`'s `columnWidth` property, + * and `registerChild` should be passed to the `Grid`'s `ref` property. + * The `adjustedWidth` value is optional; it reflects the lesser of the overall width or the width of all columns. + */ + children: (params: ColumnSizerChildProps) => ReactNode; + + /** Optional maximum allowed column width. */ + columnMaxWidth?: number; + + /** Optional minimum allowed column width. */ + columnMinWidth?: number; + + /** Number of columns in the `Grid` or `Table` child. */ + columnCount: number; + + /** Width of the `Grid` or `Table` child. */ + width: number; +}; diff --git a/lib/components/Grid/Grid.ssr.test.tsx b/lib/components/Grid/Grid.ssr.test.tsx new file mode 100644 index 000000000..66868240e --- /dev/null +++ b/lib/components/Grid/Grid.ssr.test.tsx @@ -0,0 +1,27 @@ +// @vitest-environment node + +import * as ReactDOMServer from "react-dom/server"; +import { expect, test } from "vitest"; +import { Grid } from "./Grid"; + +test("should render Grid with dom server", () => { + const rendered = ReactDOMServer.renderToString( + ( +
+ {rowIndex + ":" + columnIndex} +
+ )} + columnCount={1000} + columnWidth={20} + height={500} + rowCount={1000} + rowHeight={20} + width={500} + /> + ); + + expect(rendered).toContain("0:0"); + expect(rendered).toContain("24:24"); + expect(rendered).not.toContain("25:25"); +}); diff --git a/lib/components/Grid/Grid.test.tsx b/lib/components/Grid/Grid.test.tsx new file mode 100644 index 000000000..da88b16ad --- /dev/null +++ b/lib/components/Grid/Grid.test.tsx @@ -0,0 +1,4054 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent } from "@testing-library/react"; +import { + act, + createRef, + PureComponent, + StrictMode, + useLayoutEffect, + type CSSProperties +} from "react"; +import { hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getObserverCount, + mockResizeObserver +} from "../../utils/test/mockResizeObserver"; +import { + findDOMNode as findDOMNodeUntyped, + getMountNode, + render +} from "../../utils/test/render"; +import { + createScrollContainer, + mockWindowScrolling +} from "../../utils/test/scrollElement"; +import { CellMeasurer } from "../CellMeasurer/CellMeasurer"; +import { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import { defaultCellRangeRenderer } from "./defaultCellRangeRenderer"; +import { + defaultOverscanIndicesGetter, + SCROLL_DIRECTION_BACKWARD, + SCROLL_DIRECTION_FORWARD +} from "./defaultOverscanIndicesGetter"; +import { DEFAULT_SCROLLING_RESET_TIME_INTERVAL } from "./constants"; +import { Grid } from "./Grid"; +import type { + Alignment, + CellRangeRendererParams, + CellRendererParams, + GridImperativeAPI, + GridProps, + OverscanIndicesGetterParams, + RenderedSection, + StyleCache +} from "./types"; +import { getMaxElementSize } from "./utils/maxElementSize"; + +function findDOMNode( + instance: Parameters[0] +): HTMLDivElement { + return findDOMNodeUntyped(instance) as HTMLDivElement; +} + +const DEFAULT_COLUMN_WIDTH = 50; +const DEFAULT_HEIGHT = 100; +const DEFAULT_ROW_HEIGHT = 20; +const DEFAULT_WIDTH = 200; +const NUM_ROWS = 100; +const NUM_COLUMNS = 50; + +function getScrollbarSize0() { + return 0; +} + +function getScrollbarSize20() { + return 20; +} + +describe("Grid", () => { + function defaultCellRenderer({ + columnIndex, + key, + rowIndex, + style + }: Pick) { + return ( +
+ {`row:${rowIndex}, column:${columnIndex}`} +
+ ); + } + + function simulateScroll({ + grid, + scrollLeft = 0, + scrollTop = 0 + }: { + grid: any; + scrollLeft?: number; + scrollTop?: number; + }) { + fireEvent.scroll(findDOMNode(grid), { target: { scrollLeft, scrollTop } }); + } + + function getScrollPosition(grid: GridImperativeAPI) { + const node = findDOMNode(grid); + return { scrollLeft: node.scrollLeft, scrollTop: node.scrollTop }; + } + + function renderAndScrollToCell( + props: Partial, + params: { align?: Alignment; columnIndex?: number; rowIndex?: number } + ): GridImperativeAPI { + const grid = render(getMarkup(props)); + act(() => { + grid.scrollToCell(params); + }); + return grid; + } + + // Records the scroll directions Grid passes to its overscanIndicesGetter (i.e. its current scroll direction) + function createScrollDirectionTracker() { + let horizontal: number | undefined; + let vertical: number | undefined; + + return { + horizontal: () => horizontal, + overscanIndicesGetter: (params: OverscanIndicesGetterParams) => { + if (params.direction === "horizontal") { + horizontal = params.scrollDirection; + } else { + vertical = params.scrollDirection; + } + return defaultOverscanIndicesGetter(params); + }, + vertical: () => vertical + }; + } + + // Captures the style cache Grid passes to its cellRangeRenderer + function createStyleCacheTracker() { + let styleCache: StyleCache = {}; + + return { + cellRangeRenderer: (params: CellRangeRendererParams) => { + styleCache = params.styleCache; + return defaultCellRangeRenderer(params); + }, + keys: () => Object.keys(styleCache) + }; + } + + function getMarkup(props: Partial = {}) { + return ( + + ); + } + + describe("number of rendered children", () => { + it("should render enough children to fill the available area", () => { + const rendered = findDOMNode(render(getMarkup())); + expect(rendered.querySelectorAll(".gridItem").length).toEqual(20); // 5 rows x 4 columns + }); + + it("should not render more rows than available if the area is not filled", () => { + const rendered = findDOMNode(render(getMarkup({ rowCount: 2 }))); + expect(rendered.querySelectorAll(".gridItem").length).toEqual(8); // 2 rows x 4 columns + }); + + it("should not render more columns than available if the area is not filled", () => { + const rendered = findDOMNode(render(getMarkup({ columnCount: 2 }))); + expect(rendered.querySelectorAll(".gridItem").length).toEqual(10); // 5 rows x 2 columns + }); + + // Small performance tweak added in 5.5.6 + it("should not render/parent cells that are null or false", () => { + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + if (columnIndex === 0) { + return null; + } else if (rowIndex === 0) { + return false; + } else { + return ( +
+ {`row:${rowIndex}, column:${columnIndex}`} +
+ ); + } + } + const rendered = findDOMNode( + render( + getMarkup({ + columnCount: 3, + overscanColumnCount: 0, + overscanRowCount: 0, + rowCount: 3, + cellRenderer + }) + ) + ); + expect(rendered.querySelectorAll(".cell").length).toEqual(4); // [1,1], [1,2], [2,1], and [2,2] + expect(rendered.textContent).not.toContain("column:0"); + expect(rendered.textContent).not.toContain("row:0"); + }); + + it("should scroll to the last existing point when rows are removed", () => { + const grid = render( + getMarkup({ + rowCount: 15 + }) + ); + + simulateScroll({ + grid, + scrollTop: 200 + }); + + const updatedGrid = render( + getMarkup({ + rowCount: 10 + }) + ); + + expect(getScrollPosition(updatedGrid).scrollTop).toEqual(100); + }); + + it("should scroll to the last existing point when columns are removed", () => { + const grid = render( + getMarkup({ + columnCount: 12 + }) + ); + + simulateScroll({ + grid, + scrollLeft: 400 + }); + + const updatedGrid = render( + getMarkup({ + columnCount: 8 + }) + ); + + expect(getScrollPosition(updatedGrid).scrollLeft).toEqual(200); + }); + + it("should not scroll unseen rows are removed", () => { + render( + getMarkup({ + rowCount: 15 + }) + ); + const updatedGrid = render( + getMarkup({ + rowCount: 10 + }) + ); + + expect(getScrollPosition(updatedGrid).scrollTop).toEqual(0); + }); + + it("should not scroll when unseen columns are removed", () => { + render( + getMarkup({ + columnCount: 12 + }) + ); + const updatedGrid = render( + getMarkup({ + columnCount: 8 + }) + ); + + expect(getScrollPosition(updatedGrid).scrollLeft).toEqual(0); + }); + }); + + describe("shows and hides scrollbars based on rendered content", () => { + it("should set overflowX:hidden if columns fit within the available width and y-axis has no scrollbar", () => { + const rendered = findDOMNode( + render( + getMarkup({ + columnCount: 4, + getScrollbarSize: getScrollbarSize20, + rowCount: 5 + }) + ) + ); + expect(rendered.style.overflowX).toEqual("hidden"); + }); + + it("should set overflowX:hidden if columns and y-axis scrollbar fit within the available width", () => { + const rendered = findDOMNode( + render( + getMarkup({ + columnCount: 4, + getScrollbarSize: getScrollbarSize20, + width: 200 + getScrollbarSize20() + }) + ) + ); + expect(rendered.style.overflowX).toEqual("hidden"); + }); + + it("should leave overflowX:auto if columns require more than the available width", () => { + const rendered = findDOMNode( + render( + getMarkup({ + columnCount: 4, + getScrollbarSize: getScrollbarSize20, + width: 200 - 1, + rowCount: 5 + }) + ) + ); + expect(rendered.style.overflowX).not.toEqual("hidden"); + }); + + it("should leave overflowX:auto if columns and y-axis scrollbar require more than the available width", () => { + const rendered = findDOMNode( + render( + getMarkup({ + columnCount: 4, + getScrollbarSize: getScrollbarSize20, + width: 200 + getScrollbarSize20() - 1 + }) + ) + ); + expect(rendered.style.overflowX).not.toEqual("hidden"); + }); + + it("should set overflowY:hidden if rows fit within the available width and xaxis has no scrollbar", () => { + const rendered = findDOMNode( + render( + getMarkup({ + getScrollbarSize: getScrollbarSize20, + rowCount: 5, + columnCount: 4 + }) + ) + ); + expect(rendered.style.overflowY).toEqual("hidden"); + }); + + it("should set overflowY:hidden if rows and x-axis scrollbar fit within the available width", () => { + const rendered = findDOMNode( + render( + getMarkup({ + getScrollbarSize: getScrollbarSize20, + rowCount: 5, + height: 100 + getScrollbarSize20() + }) + ) + ); + expect(rendered.style.overflowY).toEqual("hidden"); + }); + + it("should leave overflowY:auto if rows require more than the available width", () => { + const rendered = findDOMNode( + render( + getMarkup({ + getScrollbarSize: getScrollbarSize20, + rowCount: 5, + height: 100 - 1, + columnCount: 4 + }) + ) + ); + expect(rendered.style.overflowY).not.toEqual("hidden"); + }); + + it("should leave overflowY:auto if rows and x-axis scrollbar require more than the available width", () => { + const rendered = findDOMNode( + render( + getMarkup({ + getScrollbarSize: getScrollbarSize20, + rowCount: 5, + height: 100 + getScrollbarSize20() - 1 + }) + ) + ); + expect(rendered.style.overflowY).not.toEqual("hidden"); + }); + + it("should accept styles that overwrite calculated ones", () => { + const rendered = findDOMNode( + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + height: 1, + rowCount: 1, + style: { + overflowY: "visible", + overflowX: "visible" + }, + width: 1 + }) + ) + ); + expect(rendered.style.overflowY).toEqual("visible"); + expect(rendered.style.overflowX).toEqual("visible"); + }); + }); + + /** Tests scrolling via the imperative scrollToCell() method */ + describe("scrollToCell", () => { + it("should scroll to the left", () => { + const grid = renderAndScrollToCell({}, { columnIndex: 0 }); + expect(getScrollPosition(grid).scrollLeft).toEqual(0); + }); + + it("should scroll over to the middle", () => { + const grid = renderAndScrollToCell({}, { columnIndex: 24 }); + // 50 columns * 50 item width = 2,500 total item width + // 4 columns can be visible at a time and :scrollLeft is initially 0, + // So the minimum amount of scrolling leaves the 25th item at the right (just scrolled into view). + expect(getScrollPosition(grid).scrollLeft).toEqual(1050); + }); + + it("should scroll to the far right", () => { + const grid = renderAndScrollToCell({}, { columnIndex: 49 }); + // 50 columns * 50 item width = 2,500 total item width + // Target offset for the last item then is 2,500 - 200 + expect(getScrollPosition(grid).scrollLeft).toEqual(2300); + }); + + it("should scroll to the top", () => { + const grid = renderAndScrollToCell({}, { rowIndex: 0 }); + expect(getScrollPosition(grid).scrollTop).toEqual(0); + }); + + it("should scroll down to the middle", () => { + const grid = renderAndScrollToCell({}, { rowIndex: 49 }); + // 100 rows * 20 item height = 2,000 total item height + // 5 rows can be visible at a time and :scrollTop is initially 0, + // So the minimum amount of scrolling leaves the 50th item at the bottom (just scrolled into view). + expect(getScrollPosition(grid).scrollTop).toEqual(900); + }); + + it("should scroll to the bottom", () => { + const grid = renderAndScrollToCell({}, { rowIndex: 99 }); + // 100 rows * 20 item height = 2,000 total item height + // Target offset for the last item then is 2,000 - 100 + expect(getScrollPosition(grid).scrollTop).toEqual(1900); + }); + + it("should render the cells that were scrolled to", () => { + const grid = renderAndScrollToCell({}, { columnIndex: 24, rowIndex: 49 }); + expect(findDOMNode(grid).textContent).toContain("row:49, column:24"); + expect(findDOMNode(grid).textContent).not.toContain("row:0, column:0"); + }); + + it("should scroll to a row and column just added", () => { + let grid = render(getMarkup()); + expect(getScrollPosition(grid)).toEqual({ scrollLeft: 0, scrollTop: 0 }); + grid = renderAndScrollToCell( + { + columnCount: NUM_COLUMNS + 1, + rowCount: NUM_ROWS + 1 + }, + { columnIndex: NUM_COLUMNS, rowIndex: NUM_ROWS } + ); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 2350, + scrollTop: 1920 + }); + }); + + it('should scroll to the correct position for align "start"', () => { + const grid = renderAndScrollToCell( + {}, + { + align: "start", + columnIndex: 24, + rowIndex: 49 + } + ); + // 50 columns * 50 item width = 2,500 total item width + // 100 rows * 20 item height = 2,000 total item height + // 4 columns and 5 rows can be visible at a time. + // The minimum amount of scrolling leaves the specified cell in the bottom/right corner (just scrolled into view). + // Since alignment is set to "start" we should scroll past this point until the cell is aligned top/left. + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 1200, + scrollTop: 980 + }); + }); + + it('should scroll to the correct position for align "end"', () => { + const grid = renderAndScrollToCell( + {}, + { + columnIndex: 99, + rowIndex: 99 + } + ); + act(() => { + grid.scrollToCell({ + align: "end", + columnIndex: 24, + rowIndex: 49 + }); + }); + // 50 columns * 50 item width = 2,500 total item width + // 100 rows * 20 item height = 2,000 total item height + // We first scroll past the specified cell and then back. + // The minimum amount of scrolling then should leave the specified cell in the top/left corner (just scrolled into view). + // Since alignment is set to "end" we should scroll past this point until the cell is aligned bottom/right. + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 1050, + scrollTop: 900 + }); + }); + + it('should scroll to the correct position for align "center"', () => { + const grid = renderAndScrollToCell( + {}, + { + columnIndex: 99, + rowIndex: 99 + } + ); + act(() => { + grid.scrollToCell({ + align: "center", + columnIndex: 24, + rowIndex: 49 + }); + }); + // 50 columns * 50 item width = 2,500 total item width + // Viewport width is 200 + // Column 24 starts at 1,200, center point at 1,225, so... + expect(getScrollPosition(grid).scrollLeft).toEqual(1125); + // 100 rows * 20 item height = 2,000 total item height + // Viewport height is 100 + // Row 49 starts at 980, center point at 990, so... + expect(getScrollPosition(grid).scrollTop).toEqual(940); + }); + + // Tests issue #218 + it("should set the correct :scrollTop after row and column counts increase from 0", () => { + const expectedScrollTop = + 100 * DEFAULT_ROW_HEIGHT - DEFAULT_HEIGHT + DEFAULT_ROW_HEIGHT; + const grid = renderAndScrollToCell( + { + columnCount: 0, + rowCount: 150 + }, + { rowIndex: 100 } + ); + render( + getMarkup({ + columnCount: 150, + rowCount: 150 + }) + ); + expect(getScrollPosition(grid).scrollTop).toEqual(expectedScrollTop); + }); + + it("should support scrollToCell() public method", () => { + const grid = render(getMarkup()); + expect(getScrollPosition(grid)).toEqual({ scrollLeft: 0, scrollTop: 0 }); + act(() => { + grid.scrollToCell({ + columnIndex: 24, + rowIndex: 49 + }); + }); + // 50 columns * 50 item width = 2,500 total item width + // 4 columns can be visible at a time and :scrollLeft is initially 0, + // So the minimum amount of scrolling leaves the 25th item at the right (just scrolled into view). + expect(getScrollPosition(grid).scrollLeft).toEqual(1050); + // 100 rows * 20 item height = 2,000 total item height + // 5 rows can be visible at a time and :scrollTop is initially 0, + // So the minimum amount of scrolling leaves the 50th item at the bottom (just scrolled into view). + expect(getScrollPosition(grid).scrollTop).toEqual(900); + + // Change column without affecting row + act(() => { + grid.scrollToCell({ + columnIndex: 49 + }); + }); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 2300, + scrollTop: 900 + }); + + // Change row without affecting column + act(() => { + grid.scrollToCell({ + rowIndex: 99 + }); + }); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 2300, + scrollTop: 1900 + }); + }); + + it("should scroll back to a cell that the user has scrolled away from", () => { + const grid = renderAndScrollToCell({}, { columnIndex: 24, rowIndex: 49 }); + simulateScroll({ grid, scrollLeft: 0, scrollTop: 0 }); + expect(getScrollPosition(grid)).toEqual({ scrollLeft: 0, scrollTop: 0 }); + act(() => { + grid.scrollToCell({ columnIndex: 24, rowIndex: 49 }); + }); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 1050, + scrollTop: 900 + }); + }); + + it("should ignore the column of single-column grids", () => { + const grid = renderAndScrollToCell( + { columnCount: 1, columnWidth: 250 }, + { columnIndex: 0, rowIndex: 49 } + ); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 0, + scrollTop: 900 + }); + }); + + it("should support scrollToPosition() public method", () => { + const grid = render(getMarkup()); + expect(getScrollPosition(grid)).toEqual({ scrollLeft: 0, scrollTop: 0 }); + + act(() => { + grid.scrollToPosition({ + scrollLeft: 50, + scrollTop: 100 + }); + }); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 50, + scrollTop: 100 + }); + + // Change column without affecting row + act(() => { + grid.scrollToPosition({ + scrollLeft: 25 + }); + }); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 25, + scrollTop: 100 + }); + + // Change row without affecting column + act(() => { + grid.scrollToPosition({ + scrollTop: 50 + }); + }); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 25, + scrollTop: 50 + }); + }); + + it("should clamp scrollToPosition() offsets to the scrollable range", () => { + const grid = render(getMarkup()); + + act(() => { + grid.scrollToPosition({ + scrollLeft: 10000, + scrollTop: 10000 + }); + }); + // 2,500 - 200 and 2,000 - 100 + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 2300, + scrollTop: 1900 + }); + + act(() => { + grid.scrollToPosition({ + scrollLeft: -100, + scrollTop: -100 + }); + }); + expect(getScrollPosition(grid)).toEqual({ scrollLeft: 0, scrollTop: 0 }); + }); + + it("should not re-render when scrollToPosition() is called with the current offsets", () => { + const cellRenderer = vi.fn(defaultCellRenderer); + const direction = createScrollDirectionTracker(); + const grid = render( + getMarkup({ + cellRenderer, + overscanIndicesGetter: direction.overscanIndicesGetter + }) + ); + act(() => { + grid.scrollToPosition({ scrollLeft: 100, scrollTop: 100 }); + }); + act(() => { + grid.scrollToPosition({ scrollLeft: 50, scrollTop: 50 }); + }); + expect(direction.horizontal()).toBe(SCROLL_DIRECTION_BACKWARD); + expect(direction.vertical()).toBe(SCROLL_DIRECTION_BACKWARD); + + cellRenderer.mockClear(); + act(() => { + grid.scrollToPosition({ scrollLeft: 50, scrollTop: 50 }); + grid.scrollToPosition({ scrollLeft: 50 }); + grid.scrollToPosition({}); + }); + expect(cellRenderer).not.toHaveBeenCalled(); + + // A clamped offset that equals the current one is a no-op as well + act(() => { + grid.scrollToPosition({ scrollLeft: 10000, scrollTop: 10000 }); + }); + cellRenderer.mockClear(); + act(() => { + grid.scrollToPosition({ scrollLeft: 20000, scrollTop: 20000 }); + }); + expect(cellRenderer).not.toHaveBeenCalled(); + expect(direction.horizontal()).toBe(SCROLL_DIRECTION_FORWARD); + expect(direction.vertical()).toBe(SCROLL_DIRECTION_FORWARD); + }); + + it("should sync its scroll offsets with the browser if it can't scroll as far as requested", () => { + // E.g. a Grid whose overflow is hidden by a custom style; browsers clamp the offset (and dispatch no scroll event) + const elementRef = (element: HTMLDivElement | null) => { + if (element) { + let scrollTop = 0; + Object.defineProperty(element, "scrollTop", { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = Math.min(value, 20); + } + }); + } + }; + const grid = render(getMarkup({ elementRef })); + act(() => { + grid.scrollToPosition({ scrollTop: 100 }); + }); + expect(getScrollPosition(grid).scrollTop).toEqual(20); + // Rows 1-5 are visible (rather than rows 5-9) + expect(findDOMNode(grid).textContent).toContain("row:1, column:0"); + expect(findDOMNode(grid).textContent).not.toContain("row:6, column:0"); + }); + + it("should support handleScrollEvent() public method", () => { + const grid = render(getMarkup()); + expect(getScrollPosition(grid)).toEqual({ scrollLeft: 0, scrollTop: 0 }); + + act(() => { + grid.handleScrollEvent({ + scrollLeft: 50, + scrollTop: 100 + }); + }); + const node = findDOMNode(grid); + expect( + node.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.pointerEvents + ).toEqual("none"); // isScrolling + expect(node.textContent).toContain("row:5, column:1"); + expect(node.textContent).not.toContain("row:4, column:0"); + }); + + it("should support getOffsetForCell() public method", () => { + const grid = render(getMarkup()); + const { scrollLeft, scrollTop } = grid.getOffsetForCell({ + columnIndex: 24, + rowIndex: 49 + }); + // 50 columns * 50 item width = 2,500 total item width + // 4 columns can be visible at a time and :scrollLeft is initially 0, + // So the minimum amount of scrolling leaves the 25th item at the right (just scrolled into view). + expect(scrollLeft).toEqual(1050); + // 100 rows * 20 item height = 2,000 total item height + // 5 rows can be visible at a time and :scrollTop is initially 0, + // So the minimum amount of scrolling leaves the 50th item at the bottom (just scrolled into view). + expect(scrollTop).toEqual(900); + }); + + it("should default getOffsetForCell() parameters to the last cell (and auto alignment)", () => { + const grid = render(getMarkup()); + expect(grid.getOffsetForCell()).toEqual({ + scrollLeft: 2300, + scrollTop: 1900 + }); + // The cell most recently scrolled to isn't remembered + act(() => { + grid.scrollToCell({ align: "start", columnIndex: 24, rowIndex: 49 }); + }); + expect(grid.getOffsetForCell()).toEqual({ + scrollLeft: 2300, + scrollTop: 1900 + }); + }); + + it("should not scroll if the Grid has no size", () => { + const grid = renderAndScrollToCell( + { height: 0, width: 0 }, + { columnIndex: 10, rowIndex: 50 } + ); + render(getMarkup()); + expect(getScrollPosition(grid)).toEqual({ scrollLeft: 0, scrollTop: 0 }); + }); + + it("should support getTotalRowsHeight() public method", () => { + const grid = render(getMarkup()); + act(() => { + grid.recomputeGridSize(); + }); + const totalHeight = grid.getTotalRowsHeight(); + // 100 rows * 20 item height = 2,000 total item height + expect(totalHeight).toEqual(2000); + }); + + it("should support getTotalColumnsWidth() public method", () => { + const grid = render(getMarkup()); + act(() => { + grid.recomputeGridSize(); + }); + const totalWidth = grid.getTotalColumnsWidth(); + // 50 columns * 50 item width = 2,500 total item width + expect(totalWidth).toEqual(2500); + }); + + it("should not restore scrollLeft when scrolling left and recomputeGridSize with columnIndex smaller than the cell scrolled to", () => { + const direction = createScrollDirectionTracker(); + const props = { + columnWidth: 50, + columnCount: 100, + height: 100, + overscanIndicesGetter: direction.overscanIndicesGetter, + rowCount: 100, + rowHeight: 20, + width: 100 + }; + const grid = renderAndScrollToCell(props, { + columnIndex: 50, + rowIndex: 50 + }); + + expect(getScrollPosition(grid).scrollLeft).toEqual(2450); + + simulateScroll({ grid, scrollLeft: 2250, scrollTop: 920 }); + expect(getScrollPosition(grid).scrollLeft).toEqual(2250); + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_BACKWARD); + + act(() => { + grid.recomputeGridSize({ columnIndex: 30 }); + }); + expect(getScrollPosition(grid).scrollLeft).toEqual(2250); + }); + + it("should not restore scrollTop when scrolling up and recomputeGridSize with rowIndex smaller than the cell scrolled to", () => { + const direction = createScrollDirectionTracker(); + const props = { + columnWidth: 50, + columnCount: 100, + height: 100, + overscanIndicesGetter: direction.overscanIndicesGetter, + rowCount: 100, + rowHeight: 20, + width: 100 + }; + const grid = renderAndScrollToCell(props, { + columnIndex: 50, + rowIndex: 50 + }); + + expect(getScrollPosition(grid).scrollTop).toEqual(920); + + simulateScroll({ grid, scrollLeft: 2450, scrollTop: 720 }); + expect(getScrollPosition(grid).scrollTop).toEqual(720); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_BACKWARD); + + act(() => { + grid.recomputeGridSize({ rowIndex: 20 }); + }); + expect(getScrollPosition(grid).scrollTop).toEqual(720); + }); + + it("should not restore the scroll offset of a cell scrolled to backward when recomputeGridSize is called with a smaller index", () => { + let rowHeight = 20; + const props = { + height: 100, + rowCount: 100, + rowHeight: ({ index }: { index: number }) => + index === 10 ? rowHeight : 20 + }; + const grid = renderAndScrollToCell(props, { rowIndex: 99 }); + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 50 }); + }); + expect(getScrollPosition(grid).scrollTop).toEqual(1000); + + rowHeight = 40; + act(() => { + grid.recomputeGridSize({ rowIndex: 10 }); + }); + // Scrolling backward; only cells after the target are re-aligned + expect(getScrollPosition(grid).scrollTop).toEqual(1000); + }); + + it("should take scrollbar size into account when aligning cells", () => { + const grid = renderAndScrollToCell( + { + columnWidth: 50, + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + height: 100, + rowCount: 100, + rowHeight: 20, + width: 100 + }, + { columnIndex: 50, rowIndex: 50 } + ); + + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 2450 + getScrollbarSize20(), + scrollTop: 920 + getScrollbarSize20() + }); + }); + }); + + describe("property updates", () => { + it("should not keep the cell scrolled to in view when cell sizes change", () => { + const grid = renderAndScrollToCell({}, { columnIndex: 10, rowIndex: 50 }); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 350, + scrollTop: 920 + }); + // scrollToCell is a one-time scroll; the offsets aren't adjusted afterwards + render(getMarkup({ columnWidth: 100, rowHeight: 40 })); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 350, + scrollTop: 920 + }); + act(() => { + grid.recomputeGridSize(); + }); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 350, + scrollTop: 920 + }); + }); + + it("should not keep the cell scrolled to in view when the Grid's size changes", () => { + const grid = renderAndScrollToCell({}, { columnIndex: 10, rowIndex: 50 }); + render(getMarkup({ height: 50, width: 100 })); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 350, + scrollTop: 920 + }); + }); + + it("should not keep the row scrolled to visible once the user has scrolled", () => { + const grid = renderAndScrollToCell({}, { rowIndex: 50 }); + expect(findDOMNode(grid).textContent).toContain("row:50"); + simulateScroll({ grid, scrollTop: 100 }); + render(getMarkup({ height: 20 })); + expect(getScrollPosition(grid).scrollTop).toEqual(100); + expect(findDOMNode(grid).textContent).not.toContain("row:50"); + }); + + it("should not keep the row scrolled to visible once scrollToPosition() has been called", () => { + const grid = renderAndScrollToCell({}, { rowIndex: 50 }); + act(() => { + grid.scrollToPosition({ scrollTop: 100 }); + }); + render(getMarkup({ rowHeight: 40 })); + expect(getScrollPosition(grid).scrollTop).toEqual(100); + }); + + it("should update scroll position if size shrinks smaller than the current scroll", () => { + const grid = renderAndScrollToCell({}, { columnIndex: 250 }); + render(getMarkup({ columnCount: 10 })); + expect(findDOMNode(grid).textContent).toContain("column:9"); + }); + + it("should update scroll position if size shrinks smaller than the current scroll (rows)", () => { + const grid = renderAndScrollToCell({}, { rowIndex: 500 }); + render(getMarkup({ rowCount: 10 })); + expect(findDOMNode(grid).textContent).toContain("row:9"); + }); + }); + + describe("noContentRenderer", () => { + it("should call :noContentRenderer if :columnCount is 0", () => { + const list = findDOMNode( + render( + getMarkup({ + noContentRenderer: () =>
No data
, + columnCount: 0 + }) + ) + ); + expect(list.textContent).toEqual("No data"); + }); + + it("should call :noContentRenderer if :rowCount is 0", () => { + const list = findDOMNode( + render( + getMarkup({ + noContentRenderer: () =>
No data
, + rowCount: 0 + }) + ) + ); + expect(list.textContent).toEqual("No data"); + }); + + // Sanity check for bvaughn/react-virtualized/pull/348 + it("should render an empty body if :rowCount or :columnCount changes to 0", () => { + function noContentRenderer() { + return
No data
; + } + + let list = findDOMNode( + render( + getMarkup({ + noContentRenderer + }) + ) + ); + expect(list.textContent).not.toEqual("No data"); + + list = findDOMNode( + render( + getMarkup({ + noContentRenderer, + rowCount: 0 + }) + ) + ); + expect(list.textContent).toEqual("No data"); + + list = findDOMNode( + render( + getMarkup({ + noContentRenderer + }) + ) + ); + expect(list.textContent).not.toEqual("No data"); + + list = findDOMNode( + render( + getMarkup({ + columnCount: 0, + noContentRenderer + }) + ) + ); + expect(list.textContent).toEqual("No data"); + }); + + it("should render an empty body if :columnCount is 0 and there is no :noContentRenderer", () => { + const list = findDOMNode( + render( + getMarkup({ + columnCount: 0 + }) + ) + ); + expect(list.textContent).toEqual(""); + }); + + it("should render an empty body if :rowCount is 0 and there is no :noContentRenderer", () => { + const list = findDOMNode( + render( + getMarkup({ + rowCount: 0 + }) + ) + ); + expect(list.textContent).toEqual(""); + }); + + it("should render an empty body there is a :noContentRenderer but :height or :width are 0", () => { + let list = findDOMNode( + render( + getMarkup({ + height: 0, + noContentRenderer: () =>
No data
+ }) + ) + ); + expect(list.textContent).toEqual(""); + list = findDOMNode( + render( + getMarkup({ + noContentRenderer: () =>
No data
, + width: 0 + }) + ) + ); + expect(list.textContent).toEqual(""); + }); + }); + + describe("onSectionRendered", () => { + it("should call :onSectionRendered if at least one cell is rendered", () => { + let columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex; + render( + getMarkup({ + onSectionRendered: (params) => + ({ + columnStartIndex, + columnStopIndex, + rowStartIndex, + rowStopIndex + } = params) + }) + ); + expect(columnStartIndex).toEqual(0); + expect(columnStopIndex).toEqual(3); + expect(rowStartIndex).toEqual(0); + expect(rowStopIndex).toEqual(4); + }); + + it("should not call :onSectionRendered unless the column or row start or stop indices have changed", () => { + let numCalls = 0; + let columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex; + const onSectionRendered = (params: RenderedSection) => { + columnStartIndex = params.columnStartIndex; + columnStopIndex = params.columnStopIndex; + rowStartIndex = params.rowStartIndex; + rowStopIndex = params.rowStopIndex; + numCalls++; + }; + render(getMarkup({ onSectionRendered })); + expect(numCalls).toEqual(1); + expect(columnStartIndex).toEqual(0); + expect(columnStopIndex).toEqual(3); + expect(rowStartIndex).toEqual(0); + expect(rowStopIndex).toEqual(4); + render(getMarkup({ onSectionRendered })); + expect(numCalls).toEqual(1); + expect(columnStartIndex).toEqual(0); + expect(columnStopIndex).toEqual(3); + expect(rowStartIndex).toEqual(0); + expect(rowStopIndex).toEqual(4); + }); + + it("should call :onSectionRendered if the row or column start or stop indices have changed", () => { + let numCalls = 0; + let columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex; + const onSectionRendered = (params: RenderedSection) => { + columnStartIndex = params.columnStartIndex; + columnStopIndex = params.columnStopIndex; + rowStartIndex = params.rowStartIndex; + rowStopIndex = params.rowStopIndex; + numCalls++; + }; + render(getMarkup({ onSectionRendered })); + expect(columnStartIndex).toEqual(0); + expect(columnStopIndex).toEqual(3); + expect(rowStartIndex).toEqual(0); + expect(rowStopIndex).toEqual(4); + render( + getMarkup({ + height: 50, + onSectionRendered + }) + ); + expect(numCalls).toEqual(2); + expect(columnStartIndex).toEqual(0); + expect(columnStopIndex).toEqual(3); + expect(rowStartIndex).toEqual(0); + expect(rowStopIndex).toEqual(2); + render( + getMarkup({ + height: 50, + onSectionRendered, + width: 100 + }) + ); + expect(numCalls).toEqual(3); + expect(columnStartIndex).toEqual(0); + expect(columnStopIndex).toEqual(1); + expect(rowStartIndex).toEqual(0); + expect(rowStopIndex).toEqual(2); + }); + + it("should not call :onSectionRendered if no cells are rendered", () => { + let numCalls = 0; + render( + getMarkup({ + height: 0, + onSectionRendered: () => numCalls++ + }) + ); + expect(numCalls).toEqual(0); + }); + }); + + describe("with a scrollElement", () => { + let unmockResizeObserver: () => void; + beforeEach(() => { + unmockResizeObserver = mockResizeObserver(); + }); + afterEach(() => { + render.unmount(); + unmockResizeObserver(); + vi.restoreAllMocks(); + }); + + function getSectionTracker() { + const section: Partial = {}; + return { + onSectionRendered: (params: RenderedSection) => + Object.assign(section, params), + section + }; + } + + // Renders the Grid within a scroll container (whose viewport is as big as the Grid's default size), + // below `contentOffset` pixels of other content; the container is initially scrolled to `scrollTop`. + function renderInScrollContainer( + props: Partial = {}, + { + contentOffset = 0, + height = DEFAULT_HEIGHT, + scrollTop = 0 + }: { contentOffset?: number; height?: number; scrollTop?: number } = {} + ) { + const container = createScrollContainer({ + contentOffset, + height, + width: DEFAULT_WIDTH + }); + container.element.scrollTop = scrollTop; + const scrollTo = vi.spyOn(container.element, "scrollTo"); + const markup = (moreProps: Partial = {}) => + getMarkup({ scrollElement: container.element, ...props, ...moreProps }); + const grid = render(markup()); + return { container, grid, markup, scrollTo }; + } + + it("should render the rows within the viewport of the scroll element", () => { + const { onSectionRendered, section } = getSectionTracker(); + renderInScrollContainer({ onSectionRendered }, { scrollTop: 100 }); + expect(section.rowStartIndex).toEqual(5); + expect(section.rowStopIndex).toEqual(9); + expect(section.columnStartIndex).toEqual(0); + expect(section.columnStopIndex).toEqual(3); + }); + + it("should render the rows within the viewport when the scroll element is scrolled", () => { + const { onSectionRendered, section } = getSectionTracker(); + const { container, grid } = renderInScrollContainer({ + onSectionRendered + }); + expect(section.rowStartIndex).toEqual(0); + expect(section.rowStopIndex).toEqual(4); + + container.scroll(100); + expect(section.rowStartIndex).toEqual(5); + expect(section.rowStopIndex).toEqual(9); + expect(findDOMNode(grid).textContent).toContain("row:5, column:0"); + }); + + it("should render the first rows while the Grid is below the top of the viewport", () => { + const { onSectionRendered, section } = getSectionTracker(); + const { container } = renderInScrollContainer( + { onSectionRendered }, + { contentOffset: 50 } + ); + expect(section.rowStartIndex).toEqual(0); + expect(section.rowStopIndex).toEqual(4); + + // The top of the Grid is still 20px below the top of the viewport + container.scroll(30); + expect(section.rowStartIndex).toEqual(0); + + container.scroll(150); + expect(section.rowStartIndex).toEqual(5); + expect(section.rowStopIndex).toEqual(9); + }); + + it("should not write the viewport's offset to the DOM", () => { + const { grid } = renderInScrollContainer({}, { scrollTop: 100 }); + expect(getScrollPosition(grid).scrollTop).toEqual(0); + expect(findDOMNode(grid).textContent).toContain("row:5, column:0"); + }); + + it("should measure the position of its own element within the scroll element", () => { + const { onSectionRendered, section } = getSectionTracker(); + renderInScrollContainer( + { onSectionRendered }, + { contentOffset: 50, scrollTop: 150 } + ); + expect(section.rowStartIndex).toEqual(5); + expect(section.rowStopIndex).toEqual(9); + }); + + it("should set the scroll direction when the scroll element is scrolled", async () => { + const direction = createScrollDirectionTracker(); + const { container } = renderInScrollContainer({ + overscanIndicesGetter: direction.overscanIndicesGetter, + scrollingResetTimeInterval: 10 + }); + container.scroll(100); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + container.scroll(50); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_BACKWARD); + + // Once scrolling stops, the direction is kept + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_BACKWARD); + + container.scroll(150); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + }); + + it("should still scroll horizontally itself", () => { + const { onSectionRendered, section } = getSectionTracker(); + const { grid } = renderInScrollContainer( + { onSectionRendered }, + { scrollTop: 100 } + ); + simulateScroll({ grid, scrollLeft: 250, scrollTop: 0 }); + expect(section.columnStartIndex).toEqual(5); + expect(section.columnStopIndex).toEqual(8); + expect(section.rowStartIndex).toEqual(5); + }); + + it("should scroll itself again once the scrollElement is removed", () => { + const { onSectionRendered, section } = getSectionTracker(); + const { container, grid, markup } = renderInScrollContainer( + { onSectionRendered }, + { scrollTop: 100 } + ); + expect(findDOMNode(grid).style.height).toEqual("auto"); + + render(markup({ scrollElement: undefined })); + expect(findDOMNode(grid).style.height).toEqual(`${DEFAULT_HEIGHT}px`); + expect(section.rowStartIndex).toEqual(0); + + container.scroll(200); + expect(section.rowStartIndex).toEqual(0); + + simulateScroll({ grid, scrollTop: 100 }); + expect(section.rowStartIndex).toEqual(5); + }); + + it("should link once the scrollElement is specified", () => { + const { onSectionRendered, section } = getSectionTracker(); + const container = createScrollContainer({ + height: DEFAULT_HEIGHT, + width: DEFAULT_WIDTH + }); + container.element.scrollTop = 200; + const grid = render(getMarkup({ onSectionRendered })); + expect(findDOMNode(grid).style.height).toEqual(`${DEFAULT_HEIGHT}px`); + expect(section.rowStartIndex).toEqual(0); + + render( + getMarkup({ onSectionRendered, scrollElement: container.element }) + ); + expect(findDOMNode(grid).style.height).toEqual("auto"); + expect(section.rowStartIndex).toEqual(10); + expect(getObserverCount(container.element)).toBe(1); + }); + + it("should report the viewport's offset to onScroll", () => { + const onScroll = vi.fn(); + const { container } = renderInScrollContainer( + { onScroll }, + { contentOffset: 100 } + ); + onScroll.mockClear(); + + container.scroll(200); + expect(onScroll).toHaveBeenLastCalledWith({ + clientHeight: 100, + clientWidth: 200, + scrollHeight: 2000, + scrollLeft: 0, + scrollTop: 100, + scrollWidth: 2500 + }); + + // Offsets above the Grid are reported as 0 + container.scroll(0); + expect(onScroll).toHaveBeenLastCalledWith( + expect.objectContaining({ scrollTop: 0 }) + ); + }); + + describe("scrollToCell", () => { + it("should scroll the scroll element instead of itself (#32)", () => { + const { container, grid, scrollTo } = renderInScrollContainer(); + + act(() => { + grid.scrollToCell({ rowIndex: 49 }); + }); + expect(scrollTo).toHaveBeenCalledTimes(1); + expect(scrollTo).toHaveBeenLastCalledWith(0, 900); + expect(container.element.scrollTop).toEqual(900); + expect(getScrollPosition(grid).scrollTop).toEqual(0); + + // The rendered rows are updated once the scroll element has been scrolled + expect(findDOMNode(grid).textContent).toContain("row:49, column:0"); + expect(scrollTo).toHaveBeenCalledTimes(1); + }); + + it("should scroll by the position of the Grid within the scroll element", () => { + const { grid, scrollTo } = renderInScrollContainer( + {}, + { contentOffset: 300 } + ); + + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 10 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 200 + 300); + expect(findDOMNode(grid).textContent).toContain("row:10, column:0"); + }); + + it("should support the align parameter", () => { + const { grid, scrollTo } = renderInScrollContainer( + {}, + { scrollTop: 500 } + ); + + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 10 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 200); + + act(() => { + grid.scrollToCell({ align: "end", rowIndex: 10 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 120); + + act(() => { + grid.scrollToCell({ align: "center", rowIndex: 10 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 160); + }); + + it("should not scroll to a row that is already visible within the viewport", () => { + // The top of the Grid is 50px below the top of the viewport + const { grid, scrollTo } = renderInScrollContainer( + {}, + { contentOffset: 50 } + ); + + act(() => { + grid.scrollToCell({ rowIndex: 0 }); + }); + expect(scrollTo).not.toHaveBeenCalled(); + + // The least amount possible (the end of row 2 is 10px below the bottom of the viewport) + act(() => { + grid.scrollToCell({ rowIndex: 2 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 10); + }); + + it("should scroll the last rows to the start of the viewport, leaving the scroll element to limit the offset (#1895)", () => { + // e.g. content below the Grid lets the last rows be scrolled to the top of the viewport + const { grid, scrollTo } = renderInScrollContainer(); + + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 99 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 1980); + + act(() => { + grid.scrollToCell({ align: "center", rowIndex: 99 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 1940); + }); + + it("should align a row taller than the viewport to its start with auto alignment (#1264)", () => { + const { grid, scrollTo } = renderInScrollContainer({ + rowHeight: ({ index }) => (index === 10 ? 300 : 20) + }); + + act(() => { + grid.scrollToCell({ rowIndex: 10 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 200); + }); + + it("should keep scrolling its own element horizontally", () => { + const { grid, scrollTo } = renderInScrollContainer(); + + act(() => { + grid.scrollToCell({ columnIndex: 24, rowIndex: 49 }); + }); + expect(getScrollPosition(grid).scrollLeft).toEqual(1050); + expect(scrollTo).toHaveBeenLastCalledWith(0, 900); + }); + + it("should preserve the horizontal scroll position of the scroll element (#18)", () => { + const { container, grid, scrollTo } = renderInScrollContainer(); + container.scroll(0, 30); + + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 10 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(30, 200); + }); + + it("should stop keeping the row in view once the user scrolls", () => { + const { container, grid, markup, scrollTo } = renderInScrollContainer(); + + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 10 }); + }); + expect(container.element.scrollTop).toEqual(200); + + // A scroll by the user + container.scroll(400); + + render(markup({ rowHeight: 30 })); + expect(scrollTo).toHaveBeenCalledTimes(1); + }); + }); + + describe("scrollToPosition", () => { + it("should scroll the scroll element instead of itself", () => { + const { grid, scrollTo } = renderInScrollContainer(); + + act(() => { + grid.scrollToPosition({ scrollTop: 300 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 300); + expect(getScrollPosition(grid).scrollTop).toEqual(0); + expect(findDOMNode(grid).textContent).toContain("row:15, column:0"); + }); + + it("should clamp the offset to the scrollable range", () => { + const { grid, scrollTo } = renderInScrollContainer( + {}, + { scrollTop: 100 } + ); + + act(() => { + grid.scrollToPosition({ scrollTop: 5000 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 1900); + + act(() => { + grid.scrollToPosition({ scrollTop: -100 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 0); + }); + + it("should do nothing if the offset equals the current (reported) offset", () => { + const { container, grid, scrollTo } = renderInScrollContainer( + {}, + { contentOffset: 100, scrollTop: 200 } + ); + + act(() => { + grid.scrollToPosition({ scrollTop: 100 }); + }); + expect(scrollTo).not.toHaveBeenCalled(); + + // Above the Grid, the offset is reported as 0 + container.scroll(0); + act(() => { + grid.scrollToPosition({ scrollTop: 0 }); + }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("should scroll its own element horizontally", () => { + const { grid, scrollTo } = renderInScrollContainer(); + + act(() => { + grid.scrollToPosition({ scrollLeft: 250 }); + }); + expect(getScrollPosition(grid).scrollLeft).toEqual(250); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("should set scrollTop on a scroll element without scrollTo()", () => { + const { container, grid } = renderInScrollContainer( + {}, + { contentOffset: 100 } + ); + (container.element as any).scrollTo = undefined; + + act(() => { + grid.scrollToPosition({ scrollTop: 200 }); + }); + expect(container.element.scrollTop).toEqual(300); + }); + }); + + it("should calculate offsets (getOffsetForCell) relative to the viewport", () => { + const { grid } = renderInScrollContainer({}, { contentOffset: 50 }); + expect(grid.getOffsetForCell({ rowIndex: 0 }).scrollTop).toEqual(-50); + expect(grid.getOffsetForCell({ rowIndex: 49 }).scrollTop).toEqual(900); + }); + + it("should pass the cellRenderer an :isScrolling flag while the scroll element is scrolled", async () => { + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation(({ key, style }) => ( +
+ )); + const { container } = renderInScrollContainer({ + cellRenderer, + scrollingResetTimeInterval: 10 + }); + expect(cellRenderer.mock.lastCall![0].isScrolling).toBe(false); + + cellRenderer.mockClear(); + container.scroll(100); + expect(cellRenderer).toHaveBeenCalled(); + expect(cellRenderer.mock.lastCall![0].isScrolling).toBe(true); + + cellRenderer.mockClear(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(cellRenderer).toHaveBeenCalled(); + expect(cellRenderer.mock.lastCall![0].isScrolling).toBe(false); + }); + + it("should use the viewport height of the scroll element (rather than the height prop)", () => { + const { onSectionRendered, section } = getSectionTracker(); + const { container } = renderInScrollContainer( + { height: 40, onSectionRendered }, + { height: 200 } + ); + expect(section.rowStopIndex).toEqual(9); + + // Not required with a scrollElement + render( + getMarkup({ + height: undefined, + onSectionRendered, + scrollElement: container.element + }) + ); + expect(section.rowStopIndex).toEqual(9); + }); + + it("should use the height prop until the scroll element is available", () => { + const { onSectionRendered, section } = getSectionTracker(); + const grid = render( + getMarkup({ height: 60, onSectionRendered, scrollElement: null }) + ); + // Rendered in full-height style, as if at the top of the scroll element + expect(findDOMNode(grid).style.height).toEqual("auto"); + expect(section.rowStartIndex).toEqual(0); + expect(section.rowStopIndex).toEqual(2); + }); + + it("should re-measure the viewport and position when the scroll element is resized", () => { + const { onSectionRendered, section } = getSectionTracker(); + const { container } = renderInScrollContainer( + { onSectionRendered }, + { contentOffset: 100, scrollTop: 200 } + ); + expect(section.rowStartIndex).toEqual(5); + expect(section.rowStopIndex).toEqual(9); + + container.resize({ height: 200 }); + expect(section.rowStartIndex).toEqual(5); + expect(section.rowStopIndex).toEqual(14); + + // The content above the Grid grows along with it + container.setContentOffset(140); + container.resize({ height: 100 }); + expect(section.rowStartIndex).toEqual(3); + expect(section.rowStopIndex).toEqual(7); + }); + + it("should re-measure its position after a re-render", () => { + const { onSectionRendered, section } = getSectionTracker(); + const { container, grid, markup, scrollTo } = renderInScrollContainer( + { onSectionRendered }, + { contentOffset: 100, scrollTop: 200 } + ); + expect(section.rowStartIndex).toEqual(5); + + // The content above the Grid changed size (without a scroll or resize event) + container.setContentOffset(140); + act(() => { + grid.forceUpdate(); + }); + expect(section.rowStartIndex).toEqual(3); + + container.setContentOffset(100); + render(markup({ className: "updated" })); + expect(section.rowStartIndex).toEqual(5); + + // Scrolling measures the position again as well + container.setContentOffset(0); + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 20 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 400); + }); + + it("should start and stop listening when the scrollElement changes from null to an element and back (#6)", () => { + const { onSectionRendered, section } = getSectionTracker(); + const container = createScrollContainer({ + height: DEFAULT_HEIGHT, + width: DEFAULT_WIDTH + }); + container.element.scrollTop = 250; + const addEventListener = vi.spyOn(container.element, "addEventListener"); + const removeEventListener = vi.spyOn( + container.element, + "removeEventListener" + ); + + const grid = render( + getMarkup({ onSectionRendered, scrollElement: null }) + ); + expect(section.rowStartIndex).toEqual(0); + expect(addEventListener).not.toHaveBeenCalled(); + + render( + getMarkup({ onSectionRendered, scrollElement: container.element }) + ); + expect(addEventListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function) + ); + expect(getObserverCount(container.element)).toBe(1); + expect(section.rowStartIndex).toEqual(12); + + container.scroll(500); + expect(section.rowStartIndex).toEqual(25); + + render(getMarkup({ onSectionRendered, scrollElement: null })); + expect(removeEventListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function) + ); + expect(getObserverCount(container.element)).toBe(0); + // Rendered as if at the top again, in full-height style + expect(findDOMNode(grid).style.height).toEqual("auto"); + expect(section.rowStartIndex).toEqual(0); + + container.scroll(1000); + expect(section.rowStartIndex).toEqual(0); + }); + + it("should move its listeners when the scrollElement changes from one element to another", () => { + const { onSectionRendered, section } = getSectionTracker(); + const containerA = createScrollContainer({ height: DEFAULT_HEIGHT }); + const containerB = createScrollContainer({ + adopt: false, + height: DEFAULT_HEIGHT + }); + containerB.element.scrollTop = 400; + + render( + getMarkup({ onSectionRendered, scrollElement: containerA.element }) + ); + containerB.adopt(); + render( + getMarkup({ onSectionRendered, scrollElement: containerB.element }) + ); + expect(getObserverCount(containerA.element)).toBe(0); + expect(getObserverCount(containerB.element)).toBe(1); + expect(section.rowStartIndex).toEqual(20); + + containerA.scroll(100); + expect(section.rowStartIndex).toEqual(20); + + containerB.scroll(200); + expect(section.rowStartIndex).toEqual(10); + }); + + it("should remove its listeners when it unmounts", () => { + const container = createScrollContainer({ height: DEFAULT_HEIGHT }); + const removeEventListener = vi.spyOn( + container.element, + "removeEventListener" + ); + render(getMarkup({ scrollElement: container.element })); + expect(getObserverCount(container.element)).toBe(1); + + render.unmount(); + expect(removeEventListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function) + ); + expect(getObserverCount(container.element)).toBe(0); + }); + + it("should ignore initialScrollTop", () => { + const { onSectionRendered, section } = getSectionTracker(); + const { container, grid, scrollTo } = renderInScrollContainer({ + initialScrollLeft: 100, + initialScrollTop: 500, + onSectionRendered + }); + expect(section.rowStartIndex).toEqual(0); + expect(section.columnStartIndex).toEqual(2); + expect(scrollTo).not.toHaveBeenCalled(); + expect(container.element.scrollTop).toEqual(0); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 100, + scrollTop: 0 + }); + }); + + it("should warn in DEV if there is neither a height nor a scrollElement", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + render(getMarkup({ height: undefined })); + expect(console.warn).toHaveBeenCalledWith( + "Grid requires a height prop (unless a scrollElement is specified)" + ); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it("should not warn about a missing height with a scrollElement", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const container = createScrollContainer(); + render( + getMarkup({ height: undefined, scrollElement: container.element }) + ); + render(getMarkup({ height: undefined, scrollElement: null })); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should be scrolled by the window (scrollElement="window")', () => { + const { onSectionRendered, section } = getSectionTracker(); + const windowScrolling = mockWindowScrolling({ innerHeight: 100 }); + const scrollTo = vi.spyOn(windowScrolling, "scrollTo"); + // Content above the Grid (100px) + const contentAbove = document.createElement("div"); + contentAbove.style.height = "100px"; + document.body.insertBefore(contentAbove, getMountNode()); + + const grid = render( + getMarkup({ onSectionRendered, scrollElement: "window" }) + ); + expect(findDOMNode(grid).style.height).toEqual("auto"); + expect(section.rowStartIndex).toEqual(0); + expect(section.rowStopIndex).toEqual(4); + + windowScrolling.scroll(200); + expect(section.rowStartIndex).toEqual(5); + expect(section.rowStopIndex).toEqual(9); + + windowScrolling.resize({ height: 200 }); + expect(section.rowStopIndex).toEqual(14); + + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 49 }); + }); + expect(window.scrollY).toEqual(980 + 100); + expect(section.rowStartIndex).toEqual(49); + void scrollTo; + contentAbove.remove(); + }); + + it("should work in StrictMode", () => { + const { onSectionRendered, section } = getSectionTracker(); + const ref = createRef(); + const container = createScrollContainer({ height: DEFAULT_HEIGHT }); + container.element.scrollTop = 100; + const addEventListener = vi.spyOn(container.element, "addEventListener"); + const removeEventListener = vi.spyOn( + container.element, + "removeEventListener" + ); + const scrollTo = vi.spyOn(container.element, "scrollTo"); + render( + +
+ {getMarkup({ + onSectionRendered, + ref, + scrollElement: container.element + } as Partial)} +
+
+ ); + expect(section.rowStartIndex).toEqual(5); + // Effects are mounted, unmounted and re-mounted; exactly one listener/observer remains + const countScrollListeners = (spy: typeof addEventListener) => + spy.mock.calls.filter(([type]) => type === "scroll").length; + expect( + countScrollListeners(addEventListener) - + countScrollListeners(removeEventListener) + ).toBe(1); + expect(getObserverCount(container.element)).toBe(1); + + container.scroll(200); + expect(section.rowStartIndex).toEqual(10); + + act(() => { + ref.current!.scrollToCell({ align: "start", rowIndex: 50 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 1000); + expect(section.rowStartIndex).toEqual(50); + }); + }); + + describe("styles, classNames, ids, and roles", () => { + it("should use the expected global CSS classNames", () => { + const rendered = findDOMNode(render(getMarkup())); + expect(rendered.className).toEqual("ReactVirtualized__Grid"); + }); + + it("should use a custom :className if specified", () => { + const rendered = findDOMNode(render(getMarkup({ className: "foo" }))); + expect(rendered.className).toContain("foo"); + }); + + it("should use a custom :id if specified", () => { + const rendered = findDOMNode(render(getMarkup({ id: "bar" }))); + expect(rendered.getAttribute("id")).toEqual("bar"); + }); + + it("should use a custom :style if specified", () => { + const style = { backgroundColor: "red" }; + const rendered = findDOMNode(render(getMarkup({ style }))); + expect(rendered.style.backgroundColor).toEqual("red"); + }); + + it("should use a custom :containerStyle if specified", () => { + const containerStyle = { backgroundColor: "red" }; + const rendered = findDOMNode(render(getMarkup({ containerStyle }))); + expect( + rendered.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.backgroundColor + ).toEqual("red"); + }); + + it("should have the gridcell role", () => { + const containerStyle = { backgroundColor: "red" }; + const rendered = findDOMNode(render(getMarkup({ containerStyle }))); + expect(rendered.querySelectorAll('[role="gridcell"]').length).toEqual(20); + }); + }); + + describe("onScroll", () => { + it("should trigger callback when component is mounted", () => { + const onScrollCalls: any[] = []; + render( + getMarkup({ + onScroll: (params) => onScrollCalls.push(params) + }) + ); + expect(onScrollCalls).toEqual([ + { + clientHeight: 100, + clientWidth: 200, + scrollHeight: 2000, + scrollLeft: 0, + scrollTop: 0, + scrollWidth: 2500 + } + ]); + }); + + it("should be called with the latest props", () => { + const onScrollA = vi.fn(); + const onScrollB = vi.fn(); + const grid = render(getMarkup({ onScroll: onScrollA })); + render(getMarkup({ height: 50, onScroll: onScrollB })); + simulateScroll({ grid, scrollTop: 100 }); + expect(onScrollA).toHaveBeenCalledTimes(1); // On mount + expect(onScrollB).toHaveBeenCalledTimes(1); + expect(onScrollB.mock.calls[0][0]).toMatchObject({ + clientHeight: 50, + scrollTop: 100 + }); + }); + + it("should trigger callback when component scrolls horizontally", () => { + const onScrollCalls: any[] = []; + const grid = render( + getMarkup({ + onScroll: (params) => onScrollCalls.push(params) + }) + ); + simulateScroll({ + grid, + scrollLeft: 100, + scrollTop: 0 + }); + expect(onScrollCalls.length).toEqual(2); + expect(onScrollCalls[1]).toEqual({ + clientHeight: 100, + clientWidth: 200, + scrollHeight: 2000, + scrollLeft: 100, + scrollTop: 0, + scrollWidth: 2500 + }); + }); + + it("should trigger callback when component scrolls vertically", () => { + const onScrollCalls: any[] = []; + const grid = render( + getMarkup({ + onScroll: (params) => onScrollCalls.push(params) + }) + ); + simulateScroll({ + grid, + scrollLeft: 0, + scrollTop: 100 + }); + expect(onScrollCalls.length).toEqual(2); + expect(onScrollCalls[1]).toEqual({ + clientHeight: 100, + clientWidth: 200, + scrollHeight: 2000, + scrollLeft: 0, + scrollTop: 100, + scrollWidth: 2500 + }); + }); + + it("should trigger callback with scrollLeft of 0 when total columns width is less than width", () => { + const onScrollCalls: any[] = []; + const grid = render( + getMarkup({ + columnCount: 1, + columnWidth: 50, + onScroll: (params) => onScrollCalls.push(params), + width: 200 + }) + ); + act(() => { + grid.scrollToPosition({ scrollTop: 10 }); + }); + simulateScroll({ + grid, + scrollLeft: 0, + scrollTop: 0 + }); + expect(onScrollCalls.length).toEqual(3); + expect(onScrollCalls[2]).toEqual({ + clientHeight: 100, + clientWidth: 200, + scrollHeight: 2000, + scrollLeft: 0, + scrollTop: 0, + scrollWidth: 50 + }); + }); + + it("should trigger callback with scrollTop of 0 when total rows height is less than height", () => { + const onScrollCalls: any[] = []; + const grid = render( + getMarkup({ + rowCount: 1, + rowHeight: 50, + onScroll: (params) => onScrollCalls.push(params), + height: 200 + }) + ); + act(() => { + grid.scrollToPosition({ scrollLeft: 10 }); + }); + simulateScroll({ + grid, + scrollLeft: 0, + scrollTop: 0 + }); + expect(onScrollCalls.length).toEqual(3); + expect(onScrollCalls[2]).toEqual({ + clientHeight: 200, + clientWidth: 200, + scrollHeight: 50, + scrollLeft: 0, + scrollTop: 0, + scrollWidth: 2500 + }); + }); + + // Lets onScroll listeners (e.g. ScrollSync) stay in sync with scroll-to-cell changes. + it("should trigger when scrollToCell() is called", () => { + const onScrollCalls: any[] = []; + const grid = render( + getMarkup({ + onScroll: (params) => onScrollCalls.push(params) + }) + ); + onScrollCalls.splice(0); + act(() => { + grid.scrollToCell({ columnIndex: 24, rowIndex: 49 }); + }); + expect(onScrollCalls).toEqual([ + { + clientHeight: 100, + clientWidth: 200, + scrollHeight: 2000, + scrollLeft: 1050, + scrollTop: 900, + scrollWidth: 2500 + } + ]); + }); + }); + + describe("overscanColumnCount & overscanRowCount", () => { + function createHelper() { + let columnOverscanStartIndex: number | undefined, + columnOverscanStopIndex: number | undefined, + columnStartIndex: number | undefined, + columnStopIndex: number | undefined, + rowOverscanStartIndex: number | undefined, + rowOverscanStopIndex: number | undefined, + rowStartIndex: number | undefined, + rowStopIndex: number | undefined; + + function onSectionRendered(params: any) { + columnOverscanStartIndex = params.columnOverscanStartIndex; + columnOverscanStopIndex = params.columnOverscanStopIndex; + columnStartIndex = params.columnStartIndex; + columnStopIndex = params.columnStopIndex; + rowOverscanStartIndex = params.rowOverscanStartIndex; + rowOverscanStopIndex = params.rowOverscanStopIndex; + rowStartIndex = params.rowStartIndex; + rowStopIndex = params.rowStopIndex; + } + + return { + columnOverscanStartIndex: () => columnOverscanStartIndex, + columnOverscanStopIndex: () => columnOverscanStopIndex, + columnStartIndex: () => columnStartIndex, + columnStopIndex: () => columnStopIndex, + onSectionRendered, + rowOverscanStartIndex: () => rowOverscanStartIndex, + rowOverscanStopIndex: () => rowOverscanStopIndex, + rowStartIndex: () => rowStartIndex, + rowStopIndex: () => rowStopIndex + }; + } + + it("should not overscan if disabled", () => { + const helper = createHelper(); + render( + getMarkup({ + onSectionRendered: helper.onSectionRendered + }) + ); + expect(helper.columnOverscanStartIndex()).toEqual( + helper.columnStartIndex() + ); + expect(helper.columnOverscanStopIndex()).toEqual( + helper.columnStopIndex() + ); + expect(helper.rowOverscanStartIndex()).toEqual(helper.rowStartIndex()); + expect(helper.rowOverscanStopIndex()).toEqual(helper.rowStopIndex()); + }); + + it("should overscan the specified amount", () => { + const helper = createHelper(); + renderAndScrollToCell( + { + onSectionRendered: helper.onSectionRendered, + overscanColumnCount: 2, + overscanRowCount: 5 + }, + { columnIndex: 25, rowIndex: 50 } + ); + expect(helper.columnOverscanStartIndex()).toEqual(22); + expect(helper.columnOverscanStopIndex()).toEqual(27); + expect(helper.columnStartIndex()).toEqual(22); + expect(helper.columnStopIndex()).toEqual(25); + expect(helper.rowOverscanStartIndex()).toEqual(46); + expect(helper.rowOverscanStopIndex()).toEqual(55); + expect(helper.rowStartIndex()).toEqual(46); + expect(helper.rowStopIndex()).toEqual(50); + }); + + it("should not overscan beyond the bounds of the grid", () => { + const helper = createHelper(); + render( + getMarkup({ + onSectionRendered: helper.onSectionRendered, + columnCount: 6, + overscanColumnCount: 10, + overscanRowCount: 10, + rowCount: 5 + }) + ); + expect(helper.columnOverscanStartIndex()).toEqual(0); + expect(helper.columnOverscanStopIndex()).toEqual(5); + expect(helper.columnStartIndex()).toEqual(0); + expect(helper.columnStopIndex()).toEqual(3); + expect(helper.rowOverscanStartIndex()).toEqual(0); + expect(helper.rowOverscanStopIndex()).toEqual(4); + expect(helper.rowStartIndex()).toEqual(0); + expect(helper.rowStopIndex()).toEqual(4); + }); + + it("should set the correct scroll direction", () => { + const direction = createScrollDirectionTracker(); + const grid = render( + getMarkup({ overscanIndicesGetter: direction.overscanIndicesGetter }) + ); + + simulateScroll({ + grid, + scrollLeft: 50, + scrollTop: 50 + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_FORWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + + simulateScroll({ + grid, + scrollLeft: 0, + scrollTop: 0 + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_BACKWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_BACKWARD); + + simulateScroll({ + grid, + scrollLeft: 100, + scrollTop: 100 + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_FORWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + }); + + it("should set the correct scroll direction when scroll position is updated with scrollToPosition()", () => { + const direction = createScrollDirectionTracker(); + const grid = render( + getMarkup({ overscanIndicesGetter: direction.overscanIndicesGetter }) + ); + + act(() => { + grid.scrollToPosition({ scrollLeft: 50, scrollTop: 50 }); + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_FORWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + + act(() => { + grid.scrollToPosition({ scrollLeft: 0, scrollTop: 0 }); + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_BACKWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_BACKWARD); + + act(() => { + grid.scrollToPosition({ scrollLeft: 100, scrollTop: 100 }); + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_FORWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + + // Only the axis that changed changes direction + act(() => { + grid.scrollToPosition({ scrollLeft: 50, scrollTop: 100 }); + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_BACKWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + }); + + it("should not reset scroll direction for one axis when scrolled in another", () => { + const direction = createScrollDirectionTracker(); + const grid = render( + getMarkup({ overscanIndicesGetter: direction.overscanIndicesGetter }) + ); + + simulateScroll({ + grid, + scrollLeft: 0, + scrollTop: 5 + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_FORWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + + simulateScroll({ + grid, + scrollLeft: 5, + scrollTop: 5 + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_FORWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_FORWARD); + + simulateScroll({ + grid, + scrollLeft: 5, + scrollTop: 0 + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_FORWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_BACKWARD); + + simulateScroll({ + grid, + scrollLeft: 0, + scrollTop: 0 + }); + + expect(direction.horizontal()).toEqual(SCROLL_DIRECTION_BACKWARD); + expect(direction.vertical()).toEqual(SCROLL_DIRECTION_BACKWARD); + }); + + it("should overscan in the direction being scrolled", async () => { + const helper = createHelper(); + + let onSectionRenderedResolve: any; + + function onSectionRendered(params: any) { + helper.onSectionRendered(params); + + if (onSectionRenderedResolve) { + onSectionRenderedResolve(); + } + } + + const grid = render( + getMarkup({ + onSectionRendered, + overscanColumnCount: 2, + overscanRowCount: 5 + }) + ); + + // Wait until the onSectionRendered handler / debouncer has processed + let onSectionRenderedPromise = new Promise((resolve) => { + onSectionRenderedResolve = resolve; + }); + + simulateScroll({ + grid, + scrollLeft: 200, + scrollTop: 200 + }); + + await onSectionRenderedPromise; + + // It should overscan in the direction being scrolled while scroll is in progress + expect(helper.columnOverscanStartIndex()).toEqual(4); + expect(helper.columnOverscanStopIndex()).toEqual(9); + expect(helper.columnStartIndex()).toEqual(4); + expect(helper.columnStopIndex()).toEqual(7); + expect(helper.rowOverscanStartIndex()).toEqual(10); + expect(helper.rowOverscanStopIndex()).toEqual(19); + expect(helper.rowStartIndex()).toEqual(10); + expect(helper.rowStopIndex()).toEqual(14); + + // Wait until the onSectionRendered handler / debouncer has processed + onSectionRenderedPromise = new Promise((resolve) => { + onSectionRenderedResolve = resolve; + }); + + simulateScroll({ + grid, + scrollLeft: 100, + scrollTop: 100 + }); + + await onSectionRenderedPromise; + + // It reset overscan once scrolling has finished + expect(helper.columnOverscanStartIndex()).toEqual(0); + expect(helper.columnOverscanStopIndex()).toEqual(5); + expect(helper.columnStartIndex()).toEqual(2); + expect(helper.columnStopIndex()).toEqual(5); + expect(helper.rowOverscanStartIndex()).toEqual(0); + expect(helper.rowOverscanStopIndex()).toEqual(9); + expect(helper.rowStartIndex()).toEqual(5); + expect(helper.rowStopIndex()).toEqual(9); + }); + }); + + describe("cellRangeRenderer", () => { + it("should use a custom :cellRangeRenderer if specified", () => { + let cellRangeRendererCalled = 0; + let cellRangeRendererParams: any; + const rendered = findDOMNode( + render( + getMarkup({ + cellRangeRenderer: (params) => { + cellRangeRendererParams = params; + cellRangeRendererCalled++; + + return [
Fake content
]; + } + }) + ) + ); + expect(cellRangeRendererCalled).toEqual(1); + expect(cellRangeRendererParams.columnStartIndex).toEqual(0); + expect(cellRangeRendererParams.columnStopIndex).toEqual(3); + expect(cellRangeRendererParams.rowStartIndex).toEqual(0); + expect(cellRangeRendererParams.rowStopIndex).toEqual(4); + expect(rendered.textContent).toContain("Fake content"); + }); + }); + + describe("estimated row and column sizes", () => { + it("should not estimate sizes if actual sizes are numbers", () => { + // (No cells are measured, since nothing is rendered) + const grid = render( + getMarkup({ + columnWidth: 100, + estimatedColumnSize: 150, + estimatedRowSize: 15, + height: 0, + rowHeight: 20, + width: 0 + }) + ); + expect(grid.getTotalColumnsWidth()).toEqual(NUM_COLUMNS * 100); + expect(grid.getTotalRowsHeight()).toEqual(NUM_ROWS * 20); + }); + + it("should estimate row and column sizes if actual sizes are functions", () => { + // (No cells are measured, since nothing is rendered) + const grid = render( + getMarkup({ + columnWidth: () => 100, + estimatedColumnSize: 150, + estimatedRowSize: 15, + height: 0, + rowHeight: () => 20, + width: 0 + }) + ); + expect(grid.getTotalColumnsWidth()).toEqual(NUM_COLUMNS * 150); + expect(grid.getTotalRowsHeight()).toEqual(NUM_ROWS * 15); + + // Changing an estimated size is reflected as well + render( + getMarkup({ + columnWidth: () => 100, + estimatedColumnSize: 75, + estimatedRowSize: 15, + height: 0, + rowHeight: () => 20, + width: 0 + }) + ); + expect(grid.getTotalColumnsWidth()).toEqual(NUM_COLUMNS * 75); + }); + }); + + it("should pass the cellRenderer an :isScrolling flag when scrolling is in progress", async () => { + const cellRendererCalls: any[] = []; + function cellRenderer({ + columnIndex, + isScrolling, + key, + rowIndex, + style + }: any) { + cellRendererCalls.push(isScrolling); + return defaultCellRenderer({ columnIndex, key, rowIndex, style }); + } + const grid = render( + getMarkup({ + cellRenderer + }) + ); + expect(cellRendererCalls[0]).toEqual(false); + cellRendererCalls.splice(0); + + // Give React time to process the queued setState() + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + + simulateScroll({ grid, scrollTop: 100 }); + expect(cellRendererCalls[0]).toEqual(true); + }); + + it("should pass the cellRenderer an :isScrolling flag based on the scrolling of its scroll element", async () => { + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation(({ key, style }) => ( +
+ )); + const container = createScrollContainer({ height: DEFAULT_HEIGHT }); + render( + getMarkup({ + cellRenderer, + scrollElement: container.element, + scrollingResetTimeInterval: 10 + }) + ); + cellRenderer.mockReset(); + container.scroll(10); + expect(cellRenderer).toHaveBeenCalled(); + expect(cellRenderer.mock.lastCall![0].isScrolling).toBe(true); + cellRenderer.mockReset(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(cellRenderer).toHaveBeenCalled(); + expect(cellRenderer.mock.lastCall![0].isScrolling).toBe(false); + }); + + it("should pass the cellRenderer an :isVisible flag", () => { + const cellRendererCalls: any[] = []; + function cellRenderer(props: any) { + cellRendererCalls.push(props); + return defaultCellRenderer(props); + } + render( + getMarkup({ + cellRenderer, + height: DEFAULT_ROW_HEIGHT, + overscanColumnCount: 1, + overscanRowCount: 1, + width: DEFAULT_COLUMN_WIDTH + }) + ); + cellRendererCalls.forEach((props: any) => { + expect(props.isVisible).toEqual( + props.columnIndex === 0 && props.rowIndex === 0 + ); // Only the first cell is visible + }); + }); + + describe("cell caching", () => { + it("should not cache cells if the Grid is not scrolling", () => { + const cellRendererCalls: any[] = []; + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + cellRendererCalls.push({ columnIndex, rowIndex }); + return defaultCellRenderer({ columnIndex, key, rowIndex, style }); + } + const props = { + cellRenderer, + columnWidth: 100, + height: 40, + rowHeight: 20, + width: 100 + }; + + const grid = render(getMarkup(props)); + expect(cellRendererCalls).toEqual([ + { columnIndex: 0, rowIndex: 0 }, + { columnIndex: 0, rowIndex: 1 } + ]); + + cellRendererCalls.splice(0); + + act(() => { + grid.forceUpdate(); + }); + expect(cellRendererCalls).toEqual([ + { columnIndex: 0, rowIndex: 0 }, + { columnIndex: 0, rowIndex: 1 } + ]); + }); + + it("should not cache cells if the offsets are not adjusted", () => { + const cellRendererCalls: any[] = []; + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + cellRendererCalls.push({ columnIndex, rowIndex }); + return defaultCellRenderer({ columnIndex, key, rowIndex, style }); + } + const props = { + cellRenderer, + columnWidth: 100, + height: 40, + rowHeight: 20, + rowCount: 100000, + width: 100 + }; + + const grid = render(getMarkup(props)); + expect(cellRendererCalls).toEqual([ + { columnIndex: 0, rowIndex: 0 }, + { columnIndex: 0, rowIndex: 1 } + ]); + + cellRendererCalls.splice(0); + + act(() => { + grid.forceUpdate(); + }); + expect(cellRendererCalls).toEqual([ + { columnIndex: 0, rowIndex: 0 }, + { columnIndex: 0, rowIndex: 1 } + ]); + }); + + it("should cache a cell once it has been rendered while scrolling", () => { + const cellRendererCalls: any[] = []; + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + cellRendererCalls.push({ columnIndex, rowIndex }); + return defaultCellRenderer({ columnIndex, key, rowIndex, style }); + } + const props = { + cellRenderer, + columnWidth: 100, + height: 40, + rowHeight: 20, + width: 100 + }; + + const grid = render(getMarkup(props)); + expect(cellRendererCalls).toEqual([ + { columnIndex: 0, rowIndex: 0 }, + { columnIndex: 0, rowIndex: 1 } + ]); + + simulateScroll({ grid, scrollTop: 1 }); + + cellRendererCalls.splice(0); + + // Rows 0-2 have already rendered but row 3 is not yet visible + // This means that only row 3 should be newly-created + // The others should come from the cache + act(() => { + grid.scrollToCell({ rowIndex: 3 }); + }); + expect(cellRendererCalls).toEqual([{ columnIndex: 0, rowIndex: 3 }]); + }); + + it("should clear cache once :isScrolling is false", async () => { + const cellRendererCalls: any[] = []; + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + cellRendererCalls.push({ columnIndex, rowIndex }); + return defaultCellRenderer({ columnIndex, key, rowIndex, style }); + } + const props = { + cellRenderer, + columnWidth: 100, + height: 40, + rowHeight: 20, + width: 100 + }; + + const grid = render(getMarkup(props)); + expect(cellRendererCalls).toEqual([ + { columnIndex: 0, rowIndex: 0 }, + { columnIndex: 0, rowIndex: 1 } + ]); + + simulateScroll({ grid, scrollTop: 1 }); + + // Allow scrolling timeout to complete so that cell cache is reset + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2) + ); + }); + + cellRendererCalls.splice(0); + + act(() => { + grid.forceUpdate(); + }); + expect(cellRendererCalls.length).not.toEqual(0); + }); + + it("should clear cache once its scroll element stops scrolling", async () => { + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation((params: any) => ( +
+ )); + + const container = createScrollContainer({ height: DEFAULT_HEIGHT }); + const props = { + cellRenderer, + columnCount: 1, + rowCount: 1, + scrollElement: container.element, + scrollingResetTimeInterval: 10 + }; + + render(getMarkup(props)); + container.scroll(1); + expect(cellRenderer.mock.lastCall![0].isScrolling).toBe(true); + const scrollingStyle = cellRenderer.mock.lastCall![0].style; + cellRenderer.mockReset(); + + render(getMarkup(props)); + container.scroll(2); + expect(cellRenderer).toHaveBeenCalledTimes(0); // Due to cell cache + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(cellRenderer).toHaveBeenCalledTimes(1); // Reset cell cache + expect(cellRenderer.mock.calls[0][0].isScrolling).toBe(false); + expect(cellRenderer.mock.calls[0][0].style).toEqual(scrollingStyle); + }); + + it("should clear cache if :recomputeGridSize is called", () => { + const cellRendererCalls: any[] = []; + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + cellRendererCalls.push({ columnIndex, rowIndex }); + return defaultCellRenderer({ columnIndex, key, rowIndex, style }); + } + const props = { + cellRenderer, + columnWidth: 100, + height: 40, + rowHeight: 20, + width: 100 + }; + + const grid = render(getMarkup(props)); + expect(cellRendererCalls).toEqual([ + { columnIndex: 0, rowIndex: 0 }, + { columnIndex: 0, rowIndex: 1 } + ]); + + simulateScroll({ grid, scrollTop: 1 }); + + cellRendererCalls.splice(0); + + act(() => { + grid.recomputeGridSize(); + }); + + expect(cellRendererCalls.length).not.toEqual(0); + }); + + it("should not clear cache if :isScrollingOptOut is true", async () => { + const cellRendererCalls: any[] = []; + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + cellRendererCalls.push({ columnIndex, rowIndex }); + return defaultCellRenderer({ columnIndex, key, rowIndex, style }); + } + const container = createScrollContainer({ height: 40, width: 100 }); + const props = { + cellRenderer, + columnWidth: 100, + height: 40, + rowHeight: 20, + width: 100, + isScrollingOptOut: true, + scrollElement: container.element, + scrollingResetTimeInterval: 10 + }; + + render(getMarkup(props)); + render(getMarkup(props)); + // Visible cells are cached (also when the Grid is linked to its scroll element, after mounting) + expect(cellRendererCalls).toEqual([ + { columnIndex: 0, rowIndex: 0 }, + { columnIndex: 0, rowIndex: 1 } + ]); + + cellRendererCalls.splice(0); + + // The scroll element scrolls (without changing the visible cells) + container.scroll(0); + + // Only cleared non-visible cells + expect(cellRendererCalls.length).toEqual(0); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + expect(cellRendererCalls.length).toEqual(0); + }); + + it("should not re-render visible cells when scrolling stops if :isScrollingOptOut is true", async () => { + const cellRenderer = vi.fn(defaultCellRenderer); + const grid = render( + getMarkup({ + cellRenderer, + columnWidth: 100, + height: 40, + isScrollingOptOut: true, + rowHeight: 20, + width: 100 + }) + ); + + simulateScroll({ grid, scrollTop: 20 }); + cellRenderer.mockClear(); + + // Allow the scrolling timeout to complete + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2) + ); + }); + + expect( + findDOMNode(grid).querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.pointerEvents + ).toEqual(""); // No longer scrolling + expect(cellRenderer).not.toHaveBeenCalled(); + }); + + // Bug #13 + it("should re-render visible cells when props change (or forceUpdate is called) even if :isScrollingOptOut is true", () => { + const props = { + columnWidth: 100, + height: 40, + isScrollingOptOut: true, + rowHeight: 20, + width: 100 + }; + + const grid = render( + getMarkup({ + ...props, + cellRenderer: ({ key, rowIndex, style }) => ( +
+ {`A${rowIndex}`} +
+ ) + }) + ); + expect(findDOMNode(grid).textContent).toEqual("A0A1"); + + let label = "B"; + const cellRenderer = ({ key, rowIndex, style }: CellRendererParams) => ( +
+ {`${label}${rowIndex}`} +
+ ); + render(getMarkup({ ...props, cellRenderer })); + expect(findDOMNode(grid).textContent).toEqual("B0B1"); + + label = "C"; + act(() => { + grid.forceUpdate(); + }); + expect(findDOMNode(grid).textContent).toEqual("C0C1"); + + // E.g. Table passes extra props to re-render its rows + label = "D"; + render( + getMarkup({ + ...props, + cellRenderer, + // @ts-expect-error Not a Grid prop + sortBy: "foo" + }) + ); + expect(findDOMNode(grid).textContent).toEqual("D0D1"); + }); + + it("should not trigger render by _debounceScrollEndedCallback if process slow table", async () => { + const scrollingResetTimeInterval = 50; + let cellRangeRendererCalls = 0; + function cellRangeRenderer(props: any) { + const startTime = Date.now(); + while (Date.now() - startTime <= scrollingResetTimeInterval); // imitate very slow render + cellRangeRendererCalls++; + return defaultCellRangeRenderer(props); + } + const props = { + scrollingResetTimeInterval, + cellRangeRenderer + }; + + const grid = render(getMarkup(props)); + render(getMarkup(props)); + expect(cellRangeRendererCalls).toEqual(1); + + for (let i = 1; i <= 5; i++) { + cellRangeRendererCalls = 0; + simulateScroll({ grid, scrollTop: i }); + // small wait for maybe early _debounceScrollEndedCallback + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, scrollingResetTimeInterval / 2) + ); + }); + expect(cellRangeRendererCalls).toEqual(1); + } + + cellRangeRendererCalls = 0; + // wait for real _debounceScrollEndedCallback + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, scrollingResetTimeInterval * 1.5) + ); + }); + expect(cellRangeRendererCalls).toEqual(1); + }); + + it("should support a custom :scrollingResetTimeInterval prop", async () => { + const cellRendererCalls: any[] = []; + const scrollingResetTimeInterval = + DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2; + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + cellRendererCalls.push({ columnIndex, rowIndex }); + return defaultCellRenderer({ columnIndex, key, rowIndex, style }); + } + const props = { + cellRenderer, + scrollingResetTimeInterval + }; + + const grid = render(getMarkup(props)); + expect(cellRendererCalls.length > 0).toEqual(true); + + simulateScroll({ grid, scrollTop: 1 }); + + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL) + ); + }); + + cellRendererCalls.splice(0); + render( + getMarkup({ + ...props, + className: "foo" + }) + ); + expect(cellRendererCalls.length).toEqual(0); + + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2) + ); + }); + + cellRendererCalls.splice(0); + render( + getMarkup({ + ...props, + className: "bar" + }) + ); + expect(cellRendererCalls.length).not.toEqual(0); + }); + }); + + describe("measureAllCells", () => { + it("should measure any unmeasured columns and rows", () => { + const grid = render( + getMarkup({ + columnCount: 10, + columnWidth: () => 100, + estimatedColumnSize: 150, + estimatedRowSize: 15, + height: 0, + rowCount: 10, + rowHeight: () => 20, + width: 0 + }) + ); + expect(grid.getTotalColumnsWidth()).toEqual(1500); + expect(grid.getTotalRowsHeight()).toEqual(150); + grid.measureAllCells(); + expect(grid.getTotalColumnsWidth()).toEqual(1000); + expect(grid.getTotalRowsHeight()).toEqual(200); + }); + + it("should not throw if there are no rows or columns", () => { + const grid = render( + getMarkup({ + columnCount: 0, + rowCount: 0 + }) + ); + expect(() => grid.measureAllCells()).not.toThrow(); + }); + + // #1351 (e.g. the bottom quadrants of a MultiGrid whose rows are all fixed) + it("should not throw when the column count drops to 0 while there are no rows (and vice versa)", () => { + const columnWidth = () => 45; + render(getMarkup({ columnCount: 1, columnWidth, rowCount: 0 })); + expect(() => + render(getMarkup({ columnCount: 0, columnWidth, rowCount: 0 })) + ).not.toThrow(); + + const rowHeight = () => 15; + render(getMarkup({ columnCount: 0, rowCount: 1, rowHeight })); + expect(() => + render(getMarkup({ columnCount: 0, rowCount: 0, rowHeight })) + ).not.toThrow(); + + // Cells are rendered (and measured) again once there are rows and columns + const rendered = findDOMNode( + render( + getMarkup({ columnCount: 2, columnWidth, rowCount: 1, rowHeight }) + ) + ); + expect(rendered.textContent).toContain("row:0, column:1"); + }); + + it("should measure columns if there are no rows (and vice versa)", () => { + let grid = render( + getMarkup({ + columnCount: 10, + columnWidth: () => 100, + estimatedColumnSize: 150, + rowCount: 0 + }) + ); + grid.measureAllCells(); + expect(grid.getTotalColumnsWidth()).toEqual(1000); + + grid = render( + getMarkup({ + columnCount: 0, + estimatedRowSize: 15, + rowCount: 10, + rowHeight: () => 20 + }) + ); + grid.measureAllCells(); + expect(grid.getTotalRowsHeight()).toEqual(200); + }); + }); + + describe("recomputeGridSize", () => { + it("should recompute cell sizes and other values when called", () => { + const columnIndices: any[] = []; + const rowIndices: any[] = []; + function columnWidth({ index }: any) { + columnIndices.push(index); + return 10; + } + function rowHeight({ index }: any) { + rowIndices.push(index); + return 10; + } + const props = { + columnCount: 50, + columnWidth, + height: 50, + rowHeight, + rowCount: 50, + width: 100 + }; + const component = render(getMarkup(props)); + + columnIndices.splice(0); + rowIndices.splice(0); + + act(() => { + component.recomputeGridSize(); + }); + + // Only the rows required to fill the current viewport will be rendered + expect(columnIndices[0]).toEqual(0); + expect(columnIndices[columnIndices.length - 1]).toEqual(9); + expect(rowIndices[0]).toEqual(0); + expect(rowIndices[rowIndices.length - 1]).toEqual(4); + + columnIndices.splice(0); + rowIndices.splice(0); + + act(() => { + component.recomputeGridSize({ + columnIndex: 4, + rowIndex: 2 + }); + }); + + // Only the rows required to fill the current viewport will be rendered + expect(columnIndices[0]).toEqual(4); + expect(columnIndices[columnIndices.length - 1]).toEqual(9); + expect(rowIndices[0]).toEqual(2); + expect(rowIndices[rowIndices.length - 1]).toEqual(4); + }); + }); + + describe("autoContainerWidth", () => { + it("should set the innerScrollContainer width to auto to better support single-column HOCs", () => { + const props = { + autoContainerWidth: true + }; + const rendered = findDOMNode(render(getMarkup(props))); + expect( + rendered.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.width + ).toEqual("auto"); + }); + + it("should set the innerScrollContainer width to :totalColumnsWidth unless :autoContainerWidth", () => { + const props = { + autoContainerWidth: false + }; + const rendered = findDOMNode(render(getMarkup(props))); + expect( + rendered.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.width + ).toEqual("2500px"); // 50 columns x 50px + }); + }); + + describe("with a scrollElement (full height)", () => { + it("should set the container height to auto to adjust to innerScrollContainer height", () => { + const container = createScrollContainer({ height: DEFAULT_HEIGHT }); + const grid = render(getMarkup({ scrollElement: container.element })); + expect(findDOMNode(grid).style.height).toEqual("auto"); + }); + + it("should have the viewport height (of the scroll element) still affecting number of rows rendered", () => { + const container = createScrollContainer({ height: 500 }); + const grid = render(getMarkup({ scrollElement: container.element })); + expect(findDOMNode(grid).querySelectorAll(".gridItem").length).toEqual( + 100 + ); // 25 rows x 4 columns + }); + + it("should have innerScrollContainer height to be equal number of rows * rowHeight", () => { + const container = createScrollContainer({ height: DEFAULT_HEIGHT }); + const grid = render( + getMarkup({ scrollElement: container.element }) + ); + const rendered = findDOMNode(grid); + expect( + rendered.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.height + ).toEqual("2000px"); // 100 rows * 20px rowHeight + expect(grid.getTotalRowsHeight()).toEqual(2000); + }); + }); + + describe("tabIndex", () => { + it("should be focusable by default", () => { + const rendered = findDOMNode(render(getMarkup())); + expect(rendered.tabIndex).toEqual(0); + }); + + it("should allow tabIndex to be overridden", () => { + const rendered = findDOMNode( + render( + getMarkup({ + tabIndex: -1 + }) + ) + ); + expect(rendered.tabIndex).toEqual(-1); + }); + }); + + describe("role", () => { + it("should have grid role by default", () => { + const rendered = findDOMNode(render(getMarkup())); + expect(rendered.getAttribute("role")).toEqual("grid"); + }); + + it("should allow role to be overridden", () => { + const role = null as any; + const rendered = findDOMNode(render(getMarkup({ role }))); + expect(rendered.getAttribute("role")).toEqual(role); + }); + }); + + describe("pure", () => { + it("should not re-render unless props have changed", () => { + let cellRendererCalled = false; + function cellRenderer({ key, style }: any) { + cellRendererCalled = true; + return
; + } + const markup = getMarkup({ cellRenderer }); + render(markup); + expect(cellRendererCalled).toEqual(true); + cellRendererCalled = false; + render(markup); + expect(cellRendererCalled).toEqual(false); + }); + + it("should not re-render grid components if they extend PureComponent", () => { + let componentUpdates = 0; + + class GridComponent extends PureComponent<{ + columnIndex: number; + rowIndex: number; + style: CSSProperties; + }> { + componentDidUpdate() { + componentUpdates++; + } + + render() { + const { columnIndex, rowIndex, style } = this.props; + return ( +
+ {`row:${rowIndex}, column:${columnIndex}`} +
+ ); + } + } + + function cellRenderer({ columnIndex, key, rowIndex, style }: any) { + return ( + + ); + } + + const props = { + cellRenderer, + columnWidth: 100, + height: 40, + rowHeight: 20, + width: 100 + }; + + const grid = render(getMarkup(props)); + simulateScroll({ grid }); + + expect(componentUpdates).toEqual(0); + }); + + it("should clear all but the visible rows from the style cache once :isScrolling is false", async () => { + const styleCache = createStyleCacheTracker(); + const props = { + cellRangeRenderer: styleCache.cellRangeRenderer, + columnWidth: 50, + height: 100, + overscanColumnCount: 0, + overscanRowCount: 0, + rowHeight: 50, + width: 100 + }; + + const grid = render(getMarkup(props)); + + expect(styleCache.keys().length).toBe(4); + + simulateScroll({ grid, scrollTop: 50 }); + + expect(styleCache.keys().length).toBe(6); + + // Allow scrolling timeout to complete so that cell cache is reset + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2) + ); + }); + + expect(styleCache.keys().length).toBe(4); + }); + + it("should clear style cache if :recomputeGridSize is called", () => { + const styleCache = createStyleCacheTracker(); + const props = { + cellRangeRenderer: styleCache.cellRangeRenderer, + columnWidth: 50, + height: 100, + overscanColumnCount: 0, + overscanRowCount: 0, + rowHeight: 50, + width: 100 + }; + + const grid = render(getMarkup(props)); + + expect(styleCache.keys().length).toBe(4); + + act(() => { + grid.scrollToPosition({ scrollTop: 50 }); + }); + + expect(styleCache.keys().length).toBe(6); + + act(() => { + grid.recomputeGridSize(); + }); + + expect(styleCache.keys().length).toBe(4); + }); + + it("should clear style cache if cell sizes change", () => { + const cellRendererCalls: any[] = []; + function cellRenderer(params: any) { + cellRendererCalls.push(params); + return
; + } + + const props = { + cellRenderer, + columnWidth: 100, + height: 100, + overscanColumnCount: 0, + overscanRowCount: 0, + rowHeight: 100, + width: 100 + }; + + render(getMarkup(props)); + + expect(cellRendererCalls.length).toEqual(1); + expect(cellRendererCalls[0].style.width).toEqual(100); + + render( + getMarkup({ + ...props, + columnWidth: 50, + width: 50 + }) + ); + + expect(cellRendererCalls.length).toEqual(2); + expect(cellRendererCalls[1].style.width).toEqual(50); + }); + + // Bug #16 + it("should clear style cache if cell counts change", () => { + const heights = [20, 20, 20, 20, 20]; + const rowHeight = ({ index }: { index: number }) => heights[index]; + + const props = { + columnCount: 1, + height: 100, + rowHeight, + width: 100 + }; + + const grid = render(getMarkup({ ...props, rowCount: heights.length })); + const getStyles = () => + Array.from(findDOMNode(grid).querySelectorAll(".gridItem")) + .slice(0, 3) + .map((cell) => `${cell.style.top}/${cell.style.height}`); + expect(getStyles()).toEqual(["0px/20px", "20px/20px", "40px/20px"]); + + // Prepend a 40px row (with the same, stable size getter) + heights.unshift(40); + render(getMarkup({ ...props, rowCount: heights.length })); + expect(getStyles()).toEqual(["0px/40px", "40px/20px", "60px/20px"]); + }); + }); + + it("should not pull from the style cache while scrolling if there is an offset adjustment", () => { + const cellRendererCalls: any[] = []; + function cellRenderer(params: any) { + cellRendererCalls.push(params); + return
; + } + + const grid = render( + getMarkup({ + cellRenderer, + width: 100, + height: 100, + rowHeight: 100, + columnWidth: 100, + rowCount: (getMaxElementSize() * 2) / 100 // lots of offset + }) + ); + act(() => { + grid.scrollToPosition({ scrollTop: 2000 }); + }); + cellRendererCalls.splice(0, cellRendererCalls.length - 1); + + simulateScroll({ + grid, + scrollTop: 2100 + }); + + // cellRendererCalls[0] is the cell rendered after scrollToPosition(), cellRendererCalls[1] the one rendered after scrolling + const firstProps = cellRendererCalls[0]; + const secondProps = cellRendererCalls[1]; + + expect(cellRendererCalls.length).toEqual(2); + expect(firstProps.style).not.toBe(secondProps.style); + }); + + // #1394, #1178, #1457, #1847: version 9 scaled offsets beyond 1.5M px in every browser other than Chrome + it("should not scale offsets until the content is taller than the max element size, in every browser", () => { + expect((window as Window & { chrome?: unknown }).chrome).toBeUndefined(); + expect(getMaxElementSize()).toEqual(16777100); + + const grid = render( + getMarkup({ + columnCount: 1, + columnWidth: 100, + rowCount: 100_000, + rowHeight: 30 + }) + ); + const innerScrollContainer = findDOMNode(grid).querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!; + // 100,000 rows * 30px = 3,000,000px (not scaled down to 1,500,000px) + expect(innerScrollContainer.style.height).toEqual("3000000px"); + + act(() => { + grid.scrollToCell({ align: "start", rowIndex: 50_000 }); + }); + expect(findDOMNode(grid).scrollTop).toEqual(1_500_000); + expect(findDOMNode(grid).textContent).toContain("row:50000,"); + }); + + it("should only cache styles when a :deferredMeasurementCache is provided if the cell has already been measured", () => { + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + cache.set(0, 0, 100, 100); + cache.set(1, 1, 100, 100); + + const styleCache = createStyleCacheTracker(); + render( + getMarkup({ + cellRangeRenderer: styleCache.cellRangeRenderer, + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 2 + }) + ); + + expect(styleCache.keys()).toEqual(["0-0", "1-1"]); + }); + + describe("DEV warnings", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should warn about cells that forget to include the :style property", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + + function cellRenderer(params: any) { + return
; + } + + render( + getMarkup({ + cellRenderer + }) + ); + + expect(console.warn).toHaveBeenCalledWith( + "Rendered cell should include style property for positioning." + ); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it("should warn about CellMeasurer measured cells that forget to include the :style property", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation((params: any) => ( + +
+ + )); + + render( + getMarkup({ + cellRenderer, + columnCount: 1, + deferredMeasurementCache: cache, + rowCount: 1 + }) + ); + + expect(console.warn).toHaveBeenCalledWith( + "Rendered cell should include style property for positioning." + ); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + }); + + describe("deferredMeasurementCache", () => { + it("invalidateCellSizeAfterRender should invalidate cache and refresh displayed cells after mount", () => { + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + + let invalidateCellSizeAfterRender = true; + + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation((params: any) => { + // Don't get stuck in a loop + if (invalidateCellSizeAfterRender) { + invalidateCellSizeAfterRender = false; + + params.parent.invalidateCellSizeAfterRender({ + columnIndex: 1, + rowIndex: 0 + }); + } + return
; + }); + + const props = { + cellRenderer, + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 2 + }; + + render(getMarkup(props)); + + // 4 times for initial render + 4 once cellCache was cleared + expect(cellRenderer).toHaveBeenCalledTimes(8); + }); + + it("should invalidate cache and refresh displayed cells after update", () => { + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation((params: any) => ( +
+ )); + + const props = { + cellRenderer, + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 2 + }; + + const grid = render(getMarkup(props)); + + expect(cellRenderer).toHaveBeenCalledTimes(4); + + let invalidateCellSizeAfterRender = false; + + cellRenderer.mockReset(); + cellRenderer.mockImplementation((params: any) => { + // Don't get stuck in a loop + if (invalidateCellSizeAfterRender) { + invalidateCellSizeAfterRender = false; + params.parent.invalidateCellSizeAfterRender({ + columnIndex: 1, + rowIndex: 0 + }); + } + return
; + }); + + invalidateCellSizeAfterRender = true; + act(() => { + grid.recomputeGridSize(); + }); + + // 4 times for initial render + 4 once cellCache was cleared + expect(cellRenderer).toHaveBeenCalledTimes(8); + }); + + it("should not cache cells until they have been measured by CellMeasurer", () => { + const cache = new CellMeasurerCache({ + fixedWidth: true + }); + + // Fake measure cell 0,0 but not cell 0,1 + cache.set(0, 0, 100, 30); + + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation((params: any) => ( +
+ )); + + const props = { + cellRenderer, + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 1 + }; + + // Trigger 2 renders + // The second render should re-use the style for cell 0,0 + // But should not re-use the style for cell 0,1 since it was not measured + const grid = render(getMarkup(props)); + act(() => { + grid.forceUpdate(); + }); + + // 0,0 - 0,1 - 0,0 - 0,1 + expect(cellRenderer).toHaveBeenCalledTimes(4); + const style00A = cellRenderer.mock.calls[0][0].style; + const style01A = cellRenderer.mock.calls[1][0].style; + const style00B = cellRenderer.mock.calls[2][0].style; + const style01B = cellRenderer.mock.calls[3][0].style; + expect(style00A).toBe(style00B); + expect(style01A).not.toBe(style01B); + }); + }); + + describe("onScrollbarPresenceChange", () => { + it("should not trigger on-mount if scrollbars are hidden", () => { + const onScrollbarPresenceChange = vi.fn(); + + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1 + }) + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + }); + + it("should trigger on-mount if scrollbars are visible", () => { + const onScrollbarPresenceChange = vi.fn(); + + render( + getMarkup({ + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100 + }) + ); + expect(onScrollbarPresenceChange).toHaveBeenCalled(); + + const args = onScrollbarPresenceChange.mock.calls[0][0]; + expect(args.horizontal).toBe(true); + expect(args.size).toBe(getScrollbarSize20()); + expect(args.vertical).toBe(true); + }); + + it("should trigger on-update if scrollbar visibility has changed", () => { + const onScrollbarPresenceChange = vi.fn(); + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1 + }) + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + + render( + getMarkup({ + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100 + }) + ); + expect(onScrollbarPresenceChange).toHaveBeenCalled(); + + const args = onScrollbarPresenceChange.mock.calls[0][0]; + expect(args.horizontal).toBe(true); + expect(args.size).toBe(getScrollbarSize20()); + expect(args.vertical).toBe(true); + }); + + it("should not trigger on-update if scrollbar visibility does not change", () => { + const onScrollbarPresenceChange = vi.fn(); + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1 + }) + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + render( + getMarkup({ + columnCount: 2, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 2 + }) + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + }); + }); + + describe("StrictMode", () => { + function renderStrict(props: Partial) { + const ref = createRef(); + render( + +
{getMarkup({ ...props, ref } as Partial)}
+
+ ); + return ref; + } + + function getGridNode() { + return document.body.querySelector( + ".ReactVirtualized__Grid" + )!; + } + + it("should update cell styles when cell sizes change", () => { + const ref = renderStrict({ rowHeight: 20 }); + act(() => { + ref.current!.scrollToCell({ columnIndex: 0, rowIndex: 50 }); + }); + expect(getGridNode().scrollTop).toEqual(920); + + renderStrict({ rowHeight: 40 }); + + // The scroll offset is unchanged (scrollToCell is a one-time scroll); row 23 is now at the top + expect(getGridNode().scrollTop).toEqual(920); + const cells = Array.from( + getGridNode().querySelectorAll(".gridItem") + ); + const cell = cells.find((cell) => + cell.textContent!.startsWith("row:23,") + )!; + expect(cell).toBeDefined(); + expect(cell.style.height).toEqual("40px"); + expect(cell.style.top).toEqual("920px"); + cells.forEach((cell) => expect(cell.style.height).toEqual("40px")); + + renderStrict({ columnWidth: 100, rowHeight: 40 }); + Array.from( + getGridNode().querySelectorAll(".gridItem") + ).forEach((cell) => expect(cell.style.width).toEqual("100px")); + }); + + it("should call onSectionRendered and onScroll once per change", () => { + const onScroll = vi.fn(); + const onSectionRendered = vi.fn(); + const ref = renderStrict({ onScroll, onSectionRendered }); + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onSectionRendered).toHaveBeenCalledTimes(1); + + act(() => { + ref.current!.scrollToPosition({ scrollTop: 100 }); + }); + expect(onScroll).toHaveBeenCalledTimes(2); + expect(onScroll.mock.calls[1][0].scrollTop).toEqual(100); + expect(onSectionRendered).toHaveBeenCalledTimes(2); + expect(onSectionRendered.mock.calls[1][0].rowStartIndex).toEqual(5); + }); + + it("should handle scroll events", () => { + const ref = renderStrict({}); + fireEvent.scroll(getGridNode(), { + target: { scrollLeft: 100, scrollTop: 200 } + }); + expect(getGridNode().textContent).toContain("row:10, column:2"); + expect( + ref.current!.getOffsetForCell({ columnIndex: 2, rowIndex: 10 }) + ).toEqual({ scrollLeft: 100, scrollTop: 200 }); + }); + }); + + describe("imperative handle", () => { + it("should be stable across renders", () => { + const grid = render(getMarkup()); + const updated = render(getMarkup({ width: 300 })); + expect(updated).toBe(grid); + }); + + it("should be passed to cell renderers as their parent (e.g. for CellMeasurer)", () => { + const cellRenderer = vi.fn((params: CellRendererParams) => + defaultCellRenderer(params) + ); + const grid = render(getMarkup({ cellRenderer })); + expect(cellRenderer.mock.calls[0][0].parent).toBe(grid); + }); + + it("should support forceUpdate()", () => { + const cellRenderer = vi.fn(defaultCellRenderer); + const grid = render(getMarkup({ cellRenderer })); + cellRenderer.mockClear(); + act(() => { + grid.forceUpdate(); + }); + expect(cellRenderer).toHaveBeenCalledTimes(20); + }); + + it("should support an :elementRef", () => { + const elementRef = createRef(); + const grid = render(getMarkup({ elementRef })); + expect(elementRef.current).toBe(findDOMNode(grid)); + }); + + it("should see the latest props when called by a cell during the commit phase", () => { + // E.g. a cell (like CellMeasurer) that calls a parent method when it mounts or updates, + // before the Grid's own layout effects have run + function ScrollingCell({ + columnIndex, + parent, + rowIndex, + scrollToRow, + style + }: CellRendererParams & { scrollToRow?: number }) { + useLayoutEffect(() => { + if (scrollToRow !== undefined) { + (parent as GridImperativeAPI).scrollToCell({ + rowIndex: scrollToRow + }); + } + }, [parent, scrollToRow]); + + return ( +
+ {`row:${rowIndex}, column:${columnIndex}`} +
+ ); + } + + const grid = render( + getMarkup({ + cellRenderer: ({ key, ...params }) => ( + + ), + rowCount: 10 + }) + ); + render( + getMarkup({ + cellRenderer: ({ key, ...params }) => ( + + ), + rowCount: 100 + }) + ); + // (With stale props, the Grid would only have scrolled to its 10th row) + expect(getScrollPosition(grid).scrollTop).toEqual(920); + expect(findDOMNode(grid).textContent).toContain("row:50, column:0"); + }); + }); + + describe("server rendering", () => { + it("should hydrate server-rendered markup and then measure the scrollbar size", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + const onScrollbarPresenceChange = vi.fn(); + const markup = getMarkup({ + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange + }); + + const container = document.createElement("div"); + container.innerHTML = renderToString(markup); + document.body.appendChild(container); + + let root: ReturnType | null = null; + act(() => { + root = hydrateRoot(container, markup); + }); + + expect(console.error).not.toHaveBeenCalled(); + expect(onScrollbarPresenceChange).toHaveBeenCalledTimes(1); + expect(onScrollbarPresenceChange.mock.calls[0][0]).toEqual({ + horizontal: true, + size: 20, + vertical: true + }); + + act(() => root!.unmount()); + document.body.removeChild(container); + vi.restoreAllMocks(); + }); + + function getInnerScrollContainer(element: ParentNode) { + return element.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!; + } + + function hydrate( + markup: ReturnType, + // Simulates the user scrolling the server-rendered markup before the page is hydrated + preHydrationScroll?: { scrollLeft: number; scrollTop: number } + ) { + const container = document.createElement("div"); + container.innerHTML = renderToString(markup); + document.body.appendChild(container); + + const element = container.querySelector( + ".ReactVirtualized__Grid" + )!; + if (preHydrationScroll) { + element.scrollLeft = preHydrationScroll.scrollLeft; + element.scrollTop = preHydrationScroll.scrollTop; + } + + let root: ReturnType | null = null; + act(() => { + root = hydrateRoot(container, markup); + }); + + return { + element, + unmount() { + act(() => root!.unmount()); + document.body.removeChild(container); + } + }; + } + + it("should render the cells at the initial scroll offsets, shifted into view", () => { + const html = renderToString( + getMarkup({ initialScrollLeft: 500, initialScrollTop: 600 }) + ); + // Row 30 and column 10 are at the top/left of the viewport + expect(html).toContain("row:30, column:10"); + expect(html).not.toContain("row:0, column:0"); + // The browser can't scroll before hydration, so the inner container is shifted instead + expect(html).toContain("margin-left:-500px"); + expect(html).toContain("margin-top:-600px"); + }); + + it("should apply the initial scroll offsets after hydration, without moving the rendered cells", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + const { element, unmount } = hydrate( + getMarkup({ initialScrollLeft: 500, initialScrollTop: 600 }) + ); + + expect(console.error).not.toHaveBeenCalled(); + expect(element.scrollLeft).toEqual(500); + expect(element.scrollTop).toEqual(600); + const inner = getInnerScrollContainer(element); + expect(inner.style.marginLeft).toEqual(""); + expect(inner.style.marginTop).toEqual(""); + expect(element.textContent).toContain("row:30, column:10"); + expect(element.textContent).not.toContain("row:0, column:0"); + + unmount(); + vi.restoreAllMocks(); + }); + + it("should keep the content in place if the user scrolled before hydration", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + const { element, unmount } = hydrate( + getMarkup({ initialScrollLeft: 500, initialScrollTop: 600 }), + { scrollLeft: 50, scrollTop: 40 } + ); + + expect(console.error).not.toHaveBeenCalled(); + expect(element.scrollLeft).toEqual(550); + expect(element.scrollTop).toEqual(640); + expect(getInnerScrollContainer(element).style.marginTop).toEqual(""); + expect(element.textContent).toContain("row:32, column:11"); + + unmount(); + vi.restoreAllMocks(); + }); + + it("should not shift the inner container without initial scroll offsets", () => { + const html = renderToString(getMarkup()); + expect(html).not.toContain("margin-top"); + expect(html).not.toContain("margin-left"); + }); + }); + + describe("initial scroll offsets", () => { + it("should render and scroll to the initial scroll offsets on mount", () => { + const grid = render( + getMarkup({ initialScrollLeft: 500, initialScrollTop: 600 }) + ); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 500, + scrollTop: 600 + }); + const element = findDOMNode(grid); + expect(element.textContent).toContain("row:30, column:10"); + // Client rendering doesn't shift the inner container + const inner = element.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!; + expect(inner.style.marginTop).toEqual(""); + }); + + it("should clamp the initial scroll offsets to the scrollable range", () => { + const grid = render( + getMarkup({ initialScrollLeft: 99999, initialScrollTop: 99999 }) + ); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: NUM_COLUMNS * DEFAULT_COLUMN_WIDTH - DEFAULT_WIDTH, + scrollTop: NUM_ROWS * DEFAULT_ROW_HEIGHT - DEFAULT_HEIGHT + }); + }); + + it("should ignore changes to the initial scroll offsets after mounting", () => { + const grid = render( + getMarkup({ initialScrollLeft: 500, initialScrollTop: 600 }) + ); + render(getMarkup({ initialScrollLeft: 0, initialScrollTop: 100 })); + expect(getScrollPosition(grid)).toEqual({ + scrollLeft: 500, + scrollTop: 600 + }); + }); + + it("should report the initial scroll offsets through onScroll", () => { + const onScroll = vi.fn(); + render( + getMarkup({ initialScrollLeft: 500, initialScrollTop: 600, onScroll }) + ); + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onScroll.mock.calls[0][0]).toMatchObject({ + scrollLeft: 500, + scrollTop: 600 + }); + }); + + it("should work in StrictMode", () => { + const ref = createRef(); + render( + +
+ {getMarkup({ + initialScrollLeft: 500, + initialScrollTop: 600, + ref + } as Partial)} +
+
+ ); + const element = document.body.querySelector( + ".ReactVirtualized__Grid" + )!; + expect(element.scrollLeft).toEqual(500); + expect(element.scrollTop).toEqual(600); + expect(element.textContent).toContain("row:30, column:10"); + }); + }); +}); diff --git a/lib/components/Grid/Grid.tsx b/lib/components/Grid/Grid.tsx new file mode 100644 index 000000000..304fa5a27 --- /dev/null +++ b/lib/components/Grid/Grid.tsx @@ -0,0 +1,1650 @@ +import clsx from "clsx"; +import { + forwardRef, + memo, + useCallback, + useEffect, + useImperativeHandle, + useInsertionEffect, + useReducer, + useState, + useSyncExternalStore, + type CSSProperties, + type Dispatch, + type MutableRefObject, + type ReactNode, + type UIEvent +} from "react"; +import { createCallbackMemoizer } from "../../utils/createCallbackMemoizer"; +import { getUpdatedOffsetForIndex } from "../../utils/getUpdatedOffsetForIndex"; +import { + cancelAnimationTimeout, + requestAnimationTimeout, + type AnimationTimeoutId +} from "../../utils/requestAnimationTimeout"; +import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect"; +import type { + ScrollElementLinkSource, + ScrollElementLinkTarget, + ScrollElementViewport +} from "../../utils/scrollElement/types"; +import { useMissingHeightWarning } from "../../utils/scrollElement/useMissingHeightWarning"; +import { useScrollElementLink } from "../../utils/scrollElement/useScrollElementLink"; +import { defaultGridProps } from "./defaultGridProps"; +import { + SCROLL_DIRECTION_BACKWARD, + SCROLL_DIRECTION_FORWARD +} from "./defaultOverscanIndicesGetter"; +import type { + Alignment, + CellCache, + CellSize, + CellSizeGetter, + GridImperativeAPI, + GridProps, + RenderedSection, + Scroll, + ScrollbarPresenceChange, + StyleCache +} from "./types"; +import { ScalingCellSizeAndPositionManager } from "./utils/ScalingCellSizeAndPositionManager"; +import { updateScrollIndexHelper } from "./utils/updateScrollIndexHelper"; + +/** + * Controls whether the Grid updates the DOM element's scrollLeft/scrollTop based on the current state or just observes it. + * This prevents Grid from interrupting mouse-wheel animations (see issue #2). + */ +const SCROLL_POSITION_CHANGE_REASONS = { + OBSERVED: "observed", + REQUESTED: "requested" +} as const; + +type ScrollDirection = -1 | 1; + +type ScrollState = { + isScrolling: boolean; + scrollDirectionHorizontal: ScrollDirection; + scrollDirectionVertical: ScrollDirection; + scrollLeft: number; + scrollTop: number; + scrollPositionChangeReason: "observed" | "requested" | null; +}; + +const INITIAL_SCROLL_STATE: ScrollState = { + isScrolling: false, + scrollDirectionHorizontal: SCROLL_DIRECTION_FORWARD, + scrollDirectionVertical: SCROLL_DIRECTION_FORWARD, + scrollLeft: 0, + scrollTop: 0, + scrollPositionChangeReason: null +}; + +// Props read by the imperative methods. +// They are updated in an insertion effect, so they are current when the methods are called during the commit phase +// (e.g. by a CellMeasurer cell that mounts before the Grid's own layout effects run). +type LatestValues = { + columnCount: number; + height: number; + onScroll: (params: Scroll) => void; + rowCount: number; + scrollbarSize: number; + scrollbarSizeMeasured: boolean; + scrollingResetTimeInterval: number; + width: number; +}; + +// The props that the size managers (and the style/cell caches) were last updated for. +// This is a render-time cache: it is updated while rendering, alongside the managers it describes, +// so it is always consistent with them (even if a render is repeated or discarded). +type SizeInputs = { + columnCount: number; + columnSizeGetter: CellSizeGetter; + columnWidth: CellSize; + estimatedColumnSize: number; + estimatedRowSize: number; + rowCount: number; + rowHeight: CellSize; + rowSizeGetter: CellSizeGetter; +}; + +// Range of cells (including overscan) rendered by the most recent render; used to retain their cached styles +type RenderedRange = { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; +}; + +// Values of the most recently committed render, compared by the layout effect to detect changes +// (the equivalent of the "prevProps"/"prevState" of the 9.x componentDidUpdate). +type CommittedValues = { + columnCount: number; + columnWidth: CellSize; + height: number; + rowCount: number; + rowHeight: CellSize; + scrollLeft: number; + scrollTop: number; + width: number; +}; + +// The viewport of the scroll element the Grid is linked to (its scrollElement prop; see useScrollElementLink), +// and the direction its offset last moved in (for overscanning). +// While linked, the Grid renders at its full height and is scrolled vertically by the scroll element. +type LinkState = { + direction: ScrollDirection; + viewport: ScrollElementViewport; +}; + +type GridLink = LinkState & { + source: ScrollElementLinkSource; +}; + +// Mutable, per-instance values (the equivalent of the instance fields of the 9.x class component). +// Except for the render-time caches (see below), they are only modified by the imperative methods, event handlers and effects. +type GridInternals = { + // Render-time caches (see SizeInputs); styles and (while scrolling) cells are cached by `${rowIndex}-${columnIndex}` + cellCache: CellCache; + cellCacheForceUpdateCount: number; + cellCacheProps: GridProps | null; + columnSizeAndPositionManager: ScalingCellSizeAndPositionManager; + renderedRange: RenderedRange | null; + rowSizeAndPositionManager: ScalingCellSizeAndPositionManager; + sizeInputs: SizeInputs; + styleCache: StyleCache; + + committed: CommittedValues | null; + deferredInvalidateColumnIndex: number | null; + deferredInvalidateRowIndex: number | null; + disablePointerEventsTimeoutId: AnimationTimeoutId | null; + element: HTMLDivElement | null; + latest: LatestValues; + + // The scroll element the Grid is linked to (if any); updated by the link target methods (during commit or event handlers) + link: GridLink | null; + + onGridRenderedMemoizer: ReturnType; + onScrollMemoizer: ReturnType; + scrollbarPresence: { horizontal: number; vertical: number }; + + // The most recently requested scroll state (including updates that have not been rendered yet) + scrollState: ScrollState; + // Dispatches scroll state updates (the setter of the scrollState state); set once the Grid has been committed + scrollStateDispatch: Dispatch | null; + + // The scroll offsets rendered (as a shift of the inner container) during server rendering and hydration + // still have to be applied to the scrolling element (see the layout effect) + scrollOffsetsPendingHydration: boolean; +}; + +type GridInstance = { + handle: GridImperativeAPI; + internals: GridInternals; + invokeOnScrollMemoizer: ( + scrollLeft: number, + scrollTop: number, + totalColumnsWidth: number, + totalRowsHeight: number + ) => void; + onScroll: (event: UIEvent) => void; + setScrollState: (update: Partial) => void; + updateScrollLeftForColumn: ( + alignment: Alignment, + columnIndex: number + ) => void; + updateScrollTopForRow: (alignment: Alignment, rowIndex: number) => void; + linkTarget: ScrollElementLinkTarget; +}; + +const increment = (value: number) => value + 1; + +const subscribeToNothing = () => () => {}; + +// The scrollbar size can't be measured during server rendering (or hydration) +const getServerScrollbarSize = () => undefined; + +// Whether the Grid is rendered on the client (true) or is being server-rendered or hydrated (false) +const getClientRendered = () => true; +const getServerRendered = () => false; + +function wrapSizeGetter(value: CellSize): CellSizeGetter { + return typeof value === "function" ? value : () => value; +} + +function isNumericSizeChange(prevSize: CellSize, nextSize: CellSize) { + // Don't compare cell sizes if they are functions because inline functions would cause infinite loops. + // In that event users should use the manual recompute methods to inform of changes. + return ( + (typeof prevSize === "number" || typeof nextSize === "number") && + prevSize !== nextSize + ); +} + +function createGridInstance( + initialProps: Pick< + GridProps, + | "columnCount" + | "columnWidth" + | "estimatedColumnSize" + | "estimatedRowSize" + | "initialScrollLeft" + | "initialScrollTop" + | "onScroll" + | "rowCount" + | "rowHeight" + | "scrollingResetTimeInterval" + | "width" + > & + Required< + Pick< + GridProps, + | "estimatedColumnSize" + | "estimatedRowSize" + | "onScroll" + | "scrollingResetTimeInterval" + > + > & { + // The height of the Grid (or, for a Grid with a scrollElement, of the scroll element's viewport) + height: number; + }, + setLinkState: Dispatch, + forceUpdate: () => void +): GridInstance { + const { + columnCount, + columnWidth, + estimatedColumnSize, + estimatedRowSize, + rowCount, + rowHeight + } = initialProps; + + const columnSizeGetter = wrapSizeGetter(columnWidth); + const rowSizeGetter = wrapSizeGetter(rowHeight); + + const internals: GridInternals = { + cellCache: {}, + cellCacheForceUpdateCount: 0, + cellCacheProps: null, + columnSizeAndPositionManager: new ScalingCellSizeAndPositionManager({ + cellCount: columnCount, + cellSizeGetter: columnSizeGetter, + estimatedCellSize: + typeof columnWidth === "number" ? columnWidth : estimatedColumnSize + }), + renderedRange: null, + rowSizeAndPositionManager: new ScalingCellSizeAndPositionManager({ + cellCount: rowCount, + cellSizeGetter: rowSizeGetter, + estimatedCellSize: + typeof rowHeight === "number" ? rowHeight : estimatedRowSize + }), + sizeInputs: { + columnCount, + columnSizeGetter, + columnWidth, + estimatedColumnSize, + estimatedRowSize, + rowCount, + rowHeight, + rowSizeGetter + }, + styleCache: {}, + + committed: null, + deferredInvalidateColumnIndex: null, + deferredInvalidateRowIndex: null, + disablePointerEventsTimeoutId: null, + element: null, + latest: { + columnCount, + height: initialProps.height, + onScroll: initialProps.onScroll, + rowCount, + scrollbarSize: 0, + scrollbarSizeMeasured: false, + scrollingResetTimeInterval: initialProps.scrollingResetTimeInterval, + width: initialProps.width + }, + link: null, + // Invokes onSectionRendered callback only when start/stop row or column indices change + onGridRenderedMemoizer: createCallbackMemoizer(), + onScrollMemoizer: createCallbackMemoizer(false), + scrollbarPresence: { horizontal: 0, vertical: 0 }, + scrollState: INITIAL_SCROLL_STATE, + scrollStateDispatch: null, + scrollOffsetsPendingHydration: false + }; + + // The initial scroll offsets (e.g. for server rendering) are clamped to the scrollable range, + // using the estimated sizes of the cells that haven't been measured yet. + // (They are applied to the scrolling element when the Grid mounts; see the layout effect.) + const { initialScrollLeft = 0, initialScrollTop = 0 } = initialProps; + internals.scrollState = { + ...INITIAL_SCROLL_STATE, + scrollLeft: Math.max( + 0, + Math.min( + initialScrollLeft, + internals.columnSizeAndPositionManager.getTotalSize() - + initialProps.width + ) + ), + scrollTop: Math.max( + 0, + Math.min( + initialScrollTop, + internals.rowSizeAndPositionManager.getTotalSize() - initialProps.height + ) + ) + }; + + const setScrollState = (update: Partial) => { + const prevState = internals.scrollState; + let changed = false; + for (const key in update) { + if ( + update[key as keyof ScrollState] !== prevState[key as keyof ScrollState] + ) { + changed = true; + break; + } + } + if (changed) { + const nextState = { ...prevState, ...update }; + internals.scrollState = nextState; + internals.scrollStateDispatch?.(nextState); + } + }; + + const getCalculatedScrollLeft = ( + alignment: Alignment, + scrollToColumn: number, + currentScrollLeft: number + ): number => { + const { columnCount, height, scrollbarSize, scrollbarSizeMeasured, width } = + internals.latest; + + if (columnCount > 0) { + const finalColumn = columnCount - 1; + const targetIndex = + scrollToColumn < 0 + ? finalColumn + : Math.min(finalColumn, scrollToColumn); + const totalRowsHeight = + internals.rowSizeAndPositionManager.getTotalSize(); + const scrollBarSize = + scrollbarSizeMeasured && totalRowsHeight > height ? scrollbarSize : 0; + + return internals.columnSizeAndPositionManager.getUpdatedOffsetForIndex({ + align: alignment, + containerSize: width - scrollBarSize, + currentOffset: currentScrollLeft, + targetIndex + }); + } + return 0; + }; + + const getCalculatedScrollTop = ( + alignment: Alignment, + scrollToRow: number, + currentScrollTop: number + ): number => { + const { height, rowCount, scrollbarSize, scrollbarSizeMeasured, width } = + internals.latest; + + if (rowCount > 0) { + const finalRow = rowCount - 1; + const targetIndex = + scrollToRow < 0 ? finalRow : Math.min(finalRow, scrollToRow); + const totalColumnsWidth = + internals.columnSizeAndPositionManager.getTotalSize(); + const scrollBarSize = + scrollbarSizeMeasured && totalColumnsWidth > width ? scrollbarSize : 0; + const containerSize = height - scrollBarSize; + const rowSizeAndPositionManager = internals.rowSizeAndPositionManager; + + // The viewport of a linked Grid (e.g. the window) may extend beyond the Grid itself (e.g. above or below it), + // so its offset may be outside of the Grid's scrollable range (e.g. negative). + // The offset isn't limited to the end of the Grid (e.g. so that the last rows can be scrolled to the top of the + // viewport when there is content below the Grid); the scroll element limits it to its own scrollable range (#1895). + // With "auto" alignment, a row that is already visible within the viewport is not scrolled to. + if ( + internals.link !== null && + containerSize > 0 && + !rowSizeAndPositionManager.areOffsetsAdjusted() + ) { + const { offset, size } = + rowSizeAndPositionManager.getSizeAndPositionOfCell(targetIndex); + if ( + alignment === "auto" && + offset >= currentScrollTop && + offset + size <= currentScrollTop + containerSize + ) { + return currentScrollTop; + } + + return getUpdatedOffsetForIndex({ + align: alignment, + cellOffset: offset, + cellSize: size, + containerSize, + currentOffset: currentScrollTop + }); + } + + return rowSizeAndPositionManager.getUpdatedOffsetForIndex({ + align: alignment, + containerSize, + currentOffset: currentScrollTop, + targetIndex + }); + } + return 0; + }; + + // The current vertical scroll offset: the Grid's own, or (if linked) the offset of the scroll element's viewport + // (which may be negative; see ScrollElementViewport) + const getCurrentScrollTop = () => + internals.link !== null + ? internals.link.viewport.scrollTop + : internals.scrollState.scrollTop; + + // Scrolls a linked Grid (i.e. its scroll element) + const scrollLinkTo = (link: GridLink, scrollTop: number) => { + link.source.scrollTo(scrollTop); + }; + + // Scrolls to the specified column (or to the last column, if the index is negative) + const updateScrollLeftForColumn = ( + alignment: Alignment, + columnIndex: number + ) => { + const { scrollLeft } = internals.scrollState; + const calculatedScrollLeft = getCalculatedScrollLeft( + alignment, + columnIndex, + scrollLeft + ); + + if (calculatedScrollLeft >= 0 && calculatedScrollLeft !== scrollLeft) { + setScrollState({ + scrollDirectionHorizontal: + calculatedScrollLeft > scrollLeft + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD, + scrollLeft: calculatedScrollLeft, + scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED + }); + } + }; + + // Scrolls to the specified row (or to the last row, if the index is negative) + const updateScrollTopForRow = (alignment: Alignment, rowIndex: number) => { + const scrollTop = getCurrentScrollTop(); + const calculatedScrollTop = getCalculatedScrollTop( + alignment, + rowIndex, + scrollTop + ); + + const link = internals.link; + if (link !== null) { + if (calculatedScrollTop !== scrollTop) { + scrollLinkTo(link, calculatedScrollTop); + } + return; + } + + if (calculatedScrollTop >= 0 && calculatedScrollTop !== scrollTop) { + setScrollState({ + scrollDirectionVertical: + calculatedScrollTop > scrollTop + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD, + scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED, + scrollTop: calculatedScrollTop + }); + } + }; + + const invokeOnScrollMemoizer = ( + scrollLeft: number, + scrollTop: number, + totalColumnsWidth: number, + totalRowsHeight: number + ) => { + internals.onScrollMemoizer({ + callback: ({ scrollLeft, scrollTop }) => { + const { height, onScroll, width } = internals.latest; + + onScroll({ + clientHeight: height, + clientWidth: width, + scrollHeight: totalRowsHeight, + scrollLeft, + scrollTop, + scrollWidth: totalColumnsWidth + }); + }, + indices: { + scrollLeft, + scrollTop + } + }); + }; + + const debounceScrollEndedCallback = () => { + internals.disablePointerEventsTimeoutId = null; + // isScrolling is used to determine if we reset styleCache + setScrollState({ isScrolling: false }); + }; + + /** + * Sets an :isScrolling flag for a small window of time. + * This flag is used to disable pointer events on the scrollable portion of the Grid. + * This prevents jerky/stuttery mouse-wheel scrolling. + */ + const debounceScrollEnded = () => { + if (internals.disablePointerEventsTimeoutId) { + cancelAnimationTimeout(internals.disablePointerEventsTimeoutId); + } + + internals.disablePointerEventsTimeoutId = requestAnimationTimeout( + debounceScrollEndedCallback, + internals.latest.scrollingResetTimeInterval + ); + }; + + const handle: GridImperativeAPI = { + forceUpdate() { + forceUpdate(); + }, + + getOffsetForCell({ + alignment = "auto", + columnIndex = -1, + rowIndex = -1 + } = {}) { + const { scrollLeft } = internals.scrollState; + + return { + scrollLeft: getCalculatedScrollLeft(alignment, columnIndex, scrollLeft), + scrollTop: getCalculatedScrollTop( + alignment, + rowIndex, + getCurrentScrollTop() + ) + }; + }, + + getTotalRowsHeight() { + return internals.rowSizeAndPositionManager.getTotalSize(); + }, + + getTotalColumnsWidth() { + return internals.columnSizeAndPositionManager.getTotalSize(); + }, + + handleScrollEvent({ + scrollLeft: scrollLeftParam = 0, + scrollTop: scrollTopParam = 0 + }) { + // On iOS, we can arrive at negative offsets by swiping past the start. + // To prevent flicker here, we make playing in the negative offset zone cause nothing to happen. + if (scrollTopParam < 0) { + return; + } + + // Prevent pointer events from interrupting a smooth scroll + debounceScrollEnded(); + + const { height, scrollbarSize, width } = internals.latest; + const link = internals.link; + + // When this component is shrunk drastically, React dispatches a series of back-to-back scroll events, + // Gradually converging on a scrollTop that is within the bounds of the new, smaller height. + // This causes a series of rapid renders that is slow for long lists. + // We can avoid that by doing some simple bounds checking to ensure that scroll offsets never exceed their bounds. + const totalRowsHeight = + internals.rowSizeAndPositionManager.getTotalSize(); + const totalColumnsWidth = + internals.columnSizeAndPositionManager.getTotalSize(); + const scrollLeft = Math.min( + Math.max(0, totalColumnsWidth - width + scrollbarSize), + scrollLeftParam + ); + const scrollTop = Math.min( + Math.max(0, totalRowsHeight - height + scrollbarSize), + scrollTopParam + ); + + // Certain devices (like Apple touchpad) rapid-fire duplicate events. + // Don't force a re-render if this is the case. + // The mouse may move faster then the animation frame does. + // Use requestAnimationFrame to avoid over-updating. + const prevState = internals.scrollState; + if ( + prevState.scrollLeft !== scrollLeft || + prevState.scrollTop !== scrollTop + ) { + // Track scrolling direction so we can more efficiently overscan rows to reduce empty space around the edges while scrolling. + // Don't change direction for an axis unless scroll offset has changed. + const scrollDirectionHorizontal = + scrollLeft !== prevState.scrollLeft + ? scrollLeft > prevState.scrollLeft + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD + : prevState.scrollDirectionHorizontal; + const scrollDirectionVertical = + scrollTop !== prevState.scrollTop + ? scrollTop > prevState.scrollTop + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD + : prevState.scrollDirectionVertical; + + const update: Partial = { + isScrolling: true, + scrollDirectionHorizontal, + scrollDirectionVertical, + scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.OBSERVED + }; + + // A linked Grid is scrolled vertically by its scroll element (see setViewport), so the vertical offset is ignored. + if (link === null) { + update.scrollTop = scrollTop; + } + update.scrollLeft = scrollLeft; + + setScrollState(update); + } + + invokeOnScrollMemoizer( + scrollLeft, + link !== null ? Math.max(0, link.viewport.scrollTop) : scrollTop, + totalColumnsWidth, + totalRowsHeight + ); + }, + + invalidateCellSizeAfterRender({ columnIndex, rowIndex }) { + internals.deferredInvalidateColumnIndex = + typeof internals.deferredInvalidateColumnIndex === "number" + ? Math.min(internals.deferredInvalidateColumnIndex, columnIndex) + : columnIndex; + internals.deferredInvalidateRowIndex = + typeof internals.deferredInvalidateRowIndex === "number" + ? Math.min(internals.deferredInvalidateRowIndex, rowIndex) + : rowIndex; + }, + + measureAllCells() { + const { columnCount, rowCount } = internals.latest; + if (columnCount > 0) { + internals.columnSizeAndPositionManager.getSizeAndPositionOfCell( + columnCount - 1 + ); + } + if (rowCount > 0) { + internals.rowSizeAndPositionManager.getSizeAndPositionOfCell( + rowCount - 1 + ); + } + }, + + recomputeGridSize({ columnIndex = 0, rowIndex = 0 } = {}) { + internals.columnSizeAndPositionManager.resetCell(columnIndex); + internals.rowSizeAndPositionManager.resetCell(rowIndex); + + // Clear cell cache in case we are scrolling; + // Invalid row heights likely mean invalid cached content as well. + internals.styleCache = {}; + internals.cellCache = {}; + + forceUpdate(); + }, + + scrollToCell({ align = "auto", columnIndex, rowIndex }) { + const { columnCount, height, width } = internals.latest; + + // This is a one-time scroll: the cell isn't kept in view afterwards (e.g. if cell sizes change). + // Nothing is scrolled if the size is 0, since no cells are rendered in that case. + if (height <= 0 || width <= 0) { + return; + } + + // Don't adjust scroll offset for single-column grids (eg List, Table). + // This can cause a funky scroll offset because of the vertical scrollbar width. + if (columnCount > 1 && columnIndex !== undefined && columnIndex >= 0) { + updateScrollLeftForColumn(align, columnIndex); + } + + if (rowIndex !== undefined && rowIndex >= 0) { + updateScrollTopForRow(align, rowIndex); + } + }, + + scrollToPosition({ scrollLeft, scrollTop }) { + const { height, scrollbarSize, width } = internals.latest; + const prevState = internals.scrollState; + const update: Partial = {}; + + // Offsets are clamped the same way handleScrollEvent() clamps observed offsets, + // so that an offset reported by this Grid (e.g. via onScroll) is never changed by passing it back. + // (If the browser can't scroll quite as far, the offset is corrected after it has been applied; see the layout effect.) + const totalRowsHeight = + internals.rowSizeAndPositionManager.getTotalSize(); + const totalColumnsWidth = + internals.columnSizeAndPositionManager.getTotalSize(); + + if (typeof scrollLeft === "number" && !isNaN(scrollLeft)) { + const clampedScrollLeft = Math.max( + 0, + Math.min(scrollLeft, totalColumnsWidth - width + scrollbarSize) + ); + if (clampedScrollLeft !== prevState.scrollLeft) { + update.scrollDirectionHorizontal = + clampedScrollLeft > prevState.scrollLeft + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD; + update.scrollLeft = clampedScrollLeft; + } + } + + const link = internals.link; + if (typeof scrollTop === "number" && !isNaN(scrollTop) && link !== null) { + // A linked Grid scrolls its scroll element instead. + // The current offset is clamped as well (it is reported that way by onScroll), + // so that passing back a reported offset doesn't scroll (e.g. while the Grid is below the top of the viewport). + const maxScrollTop = Math.max(0, totalRowsHeight - height); + const clampedScrollTop = Math.max(0, Math.min(scrollTop, maxScrollTop)); + const currentScrollTop = Math.max( + 0, + Math.min(link.viewport.scrollTop, maxScrollTop) + ); + if (clampedScrollTop !== currentScrollTop) { + scrollLinkTo(link, clampedScrollTop); + } + } else if (typeof scrollTop === "number" && !isNaN(scrollTop)) { + const clampedScrollTop = Math.max( + 0, + Math.min(scrollTop, totalRowsHeight - height + scrollbarSize) + ); + if (clampedScrollTop !== prevState.scrollTop) { + update.scrollDirectionVertical = + clampedScrollTop > prevState.scrollTop + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD; + update.scrollTop = clampedScrollTop; + } + } + + // No-op (no state update or render) if the offsets haven't changed + if (update.scrollLeft !== undefined || update.scrollTop !== undefined) { + update.scrollPositionChangeReason = + SCROLL_POSITION_CHANGE_REASONS.REQUESTED; + setScrollState(update); + } + } + }; + + // Linked to the Grid's scroll element (if it has one) by useScrollElementLink + const linkTarget: ScrollElementLinkTarget = { + getElement() { + return internals.element; + }, + + link(source, viewport) { + if (source === null) { + if (internals.link !== null) { + internals.link = null; + setLinkState(null); + } + return; + } + + const direction = internals.link?.direction ?? SCROLL_DIRECTION_FORWARD; + internals.link = { direction, source, viewport }; + setLinkState({ direction, viewport }); + }, + + setViewport(viewport) { + const link = internals.link; + if (link === null) { + return; + } + + const prevViewport = link.viewport; + if ( + prevViewport.height === viewport.height && + prevViewport.isScrolling === viewport.isScrolling && + prevViewport.scrollTop === viewport.scrollTop + ) { + return; + } + + if (viewport.scrollTop !== prevViewport.scrollTop) { + // Track the scrolling direction (for overscanning) + link.direction = + viewport.scrollTop > prevViewport.scrollTop + ? SCROLL_DIRECTION_FORWARD + : SCROLL_DIRECTION_BACKWARD; + } + + link.viewport = viewport; + setLinkState({ direction: link.direction, viewport }); + } + }; + + const onScroll = (event: UIEvent) => { + // In certain edge-cases React dispatches an onScroll event with an invalid target.scrollLeft / target.scrollTop. + // This invalid event can be detected by comparing event.target to this component's scrollable DOM element. + // See issue #404 for more information. + if (event.target === internals.element) { + handle.handleScrollEvent(event.target as HTMLDivElement); + } + }; + + return { + handle, + internals, + linkTarget, + invokeOnScrollMemoizer, + onScroll, + setScrollState, + updateScrollLeftForColumn, + updateScrollTopForRow + }; +} + +/** + * Updates the size managers (and invalidates the style and cell caches) after a size-related prop has changed. + * This is the render-time part of the 9.x getDerivedStateFromProps. + */ +function updateSizeInputs(internals: GridInternals, next: SizeInputs) { + const prev = internals.sizeInputs; + + // Cached styles (and the cells rendered with them) are stale once cell sizes or counts change. + // Counts matter too: a size getter may return different values for an index after rows are added or removed (bug #16). + if ( + next.columnWidth !== prev.columnWidth || + next.rowHeight !== prev.rowHeight || + next.columnCount !== prev.columnCount || + next.rowCount !== prev.rowCount + ) { + internals.styleCache = {}; + internals.cellCache = {}; + } + + internals.columnSizeAndPositionManager.configure({ + cellCount: next.columnCount, + estimatedCellSize: + typeof next.columnWidth === "number" + ? next.columnWidth + : next.estimatedColumnSize, + cellSizeGetter: next.columnSizeGetter + }); + internals.rowSizeAndPositionManager.configure({ + cellCount: next.rowCount, + estimatedCellSize: + typeof next.rowHeight === "number" + ? next.rowHeight + : next.estimatedRowSize, + cellSizeGetter: next.rowSizeGetter + }); + + // Cells are (re)measured along an axis whose count (or numeric cell size) has changed; + // if either count was 0, along both axes. + // (Cells along one axis may have been measured while the other count was 0, e.g. the columns of a MultiGrid quadrant without rows; + // so they are also reset if the count stays 0. See #1351.) + const eitherCountWasZero = prev.columnCount === 0 || prev.rowCount === 0; + if ( + eitherCountWasZero || + prev.columnCount !== next.columnCount || + isNumericSizeChange(prev.columnWidth, next.columnWidth) + ) { + internals.columnSizeAndPositionManager.resetCell(0); + } + if ( + eitherCountWasZero || + prev.rowCount !== next.rowCount || + isNumericSizeChange(prev.rowHeight, next.rowHeight) + ) { + internals.rowSizeAndPositionManager.resetCell(0); + } + + internals.sizeInputs = next; +} + +// Shallowly compares props +function areCellPropsEqual(prevProps: GridProps, nextProps: GridProps) { + if (prevProps === nextProps) { + return true; + } + + const prev = prevProps as { [key: string]: unknown }; + const next = nextProps as { [key: string]: unknown }; + for (const key in prev) { + if (prev[key] !== next[key]) { + return false; + } + } + for (const key in next) { + if (!(key in prev)) { + return false; + } + } + return true; +} + +/** + * Drops the cached styles (and cells) of cells that were not rendered by the previous render. + * This keeps the caches from growing too large, while avoiding unnecessary re-renders of the visible cells. + */ +function resetStyleCache(internals: GridInternals, keepCells: boolean) { + const { cellCache, renderedRange, styleCache } = internals; + + // Reset cell and style caches once scrolling stops. + // This makes Grid simpler to use (since cells commonly change). + // And it keeps the caches from growing too large. + // Performance is most sensitive when a user is scrolling. + // Don't clear visible cells from cellCache if isScrollingOptOut is specified (and the cells are still current). + // This keeps the cellCache to a resonable size. + const nextCellCache: CellCache = {}; + const nextStyleCache: StyleCache = {}; + + // Copy over the visible cell styles so avoid unnecessary re-render. + if (renderedRange !== null) { + for ( + let rowIndex = renderedRange.rowStartIndex; + rowIndex <= renderedRange.rowStopIndex; + rowIndex++ + ) { + for ( + let columnIndex = renderedRange.columnStartIndex; + columnIndex <= renderedRange.columnStopIndex; + columnIndex++ + ) { + const key = `${rowIndex}-${columnIndex}`; + const style = styleCache[key]; + if (style !== undefined) { + nextStyleCache[key] = style; + } + + if (keepCells) { + const cell = cellCache[key]; + if (cell !== undefined) { + nextCellCache[key] = cell; + } + } + } + } + } + + internals.cellCache = nextCellCache; + internals.styleCache = nextStyleCache; +} + +/** + * Renders tabular data with virtualization along the vertical and horizontal axes. + * Row heights and column widths must be known ahead of time and specified as properties. + * + * Scrolling is done through the imperative handle (`ref`): see `scrollToCell` and `scrollToPosition`. + */ +export const Grid = /* @__PURE__ */ memo( + /* @__PURE__ */ forwardRef( + function Grid(props, ref) { + // (The props object itself is needed as well, to detect prop changes for isScrollingOptOut; see below.) + const { + "aria-label": ariaLabel = defaultGridProps["aria-label"], + "aria-readonly": ariaReadonly = defaultGridProps["aria-readonly"], + autoContainerWidth = defaultGridProps.autoContainerWidth, + cellRangeRenderer = defaultGridProps.cellRangeRenderer, + cellRenderer, + className, + columnCount, + columnWidth, + containerProps, + containerRole = defaultGridProps.containerRole, + containerStyle = defaultGridProps.containerStyle, + deferredMeasurementCache, + elementRef, + estimatedColumnSize = defaultGridProps.estimatedColumnSize, + estimatedRowSize = defaultGridProps.estimatedRowSize, + getScrollbarSize = defaultGridProps.getScrollbarSize, + height: heightProp, + id, + initialScrollLeft, + initialScrollTop, + isScrollingOptOut = defaultGridProps.isScrollingOptOut, + noContentRenderer = defaultGridProps.noContentRenderer, + onScroll = defaultGridProps.onScroll, + onScrollbarPresenceChange = defaultGridProps.onScrollbarPresenceChange, + onSectionRendered = defaultGridProps.onSectionRendered, + overscanColumnCount = defaultGridProps.overscanColumnCount, + overscanIndicesGetter = defaultGridProps.overscanIndicesGetter, + overscanRowCount = defaultGridProps.overscanRowCount, + role = defaultGridProps.role, + rowCount, + rowHeight, + scrollElement, + scrollingResetTimeInterval = defaultGridProps.scrollingResetTimeInterval, + style = defaultGridProps.style, + tabIndex = defaultGridProps.tabIndex, + width + } = props; + + const [linkState, setLinkState] = useState(null); + const [forceUpdateCount, forceUpdate] = useReducer(increment, 0); + + // A Grid with a scrollElement is scrolled vertically by that element (e.g. the window) rather than scrolling itself: + // it renders at its full height, and renders the rows within the scroll element's viewport. + // (It may still scroll horizontally itself.) + // Until the scroll element has been measured (e.g. during server rendering), the height prop is used as the viewport height. + const linked = scrollElement !== undefined; + const height = + linkState !== null ? linkState.viewport.height : (heightProp ?? 0); + + useMissingHeightWarning("Grid", heightProp, scrollElement); + + // The instance (and its stable imperative handle) is created once + const [ + { + handle, + internals, + invokeOnScrollMemoizer, + onScroll: onScrollEvent, + setScrollState, + updateScrollLeftForColumn, + updateScrollTopForRow, + linkTarget + } + ] = useState(() => + createGridInstance( + { + columnCount, + columnWidth, + estimatedColumnSize, + estimatedRowSize, + height, + initialScrollLeft, + // The vertical offset of a Grid with a scrollElement is determined by the scroll element + initialScrollTop: linked ? undefined : initialScrollTop, + onScroll, + rowCount, + rowHeight, + scrollingResetTimeInterval, + width + }, + setLinkState, + forceUpdate + ) + ); + + // (Initialized with the initial scroll offsets; see createGridInstance) + const [scrollState, setScrollStateDispatch] = useState( + () => internals.scrollState + ); + + useImperativeHandle(ref, () => handle, [handle]); + + useScrollElementLink( + scrollElement, + linkTarget, + scrollingResetTimeInterval + ); + + // If this component is server-rendered (or hydrated), the scrollbar size is measured after hydration. + const measuredScrollbarSize = useSyncExternalStore( + subscribeToNothing, + getScrollbarSize, + getServerScrollbarSize + ); + const scrollbarSizeMeasured = measuredScrollbarSize !== undefined; + const scrollbarSize = measuredScrollbarSize ?? 0; + + const latest: LatestValues = { + columnCount, + height, + onScroll, + rowCount, + scrollbarSize, + scrollbarSizeMeasured, + scrollingResetTimeInterval, + width + }; + useInsertionEffect(() => { + internals.latest = latest; + internals.scrollStateDispatch = setScrollStateDispatch; + }); + + // The browser can't apply a scroll offset before the page has been hydrated. + // So if the Grid is server-rendered (or hydrated) with initial scroll offsets, + // its inner container is shifted by those offsets instead (so that the rendered cells are visible), + // until the offsets have been applied to the scrolling element after hydration (see the layout effect). + const clientRendered = useSyncExternalStore( + subscribeToNothing, + getClientRendered, + getServerRendered + ); + const [hydrationShift, setHydrationShift] = useState<{ + left: number; + top: number; + } | null>(() => + clientRendered || + (internals.scrollState.scrollLeft === 0 && + internals.scrollState.scrollTop === 0) + ? null + : { + left: internals.scrollState.scrollLeft, + top: internals.scrollState.scrollTop + } + ); + + const setScrollingContainerRef = useCallback( + (element: HTMLDivElement | null) => { + internals.element = element; + + if (typeof elementRef === "function") { + elementRef(element); + } else if (elementRef) { + (elementRef as MutableRefObject).current = + element; + } + }, + [elementRef, internals] + ); + + // Update cell metadata (and invalidate cached styles) if cell counts or sizes have changed + const sizeInputs = internals.sizeInputs; + if ( + columnCount !== sizeInputs.columnCount || + columnWidth !== sizeInputs.columnWidth || + estimatedColumnSize !== sizeInputs.estimatedColumnSize || + rowCount !== sizeInputs.rowCount || + rowHeight !== sizeInputs.rowHeight || + estimatedRowSize !== sizeInputs.estimatedRowSize + ) { + updateSizeInputs(internals, { + columnCount, + columnSizeGetter: + columnWidth === sizeInputs.columnWidth + ? sizeInputs.columnSizeGetter + : wrapSizeGetter(columnWidth), + columnWidth, + estimatedColumnSize, + estimatedRowSize, + rowCount, + rowHeight, + rowSizeGetter: + rowHeight === sizeInputs.rowHeight + ? sizeInputs.rowSizeGetter + : wrapSizeGetter(rowHeight) + }); + } + + const { columnSizeAndPositionManager, rowSizeAndPositionManager } = + internals; + + const isScrolling = + scrollState.isScrolling || + (linkState !== null && linkState.viewport.isScrolling); + const scrollLeft = scrollState.scrollLeft; + const scrollTop = linked + ? Math.max(0, linkState?.viewport.scrollTop ?? 0) + : scrollState.scrollTop; + const scrollDirectionHorizontal = scrollState.scrollDirectionHorizontal; + const scrollDirectionVertical = + linkState !== null + ? linkState.direction + : scrollState.scrollDirectionVertical; + + // Reset the style cache (keeping the styles of the cells rendered last time) once scrolling has stopped. + // (For a linked Grid, this includes the scrolling of its scroll element.) + if (!isScrolling) { + // With isScrollingOptOut, cached cells are kept as well; but only if they are still current (bug #13): + // i.e. the props haven't changed (and forceUpdate() hasn't been called) since the cells were cached. + const keepCells = + isScrollingOptOut && + internals.cellCacheForceUpdateCount === forceUpdateCount && + internals.cellCacheProps !== null && + areCellPropsEqual(internals.cellCacheProps, props); + + resetStyleCache(internals, keepCells); + + internals.cellCacheProps = props; + internals.cellCacheForceUpdateCount = forceUpdateCount; + } + + let childrenToDisplay: ReactNode[] = []; + let renderedSection: Partial | null = null; + + // Render only enough columns and rows to cover the visible area of the grid. + if (height > 0 && width > 0) { + const visibleColumnIndices = + columnSizeAndPositionManager.getVisibleCellRange({ + containerSize: width, + offset: scrollLeft + }); + const visibleRowIndices = rowSizeAndPositionManager.getVisibleCellRange( + { + containerSize: height, + offset: scrollTop + } + ); + + const horizontalOffsetAdjustment = + columnSizeAndPositionManager.getOffsetAdjustment({ + containerSize: width, + offset: scrollLeft + }); + const verticalOffsetAdjustment = + rowSizeAndPositionManager.getOffsetAdjustment({ + containerSize: height, + offset: scrollTop + }); + + const overscanColumnIndices = overscanIndicesGetter({ + direction: "horizontal", + cellCount: columnCount, + overscanCellsCount: overscanColumnCount, + scrollDirection: scrollDirectionHorizontal, + startIndex: + typeof visibleColumnIndices.start === "number" + ? visibleColumnIndices.start + : 0, + stopIndex: + typeof visibleColumnIndices.stop === "number" + ? visibleColumnIndices.stop + : -1 + }); + + const overscanRowIndices = overscanIndicesGetter({ + direction: "vertical", + cellCount: rowCount, + overscanCellsCount: overscanRowCount, + scrollDirection: scrollDirectionVertical, + startIndex: + typeof visibleRowIndices.start === "number" + ? visibleRowIndices.start + : 0, + stopIndex: + typeof visibleRowIndices.stop === "number" + ? visibleRowIndices.stop + : -1 + }); + + let columnStartIndex = overscanColumnIndices.overscanStartIndex; + let columnStopIndex = overscanColumnIndices.overscanStopIndex; + let rowStartIndex = overscanRowIndices.overscanStartIndex; + let rowStopIndex = overscanRowIndices.overscanStopIndex; + + // Advanced use-cases (eg CellMeasurer) require batched measurements to determine accurate sizes. + if (deferredMeasurementCache) { + // If rows have a dynamic height, scan the rows we are about to render. + // If any have not yet been measured, then we need to render all columns initially, + // Because the height of the row is equal to the tallest cell within that row, + // (And so we can't know the height without measuring all column-cells first). + if (!deferredMeasurementCache.hasFixedHeight()) { + for ( + let rowIndex = rowStartIndex; + rowIndex <= rowStopIndex; + rowIndex++ + ) { + if (!deferredMeasurementCache.has(rowIndex, 0)) { + columnStartIndex = 0; + columnStopIndex = columnCount - 1; + break; + } + } + } + + // If columns have a dynamic width, scan the columns we are about to render. + // If any have not yet been measured, then we need to render all rows initially, + // Because the width of the column is equal to the widest cell within that column, + // (And so we can't know the width without measuring all row-cells first). + if (!deferredMeasurementCache.hasFixedWidth()) { + for ( + let columnIndex = columnStartIndex; + columnIndex <= columnStopIndex; + columnIndex++ + ) { + if (!deferredMeasurementCache.has(0, columnIndex)) { + rowStartIndex = 0; + rowStopIndex = rowCount - 1; + break; + } + } + } + } + + childrenToDisplay = cellRangeRenderer({ + cellCache: internals.cellCache, + cellRenderer, + columnSizeAndPositionManager, + columnStartIndex, + columnStopIndex, + deferredMeasurementCache, + horizontalOffsetAdjustment, + isScrolling, + isScrollingOptOut, + parent: handle, + rowSizeAndPositionManager, + rowStartIndex, + rowStopIndex, + scrollLeft, + scrollTop, + styleCache: internals.styleCache, + verticalOffsetAdjustment, + visibleColumnIndices, + visibleRowIndices + }); + + // Retained by the next resetStyleCache() + internals.renderedRange = { + columnStartIndex, + columnStopIndex, + rowStartIndex, + rowStopIndex + }; + + // Reported by onSectionRendered once committed. + // Indices may be undefined (if no cells are visible); the memoizer skips the callback in that case. + renderedSection = { + columnOverscanStartIndex: columnStartIndex, + columnOverscanStopIndex: columnStopIndex, + columnStartIndex: visibleColumnIndices.start, + columnStopIndex: visibleColumnIndices.stop, + rowOverscanStartIndex: rowStartIndex, + rowOverscanStopIndex: rowStopIndex, + rowStartIndex: visibleRowIndices.start, + rowStopIndex: visibleRowIndices.stop + }; + } + + const totalColumnsWidth = columnSizeAndPositionManager.getTotalSize(); + const totalRowsHeight = rowSizeAndPositionManager.getTotalSize(); + + // Force browser to hide scrollbars when we know they aren't necessary. + // Otherwise once scrollbars appear they may not disappear again. + // For more info see issue #116 + const verticalScrollBarSize = + totalRowsHeight > height ? scrollbarSize : 0; + const horizontalScrollBarSize = + totalColumnsWidth > width ? scrollbarSize : 0; + + // Equivalent of the 9.x componentDidMount/componentDidUpdate + useIsomorphicLayoutEffect(() => { + const committed = internals.committed; + const element = internals.element; + + // If cell sizes have been invalidated (eg we are using CellMeasurer) then reset cached positions. + // We must do this at the start of the method as we may calculate and update scroll position below. + if ( + typeof internals.deferredInvalidateColumnIndex === "number" && + typeof internals.deferredInvalidateRowIndex === "number" + ) { + const columnIndex = internals.deferredInvalidateColumnIndex; + const rowIndex = internals.deferredInvalidateRowIndex; + + internals.deferredInvalidateColumnIndex = null; + internals.deferredInvalidateRowIndex = null; + + handle.recomputeGridSize({ columnIndex, rowIndex }); + } + + if (committed === null && hydrationShift !== null) { + // Hydrated with initial scroll offsets (rendered as a shift of the inner container; see above): + // remove the shift, then apply the offsets to the scrolling element (on the next commit, before the browser paints). + // If the user scrolled before the page was hydrated, the content they see is kept in place. + internals.scrollOffsetsPendingHydration = true; + setHydrationShift(null); + if (element && (element.scrollLeft > 0 || element.scrollTop > 0)) { + setScrollState({ + scrollLeft: hydrationShift.left + element.scrollLeft, + scrollTop: hydrationShift.top + element.scrollTop + }); + } + } else if (committed === null) { + // Mount + if (element) { + if (element.scrollLeft !== scrollState.scrollLeft) { + element.scrollLeft = scrollState.scrollLeft; + } + if (element.scrollTop !== scrollState.scrollTop) { + element.scrollTop = scrollState.scrollTop; + } + } + } else { + if ( + internals.scrollOffsetsPendingHydration && + hydrationShift === null + ) { + internals.scrollOffsetsPendingHydration = false; + if (element) { + element.scrollLeft = scrollState.scrollLeft; + if (!linked) { + element.scrollTop = scrollState.scrollTop; + } + } + } + + // Handle edge case where column or row count has only just increased over 0. + // In this case we may have to restore a previously-specified scroll offset. + // For more info see bvaughn/react-virtualized/issues/218 + const columnOrRowCountJustIncreasedFromZero = + (columnCount > 0 && committed.columnCount === 0) || + (rowCount > 0 && committed.rowCount === 0); + + // Make sure requested changes to :scrollLeft or :scrollTop get applied. + // Assigning to scrollLeft/scrollTop tells the browser to interrupt any running scroll animations, + // And to discard any pending async changes to the scroll position that may have happened in the meantime (e.g. on a separate scrolling thread). + // So we only set these when we require an adjustment of the scroll position. + // See issue #2 for more information. + if ( + element && + scrollState.scrollPositionChangeReason === + SCROLL_POSITION_CHANGE_REASONS.REQUESTED + ) { + // @TRICKY A linked Grid leaves its vertical scroll offset to its scroll element. + // In this case we should avoid checking scrollingContainer.scrollTop since it forces layout/flow. + const update: Partial = {}; + if ( + scrollState.scrollLeft >= 0 && + (scrollState.scrollLeft !== element.scrollLeft || + columnOrRowCountJustIncreasedFromZero) + ) { + element.scrollLeft = scrollState.scrollLeft; + + // If the browser couldn't scroll that far (e.g. its overflow is hidden by a custom style), + // no scroll event is dispatched; so update the offset to match. + if (Math.abs(element.scrollLeft - scrollState.scrollLeft) >= 1) { + update.scrollLeft = element.scrollLeft; + } + } + if ( + !linked && + scrollState.scrollTop >= 0 && + (scrollState.scrollTop !== element.scrollTop || + columnOrRowCountJustIncreasedFromZero) + ) { + element.scrollTop = scrollState.scrollTop; + + if (Math.abs(element.scrollTop - scrollState.scrollTop) >= 1) { + update.scrollTop = element.scrollTop; + } + } + if ( + update.scrollLeft !== undefined || + update.scrollTop !== undefined + ) { + update.scrollPositionChangeReason = + SCROLL_POSITION_CHANGE_REASONS.OBSERVED; + setScrollState(update); + } + } + + // If the Grid (or its number of cells) has shrunk, make sure it isn't scrolled past its last cell + updateScrollIndexHelper({ + cellSizeAndPositionManager: columnSizeAndPositionManager, + previousCellsCount: committed.columnCount, + previousSize: committed.width, + scrollOffset: scrollState.scrollLeft, + size: width, + updateScrollIndexCallback: (columnIndex) => + updateScrollLeftForColumn("auto", columnIndex) + }); + updateScrollIndexHelper({ + cellSizeAndPositionManager: rowSizeAndPositionManager, + previousCellsCount: committed.rowCount, + previousSize: committed.height, + // The viewport of a linked Grid may extend past its end (e.g. the window may be scrolled past it), + // so its offset doesn't need to be corrected when rows are removed + scrollOffset: linked ? 0 : scrollState.scrollTop, + size: height, + updateScrollIndexCallback: (rowIndex) => + updateScrollTopForRow("auto", rowIndex) + }); + } + + // An empty Grid is scrolled back to the start + if ( + (columnCount === 0 && scrollState.scrollLeft !== 0) || + (rowCount === 0 && scrollState.scrollTop !== 0) + ) { + setScrollState({ scrollLeft: 0, scrollTop: 0 }); + } + + // Update onSectionRendered callback if start/stop indices have changed + if (renderedSection !== null) { + internals.onGridRenderedMemoizer({ + callback: onSectionRendered, + indices: renderedSection as RenderedSection + }); + } + + // Initialize onScroll callback (on mount); changes to the scroll offsets should also notify onScroll listeners + if ( + committed === null || + scrollLeft !== committed.scrollLeft || + scrollTop !== committed.scrollTop + ) { + invokeOnScrollMemoizer( + scrollLeft, + scrollTop, + totalColumnsWidth, + totalRowsHeight + ); + } + + const { scrollbarPresence } = internals; + if ( + horizontalScrollBarSize !== scrollbarPresence.horizontal || + verticalScrollBarSize !== scrollbarPresence.vertical + ) { + internals.scrollbarPresence = { + horizontal: horizontalScrollBarSize, + vertical: verticalScrollBarSize + }; + + const params: ScrollbarPresenceChange = { + horizontal: horizontalScrollBarSize > 0, + size: scrollbarSize, + vertical: verticalScrollBarSize > 0 + }; + onScrollbarPresenceChange(params); + } + + internals.committed = { + columnCount, + columnWidth, + height, + rowCount, + rowHeight, + scrollLeft, + scrollTop, + width + }; + }); + + useEffect( + () => () => { + if (internals.disablePointerEventsTimeoutId) { + cancelAnimationTimeout(internals.disablePointerEventsTimeoutId); + internals.disablePointerEventsTimeoutId = null; + } + }, + [internals] + ); + + const gridStyle: CSSProperties = { + boxSizing: "border-box", + direction: "ltr", + // A linked Grid renders at its full height (it doesn't scroll vertically itself) + height: linked ? "auto" : height, + position: "relative", + width, + WebkitOverflowScrolling: "touch", + willChange: "transform" + }; + + // Also explicitly init styles to 'auto' if scrollbars are required. + // This works around an obscure edge case where external CSS styles have not yet been loaded, + // But an initial scroll index of offset is set as an external prop. + // Without this style, Grid would render the correct range of cells but would NOT update its internal offset. + // This was originally reported via clauderic/react-infinite-calendar/issues/23 + gridStyle.overflowX = + totalColumnsWidth + verticalScrollBarSize <= width ? "hidden" : "auto"; + gridStyle.overflowY = + totalRowsHeight + horizontalScrollBarSize <= height ? "hidden" : "auto"; + + const showNoContentRenderer = + childrenToDisplay.length === 0 && height > 0 && width > 0; + + return ( +
+ {childrenToDisplay.length > 0 && ( +
+ {childrenToDisplay} +
+ )} + {showNoContentRenderer && noContentRenderer()} +
+ ); + } + ) +); diff --git a/source/Grid/accessibilityOverscanIndicesGetter.jest.js b/lib/components/Grid/accessibilityOverscanIndicesGetter.test.ts similarity index 53% rename from source/Grid/accessibilityOverscanIndicesGetter.jest.js rename to lib/components/Grid/accessibilityOverscanIndicesGetter.test.ts index 4184e1a95..d281d6f6e 100644 --- a/source/Grid/accessibilityOverscanIndicesGetter.jest.js +++ b/lib/components/Grid/accessibilityOverscanIndicesGetter.test.ts @@ -1,37 +1,42 @@ -import overscanIndicesGetter, { +import { describe, expect, it } from "vitest"; +import { + accessibilityOverscanIndicesGetter as overscanIndicesGetter, SCROLL_DIRECTION_BACKWARD, SCROLL_DIRECTION_FORWARD, -} from './accessibilityOverscanIndicesGetter'; + SCROLL_DIRECTION_VERTICAL +} from "./accessibilityOverscanIndicesGetter"; +import type { OverscanIndicesGetterParams } from "./types"; -describe('overscanIndicesGetter', () => { +describe("overscanIndicesGetter", () => { function testHelper({ cellCount, startIndex, stopIndex, overscanCellsCount, - scrollDirection, - }) { + scrollDirection + }: Omit) { return overscanIndicesGetter({ cellCount, + direction: SCROLL_DIRECTION_VERTICAL, overscanCellsCount, scrollDirection, startIndex, - stopIndex, + stopIndex }); } - it('should still overscan by 1 (for keyboard accessibility) if :overscanCellsCount is 0', () => { + it("should still overscan by 1 (for keyboard accessibility) if :overscanCellsCount is 0", () => { expect( testHelper({ cellCount: 100, startIndex: 10, stopIndex: 20, overscanCellsCount: 0, - scrollDirection: SCROLL_DIRECTION_BACKWARD, - }), + scrollDirection: SCROLL_DIRECTION_BACKWARD + }) ).toEqual({ overscanStartIndex: 9, - overscanStopIndex: 21, + overscanStopIndex: 21 }); expect( @@ -40,71 +45,71 @@ describe('overscanIndicesGetter', () => { startIndex: 10, stopIndex: 20, overscanCellsCount: 0, - scrollDirection: SCROLL_DIRECTION_FORWARD, - }), + scrollDirection: SCROLL_DIRECTION_FORWARD + }) ).toEqual({ overscanStartIndex: 9, - overscanStopIndex: 21, + overscanStopIndex: 21 }); }); - it('should overscan forward', () => { + it("should overscan forward", () => { expect( testHelper({ cellCount: 100, startIndex: 20, stopIndex: 30, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_FORWARD, - }), + scrollDirection: SCROLL_DIRECTION_FORWARD + }) ).toEqual({ overscanStartIndex: 19, - overscanStopIndex: 40, + overscanStopIndex: 40 }); }); - it('should overscan backward', () => { + it("should overscan backward", () => { expect( testHelper({ cellCount: 100, startIndex: 20, stopIndex: 30, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_BACKWARD, - }), + scrollDirection: SCROLL_DIRECTION_BACKWARD + }) ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 31, + overscanStopIndex: 31 }); }); - it('should not overscan beyond the start of the list', () => { + it("should not overscan beyond the start of the list", () => { expect( testHelper({ cellCount: 100, startIndex: 5, stopIndex: 15, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_BACKWARD, - }), + scrollDirection: SCROLL_DIRECTION_BACKWARD + }) ).toEqual({ overscanStartIndex: 0, - overscanStopIndex: 16, + overscanStopIndex: 16 }); }); - it('should not overscan beyond the end of the list', () => { + it("should not overscan beyond the end of the list", () => { expect( testHelper({ cellCount: 25, startIndex: 10, stopIndex: 20, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_FORWARD, - }), + scrollDirection: SCROLL_DIRECTION_FORWARD + }) ).toEqual({ overscanStartIndex: 9, - overscanStopIndex: 24, + overscanStopIndex: 24 }); }); }); diff --git a/source/Grid/accessibilityOverscanIndicesGetter.js b/lib/components/Grid/accessibilityOverscanIndicesGetter.ts similarity index 67% rename from source/Grid/accessibilityOverscanIndicesGetter.js rename to lib/components/Grid/accessibilityOverscanIndicesGetter.ts index 2dfa86d5b..a35650272 100644 --- a/source/Grid/accessibilityOverscanIndicesGetter.js +++ b/lib/components/Grid/accessibilityOverscanIndicesGetter.ts @@ -1,24 +1,22 @@ -// @flow - -import type {OverscanIndicesGetterParams, OverscanIndices} from './types'; +import type { OverscanIndices, OverscanIndicesGetterParams } from "./types"; export const SCROLL_DIRECTION_BACKWARD = -1; export const SCROLL_DIRECTION_FORWARD = 1; -export const SCROLL_DIRECTION_HORIZONTAL = 'horizontal'; -export const SCROLL_DIRECTION_VERTICAL = 'vertical'; +export const SCROLL_DIRECTION_HORIZONTAL = "horizontal"; +export const SCROLL_DIRECTION_VERTICAL = "vertical"; /** * Calculates the number of cells to overscan before and after a specified range. * This function ensures that overscanning doesn't exceed the available cells. */ -export default function defaultOverscanIndicesGetter({ +export function accessibilityOverscanIndicesGetter({ cellCount, overscanCellsCount, scrollDirection, startIndex, - stopIndex, + stopIndex }: OverscanIndicesGetterParams): OverscanIndices { // Make sure we render at least 1 cell extra before and after (except near boundaries) // This is necessary in order to support keyboard navigation (TAB/SHIFT+TAB) in some cases @@ -28,15 +26,12 @@ export default function defaultOverscanIndicesGetter({ if (scrollDirection === SCROLL_DIRECTION_FORWARD) { return { overscanStartIndex: Math.max(0, startIndex - 1), - overscanStopIndex: Math.min( - cellCount - 1, - stopIndex + overscanCellsCount, - ), + overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount) }; } else { return { overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), - overscanStopIndex: Math.min(cellCount - 1, stopIndex + 1), + overscanStopIndex: Math.min(cellCount - 1, stopIndex + 1) }; } } diff --git a/lib/components/Grid/constants.ts b/lib/components/Grid/constants.ts new file mode 100644 index 000000000..61832c640 --- /dev/null +++ b/lib/components/Grid/constants.ts @@ -0,0 +1,5 @@ +/** + * Specifies the number of milliseconds during which to disable pointer events while a scroll is in progress. + * This improves performance and makes scrolling smoother. + */ +export const DEFAULT_SCROLLING_RESET_TIME_INTERVAL = 150; diff --git a/source/Grid/defaultCellRangeRenderer.js b/lib/components/Grid/defaultCellRangeRenderer.ts similarity index 65% rename from source/Grid/defaultCellRangeRenderer.js rename to lib/components/Grid/defaultCellRangeRenderer.ts index 38caaac55..0cfd727e8 100644 --- a/source/Grid/defaultCellRangeRenderer.js +++ b/lib/components/Grid/defaultCellRangeRenderer.ts @@ -1,14 +1,17 @@ -/** @flow */ - -import type {CellRangeRendererParams} from './types'; -import React from 'react'; +import { cloneElement, type CSSProperties, type ReactElement } from "react"; +import type { CellMeasurerParent } from "../CellMeasurer/types"; +import type { + CellRangeRendererParams, + CellRendererParams, + CellRenderer +} from "./types"; /** * Default implementation of cellRangeRenderer used by Grid. * This renderer supports cell-caching while the user is scrolling. */ -export default function defaultCellRangeRenderer({ +export function defaultCellRangeRenderer({ cellCache, cellRenderer, columnSizeAndPositionManager, @@ -25,9 +28,9 @@ export default function defaultCellRangeRenderer({ styleCache, verticalOffsetAdjustment, visibleColumnIndices, - visibleRowIndices, -}: CellRangeRendererParams) { - const renderedCells = []; + visibleRowIndices +}: CellRangeRendererParams): ReactElement[] { + const renderedCells: ReactElement[] = []; // Browsers have native size limits for elements (eg Chrome 33M pixels, IE 1.5M pixes). // User cannot scroll beyond these size limitations. @@ -41,23 +44,24 @@ export default function defaultCellRangeRenderer({ const canCacheStyle = !isScrolling && !areOffsetsAdjusted; for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) { - let rowDatum = rowSizeAndPositionManager.getSizeAndPositionOfCell(rowIndex); + const rowDatum = + rowSizeAndPositionManager.getSizeAndPositionOfCell(rowIndex); for ( let columnIndex = columnStartIndex; columnIndex <= columnStopIndex; columnIndex++ ) { - let columnDatum = columnSizeAndPositionManager.getSizeAndPositionOfCell( - columnIndex, - ); - let isVisible = - columnIndex >= visibleColumnIndices.start && - columnIndex <= visibleColumnIndices.stop && - rowIndex >= visibleRowIndices.start && - rowIndex <= visibleRowIndices.stop; - let key = `${rowIndex}-${columnIndex}`; - let style; + const columnDatum = + columnSizeAndPositionManager.getSizeAndPositionOfCell(columnIndex); + // Note that start/stop may be undefined (in which case the comparisons below are false) + const isVisible = + columnIndex >= visibleColumnIndices.start! && + columnIndex <= visibleColumnIndices.stop! && + rowIndex >= visibleRowIndices.start! && + rowIndex <= visibleRowIndices.stop!; + const key = `${rowIndex}-${columnIndex}`; + let style: CSSProperties; // Cache style objects so shallow-compare doesn't re-render unnecessarily. if (canCacheStyle && styleCache[key]) { @@ -73,36 +77,36 @@ export default function defaultCellRangeRenderer({ // And give them width/height of 'auto' so they can grow larger than the parent Grid if necessary. // Positioning them further to the right/bottom influences their measured size. style = { - height: 'auto', + height: "auto", left: 0, - position: 'absolute', + position: "absolute", top: 0, - width: 'auto', + width: "auto" }; } else { style = { height: rowDatum.size, left: columnDatum.offset + horizontalOffsetAdjustment, - position: 'absolute', + position: "absolute", top: rowDatum.offset + verticalOffsetAdjustment, - width: columnDatum.size, + width: columnDatum.size }; styleCache[key] = style; } } - let cellRendererParams = { + const cellRendererParams: CellRendererParams = { columnIndex, isScrolling, isVisible, key, parent, rowIndex, - style, + style }; - let renderedCell; + let renderedCell: ReturnType; // Avoid re-creating cells while scrolling. // This can lead to the same cell being created many times and can cause performance issues for "heavy" cells. @@ -135,12 +139,14 @@ export default function defaultCellRangeRenderer({ continue; } - if (process.env.NODE_ENV !== 'production') { - warnAboutMissingStyle(parent, renderedCell); + const cellElement = renderedCell as ReactElement; + + if (process.env.NODE_ENV !== "production") { + warnAboutMissingStyle(parent, cellElement); } - if (!renderedCell.props.role) { - renderedCell = React.cloneElement(renderedCell, {role: 'gridcell'}); + if (!cellElement.props.role) { + renderedCell = cloneElement(cellElement, { role: "gridcell" }); } renderedCells.push(renderedCell); @@ -150,13 +156,28 @@ export default function defaultCellRangeRenderer({ return renderedCells; } -function warnAboutMissingStyle(parent, renderedCell) { - if (process.env.NODE_ENV !== 'production') { +// Props that Grid reads from rendered cells (React 19's types default element props to unknown) +type CellElementProps = { + children?: unknown; + role?: string; + style?: CSSProperties; +}; + +function warnAboutMissingStyle( + parent: CellMeasurerParent & { __warnedAboutMissingStyle?: boolean }, + renderedCell: ReactElement +) { + if (process.env.NODE_ENV !== "production") { if (renderedCell) { // If the direct child is a CellMeasurer, then we should check its child // See issue #611 - if (renderedCell.type && renderedCell.type.__internalCellMeasurerFlag) { - renderedCell = renderedCell.props.children; + if ( + renderedCell.type && + (renderedCell.type as { __internalCellMeasurerFlag?: boolean }) + .__internalCellMeasurerFlag + ) { + renderedCell = renderedCell.props + .children as ReactElement; } if ( @@ -168,7 +189,7 @@ function warnAboutMissingStyle(parent, renderedCell) { parent.__warnedAboutMissingStyle = true; console.warn( - 'Rendered cell should include style property for positioning.', + "Rendered cell should include style property for positioning." ); } } diff --git a/lib/components/Grid/defaultGridProps.ts b/lib/components/Grid/defaultGridProps.ts new file mode 100644 index 000000000..2cb8ddd4b --- /dev/null +++ b/lib/components/Grid/defaultGridProps.ts @@ -0,0 +1,60 @@ +import scrollbarSize from "dom-helpers/scrollbarSize"; +import { DEFAULT_SCROLLING_RESET_TIME_INTERVAL } from "./constants"; +import { defaultCellRangeRenderer } from "./defaultCellRangeRenderer"; +import { defaultOverscanIndicesGetter } from "./defaultOverscanIndicesGetter"; +import type { GridProps } from "./types"; + +type DefaultedPropKeys = + | "aria-label" + | "aria-readonly" + | "autoContainerWidth" + | "cellRangeRenderer" + | "containerRole" + | "containerStyle" + | "estimatedColumnSize" + | "estimatedRowSize" + | "getScrollbarSize" + | "isScrollingOptOut" + | "noContentRenderer" + | "onScroll" + | "onScrollbarPresenceChange" + | "onSectionRendered" + | "overscanColumnCount" + | "overscanIndicesGetter" + | "overscanRowCount" + | "role" + | "scrollingResetTimeInterval" + | "style" + | "tabIndex"; + +const noop = () => {}; + +/** + * Default values for `Grid`'s optional props. + * (The PURE annotations let bundlers drop this object if it isn't used.) + */ +export const defaultGridProps: Readonly< + Required> +> = /* @__PURE__ */ Object.freeze({ + "aria-label": "grid", + "aria-readonly": true, + autoContainerWidth: false, + cellRangeRenderer: defaultCellRangeRenderer, + containerRole: "row", + containerStyle: /* @__PURE__ */ Object.freeze({}), + estimatedColumnSize: 100, + estimatedRowSize: 30, + getScrollbarSize: scrollbarSize, + isScrollingOptOut: false, + noContentRenderer: () => null, + onScroll: noop, + onScrollbarPresenceChange: noop, + onSectionRendered: noop, + overscanColumnCount: 0, + overscanIndicesGetter: defaultOverscanIndicesGetter, + overscanRowCount: 10, + role: "grid", + scrollingResetTimeInterval: DEFAULT_SCROLLING_RESET_TIME_INTERVAL, + style: /* @__PURE__ */ Object.freeze({}), + tabIndex: 0 +}); diff --git a/source/Grid/defaultOverscanIndicesGetter.jest.js b/lib/components/Grid/defaultOverscanIndicesGetter.test.ts similarity index 54% rename from source/Grid/defaultOverscanIndicesGetter.jest.js rename to lib/components/Grid/defaultOverscanIndicesGetter.test.ts index c8984e3fd..4f2b8fcea 100644 --- a/source/Grid/defaultOverscanIndicesGetter.jest.js +++ b/lib/components/Grid/defaultOverscanIndicesGetter.test.ts @@ -1,37 +1,42 @@ -import overscanIndicesGetter, { +import { describe, expect, it } from "vitest"; +import { + defaultOverscanIndicesGetter as overscanIndicesGetter, SCROLL_DIRECTION_BACKWARD, SCROLL_DIRECTION_FORWARD, -} from './defaultOverscanIndicesGetter'; + SCROLL_DIRECTION_VERTICAL +} from "./defaultOverscanIndicesGetter"; +import type { OverscanIndicesGetterParams } from "./types"; -describe('overscanIndicesGetter', () => { +describe("overscanIndicesGetter", () => { function testHelper({ cellCount, startIndex, stopIndex, overscanCellsCount, - scrollDirection, - }) { + scrollDirection + }: Omit) { return overscanIndicesGetter({ cellCount, + direction: SCROLL_DIRECTION_VERTICAL, overscanCellsCount, scrollDirection, startIndex, - stopIndex, + stopIndex }); } - it('should not overscan if :overscanCellsCount is 0', () => { + it("should not overscan if :overscanCellsCount is 0", () => { expect( testHelper({ cellCount: 100, startIndex: 10, stopIndex: 20, overscanCellsCount: 0, - scrollDirection: SCROLL_DIRECTION_BACKWARD, - }), + scrollDirection: SCROLL_DIRECTION_BACKWARD + }) ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 20, + overscanStopIndex: 20 }); expect( @@ -40,71 +45,71 @@ describe('overscanIndicesGetter', () => { startIndex: 10, stopIndex: 20, overscanCellsCount: 0, - scrollDirection: SCROLL_DIRECTION_FORWARD, - }), + scrollDirection: SCROLL_DIRECTION_FORWARD + }) ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 20, + overscanStopIndex: 20 }); }); - it('should overscan forward', () => { + it("should overscan forward", () => { expect( testHelper({ cellCount: 100, startIndex: 20, stopIndex: 30, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_FORWARD, - }), + scrollDirection: SCROLL_DIRECTION_FORWARD + }) ).toEqual({ overscanStartIndex: 20, - overscanStopIndex: 40, + overscanStopIndex: 40 }); }); - it('should overscan backward', () => { + it("should overscan backward", () => { expect( testHelper({ cellCount: 100, startIndex: 20, stopIndex: 30, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_BACKWARD, - }), + scrollDirection: SCROLL_DIRECTION_BACKWARD + }) ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 30, + overscanStopIndex: 30 }); }); - it('should not overscan beyond the start of the list', () => { + it("should not overscan beyond the start of the list", () => { expect( testHelper({ cellCount: 100, startIndex: 5, stopIndex: 15, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_BACKWARD, - }), + scrollDirection: SCROLL_DIRECTION_BACKWARD + }) ).toEqual({ overscanStartIndex: 0, - overscanStopIndex: 15, + overscanStopIndex: 15 }); }); - it('should not overscan beyond the end of the list', () => { + it("should not overscan beyond the end of the list", () => { expect( testHelper({ cellCount: 25, startIndex: 10, stopIndex: 20, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_FORWARD, - }), + scrollDirection: SCROLL_DIRECTION_FORWARD + }) ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 24, + overscanStopIndex: 24 }); }); }); diff --git a/source/Grid/defaultOverscanIndicesGetter.js b/lib/components/Grid/defaultOverscanIndicesGetter.ts similarity index 58% rename from source/Grid/defaultOverscanIndicesGetter.js rename to lib/components/Grid/defaultOverscanIndicesGetter.ts index c4603dcfb..c34c0b279 100644 --- a/source/Grid/defaultOverscanIndicesGetter.js +++ b/lib/components/Grid/defaultOverscanIndicesGetter.ts @@ -1,37 +1,32 @@ -// @flow - -import type {OverscanIndicesGetterParams, OverscanIndices} from './types'; +import type { OverscanIndices, OverscanIndicesGetterParams } from "./types"; export const SCROLL_DIRECTION_BACKWARD = -1; export const SCROLL_DIRECTION_FORWARD = 1; -export const SCROLL_DIRECTION_HORIZONTAL = 'horizontal'; -export const SCROLL_DIRECTION_VERTICAL = 'vertical'; +export const SCROLL_DIRECTION_HORIZONTAL = "horizontal"; +export const SCROLL_DIRECTION_VERTICAL = "vertical"; /** * Calculates the number of cells to overscan before and after a specified range. * This function ensures that overscanning doesn't exceed the available cells. */ -export default function defaultOverscanIndicesGetter({ +export function defaultOverscanIndicesGetter({ cellCount, overscanCellsCount, scrollDirection, startIndex, - stopIndex, + stopIndex }: OverscanIndicesGetterParams): OverscanIndices { if (scrollDirection === SCROLL_DIRECTION_FORWARD) { return { overscanStartIndex: Math.max(0, startIndex), - overscanStopIndex: Math.min( - cellCount - 1, - stopIndex + overscanCellsCount, - ), + overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount) }; } else { return { overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), - overscanStopIndex: Math.min(cellCount - 1, stopIndex), + overscanStopIndex: Math.min(cellCount - 1, stopIndex) }; } } diff --git a/lib/components/Grid/types.ts b/lib/components/Grid/types.ts new file mode 100644 index 000000000..4ed80695a --- /dev/null +++ b/lib/components/Grid/types.ts @@ -0,0 +1,560 @@ +import type { + CSSProperties, + HTMLAttributes, + ReactElement, + ReactNode, + Ref +} from "react"; +import type { + CellMeasureCache, + CellMeasurerParent +} from "../CellMeasurer/types"; +import type { ScrollElement } from "../../utils/scrollElement/types"; +import type { ScalingCellSizeAndPositionManager } from "./utils/ScalingCellSizeAndPositionManager"; + +/** Identifies a cell by its column and row index. */ +export type CellPosition = { columnIndex: number; rowIndex: number }; + +/** Parameters passed to a `Grid` `cellRenderer`. */ +export type CellRendererParams = { + /** Horizontal (column) index of cell. */ + columnIndex: number; + + /** The Grid is currently being scrolled. */ + isScrolling: boolean; + + /** This cell is visible within the grid (eg it is not an overscanned cell). */ + isVisible: boolean; + + /** Unique key within array of cells. */ + key: string; + + /** + * Reference to the parent Grid instance (or the component that wraps it, eg `MultiGrid`). + * This value can be passed as-is to `CellMeasurer`. + */ + parent: CellMeasurerParent; + + /** Vertical (row) index of cell. */ + rowIndex: number; + + /** + * Style object to be applied to cell (to position it). + * This must be passed through to the rendered cell element. + */ + style: CSSProperties; +}; + +/** + * Responsible for rendering a single cell, given its row and column index. + * Cells must be rendered with the `key` and `style` parameters provided. + */ +export type CellRenderer = ( + props: CellRendererParams +) => ReactElement | false | null | undefined; + +/** Temporary cell cache (used while scrolling), mapped by `${rowIndex}-${columnIndex}` key. */ +export type CellCache = { [key: string]: ReturnType }; + +/** Temporary cell style cache, mapped by `${rowIndex}-${columnIndex}` key. */ +export type StyleCache = { [key: string]: CSSProperties }; + +/** Parameters passed to a `Grid` `cellRangeRenderer`. */ +export type CellRangeRendererParams = { + /** Temporary cell cache used while scrolling. */ + cellCache: CellCache; + + /** Cell renderer prop supplied to Grid. */ + cellRenderer: CellRenderer; + + /** Manages size and position metadata of columns. */ + columnSizeAndPositionManager: ScalingCellSizeAndPositionManager; + + /** Index of first column (inclusive) to render. */ + columnStartIndex: number; + + /** Index of last column (inclusive) to render. */ + columnStopIndex: number; + + /** CellMeasurer cache, if one was passed to Grid. */ + deferredMeasurementCache?: CellMeasureCache; + + /** Horizontal pixel offset (required for scaling). */ + horizontalOffsetAdjustment: number; + + /** The Grid is currently being scrolled. */ + isScrolling: boolean; + + /** Always cache cells (even when the Grid is not scrolling). */ + isScrollingOptOut: boolean; + + /** Reference to the parent Grid instance (or the component that wraps it). */ + parent: CellMeasurerParent; + + /** Manages size and position metadata of rows. */ + rowSizeAndPositionManager: ScalingCellSizeAndPositionManager; + + /** Index of first row (inclusive) to render. */ + rowStartIndex: number; + + /** Index of last row (inclusive) to render. */ + rowStopIndex: number; + + /** Current horizontal scroll offset of Grid. */ + scrollLeft: number; + + /** Current vertical scroll offset of Grid. */ + scrollTop: number; + + /** Temporary style (size & position) cache used while scrolling. */ + styleCache: StyleCache; + + /** Vertical pixel offset (required for scaling). */ + verticalOffsetAdjustment: number; + + /** Range of visible (not overscanned) columns. */ + visibleColumnIndices: VisibleCellRange; + + /** Range of visible (not overscanned) rows. */ + visibleRowIndices: VisibleCellRange; +}; + +/** Responsible for rendering a group of cells given their index ranges. */ +export type CellRangeRenderer = ( + params: CellRangeRendererParams +) => ReactNode[]; + +/** Returns the size (width or height) of the cell at the specified index. */ +export type CellSizeGetter = (params: { index: number }) => number; + +/** Either a fixed cell size (number) or a function that returns the size of a cell given its index. */ +export type CellSize = CellSizeGetter | number; + +/** Renders content to be shown when a Grid has no cells. */ +export type NoContentRenderer = () => ReactNode; + +/** Parameters passed to the `Grid` `onScroll` callback. */ +export type Scroll = { + clientHeight: number; + clientWidth: number; + scrollHeight: number; + scrollLeft: number; + scrollTop: number; + scrollWidth: number; +}; + +/** Parameters passed to the `Grid` `onScrollbarPresenceChange` callback. */ +export type ScrollbarPresenceChange = { + horizontal: boolean; + vertical: boolean; + size: number; +}; + +/** Parameters passed to the `Grid` `onSectionRendered` callback. */ +export type RenderedSection = { + columnOverscanStartIndex: number; + columnOverscanStopIndex: number; + columnStartIndex: number; + columnStopIndex: number; + rowOverscanStartIndex: number; + rowOverscanStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; +}; + +/** Parameters passed to an `OverscanIndicesGetter`. */ +export type OverscanIndicesGetterParams = { + /** One of SCROLL_DIRECTION_HORIZONTAL or SCROLL_DIRECTION_VERTICAL */ + direction: "horizontal" | "vertical"; + + /** One of SCROLL_DIRECTION_BACKWARD (-1) or SCROLL_DIRECTION_FORWARD (1) */ + scrollDirection: -1 | 1; + + /** Number of rows or columns in the current axis */ + cellCount: number; + + /** Maximum number of cells to over-render in either direction */ + overscanCellsCount: number; + + /** Begin of range of visible cells */ + startIndex: number; + + /** End of range of visible cells */ + stopIndex: number; +}; + +/** Range of cells (inclusive) to render, including overscanned cells. */ +export type OverscanIndices = { + overscanStartIndex: number; + overscanStopIndex: number; +}; + +/** Calculates the number of cells to overscan before and after a specified range. */ +export type OverscanIndicesGetter = ( + params: OverscanIndicesGetterParams +) => OverscanIndices; + +/** Alignment of a scrolled-to cell within the Grid. */ +export type Alignment = "auto" | "end" | "start" | "center"; + +/** Range of visible cells; empty if there are no cells. */ +export type VisibleCellRange = { + start?: number; + stop?: number; +}; + +/** Scroll offsets (as accepted by `scrollToPosition` and `handleScrollEvent`). */ +export type ScrollPosition = { + scrollLeft?: number; + scrollTop?: number; +}; + +export type GridProps = { + /** + * ARIA label for the grid element. + * @default "grid" + */ + "aria-label"?: string; + + /** + * ARIA readonly attribute for the grid element. + * @default true + */ + "aria-readonly"?: boolean; + + /** + * Set the width of the inner scrollable container to 'auto'. + * This is useful for single-column Grids to ensure that the column doesn't extend below a vertical scrollbar. + * @default false + */ + autoContainerWidth?: boolean; + + /** + * Responsible for rendering a group of cells given their index ranges. + * + * This is an advanced property. + * It is useful for situations where the `Grid` requires additional, overlayed UI (such as a Gantt chart or a calendar application). + * The easiest way to override it is to decorate `defaultCellRangeRenderer`. + * + * Defaults to `defaultCellRangeRenderer`. + */ + cellRangeRenderer?: CellRangeRenderer; + + /** + * Responsible for rendering a cell given an row and column index. + * It receives `columnIndex`, `isScrolling`, `isVisible`, `key`, `parent`, `rowIndex`, and `style` parameters. + * + * The `style` parameter specifies how the cell is to be sized and positioned and must be passed through to the rendered cell element. + * The `key` parameter must also be applied to the rendered element. + */ + cellRenderer: CellRenderer; + + /** + * Optional custom CSS class name to attach to root `Grid` element. + */ + className?: string; + + /** + * Number of columns in grid. + */ + columnCount: number; + + /** + * Either a fixed column width (number) or a function that returns the width of a column given its index: `({ index: number }): number`. + * If function is used, specify `estimatedColumnSize` for more consistent scrolling behavior. + */ + columnWidth: CellSize; + + /** + * Additional props (eg `onWheel`) to spread onto the outer `Grid` element. + */ + containerProps?: HTMLAttributes; + + /** + * ARIA role for the inner cell-container. + * @default "row" + */ + containerRole?: string; + + /** + * Optional custom inline style to attach to inner cell-container element. + * @default {} + */ + containerStyle?: CSSProperties; + + /** + * If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache. + * A shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data. + */ + deferredMeasurementCache?: CellMeasureCache; + + /** + * Reference to the outer (scrolling) DOM element. + */ + elementRef?: Ref; + + /** + * Used to estimate the total width of a `Grid` before all of its columns have actually been measured. + * The estimated total width is adjusted as columns are rendered. + * @default 100 + */ + estimatedColumnSize?: number; + + /** + * Used to estimate the total height of a `Grid` before all of its rows have actually been measured. + * The estimated total height is adjusted as rows are rendered. + * @default 30 + */ + estimatedRowSize?: number; + + /** + * Returns the size of the browser's scrollbar. + * Exposed for testing purposes only. + * + * Defaults to `dom-helpers`' `scrollbarSize` (which returns `undefined` during server rendering). + */ + getScrollbarSize?: () => number; + + /** + * Height of Grid; this property determines the number of visible (vs virtualized) rows. + * Required, unless a `scrollElement` is specified. + * + * With a `scrollElement`, the height of the scroll element's viewport is used instead; + * this prop is then only used until the scroll element has been measured (e.g. during server rendering). + */ + height?: number; + + /** + * Optional custom id to attach to root `Grid` element. + */ + id?: string; + + /** + * Horizontal scroll offset to initially render (and scroll to) the `Grid` at. + * Only the initial value is used: changing this prop later has no effect (use `scrollToPosition` to scroll). + * This also works with server rendering: the cells at this offset are rendered on the server, + * and they stay in place when the page is hydrated. + */ + initialScrollLeft?: number; + + /** + * Vertical scroll offset to initially render (and scroll to) the `Grid` at. + * Only the initial value is used: changing this prop later has no effect (use `scrollToPosition` to scroll). + * This also works with server rendering: the cells at this offset are rendered on the server, + * and they stay in place when the page is hydrated. + * Ignored if a `scrollElement` is specified (which then determines the vertical offset). + */ + initialScrollTop?: number; + + /** + * Prevents re-rendering of visible cells on scroll end. + * (Opts out of the isScrolling param passed to cellRangeRenderer, to avoid the extra render when scroll stops.) + * Visible cells are still re-rendered when the `Grid`'s props change or `forceUpdate()` is called. + * @default false + */ + isScrollingOptOut?: boolean; + + /** + * Optional renderer to be rendered inside the grid when either `rowCount` or `columnCount` is empty. + * Renders nothing by default. + */ + noContentRenderer?: NoContentRenderer; + + /** + * Callback invoked whenever the scroll offset changes within the inner scrollable region: + * `({ clientHeight: number, clientWidth: number, scrollHeight: number, scrollLeft: number, scrollTop: number, scrollWidth: number }): void`. + * This callback can be used to sync scrolling between lists, tables, or grids. + */ + onScroll?: (params: Scroll) => void; + + /** + * Called whenever a horizontal or vertical scrollbar is added or removed: + * `({ horizontal: boolean, size: number, vertical: boolean }): void`. + * This prop is not intended for end-user use; + * it is used by MultiGrid to support fixed-row/fixed-column scroll syncing. + */ + onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void; + + /** + * Callback invoked with information about the section of the Grid that was just rendered. + * This callback is only invoked when visible rows have changed: + * `({ columnOverscanStartIndex: number, columnOverscanStopIndex: number, columnStartIndex: number, columnStopIndex: number, rowOverscanStartIndex: number, rowOverscanStopIndex: number, rowStartIndex: number, rowStopIndex: number }): void`. + */ + onSectionRendered?: (params: RenderedSection) => void; + + /** + * Number of columns to render before/after the visible slice of the grid. + * This can help reduce flickering during scrolling on certain browsers/devices. + * + * Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event. + * Aim to use the lowest value possible that still generally avoids any empty space from appearing during normal scrolling. + * @default 0 + */ + overscanColumnCount?: number; + + /** + * Responsible for calculating the number of cells to overscan before and after a specified range. + * By default, cells are overscanned in the direction the user is scrolling. + * This function receives `direction`, `cellCount`, `scrollDirection`, `overscanCellsCount`, `startIndex`, and `stopIndex` parameters + * and returns an `{ overscanStartIndex, overscanStopIndex }` object. + * + * Defaults to `defaultOverscanIndicesGetter`. + */ + overscanIndicesGetter?: OverscanIndicesGetter; + + /** + * Number of rows to render above/below the visible slice of the grid. + * This can help reduce flickering during scrolling on certain browsers/devices. + * + * Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event. + * The default value (10) is a good starting point; lower it if possible. + * @default 10 + */ + overscanRowCount?: number; + + /** + * Optional override of ARIA role default. + * @default "grid" + */ + role?: string; + + /** + * Number of rows in grid. + */ + rowCount: number; + + /** + * Either a fixed row height (number) or a function that returns the height of a row given its index: `({ index: number }): number`. + * If function is used, specify `estimatedRowSize` for more consistent scrolling behavior. + */ + rowHeight: CellSize; + + /** + * Element that scrolls the `Grid` (instead of the `Grid` scrolling itself): `"window"`, or a scrollable DOM element + * (e.g. a container that the `Grid` is rendered within, along with other content). + * + * The `Grid` then renders at its full height (it may still scroll horizontally itself), renders only the rows visible within the scroll element's viewport, + * and its scroll methods scroll the scroll element. + * Its position within the scroll element is measured when the scroll element scrolls or is resized, and when the `Grid` re-renders + * (e.g. call `forceUpdate()` if content above the `Grid` changes size without the `Grid` re-rendering). + * Pass `null` while the scroll element isn't available yet (e.g. before a ref to it has been attached); + * the `Grid` then renders the rows at its top. + */ + scrollElement?: ScrollElement | null; + + /** + * Wait this amount of time (in milliseconds) after the last scroll event before resetting Grid `pointer-events`. + * @default 150 + */ + scrollingResetTimeInterval?: number; + + /** + * Optional custom inline style to attach to root `Grid` element. + * @default {} + */ + style?: CSSProperties; + + /** + * Optional override of tab index default. + * @default 0 + */ + tabIndex?: number | null; + + /** + * Width of Grid; this property determines the number of visible (vs virtualized) columns. + */ + width: number; +}; + +/** Public (imperative) methods of a `Grid` (its ref handle). */ +export interface GridImperativeAPI { + /** + * Forcefully re-render the `Grid` (and its visible cells), + * e.g. because the data rendered by `cellRenderer` has changed but the `Grid`'s props have not. + */ + forceUpdate(): void; + + /** + * Gets offsets for a given cell and alignment (see `scrollToCell` for the alignment values). + * The alignment defaults to "auto"; an index of `-1` (the default) refers to the last column or row. + */ + getOffsetForCell(params?: { + alignment?: Alignment; + columnIndex?: number; + rowIndex?: number; + }): { scrollLeft: number; scrollTop: number }; + + /** + * Gets estimated total rows' height. + */ + getTotalRowsHeight(): number; + + /** + * Gets estimated total columns' width. + */ + getTotalColumnsWidth(): number; + + /** + * This method handles a scroll event originating from an external scroll control. + * It's an advanced method and should probably not be used unless you're implementing a custom scroll-bar solution. + */ + handleScrollEvent(params: ScrollPosition): void; + + /** + * Invalidate the size of a cell and recompute visible cells after the current render has been committed. + * This is a deferred version of `recomputeGridSize()` (batching the invalidations of a render); + * it is intended for advanced use-cases like `CellMeasurer`. + */ + invalidateCellSizeAfterRender(params: CellPosition): void; + + /** + * Pre-measure all columns and rows in a `Grid`. + * + * Typically cells are only measured as needed and estimated sizes are used for cells that have not yet been measured. + * This method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one). + */ + measureAllCells(): void; + + /** + * Recomputes row heights and column widths after the specified index (both default to 0). + * + * This function should be called if dynamic column or row sizes have changed but nothing else has. + * Since `Grid` only receives `columnCount` and `rowCount` it has no way of detecting when the underlying data changes. + * + * This method will also force a render cycle to ensure that the updated measurements are reflected in the rendered grid. + */ + recomputeGridSize(params?: { columnIndex?: number; rowIndex?: number }): void; + + /** + * Ensure the specified column and/or row is visible (scrolling if necessary). + * This method can be used to safely scroll back to a cell that a user has scrolled away from even if it was previously scrolled to. + * + * The `align` parameter (default "auto") controls the alignment of the cell: + * "auto" scrolls the least amount possible to ensure that the specified cell is fully visible + * (a cell larger than the `Grid` is aligned to its start, so that its start is visible); + * "start" aligns the cell to the top/left of the `Grid` and "end" to the bottom/right; + * "center" aligns the cell in the middle of the `Grid`. + * + * This is a one-time scroll: the cell is not kept in view afterwards (e.g. if the `Grid`'s size or its cell sizes change). + * Cells that haven't been measured yet (e.g. by `CellMeasurer`) are positioned using their estimated sizes. + * Nothing is scrolled while the `Grid`'s width or height is 0. + * The column is ignored for single-column grids (e.g. `List`, `Table`). + * + * If the `Grid` has a `scrollElement`, the scroll element (e.g. the window) is scrolled vertically instead. + */ + scrollToCell(params: { + align?: Alignment; + columnIndex?: number; + rowIndex?: number; + }): void; + + /** + * Scroll to the specified offset(s); an omitted offset is left unchanged. + * Offsets are clamped to the scrollable range. + * Calling this method with the current offsets does nothing. + * + * If the `Grid` has a `scrollElement`, `scrollTop` scrolls the scroll element (e.g. the window) instead + * (to the position at which the `Grid` is scrolled by `scrollTop`). + */ + scrollToPosition(params: ScrollPosition): void; +} diff --git a/lib/components/Grid/utils/CellSizeAndPositionManager.test.ts b/lib/components/Grid/utils/CellSizeAndPositionManager.test.ts new file mode 100644 index 000000000..b9328a6a3 --- /dev/null +++ b/lib/components/Grid/utils/CellSizeAndPositionManager.test.ts @@ -0,0 +1,509 @@ +import { describe, expect, it } from "vitest"; +import type { Alignment } from "../types"; +import { CellSizeAndPositionManager } from "./CellSizeAndPositionManager"; + +describe("CellSizeAndPositionManager", () => { + function getCellSizeAndPositionManager({ + cellCount = 100, + estimatedCellSize = 15 + } = {}) { + const cellSizeGetterCalls: number[] = []; + const cellSizeAndPositionManager = new CellSizeAndPositionManager({ + cellCount, + cellSizeGetter: ({ index }) => { + cellSizeGetterCalls.push(index); + return 10; + }, + estimatedCellSize + }); + + return { + cellSizeAndPositionManager, + cellSizeGetterCalls + }; + } + + describe("configure", () => { + it("should update inner :cellCount and :estimatedCellSize", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(cellSizeAndPositionManager.getCellCount()).toEqual(100); + expect(cellSizeAndPositionManager.getEstimatedCellSize()).toEqual(15); + + // @ts-expect-error cellSizeGetter is intentionally omitted + cellSizeAndPositionManager.configure({ + cellCount: 20, + estimatedCellSize: 30 + }); + expect(cellSizeAndPositionManager.getCellCount()).toEqual(20); + expect(cellSizeAndPositionManager.getEstimatedCellSize()).toEqual(30); + }); + + it("should forget the measurements of cells beyond a smaller :cellCount (#1351)", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager({ + cellCount: 5, + estimatedCellSize: 100 + }); + cellSizeAndPositionManager.getSizeAndPositionOfCell(4); + expect(cellSizeAndPositionManager.getTotalSize()).toEqual(50); + + // (configure() is called with the same getter and estimate when only the count changes) + cellSizeAndPositionManager.configure({ + cellCount: 2, + cellSizeGetter: () => 10, + estimatedCellSize: 100 + }); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); + expect(cellSizeAndPositionManager.getTotalSize()).toEqual(20); + + cellSizeAndPositionManager.configure({ + cellCount: 0, + cellSizeGetter: () => 10, + estimatedCellSize: 100 + }); + expect(cellSizeAndPositionManager.getTotalSize()).toEqual(0); + expect( + cellSizeAndPositionManager.getVisibleCellRange({ + containerSize: 100, + offset: 0 + }) + ).toEqual({}); + }); + }); + + describe("findNearestCell", () => { + it("should error if given NaN", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(() => cellSizeAndPositionManager._findNearestCell(NaN)).toThrow(); + }); + + it("should gracefully handle offets outisde of bounds (to account for elastic scrolling)", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(cellSizeAndPositionManager._findNearestCell(-100)).toEqual(0); + expect(cellSizeAndPositionManager._findNearestCell(1234567890)).toEqual( + 99 + ); + }); + + it("should find the first cell", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(cellSizeAndPositionManager._findNearestCell(0)).toEqual(0); + expect(cellSizeAndPositionManager._findNearestCell(9)).toEqual(0); + }); + + it("should find the last cell", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(cellSizeAndPositionManager._findNearestCell(990)).toEqual(99); + expect(cellSizeAndPositionManager._findNearestCell(991)).toEqual(99); + }); + + it("should find the a cell that exactly matches a specified offset in the middle", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(cellSizeAndPositionManager._findNearestCell(100)).toEqual(10); + }); + + it("should find the cell closest to (but before) the specified offset in the middle", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(cellSizeAndPositionManager._findNearestCell(101)).toEqual(10); + }); + }); + + describe("getSizeAndPositionOfCell", () => { + it("should error if an invalid index is specified", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(() => + cellSizeAndPositionManager.getSizeAndPositionOfCell(-1) + ).toThrow(); + expect(() => + cellSizeAndPositionManager.getSizeAndPositionOfCell(100) + ).toThrow(); + }); + + it("should return the correct size and position information for the requested cell", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfCell(0).offset + ).toEqual(0); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfCell(0).size + ).toEqual(10); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfCell(1).offset + ).toEqual(10); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfCell(2).offset + ).toEqual(20); + }); + + it("should only measure the necessary cells to return the information requested", () => { + const { cellSizeAndPositionManager, cellSizeGetterCalls } = + getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(0); + expect(cellSizeGetterCalls).toEqual([0]); + }); + + it("should just-in-time measure all cells up to the requested cell if no cells have yet been measured", () => { + const { cellSizeAndPositionManager, cellSizeGetterCalls } = + getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + expect(cellSizeGetterCalls).toEqual([0, 1, 2, 3, 4, 5]); + }); + + it("should just-in-time measure cells up to the requested cell if some but not all cells have been measured", () => { + const { cellSizeAndPositionManager, cellSizeGetterCalls } = + getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + cellSizeGetterCalls.splice(0); + cellSizeAndPositionManager.getSizeAndPositionOfCell(10); + expect(cellSizeGetterCalls).toEqual([6, 7, 8, 9, 10]); + }); + + it("should return cached size and position data if cell has already been measured", () => { + const { cellSizeAndPositionManager, cellSizeGetterCalls } = + getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + cellSizeGetterCalls.splice(0); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + expect(cellSizeGetterCalls).toEqual([]); + }); + + it("should not mark trailing unmeasured (null) cells as measured", () => { + const cellSizeAndPositionManager = new CellSizeAndPositionManager({ + cellCount: 10, + // Cells 0-2 are measured; cells 3+ have not been measured yet (null) + cellSizeGetter: ({ index }) => + (index < 3 ? 10 : null) as unknown as number, + estimatedCellSize: 15 + }); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell() + ).toEqual({ + offset: 20, + size: 10 + }); + // 3 measured cells (30) + 7 unmeasured cells at the estimated size (105) + expect(cellSizeAndPositionManager.getTotalSize()).toEqual(135); + }); + + it("should track the last measured cell if the size getter throws partway through", () => { + const cellSizeAndPositionManager = new CellSizeAndPositionManager({ + cellCount: 10, + cellSizeGetter: ({ index }) => { + if (index === 3) { + throw Error("Expected error"); + } + return 10; + }, + estimatedCellSize: 15 + }); + expect(() => + cellSizeAndPositionManager.getSizeAndPositionOfCell(5) + ).toThrow("Expected error"); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell() + ).toEqual({ + offset: 20, + size: 10 + }); + }); + }); + + describe("getSizeAndPositionOfLastMeasuredCell", () => { + it("should return an empty object if no cached cells are present", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell() + ).toEqual({ + offset: 0, + size: 0 + }); + }); + + it("should return size and position data for the highest/last measured cell", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell() + ).toEqual({ + offset: 50, + size: 10 + }); + }); + }); + + describe("getTotalSize", () => { + it("should calculate total size based purely on :estimatedCellSize if no measurements have been done", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + expect(cellSizeAndPositionManager.getTotalSize()).toEqual(1500); + }); + + it("should calculate total size based on a mixture of actual cell sizes and :estimatedCellSize if some cells have been measured", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(49); + expect(cellSizeAndPositionManager.getTotalSize()).toEqual(1250); + }); + + it("should calculate total size based on the actual measured sizes if all cells have been measured", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(99); + expect(cellSizeAndPositionManager.getTotalSize()).toEqual(1000); + }); + }); + + describe("getUpdatedOffsetForIndex", () => { + function getUpdatedOffsetForIndexHelper({ + align = "auto", + cellCount = 10, + cellSize = 10, + containerSize = 50, + currentOffset = 0, + estimatedCellSize = 15, + targetIndex = 0 + }: { + align?: Alignment; + cellCount?: number; + cellSize?: number; + containerSize?: number; + currentOffset?: number; + estimatedCellSize?: number; + targetIndex?: number; + }) { + const cellSizeAndPositionManager = new CellSizeAndPositionManager({ + cellCount, + cellSizeGetter: () => cellSize, + estimatedCellSize + }); + + return cellSizeAndPositionManager.getUpdatedOffsetForIndex({ + align, + containerSize, + currentOffset, + targetIndex + }); + } + + it("should scroll to the beginning", () => { + expect( + getUpdatedOffsetForIndexHelper({ + currentOffset: 100, + targetIndex: 0 + }) + ).toEqual(0); + }); + + it("should align a cell larger than the container to its start with auto alignment (#1264)", () => { + // Cells are 100px (the container is 50px): the start of cell 3 is at 300px + expect( + getUpdatedOffsetForIndexHelper({ + cellSize: 100, + currentOffset: 0, + targetIndex: 3 + }) + ).toEqual(300); + expect( + getUpdatedOffsetForIndexHelper({ + cellSize: 100, + currentOffset: 600, + targetIndex: 3 + }) + ).toEqual(300); + }); + + it("should scroll to the end", () => { + expect( + getUpdatedOffsetForIndexHelper({ + currentOffset: 0, + targetIndex: 9 + }) + ).toEqual(50); + }); + + it("should scroll forward to the middle", () => { + expect( + getUpdatedOffsetForIndexHelper({ + currentOffset: 0, + targetIndex: 6 + }) + ).toEqual(20); + }); + + it("should scroll backward to the middle", () => { + expect( + getUpdatedOffsetForIndexHelper({ + currentOffset: 50, + targetIndex: 2 + }) + ).toEqual(20); + }); + + it("should not scroll if an item is already visible", () => { + expect( + getUpdatedOffsetForIndexHelper({ + currentOffset: 20, + targetIndex: 3 + }) + ).toEqual(20); + }); + + it("should honor specified :align values", () => { + expect( + getUpdatedOffsetForIndexHelper({ + align: "auto", + currentOffset: 0, + targetIndex: 5 + }) + ).toEqual(10); + expect( + getUpdatedOffsetForIndexHelper({ + align: "start", + currentOffset: 0, + targetIndex: 5 + }) + ).toEqual(50); + expect( + getUpdatedOffsetForIndexHelper({ + align: "auto", + currentOffset: 50, + targetIndex: 4 + }) + ).toEqual(40); + expect( + getUpdatedOffsetForIndexHelper({ + align: "end", + currentOffset: 50, + targetIndex: 5 + }) + ).toEqual(10); + expect( + getUpdatedOffsetForIndexHelper({ + align: "center", + currentOffset: 50, + targetIndex: 5 + }) + ).toEqual(30); + }); + + it("should not scroll past the safe bounds even if the specified :align requests it", () => { + expect( + getUpdatedOffsetForIndexHelper({ + align: "end", + currentOffset: 50, + targetIndex: 0 + }) + ).toEqual(0); + expect( + getUpdatedOffsetForIndexHelper({ + align: "center", + currentOffset: 50, + targetIndex: 1 + }) + ).toEqual(0); + expect( + getUpdatedOffsetForIndexHelper({ + align: "start", + currentOffset: 0, + targetIndex: 9 + }) + ).toEqual(50); + + // TRICKY: We would expect this to be positioned at 50. + // But since the :estimatedCellSize is 15 and we only measure up to the 8th item, + // The helper assumes it can scroll farther than it actually can. + // Not sure if this edge case is worth "fixing" or just acknowledging... + expect( + getUpdatedOffsetForIndexHelper({ + align: "center", + currentOffset: 0, + targetIndex: 8 + }) + ).toEqual(55); + }); + + it("should always return an offset of 0 when :containerSize is 0", () => { + expect( + getUpdatedOffsetForIndexHelper({ + containerSize: 0, + currentOffset: 50, + targetIndex: 2 + }) + ).toEqual(0); + }); + }); + + describe("getVisibleCellRange", () => { + it("should not return any indices if :cellCount is 0", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager({ + cellCount: 0 + }); + const { start, stop } = cellSizeAndPositionManager.getVisibleCellRange({ + containerSize: 50, + offset: 0 + }); + expect(start).toEqual(undefined); + expect(stop).toEqual(undefined); + }); + + it("should return a visible range of cells for the beginning of the list", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + const { start, stop } = cellSizeAndPositionManager.getVisibleCellRange({ + containerSize: 50, + offset: 0 + }); + expect(start).toEqual(0); + expect(stop).toEqual(4); + }); + + it("should return a visible range of cells for the middle of the list where some are partially visible", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + const { start, stop } = cellSizeAndPositionManager.getVisibleCellRange({ + containerSize: 50, + offset: 425 + }); + // 42 and 47 are partially visible + expect(start).toEqual(42); + expect(stop).toEqual(47); + }); + + it("should return a visible range of cells for the end of the list", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + const { start, stop } = cellSizeAndPositionManager.getVisibleCellRange({ + containerSize: 50, + offset: 950 + }); + expect(start).toEqual(95); + expect(stop).toEqual(99); + }); + }); + + describe("resetCell", () => { + it("should clear size and position metadata for the specified index and all cells after it", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + cellSizeAndPositionManager.resetCell(3); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(2); + cellSizeAndPositionManager.resetCell(0); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1); + }); + + it("should not clear size and position metadata for cells before the specified index", () => { + const { cellSizeAndPositionManager, cellSizeGetterCalls } = + getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + cellSizeGetterCalls.splice(0); + cellSizeAndPositionManager.resetCell(3); + cellSizeAndPositionManager.getSizeAndPositionOfCell(4); + expect(cellSizeGetterCalls).toEqual([3, 4]); + }); + + it("should not skip over any unmeasured or previously-cleared cells", () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + cellSizeAndPositionManager.resetCell(2); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); + cellSizeAndPositionManager.resetCell(4); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); + cellSizeAndPositionManager.resetCell(0); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1); + }); + }); +}); diff --git a/source/Grid/utils/CellSizeAndPositionManager.js b/lib/components/Grid/utils/CellSizeAndPositionManager.ts similarity index 80% rename from source/Grid/utils/CellSizeAndPositionManager.js rename to lib/components/Grid/utils/CellSizeAndPositionManager.ts index 4e48e18d1..f08852a92 100644 --- a/source/Grid/utils/CellSizeAndPositionManager.js +++ b/lib/components/Grid/utils/CellSizeAndPositionManager.ts @@ -1,44 +1,42 @@ -/** @flow */ - -import type {Alignment, CellSizeGetter, VisibleCellRange} from '../types'; +import type { Alignment, CellSizeGetter, VisibleCellRange } from "../types"; type CellSizeAndPositionManagerParams = { - cellCount: number, - cellSizeGetter: CellSizeGetter, - estimatedCellSize: number, + cellCount: number; + cellSizeGetter: CellSizeGetter; + estimatedCellSize: number; }; type ConfigureParams = { - cellCount: number, - estimatedCellSize: number, - cellSizeGetter: CellSizeGetter, + cellCount: number; + estimatedCellSize: number; + cellSizeGetter: CellSizeGetter; }; type GetUpdatedOffsetForIndex = { - align: Alignment, - containerSize: number, - currentOffset: number, - targetIndex: number, + align?: Alignment; + containerSize: number; + currentOffset: number; + targetIndex: number; }; type GetVisibleCellRangeParams = { - containerSize: number, - offset: number, + containerSize: number; + offset: number; }; -type SizeAndPositionData = { - offset: number, - size: number, +export type SizeAndPositionData = { + offset: number; + size: number; }; /** * Just-in-time calculates and caches size and position information for a collection of cells. */ -export default class CellSizeAndPositionManager { +export class CellSizeAndPositionManager { // Cache of size and position data for cells, mapped by cell index. // Note that invalid values may exist in this map so only rely on cells up to this._lastMeasuredIndex - _cellSizeAndPositionData = {}; + _cellSizeAndPositionData: { [index: number]: SizeAndPositionData } = {}; // Measurements for cells up to this index can be trusted; cells afterward should be estimated. _lastMeasuredIndex = -1; @@ -53,7 +51,7 @@ export default class CellSizeAndPositionManager { constructor({ cellCount, cellSizeGetter, - estimatedCellSize, + estimatedCellSize }: CellSizeAndPositionManagerParams) { this._cellSizeGetter = cellSizeGetter; this._cellCount = cellCount; @@ -64,8 +62,11 @@ export default class CellSizeAndPositionManager { return false; } - configure({cellCount, estimatedCellSize, cellSizeGetter}: ConfigureParams) { + configure({ cellCount, estimatedCellSize, cellSizeGetter }: ConfigureParams) { this._cellCount = cellCount; + // Cells beyond the new count are no longer measured + // (otherwise the total size, which estimates the size of the unmeasured cells, could even be negative; see #1351) + this._lastMeasuredIndex = Math.min(this._lastMeasuredIndex, cellCount - 1); this._estimatedCellSize = estimatedCellSize; this._cellSizeGetter = cellSizeGetter; } @@ -93,18 +94,19 @@ export default class CellSizeAndPositionManager { getSizeAndPositionOfCell(index: number): SizeAndPositionData { if (index < 0 || index >= this._cellCount) { throw Error( - `Requested index ${index} is outside of range 0..${this._cellCount}`, + `Requested index ${index} is outside of range 0..${this._cellCount}` ); } if (index > this._lastMeasuredIndex) { - let lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell(); + const lastMeasuredCellSizeAndPosition = + this.getSizeAndPositionOfLastMeasuredCell(); let offset = lastMeasuredCellSizeAndPosition.offset + lastMeasuredCellSizeAndPosition.size; - for (var i = this._lastMeasuredIndex + 1; i <= index; i++) { - let size = this._cellSizeGetter({index: i}); + for (let i = this._lastMeasuredIndex + 1; i <= index; i++) { + const size = this._cellSizeGetter({ index: i }); // undefined or NaN probably means a logic error in the size getter. // null means we're using CellMeasurer and haven't yet measured a given index. @@ -113,19 +115,19 @@ export default class CellSizeAndPositionManager { } else if (size === null) { this._cellSizeAndPositionData[i] = { offset, - size: 0, + size: 0 }; this._lastBatchedIndex = index; } else { this._cellSizeAndPositionData[i] = { offset, - size, + size }; offset += size; - this._lastMeasuredIndex = index; + this._lastMeasuredIndex = i; } } } @@ -138,7 +140,7 @@ export default class CellSizeAndPositionManager { ? this._cellSizeAndPositionData[this._lastMeasuredIndex] : { offset: 0, - size: 0, + size: 0 }; } @@ -148,7 +150,8 @@ export default class CellSizeAndPositionManager { * As cells are measured, the estimate will be updated. */ getTotalSize(): number { - const lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell(); + const lastMeasuredCellSizeAndPosition = + this.getSizeAndPositionOfLastMeasuredCell(); const totalSizeOfMeasuredCells = lastMeasuredCellSizeAndPosition.offset + lastMeasuredCellSizeAndPosition.size; @@ -170,10 +173,10 @@ export default class CellSizeAndPositionManager { * @return Offset to use to ensure the specified cell is visible */ getUpdatedOffsetForIndex({ - align = 'auto', + align = "auto", containerSize, currentOffset, - targetIndex, + targetIndex }: GetUpdatedOffsetForIndex): number { if (containerSize <= 0) { return 0; @@ -183,20 +186,24 @@ export default class CellSizeAndPositionManager { const maxOffset = datum.offset; const minOffset = maxOffset - containerSize + datum.size; - let idealOffset; + let idealOffset: number; switch (align) { - case 'start': + case "start": idealOffset = maxOffset; break; - case 'end': + case "end": idealOffset = minOffset; break; - case 'center': + case "center": idealOffset = maxOffset - (containerSize - datum.size) / 2; break; default: - idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset)); + // A cell that doesn't fit within the container is aligned to its start, so that its start is visible (#1264) + idealOffset = + datum.size >= containerSize + ? maxOffset + : Math.max(minOffset, Math.min(maxOffset, currentOffset)); break; } @@ -206,7 +213,8 @@ export default class CellSizeAndPositionManager { } getVisibleCellRange(params: GetVisibleCellRangeParams): VisibleCellRange { - let {containerSize, offset} = params; + const { containerSize } = params; + let { offset } = params; const totalSize = this.getTotalSize(); @@ -230,7 +238,7 @@ export default class CellSizeAndPositionManager { return { start, - stop, + stop }; } @@ -278,7 +286,7 @@ export default class CellSizeAndPositionManager { return this._binarySearch( Math.min(index, this._cellCount - 1), Math.floor(index / 2), - offset, + offset ); } @@ -297,7 +305,8 @@ export default class CellSizeAndPositionManager { // So make sure the offset is at least 0 or no match will be found. offset = Math.max(0, offset); - const lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell(); + const lastMeasuredCellSizeAndPosition = + this.getSizeAndPositionOfLastMeasuredCell(); const lastMeasuredIndex = Math.max(0, this._lastMeasuredIndex); if (lastMeasuredCellSizeAndPosition.offset >= offset) { diff --git a/lib/components/Grid/utils/ScalingCellSizeAndPositionManager.test.ts b/lib/components/Grid/utils/ScalingCellSizeAndPositionManager.test.ts new file mode 100644 index 000000000..c3c20b285 --- /dev/null +++ b/lib/components/Grid/utils/ScalingCellSizeAndPositionManager.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { ScalingCellSizeAndPositionManager } from "./ScalingCellSizeAndPositionManager"; + +describe("ScalingCellSizeAndPositionManager", () => { + function init({ + cellCount = 10, + cellSize = 10, + estimatedCellSize = 10, + maxScrollSize = 50 + } = {}) { + const cellSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ + cellCount, + cellSizeGetter: () => cellSize, + estimatedCellSize, + maxScrollSize + }); + + return cellSizeAndPositionManager; + } + + describe("_getOffsetPercentage", () => { + it("should return the correct offset fraction", () => { + const expectations = [ + { offset: 0, expectedOffsetPercentage: 0 }, + { offset: 35, expectedOffsetPercentage: 0.5 }, + { offset: 70, expectedOffsetPercentage: 1 } + ]; + const instance = init(); + expectations.forEach((expectation) => { + expect( + instance._getOffsetPercentage({ + containerSize: 30, + offset: expectation.offset, + totalSize: 100 + }) + ).toBe(expectation.expectedOffsetPercentage); + }); + }); + }); + + describe("getOffsetAdjustment", () => { + it("should always return 0 as the adjustment for unscaled lists", () => { + const maxScrollSizes = [100, 150]; + maxScrollSizes.forEach((maxScrollSize) => { + const instance = init({ + cellCount: 10, + maxScrollSize + }); + const offsets = [0, 35, 70]; + offsets.forEach((offset) => { + expect( + instance.getOffsetAdjustment({ + containerSize: 30, + offset + }) + ).toBe(0); + }); + }); + }); + + it("should properly scale an offset at the beginning, middle, and end of the list", () => { + const offsetsAndExpectedAdjustements = [ + { offset: 0, expectedAdjustment: -0 }, + { offset: 10, expectedAdjustment: -25 }, + { offset: 20, expectedAdjustment: -50 } + ]; + const instance = init(); + offsetsAndExpectedAdjustements.forEach((offsetAndExpectedAdjustement) => { + expect( + instance.getOffsetAdjustment({ + containerSize: 30, + offset: offsetAndExpectedAdjustement.offset + }) + ).toBe(offsetAndExpectedAdjustement.expectedAdjustment); + }); + }); + }); + + describe("getTotalSize", () => { + it("should return :totalSize if it is not greater than :maxScrollSize", () => { + const maxScrollSizes = [500, 750]; + maxScrollSizes.forEach((maxScrollSize) => { + const instance = init({ + cellCount: 50, + maxScrollSize + }); + expect(instance.getTotalSize()).toEqual(500); + }); + }); + + it("should return :maxScrollSize if :totalSize is greater", () => { + const instance = init({ + cellCount: 100, + maxScrollSize: 100 + }); + expect(instance.getTotalSize()).toEqual(100); + }); + }); + + describe("getUpdatedOffsetForIndex", () => { + it("should scroll to a cell before the current range", () => { + const data = [ + { targetIndex: 0, expectedOffset: 0 }, + { targetIndex: 1, expectedOffset: 3 }, // (unsafe: 10) + { targetIndex: 2, expectedOffset: 6 } // (unsafe: 20) + ]; + const instance = init(); + data.forEach((datum) => { + expect( + instance.getUpdatedOffsetForIndex({ + containerSize: 30, + currentOffset: 10, // (unsafe: 35) + targetIndex: datum.targetIndex + }) + ).toBe(datum.expectedOffset); + }); + }); + + it("should scroll to a cell after the current range", () => { + const data = [ + { targetIndex: 7, expectedOffset: 14 }, // (unsafe: 50) + { targetIndex: 9, expectedOffset: 20 } // (unsafe: 70) + ]; + const instance = init(); + data.forEach((datum) => { + expect( + instance.getUpdatedOffsetForIndex({ + containerSize: 30, + currentOffset: 0, + targetIndex: datum.targetIndex + }) + ).toBe(datum.expectedOffset); + }); + }); + + it("should not scroll to a cell already visible within the current range", () => { + const instance = init(); + expect( + instance.getUpdatedOffsetForIndex({ + containerSize: 30, + currentOffset: 10, // (unsafe: 35) + targetIndex: 4 + }) + ).toBe(10); + }); + }); + + describe("getVisibleCellRange", () => { + it("should correct identify the first set of cells", () => { + const instance = init(); + expect( + instance.getVisibleCellRange({ + containerSize: 30, + offset: 0 + }) + ).toEqual({ + start: 0, + stop: 2 + }); + }); + + it("should correct identify cells in the middle", () => { + const instance = init(); + expect( + instance.getVisibleCellRange({ + containerSize: 30, + offset: 2.85 // (unsafe: 10) + }) + ).toEqual({ + start: 1, + stop: 3 + }); + }); + + it("should correct identify partially visible cells", () => { + const instance = init(); + expect( + instance.getVisibleCellRange({ + containerSize: 30, + offset: 10 // (unsafe: 35) + }) + ).toEqual({ + start: 3, + stop: 6 + }); + }); + + it("should correct identify the last set of cells", () => { + const instance = init(); + expect( + instance.getVisibleCellRange({ + containerSize: 30, + offset: 20 + }) + ).toEqual({ + start: 7, + stop: 9 + }); + }); + }); +}); diff --git a/source/Grid/utils/ScalingCellSizeAndPositionManager.js b/lib/components/Grid/utils/ScalingCellSizeAndPositionManager.ts similarity index 76% rename from source/Grid/utils/ScalingCellSizeAndPositionManager.js rename to lib/components/Grid/utils/ScalingCellSizeAndPositionManager.ts index 78f39fbab..71f642dd6 100644 --- a/source/Grid/utils/ScalingCellSizeAndPositionManager.js +++ b/lib/components/Grid/utils/ScalingCellSizeAndPositionManager.ts @@ -1,13 +1,14 @@ -/** @flow */ +import type { Alignment, CellSizeGetter, VisibleCellRange } from "../types"; -import type {Alignment, CellSizeGetter, VisibleCellRange} from '../types'; - -import CellSizeAndPositionManager from './CellSizeAndPositionManager'; -import {getMaxElementSize} from './maxElementSize.js'; +import { + CellSizeAndPositionManager, + type SizeAndPositionData +} from "./CellSizeAndPositionManager"; +import { getMaxElementSize } from "./maxElementSize"; type ContainerSizeAndOffset = { - containerSize: number, - offset: number, + containerSize: number; + offset: number; }; /** @@ -17,20 +18,20 @@ type ContainerSizeAndOffset = { */ type Params = { - maxScrollSize?: number, - cellCount: number, - cellSizeGetter: CellSizeGetter, - estimatedCellSize: number, + maxScrollSize?: number; + cellCount: number; + cellSizeGetter: CellSizeGetter; + estimatedCellSize: number; }; /** * Extends CellSizeAndPositionManager and adds scaling behavior for lists that are too large to fit within a browser's native limits. */ -export default class ScalingCellSizeAndPositionManager { +export class ScalingCellSizeAndPositionManager { _cellSizeAndPositionManager: CellSizeAndPositionManager; _maxScrollSize: number; - constructor({maxScrollSize = getMaxElementSize(), ...params}: Params) { + constructor({ maxScrollSize = getMaxElementSize(), ...params }: Params) { // Favor composition over inheritance to simplify IE10 support this._cellSizeAndPositionManager = new CellSizeAndPositionManager(params); this._maxScrollSize = maxScrollSize; @@ -43,10 +44,10 @@ export default class ScalingCellSizeAndPositionManager { } configure(params: { - cellCount: number, - estimatedCellSize: number, - cellSizeGetter: CellSizeGetter, - }) { + cellCount: number; + estimatedCellSize: number; + cellSizeGetter: CellSizeGetter; + }): void { this._cellSizeAndPositionManager.configure(params); } @@ -68,24 +69,24 @@ export default class ScalingCellSizeAndPositionManager { */ getOffsetAdjustment({ containerSize, - offset, // safe + offset // safe }: ContainerSizeAndOffset): number { const totalSize = this._cellSizeAndPositionManager.getTotalSize(); const safeTotalSize = this.getTotalSize(); const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, - totalSize: safeTotalSize, + totalSize: safeTotalSize }); return Math.round(offsetPercentage * (safeTotalSize - totalSize)); } - getSizeAndPositionOfCell(index: number) { + getSizeAndPositionOfCell(index: number): SizeAndPositionData { return this._cellSizeAndPositionManager.getSizeAndPositionOfCell(index); } - getSizeAndPositionOfLastMeasuredCell() { + getSizeAndPositionOfLastMeasuredCell(): SizeAndPositionData { return this._cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell(); } @@ -93,53 +94,53 @@ export default class ScalingCellSizeAndPositionManager { getTotalSize(): number { return Math.min( this._maxScrollSize, - this._cellSizeAndPositionManager.getTotalSize(), + this._cellSizeAndPositionManager.getTotalSize() ); } /** See CellSizeAndPositionManager#getUpdatedOffsetForIndex */ getUpdatedOffsetForIndex({ - align = 'auto', + align = "auto", containerSize, currentOffset, // safe - targetIndex, + targetIndex }: { - align: Alignment, - containerSize: number, - currentOffset: number, - targetIndex: number, - }) { + align?: Alignment; + containerSize: number; + currentOffset: number; + targetIndex: number; + }): number { currentOffset = this._safeOffsetToOffset({ containerSize, - offset: currentOffset, + offset: currentOffset }); const offset = this._cellSizeAndPositionManager.getUpdatedOffsetForIndex({ align, containerSize, currentOffset, - targetIndex, + targetIndex }); return this._offsetToSafeOffset({ containerSize, - offset, + offset }); } /** See CellSizeAndPositionManager#getVisibleCellRange */ getVisibleCellRange({ containerSize, - offset, // safe + offset // safe }: ContainerSizeAndOffset): VisibleCellRange { offset = this._safeOffsetToOffset({ containerSize, - offset, + offset }); return this._cellSizeAndPositionManager.getVisibleCellRange({ containerSize, - offset, + offset }); } @@ -150,12 +151,12 @@ export default class ScalingCellSizeAndPositionManager { _getOffsetPercentage({ containerSize, offset, // safe - totalSize, + totalSize }: { - containerSize: number, - offset: number, - totalSize: number, - }) { + containerSize: number; + offset: number; + totalSize: number; + }): number { return totalSize <= containerSize ? 0 : offset / (totalSize - containerSize); @@ -163,7 +164,7 @@ export default class ScalingCellSizeAndPositionManager { _offsetToSafeOffset({ containerSize, - offset, // unsafe + offset // unsafe }: ContainerSizeAndOffset): number { const totalSize = this._cellSizeAndPositionManager.getTotalSize(); const safeTotalSize = this.getTotalSize(); @@ -174,7 +175,7 @@ export default class ScalingCellSizeAndPositionManager { const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, - totalSize, + totalSize }); return Math.round(offsetPercentage * (safeTotalSize - containerSize)); @@ -183,7 +184,7 @@ export default class ScalingCellSizeAndPositionManager { _safeOffsetToOffset({ containerSize, - offset, // safe + offset // safe }: ContainerSizeAndOffset): number { const totalSize = this._cellSizeAndPositionManager.getTotalSize(); const safeTotalSize = this.getTotalSize(); @@ -194,7 +195,7 @@ export default class ScalingCellSizeAndPositionManager { const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, - totalSize: safeTotalSize, + totalSize: safeTotalSize }); return Math.round(offsetPercentage * (totalSize - containerSize)); diff --git a/lib/components/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.test.ts b/lib/components/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.test.ts new file mode 100644 index 000000000..d3ee98288 --- /dev/null +++ b/lib/components/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { calculateSizeAndPositionDataAndUpdateScrollOffset } from "./calculateSizeAndPositionDataAndUpdateScrollOffset"; + +describe("calculateSizeAndPositionDataAndUpdateScrollOffset", () => { + function helper({ + cellCount = 100, + cellSize = 10, + computeMetadataCallbackProps = {}, + nextCellsCount = 100, + nextCellSize = 10, + nextScrollToIndex, + scrollToIndex + }: { + cellCount?: number; + cellSize?: number | (() => void); + computeMetadataCallbackProps?: object; + nextCellsCount?: number; + nextCellSize?: number | (() => void); + nextScrollToIndex?: number; + scrollToIndex?: number; + } = {}) { + const computeMetadataCallbackCalls: unknown[] = []; + const updateScrollOffsetForScrollToIndexCalls: unknown[] = []; + + // Note that this test intentionally passes values that don't match the declared types (eg size functions) + calculateSizeAndPositionDataAndUpdateScrollOffset({ + cellCount, + cellSize: cellSize as number, + computeMetadataCallback: (params) => + computeMetadataCallbackCalls.push(params), + computeMetadataCallbackProps, + nextCellsCount, + nextCellSize: nextCellSize as number, + nextScrollToIndex: nextScrollToIndex as number, + scrollToIndex: scrollToIndex as number, + updateScrollOffsetForScrollToIndex: (params?: unknown) => + updateScrollOffsetForScrollToIndexCalls.push(params) + }); + + return { + computeMetadataCallbackCalls, + updateScrollOffsetForScrollToIndexCalls + }; + } + + it("should call :computeMetadataCallback if :cellCount has changed", () => { + const { computeMetadataCallbackCalls } = helper({ + cellCount: 100, + nextCellsCount: 200 + }); + expect(computeMetadataCallbackCalls.length).toEqual(1); + }); + + it("should call :computeMetadataCallback if numeric :cellSize has changed", () => { + const { computeMetadataCallbackCalls } = helper({ + cellSize: 10, + nextCellSize: 20 + }); + expect(computeMetadataCallbackCalls.length).toEqual(1); + }); + + it("should not call :computeMetadataCallback if :cellSize callback has changed", () => { + const { computeMetadataCallbackCalls } = helper({ + cellSize: () => {}, + nextCellSize: () => {} + }); + expect(computeMetadataCallbackCalls.length).toEqual(0); + }); + + it("should not call :updateScrollOffsetForScrollToIndex if :scrollToIndex is not specified", () => { + const { updateScrollOffsetForScrollToIndexCalls } = helper(); + expect(updateScrollOffsetForScrollToIndexCalls.length).toEqual(0); + }); + + it("should not call :updateScrollOffsetForScrollToIndex if :scrollToIndex has also changed", () => { + const { updateScrollOffsetForScrollToIndexCalls } = helper({ + scrollToIndex: 10, + nextScrollToIndex: 20 + }); + expect(updateScrollOffsetForScrollToIndexCalls.length).toEqual(0); + }); + + it("should not call :computeMetadataCallback if the above conditions are not true", () => { + const { computeMetadataCallbackCalls } = helper(); + expect(computeMetadataCallbackCalls.length).toEqual(0); + }); +}); diff --git a/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js b/lib/components/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.ts similarity index 72% rename from source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js rename to lib/components/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.ts index 7a9108990..693e9368e 100644 --- a/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js +++ b/lib/components/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.ts @@ -1,39 +1,37 @@ -// @flow - /** * Helper method that determines when to recalculate row or column metadata. */ type Params = { // Number of rows or columns in the current axis - cellCount: number, + cellCount: number; // Width or height of cells for the current axis - cellSize: ?number, + cellSize: number | null | undefined; // Method to invoke if cell metadata should be recalculated - computeMetadataCallback: (props: T) => void, + computeMetadataCallback: (props: T) => void; // Parameters to pass to :computeMetadataCallback - computeMetadataCallbackProps: T, + computeMetadataCallbackProps: T; // Newly updated number of rows or columns in the current axis - nextCellsCount: number, + nextCellsCount: number; // Newly updated width or height of cells for the current axis - nextCellSize: ?number, + nextCellSize: number | null | undefined; // Newly updated scroll-to-index - nextScrollToIndex: number, + nextScrollToIndex: number; // Scroll-to-index - scrollToIndex: number, + scrollToIndex: number; // Callback to invoke if the scroll position should be recalculated - updateScrollOffsetForScrollToIndex: () => void, + updateScrollOffsetForScrollToIndex: () => void; }; -export default function calculateSizeAndPositionDataAndUpdateScrollOffset({ +export function calculateSizeAndPositionDataAndUpdateScrollOffset({ cellCount, cellSize, computeMetadataCallback, @@ -42,13 +40,13 @@ export default function calculateSizeAndPositionDataAndUpdateScrollOffset({ nextCellSize, nextScrollToIndex, scrollToIndex, - updateScrollOffsetForScrollToIndex, -}: Params<*>) { + updateScrollOffsetForScrollToIndex +}: Params) { // Don't compare cell sizes if they are functions because inline functions would cause infinite loops. // In that event users should use the manual recompute methods to inform of changes. if ( cellCount !== nextCellsCount || - ((typeof cellSize === 'number' || typeof nextCellSize === 'number') && + ((typeof cellSize === "number" || typeof nextCellSize === "number") && cellSize !== nextCellSize) ) { computeMetadataCallback(computeMetadataCallbackProps); diff --git a/lib/components/Grid/utils/maxElementSize.ts b/lib/components/Grid/utils/maxElementSize.ts new file mode 100644 index 000000000..5cd4ff9eb --- /dev/null +++ b/lib/components/Grid/utils/maxElementSize.ts @@ -0,0 +1,7 @@ +// The largest size (in pixels) of the scrollable content; beyond it, scroll offsets are scaled (see ScalingCellSizeAndPositionManager). +// Modern browsers support at least this size: Chromium and WebKit support taller elements, and Firefox about 17.9M px. +// (Version 9 used a much smaller 1.5M px limit, required by Internet Explorer, in every browser other than Chrome; +// React 18+ doesn't support Internet Explorer.) +const MAX_ELEMENT_SIZE = 1.67771e7; + +export const getMaxElementSize = (): number => MAX_ELEMENT_SIZE; diff --git a/lib/components/Grid/utils/updateScrollIndexHelper.test.ts b/lib/components/Grid/utils/updateScrollIndexHelper.test.ts new file mode 100644 index 000000000..4818f71fb --- /dev/null +++ b/lib/components/Grid/utils/updateScrollIndexHelper.test.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it } from "vitest"; +import { CellSizeAndPositionManager } from "./CellSizeAndPositionManager"; +import { updateScrollIndexHelper } from "./updateScrollIndexHelper"; + +// Default cell sizes and offsets for use in shared tests +export function getCellSizeAndPositionManager({ + cellCount = CELL_SIZES.length, + estimatedCellSize = 10 +}: { + cellCount?: number; + estimatedCellSize?: number; +}) { + return new CellSizeAndPositionManager({ + cellCount, + cellSizeGetter: ({ index }) => CELL_SIZES[index % CELL_SIZES.length], + estimatedCellSize + }); +} + +const CELL_SIZES = [ + 10, // 0: 0..0 (min) + 20, // 1: 0..10 + 15, // 2: 0..30 + 10, // 3: 5..45 + 15, // 4: 20..55 + 30, // 5: 50..70 + 20, // 6: 70..100 + 10, // 7: 80..110 + 30 // 8: 110..110 (max) +]; + +describe("updateScrollIndexHelper", () => { + function helper({ + cellCount = undefined, + cellSizeAndPositionManager, + previousCellsCount = undefined, + previousSize = 50, + scrollOffset = 0, + size = 50 + }: { + cellCount?: number; + cellSizeAndPositionManager?: any; + previousCellsCount?: number; + previousSize?: number; + scrollOffset?: number; + size?: number; + } = {}) { + cellSizeAndPositionManager = + cellSizeAndPositionManager || + getCellSizeAndPositionManager({ cellCount }); + cellCount = + cellCount === undefined + ? cellSizeAndPositionManager.getCellCount() + : cellCount; + previousCellsCount = + previousCellsCount === undefined ? cellCount : previousCellsCount; + + const indices: number[] = []; + + updateScrollIndexHelper({ + cellSizeAndPositionManager, + previousCellsCount: previousCellsCount!, + previousSize, + scrollOffset, + size, + updateScrollIndexCallback: (index) => indices.push(index) + }); + + return indices; + } + + it("should not call :updateScrollIndexCallback if neither :size nor :cellCount have changed", () => { + expect(helper({ scrollOffset: 510 })).toEqual([]); + }); + + it("should call :updateScrollIndexCallback with the last index if :cellCount has been reduced past the current scroll offset", () => { + expect( + helper({ + previousCellsCount: 100, + scrollOffset: 510 + }) + ).toEqual([CELL_SIZES.length - 1]); + }); + + it("should call :updateScrollIndexCallback with the last index if :size has been reduced past the current scroll offset", () => { + expect( + helper({ + previousSize: 100, + scrollOffset: 510, + size: 50 + }) + ).toEqual([CELL_SIZES.length - 1]); + }); + + it("should not call :updateScrollIndexCallback if :size has been reduced but the scroll offset is still within range", () => { + expect( + helper({ + previousSize: 100, + scrollOffset: 0, + size: 50 + }) + ).toEqual([]); + }); + + it("should not measure rows if :size or :cellCount have been reduced but only use already measured (or estimated) total size", () => { + const cellSizeAndPositionManager = { + getCellCount: () => CELL_SIZES.length, + getTotalSize: () => 560 + }; + + expect( + helper({ + cellSizeAndPositionManager, + previousSize: 100, + scrollOffset: 510, + size: 50 + }) + ).toEqual([]); + }); + + it("should not call :updateScrollIndexCallback if :cellCount has been increased", () => { + expect( + helper({ + cellCount: 100, + previousCellsCount: 50, + scrollOffset: 510 + }) + ).toEqual([]); + }); + + it("should not call :updateScrollIndexCallback if :size has been increased", () => { + expect( + helper({ + previousSize: 50, + scrollOffset: 510, + size: 100 + }) + ).toEqual([]); + }); +}); diff --git a/lib/components/Grid/utils/updateScrollIndexHelper.ts b/lib/components/Grid/utils/updateScrollIndexHelper.ts new file mode 100644 index 000000000..b70c6eef1 --- /dev/null +++ b/lib/components/Grid/utils/updateScrollIndexHelper.ts @@ -0,0 +1,55 @@ +import type { ScalingCellSizeAndPositionManager } from "./ScalingCellSizeAndPositionManager"; + +/** + * Helper function that ensures that the scroll offset isn't past the last column/row of cells + * after the size of the Grid (or its number of columns/rows) has decreased. + */ + +type Params = { + // Manages size and position metadata of cells + cellSizeAndPositionManager: Pick< + ScalingCellSizeAndPositionManager, + "getCellCount" | "getTotalSize" + >; + + // Previous number of rows or columns + previousCellsCount: number; + + // Previous width or height of the virtualized container + previousSize: number; + + // Current scrollLeft or scrollTop + scrollOffset: number; + + // Width or height of the virtualized container + size: number; + + // Callback to invoke with the index of the last cell + updateScrollIndexCallback: (index: number) => void; +}; + +export function updateScrollIndexHelper({ + cellSizeAndPositionManager, + previousCellsCount, + previousSize, + scrollOffset, + size, + updateScrollIndexCallback +}: Params) { + const cellCount = cellSizeAndPositionManager.getCellCount(); + + // If the size or number of children have decreased, + // make sure we aren't scrolled too far past the current content. + if ( + cellCount > 0 && + (size < previousSize || cellCount < previousCellsCount) + ) { + // We need to ensure that the current scroll offset is still within the collection's range. + // To do this, we don't need to measure everything; CellMeasurer would perform poorly. + // Just check to make sure we're still okay. + // Only adjust the scroll position if we've scrolled below the last set of rows. + if (scrollOffset > cellSizeAndPositionManager.getTotalSize() - size) { + updateScrollIndexCallback(cellCount - 1); + } + } +} diff --git a/lib/components/InfiniteLoader/InfiniteLoader.test.tsx b/lib/components/InfiniteLoader/InfiniteLoader.test.tsx new file mode 100644 index 000000000..783cb3096 --- /dev/null +++ b/lib/components/InfiniteLoader/InfiniteLoader.test.tsx @@ -0,0 +1,822 @@ +import { + act, + cloneElement, + Component, + createRef, + forwardRef, + StrictMode, + useImperativeHandle +} from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "../../utils/test/render"; +import { List } from "../List/List"; +import type { ListImperativeAPI, RowRendererParams } from "../List/types"; +import { defaultInfiniteLoaderProps } from "./defaultInfiniteLoaderProps"; +import { InfiniteLoader } from "./InfiniteLoader"; +import { + forceUpdateReactVirtualizedComponent, + isRangeVisible, + scanForUnloadedRanges +} from "./utils"; +import type { + IndexRange, + InfiniteLoaderChildInstance, + InfiniteLoaderChildProps, + InfiniteLoaderImperativeAPI +} from "./types"; + +type ScanParams = Parameters[0]; + +describe("InfiniteLoader", () => { + let innerOnRowsRendered: InfiniteLoaderChildProps["onRowsRendered"]; + let isRowLoadedCalls: number[] = []; + let isRowLoadedMap: Record = {}; + let loadMoreRowsCalls: IndexRange[] = []; + let rowRendererCalls: number[] = []; + + beforeEach(() => { + isRowLoadedCalls = []; + isRowLoadedMap = {}; + loadMoreRowsCalls = []; + rowRendererCalls = []; + }); + + function defaultIsRowLoaded({ index }: { index: number }) { + isRowLoadedCalls.push(index); + return !!isRowLoadedMap[index]; + } + + function defaultLoadMoreRows({ startIndex, stopIndex }: IndexRange) { + loadMoreRowsCalls.push({ startIndex, stopIndex }); + } + + function rowRenderer({ index, key, style }: RowRendererParams) { + rowRendererCalls.push(index); + return
; + } + + // The rendered List (in addition to registering it with the InfiniteLoader) + let list: ListImperativeAPI | null = null; + const listRefs = new WeakMap< + InfiniteLoaderChildProps["registerChild"], + (instance: ListImperativeAPI | null) => void + >(); + function getListRef( + registerChild: InfiniteLoaderChildProps["registerChild"] + ) { + let ref = listRefs.get(registerChild); + if (!ref) { + ref = (instance: ListImperativeAPI | null) => { + list = instance; + registerChild(instance); + }; + listRefs.set(registerChild, ref); + } + return ref; + } + + function scrollToRow(index: number) { + act(() => { + list!.scrollToRow({ index }); + }); + } + + function getMarkup({ + height = 100, + isRowLoaded = defaultIsRowLoaded, + loadMoreRows = defaultLoadMoreRows, + minimumBatchSize = 1, + rowHeight = 20, + rowCount = 100, + threshold = 10, + width = 200 + }: { + height?: number; + isRowLoaded?: (params: { index: number }) => boolean; + loadMoreRows?: (params: IndexRange) => Promise | void; + minimumBatchSize?: number; + rowHeight?: number; + rowCount?: number; + threshold?: number; + width?: number; + } = {}) { + return ( + + {({ onRowsRendered, registerChild }) => { + innerOnRowsRendered = onRowsRendered; + + return ( + + ); + }} + + ); + } + + it("should call :isRowLoaded for all rows within the threshold each time a range of rows are rendered", () => { + render(getMarkup()); + expect(isRowLoadedCalls).toEqual([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 + ]); + }); + + it("should call :isRowLoaded for all rows within the rowCount each time a range of rows are rendered", () => { + render(getMarkup({ rowCount: 10 })); + expect(isRowLoadedCalls).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + it("should call :loadMoreRows for unloaded rows within the threshold", () => { + render(getMarkup()); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 14 }]); + }); + + it("should call :loadMoreRows for unloaded rows within the rowCount", () => { + render(getMarkup({ rowCount: 10 })); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 9 }]); + }); + + it("should :forceUpdate once rows have loaded if :loadMoreRows returns a Promise", async () => { + let savedResolve: (value?: unknown) => void = () => {}; + function loadMoreRows() { + return new Promise((resolve) => { + savedResolve = resolve; + }); + } + render(getMarkup({ loadMoreRows })); + rowRendererCalls.splice(0); + await act(async () => { + await savedResolve(); + }); + expect(rowRendererCalls.length > 0).toEqual(true); + }); + + it("should not :forceUpdate once rows have loaded rows are no longer visible", async () => { + const resolves: Array<(value?: unknown) => void> = []; + function loadMoreRows() { + return new Promise((resolve) => { + resolves.push(resolve); + }); + } + render(getMarkup({ loadMoreRows })); + // Simulate a new range of rows being loaded + innerOnRowsRendered({ startIndex: 100, stopIndex: 101 }); + rowRendererCalls.splice(0); + await act(async () => { + await resolves[0](); // Resolve the first request only, not the simulated row-change + }); + expect(rowRendererCalls.length).toEqual(0); + }); + + describe("minimumBatchSize", () => { + it("should respect the specified :minimumBatchSize when scrolling down", () => { + render( + getMarkup({ + minimumBatchSize: 10, + threshold: 0 + }) + ); + expect(loadMoreRowsCalls.length).toEqual(1); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 9 }]); + }); + + it("should respect the specified :minimumBatchSize when scrolling up", () => { + render( + getMarkup({ + minimumBatchSize: 10, + threshold: 0 + }) + ); + scrollToRow(20); + loadMoreRowsCalls.splice(0); + render( + getMarkup({ + isRowLoaded: ({ index }) => index >= 20, + minimumBatchSize: 10, + threshold: 0 + }) + ); + scrollToRow(15); + expect(loadMoreRowsCalls.length).toEqual(1); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 10, stopIndex: 19 }]); + }); + + it("should not interfere with :threshold", () => { + render( + getMarkup({ + minimumBatchSize: 10, + threshold: 10 + }) + ); + expect(loadMoreRowsCalls.length).toEqual(1); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 14 }]); + }); + + it("should respect the specified :minimumBatchSize if a user scrolls past the previous range", () => { + const isRowLoadedIndices: Record = {}; + + function isRowLoaded({ index }: { index: number }) { + if (!isRowLoadedIndices[index]) { + isRowLoadedIndices[index] = true; + + return false; + } else { + return true; + } + } + + render( + getMarkup({ + isRowLoaded, + minimumBatchSize: 10, + threshold: 0 + }) + ); + // Simulate a new range of rows being loaded + innerOnRowsRendered({ startIndex: 5, stopIndex: 10 }); + expect(loadMoreRowsCalls).toEqual([ + { startIndex: 0, stopIndex: 9 }, + { startIndex: 10, stopIndex: 19 } + ]); + }); + + it("should not exceed ending boundaries if :minimumBatchSize is larger than needed", () => { + render( + getMarkup({ + minimumBatchSize: 10, + rowCount: 25, + threshold: 0 + }) + ); + // Simulate a new range of rows being loaded + innerOnRowsRendered({ startIndex: 18, stopIndex: 22 }); + expect(loadMoreRowsCalls).toEqual([ + { startIndex: 0, stopIndex: 9 }, + { startIndex: 15, stopIndex: 24 } + ]); + }); + + it("should not exceed beginning boundaries if :minimumBatchSize is larger than needed", () => { + render( + getMarkup({ + minimumBatchSize: 10, + threshold: 0 + }) + ); + scrollToRow(15); + loadMoreRowsCalls.splice(0); + render( + getMarkup({ + isRowLoaded: ({ index }) => index >= 6, + minimumBatchSize: 10, + threshold: 0 + }) + ); + scrollToRow(2); + expect(loadMoreRowsCalls.length).toEqual(1); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 5 }]); + }); + }); + + // Verifies improved memoization; see bvaughn/react-virtualized/issues/345 + it("should memoize calls to :loadMoreRows (not calling unless unloaded ranges have changed)", () => { + render( + getMarkup({ + isRowLoaded: () => false, + minimumBatchSize: 20, + threshold: 0 + }) + ); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 19 }]); + innerOnRowsRendered({ startIndex: 0, stopIndex: 15 }); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 19 }]); + loadMoreRowsCalls.splice(0); + innerOnRowsRendered({ startIndex: 0, stopIndex: 20 }); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 20 }]); + }); + + it("resetLoadMoreRowsCache should reset memoized state", () => { + const component = render( + getMarkup({ + isRowLoaded: () => false, + minimumBatchSize: 20, + threshold: 0 + }) + ); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 19 }]); + innerOnRowsRendered({ startIndex: 0, stopIndex: 15 }); + loadMoreRowsCalls.splice(0); + expect(loadMoreRowsCalls).toEqual([]); + act(() => { + component.resetLoadMoreRowsCache(); + }); + innerOnRowsRendered({ startIndex: 0, stopIndex: 15 }); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 19 }]); + }); + + it("resetLoadMoreRowsCache should call :loadMoreRows if :autoReload parameter is true", () => { + const component = render( + getMarkup({ + isRowLoaded: () => false, + minimumBatchSize: 1, + threshold: 0 + }) + ); + + // Simulate a new range of rows being loaded + loadMoreRowsCalls.splice(0); + innerOnRowsRendered({ startIndex: 0, stopIndex: 10 }); + act(() => { + component.resetLoadMoreRowsCache(true); + }); + expect(loadMoreRowsCalls[loadMoreRowsCalls.length - 1]).toEqual({ + startIndex: 0, + stopIndex: 10 + }); + + // Simulate a new range of rows being loaded + loadMoreRowsCalls.splice(0); + innerOnRowsRendered({ startIndex: 20, stopIndex: 30 }); + expect(loadMoreRowsCalls[loadMoreRowsCalls.length - 1]).toEqual({ + startIndex: 20, + stopIndex: 30 + }); + + loadMoreRowsCalls.splice(0); + act(() => { + component.resetLoadMoreRowsCache(true); + }); + expect(loadMoreRowsCalls[loadMoreRowsCalls.length - 1]).toEqual({ + startIndex: 20, + stopIndex: 30 + }); + }); + + it("should expose a stable imperative handle", () => { + const first = render(getMarkup()); + const second = render(getMarkup()); + expect(typeof first.resetLoadMoreRowsCache).toBe("function"); + expect(second).toBe(first); + }); + + it("should pass stable :onRowsRendered and :registerChild functions to children", () => { + const params: InfiniteLoaderChildProps[] = []; + const children = (childProps: InfiniteLoaderChildProps) => { + params.push(childProps); + return null; + }; + render( + true} loadMoreRows={() => {}}> + {children} + + ); + render( + false} loadMoreRows={() => {}}> + {children} + + ); + expect(params.length).toBe(2); + expect(params[1].onRowsRendered).toBe(params[0].onRowsRendered); + expect(params[1].registerChild).toBe(params[0].registerChild); + }); + + it("should use the default prop values", () => { + expect(defaultInfiniteLoaderProps).toEqual({ + minimumBatchSize: 10, + rowCount: 0, + threshold: 15 + }); + expect(Object.isFrozen(defaultInfiniteLoaderProps)).toBe(true); + + let onRowsRendered: InfiniteLoaderChildProps["onRowsRendered"] = () => {}; + render( + + {(params) => { + onRowsRendered = params.onRowsRendered; + return null; + }} + + ); + // rowCount defaults to 0, so there is nothing to load + onRowsRendered({ startIndex: 0, stopIndex: 5 }); + expect(loadMoreRowsCalls).toEqual([]); + + render( + + {(params) => { + onRowsRendered = params.onRowsRendered; + return null; + }} + + ); + // threshold defaults to 15 (0 + 1 + 15 => 0-16) and minimumBatchSize to 10 + onRowsRendered({ startIndex: 0, stopIndex: 1 }); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 16 }]); + loadMoreRowsCalls.splice(0); + isRowLoadedMap = Object.fromEntries( + Array.from({ length: 63 }, (_, index) => [index, true]) + ); + // threshold 15 => 35-65 is scanned; minimumBatchSize 10 => 63-72 is loaded + onRowsRendered({ startIndex: 50, stopIndex: 50 }); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 63, stopIndex: 72 }]); + }); + + it("should use the latest props when the child calls :onRowsRendered during its commit", () => { + render(getMarkup({ rowCount: 10 })); + loadMoreRowsCalls.splice(0); + // The child List re-renders with (and reports) the new rowCount; + // InfiniteLoader must already see the new isRowLoaded/rowCount at that point. + render( + getMarkup({ + isRowLoaded: ({ index }) => index < 10, + rowCount: 20 + }) + ); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 10, stopIndex: 14 }]); + }); + + it("should re-render a function component child via its imperative handle once rows have loaded", async () => { + const recomputeGridSize = vi.fn(); + const FunctionChild = forwardRef< + InfiniteLoaderChildInstance, + { onRowsRendered: InfiniteLoaderChildProps["onRowsRendered"] } + >(function FunctionChild({ onRowsRendered }, ref) { + useImperativeHandle(ref, () => ({ recomputeGridSize }), []); + onRowsRenderedFromChild = onRowsRendered; + return
; + }); + let onRowsRenderedFromChild: InfiniteLoaderChildProps["onRowsRendered"] = + () => {}; + + let savedResolve: (value?: unknown) => void = () => {}; + render( + false} + loadMoreRows={() => + new Promise((resolve) => { + savedResolve = resolve; + }) + } + rowCount={100} + > + {({ onRowsRendered, registerChild }) => ( + + )} + + ); + act(() => { + onRowsRenderedFromChild({ startIndex: 5, stopIndex: 10 }); + }); + await act(async () => { + await savedResolve(); + }); + expect(recomputeGridSize).toHaveBeenCalledTimes(1); + expect(recomputeGridSize).toHaveBeenCalledWith({ rowIndex: 5 }); + }); + + it("should not update the child if rows finish loading after unmount", async () => { + const recomputeGridSize = vi.fn(); + let savedResolve: (value?: unknown) => void = () => {}; + let onRowsRendered: InfiniteLoaderChildProps["onRowsRendered"] = () => {}; + let registerChild: InfiniteLoaderChildProps["registerChild"] = () => {}; + render( + false} + loadMoreRows={() => + new Promise((resolve) => { + savedResolve = resolve; + }) + } + rowCount={100} + > + {(params) => { + onRowsRendered = params.onRowsRendered; + registerChild = params.registerChild; + return
{}} />; + }} + + ); + registerChild({ recomputeGridSize }); + onRowsRendered({ startIndex: 0, stopIndex: 10 }); + + // Unmounting a child detaches its ref, which unregisters it + registerChild(null); + render.unmount(); + + await act(async () => { + await savedResolve(); + }); + expect(recomputeGridSize).not.toHaveBeenCalled(); + }); + + it("should not update a List that is unmounted before rows finish loading", async () => { + const consoleError = vi.spyOn(console, "error"); + let savedResolve: (value?: unknown) => void = () => {}; + render( + getMarkup({ + loadMoreRows: () => + new Promise((resolve) => { + savedResolve = resolve; + }) + }) + ); + render.unmount(); + rowRendererCalls.splice(0); + await act(async () => { + await savedResolve(); + }); + expect(rowRendererCalls).toEqual([]); + expect(consoleError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("should work in StrictMode", async () => { + const consoleError = vi.spyOn(console, "error"); + const resolves: Array<(value?: unknown) => void> = []; + function loadMoreRows(range: IndexRange) { + loadMoreRowsCalls.push(range); + return new Promise((resolve) => { + resolves.push(resolve); + }); + } + const handleRef = createRef(); + render( + + {cloneElement(getMarkup({ loadMoreRows }), { ref: handleRef })} + + ); + const handle = handleRef.current!; + // Memoization survives StrictMode's double render / effect re-run + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 14 }]); + + rowRendererCalls.splice(0); + await act(async () => { + await resolves[0](); + }); + expect(rowRendererCalls.length > 0).toEqual(true); + + loadMoreRowsCalls.splice(0); + act(() => { + innerOnRowsRendered({ startIndex: 0, stopIndex: 4 }); + }); + expect(loadMoreRowsCalls).toEqual([]); + act(() => { + handle.resetLoadMoreRowsCache(true); + }); + expect(loadMoreRowsCalls).toEqual([{ startIndex: 0, stopIndex: 14 }]); + + expect(consoleError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); + +describe("scanForUnloadedRanges", () => { + function createIsRowLoaded(rows: boolean[]) { + return ({ index }: { index: number }) => rows[index]; + } + + it("should return an empty array for a range of rows that have all been loaded", () => { + expect( + scanForUnloadedRanges({ + isRowLoaded: createIsRowLoaded([true, true, true]), + startIndex: 0, + stopIndex: 2 + } as ScanParams) + ).toEqual([]); + }); + + it("return a range of only 1 unloaded row", () => { + expect( + scanForUnloadedRanges({ + isRowLoaded: createIsRowLoaded([true, false, true]), + startIndex: 0, + stopIndex: 2 + } as ScanParams) + ).toEqual([{ startIndex: 1, stopIndex: 1 }]); + }); + + it("return a range of multiple unloaded rows", () => { + expect( + scanForUnloadedRanges({ + isRowLoaded: createIsRowLoaded([false, false, true]), + startIndex: 0, + stopIndex: 2 + } as ScanParams) + ).toEqual([{ startIndex: 0, stopIndex: 1 }]); + }); + + it("return multiple ranges of unloaded rows", () => { + expect( + scanForUnloadedRanges({ + isRowLoaded: createIsRowLoaded([ + true, + false, + false, + true, + false, + true, + false + ]), + startIndex: 0, + stopIndex: 6 + } as ScanParams) + ).toEqual([ + { startIndex: 1, stopIndex: 2 }, + { startIndex: 4, stopIndex: 4 }, + { startIndex: 6, stopIndex: 6 } + ]); + }); +}); + +describe("isRangeVisible", () => { + it("first row(s) are visible", () => { + expect( + isRangeVisible({ + lastRenderedStartIndex: 10, + lastRenderedStopIndex: 20, + startIndex: 20, + stopIndex: 30 + }) + ).toEqual(true); + }); + + it("last row(s) are visible", () => { + expect( + isRangeVisible({ + lastRenderedStartIndex: 10, + lastRenderedStopIndex: 20, + startIndex: 0, + stopIndex: 10 + }) + ).toEqual(true); + }); + + it("all row(s) are visible", () => { + expect( + isRangeVisible({ + lastRenderedStartIndex: 10, + lastRenderedStopIndex: 20, + startIndex: 12, + stopIndex: 14 + }) + ).toEqual(true); + }); + + it("no row(s) are visible", () => { + expect( + isRangeVisible({ + lastRenderedStartIndex: 10, + lastRenderedStopIndex: 20, + startIndex: 0, + stopIndex: 9 + }) + ).toEqual(false); + + expect( + isRangeVisible({ + lastRenderedStartIndex: 10, + lastRenderedStopIndex: 20, + startIndex: 21, + stopIndex: 30 + }) + ).toEqual(false); + }); +}); + +describe("forceUpdateReactVirtualizedComponent", () => { + it("should call :recomputeGridSize if defined", () => { + const recomputeGridSize = vi.fn(); + class TestComponent extends Component { + recomputeGridSize = recomputeGridSize; + render() { + return
; + } + } + forceUpdateReactVirtualizedComponent( + render(), + 10 + ); + expect(recomputeGridSize).toHaveBeenCalledTimes(1); + expect(recomputeGridSize).toHaveBeenCalledWith({ rowIndex: 10 }); + }); + + it("should only recompute List row heights from the specified index", () => { + const rowHeightCalls: number[] = []; + const list = render( + { + rowHeightCalls.push(index); + return 20; + }} + rowRenderer={({ key, style }) =>
} + width={200} + /> + ); + expect(rowHeightCalls).toContain(0); + + rowHeightCalls.splice(0); + act(() => { + forceUpdateReactVirtualizedComponent(list, 3); + }); + expect(rowHeightCalls.length).toBeGreaterThan(0); + expect(Math.min(...rowHeightCalls)).toBe(3); + }); + + it("should called :recomputeRowHeights if defined", () => { + const recomputeRowHeights = vi.fn(); + class TestComponent extends Component { + recomputeRowHeights = recomputeRowHeights; + render() { + return
; + } + } + forceUpdateReactVirtualizedComponent( + render(), + 10 + ); + expect(recomputeRowHeights).toHaveBeenCalledTimes(1); + expect(recomputeRowHeights).toHaveBeenCalledWith(10); + }); + + it("should call :forceUpdateGrid if neither recompute method is defined", () => { + const forceUpdateGrid = vi.fn(); + const forceUpdate = vi.fn(); + forceUpdateReactVirtualizedComponent({ forceUpdate, forceUpdateGrid }, 10); + expect(forceUpdateGrid).toHaveBeenCalledTimes(1); + expect(forceUpdate).not.toHaveBeenCalled(); + }); + + it("should call :forceUpdate otherwise", () => { + const forceUpdate = vi.fn(); + class TestComponent extends Component { + forceUpdate = forceUpdate; + render() { + return
; + } + } + forceUpdateReactVirtualizedComponent( + render(), + 10 + ); + expect(forceUpdate).toHaveBeenCalledTimes(1); + }); + + it("should prefer the methods in order: recomputeGridSize, recomputeRowHeights, forceUpdateGrid, forceUpdate", () => { + const child = { + forceUpdate: vi.fn(), + forceUpdateGrid: vi.fn(), + recomputeGridSize: vi.fn(), + recomputeRowHeights: vi.fn() + }; + forceUpdateReactVirtualizedComponent(child, 3); + expect(child.recomputeGridSize).toHaveBeenCalledWith({ rowIndex: 3 }); + expect(child.recomputeRowHeights).not.toHaveBeenCalled(); + expect(child.forceUpdateGrid).not.toHaveBeenCalled(); + expect(child.forceUpdate).not.toHaveBeenCalled(); + + const { recomputeRowHeights, forceUpdateGrid, forceUpdate } = child; + forceUpdateReactVirtualizedComponent( + { forceUpdate, forceUpdateGrid, recomputeRowHeights }, + 3 + ); + expect(recomputeRowHeights).toHaveBeenCalledWith(3); + expect(forceUpdateGrid).not.toHaveBeenCalled(); + expect(forceUpdate).not.toHaveBeenCalled(); + }); + + it("should default the index to 0", () => { + const recomputeGridSize = vi.fn(); + forceUpdateReactVirtualizedComponent({ recomputeGridSize }); + expect(recomputeGridSize).toHaveBeenCalledWith({ rowIndex: 0 }); + }); + + it("should warn (in DEV) if the child implements none of the methods", () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + forceUpdateReactVirtualizedComponent({}, 0); + expect(consoleWarn).toHaveBeenCalledTimes(1); + consoleWarn.mockRestore(); + }); +}); diff --git a/lib/components/InfiniteLoader/InfiniteLoader.tsx b/lib/components/InfiniteLoader/InfiniteLoader.tsx new file mode 100644 index 000000000..2ca8962f3 --- /dev/null +++ b/lib/components/InfiniteLoader/InfiniteLoader.tsx @@ -0,0 +1,187 @@ +import { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useInsertionEffect, + useMemo, + useRef +} from "react"; +import { createCallbackMemoizer } from "../../utils/createCallbackMemoizer"; +import type { + IndexRange, + InfiniteLoaderChildInstance, + InfiniteLoaderImperativeAPI, + InfiniteLoaderProps +} from "./types"; +import { defaultInfiniteLoaderProps } from "./defaultInfiniteLoaderProps"; +import { + forceUpdateReactVirtualizedComponent, + isRangeVisible, + scanForUnloadedRanges +} from "./utils"; + +type LatestProps = Pick & { + minimumBatchSize: number; + rowCount: number; + threshold: number; +}; + +/** + * Higher-order component that manages lazy-loading for "infinite" data. + * This component decorates a virtual component and just-in-time prefetches rows as a user scrolls. + * It is intended as a convenience component; fork it if you'd like finer-grained control over data-loading. + */ +export const InfiniteLoader = /* @__PURE__ */ memo( + /* @__PURE__ */ forwardRef( + function InfiniteLoader( + { + children, + isRowLoaded, + loadMoreRows, + minimumBatchSize = defaultInfiniteLoaderProps.minimumBatchSize, + rowCount = defaultInfiniteLoaderProps.rowCount, + threshold = defaultInfiniteLoaderProps.threshold + }, + ref + ) { + // The registered child calls onRowsRendered from its own commit phase (e.g. componentDidMount/componentDidUpdate), + // which runs before this component's layout effects; so the latest props are stored in an insertion effect, + // which runs before any layout effects (without mutating anything during render). + const latestPropsRef = useRef({ + isRowLoaded, + loadMoreRows, + minimumBatchSize, + rowCount, + threshold + }); + useInsertionEffect(() => { + latestPropsRef.current = { + isRowLoaded, + loadMoreRows, + minimumBatchSize, + rowCount, + threshold + }; + }); + + const lastRenderedRangeRef = useRef<{ + startIndex: number | undefined; + stopIndex: number | undefined; + }>({ startIndex: undefined, stopIndex: undefined }); + const loadMoreRowsMemoizerRef = useRef | null>(null); + const registeredChildRef = useRef( + null + ); + + const getLoadMoreRowsMemoizer = useCallback(() => { + if (loadMoreRowsMemoizerRef.current === null) { + loadMoreRowsMemoizerRef.current = createCallbackMemoizer(); + } + return loadMoreRowsMemoizerRef.current; + }, []); + + const loadUnloadedRanges = useCallback((unloadedRanges: IndexRange[]) => { + const { loadMoreRows } = latestPropsRef.current; + + unloadedRanges.forEach((unloadedRange) => { + const promise = loadMoreRows(unloadedRange); + if (promise) { + promise.then(() => { + // Refresh the visible rows if any of them have just been loaded. + // Otherwise they will remain in their unloaded visual state. + // (If the child has been unmounted, it is no longer registered and nothing is updated.) + const { startIndex, stopIndex } = lastRenderedRangeRef.current; + if ( + isRangeVisible({ + lastRenderedStartIndex: startIndex as number, + lastRenderedStopIndex: stopIndex as number, + startIndex: unloadedRange.startIndex, + stopIndex: unloadedRange.stopIndex + }) + ) { + const registeredChild = registeredChildRef.current; + if (registeredChild) { + forceUpdateReactVirtualizedComponent( + registeredChild, + startIndex + ); + } + } + }); + } + }); + }, []); + + const loadRange = useCallback( + (startIndex: number, stopIndex: number) => { + const { isRowLoaded, minimumBatchSize, rowCount, threshold } = + latestPropsRef.current; + + const unloadedRanges = scanForUnloadedRanges({ + isRowLoaded, + minimumBatchSize, + rowCount, + startIndex: Math.max(0, startIndex - threshold), + stopIndex: Math.min(rowCount - 1, stopIndex + threshold) + }); + + // For memoize comparison + const squashedUnloadedRanges = ([] as number[]).concat( + ...unloadedRanges.map(({ startIndex, stopIndex }) => [ + startIndex, + stopIndex + ]) + ); + + getLoadMoreRowsMemoizer()({ + callback: () => { + loadUnloadedRanges(unloadedRanges); + }, + indices: { squashedUnloadedRanges } + }); + }, + [getLoadMoreRowsMemoizer, loadUnloadedRanges] + ); + + const onRowsRendered = useCallback( + ({ startIndex, stopIndex }: IndexRange) => { + lastRenderedRangeRef.current = { startIndex, stopIndex }; + + loadRange(startIndex, stopIndex); + }, + [loadRange] + ); + + const registerChild = useCallback( + (registeredChild: InfiniteLoaderChildInstance | null) => { + registeredChildRef.current = registeredChild; + }, + [] + ); + + // The handle object is created once, so it stays stable even if the ref itself changes. + const imperativeHandle = useMemo( + () => ({ + resetLoadMoreRowsCache(autoReload?: boolean) { + loadMoreRowsMemoizerRef.current = createCallbackMemoizer(); + + if (autoReload) { + const { startIndex, stopIndex } = lastRenderedRangeRef.current; + loadRange(startIndex as number, stopIndex as number); + } + } + }), + [loadRange] + ); + useImperativeHandle(ref, () => imperativeHandle, [imperativeHandle]); + + return children({ + onRowsRendered, + registerChild + }); + } + ) +); diff --git a/lib/components/InfiniteLoader/defaultInfiniteLoaderProps.ts b/lib/components/InfiniteLoader/defaultInfiniteLoaderProps.ts new file mode 100644 index 000000000..c5080d2c1 --- /dev/null +++ b/lib/components/InfiniteLoader/defaultInfiniteLoaderProps.ts @@ -0,0 +1,10 @@ +import type { InfiniteLoaderProps } from "./types"; + +/** + * Default values for `InfiniteLoader`'s optional props. + */ +export const defaultInfiniteLoaderProps = /* @__PURE__ */ Object.freeze({ + minimumBatchSize: 10, + rowCount: 0, + threshold: 15 +} as const satisfies Partial); diff --git a/lib/components/InfiniteLoader/types.ts b/lib/components/InfiniteLoader/types.ts new file mode 100644 index 000000000..0d885b985 --- /dev/null +++ b/lib/components/InfiniteLoader/types.ts @@ -0,0 +1,122 @@ +import type { ReactNode } from "react"; +import type { CellPosition } from "../Grid/types"; + +/** + * A range of rows (inclusive). + */ +export type IndexRange = { + /** Index of the first row in the range. */ + startIndex: number; + + /** Index of the last row in the range (inclusive). */ + stopIndex: number; +}; + +/** + * Structural type describing the virtualized component registered via `registerChild`: + * either a component instance or a function component's imperative handle (e.g. `List`, `Table`, `Grid` or `MultiGrid`). + * + * Once rows have finished loading, `InfiniteLoader` re-renders the child by calling the **first** of these methods that it implements: + * `recomputeGridSize`, `recomputeRowHeights`, `forceUpdateGrid`, `forceUpdate`. + * Function components have no `forceUpdate()` method, so a custom function component child should expose one of the other methods + * through its imperative handle (e.g. via `useImperativeHandle`). + */ +export interface InfiniteLoaderChildInstance { + /** + * Recomputes cell sizes and re-renders (e.g. `Grid`, `List`, `Table` or `MultiGrid`); preferred over the other methods if defined. + * Note that `InfiniteLoader` calls this method with the most recently rendered start index (`{ rowIndex }`). + */ + recomputeGridSize?(params?: Partial): void; + + /** + * Recomputes row heights after the specified index and re-renders (e.g. `List` or `Table`); used if `recomputeGridSize` is not defined. + * Note that `InfiniteLoader` calls this method with the most recently rendered start index. + */ + recomputeRowHeights?(index?: number): void; + + /** Re-renders the inner `Grid` (e.g. `List` or `Table`); used if neither recompute method is defined. */ + forceUpdateGrid?(): void; + + /** + * Forces a class component to re-render; used only if none of the other methods are defined. + * (Function components have no `forceUpdate()`; expose one of the other methods instead.) + */ + forceUpdate?(): void; +} + +/** + * Parameters passed to the `InfiniteLoader` children render function. + */ +export type InfiniteLoaderChildProps = { + /** + * This function should be passed as the child's `onRowsRendered` property. + * It informs the loader when the user is scrolling. + */ + onRowsRendered: (params: IndexRange) => void; + + /** + * This function should be set as the child's `ref` property. + * The child (instance or imperative handle) should implement one of the methods described by `InfiniteLoaderChildInstance`. + * It enables a set of rows to be refreshed once their data has finished loading. + */ + registerChild: (registeredChild: InfiniteLoaderChildInstance | null) => void; +}; + +export type InfiniteLoaderProps = { + /** + * Function responsible for rendering a virtualized component. + * It is passed `onRowsRendered` and `registerChild` parameters. + * The `onRowsRendered` function should be passed through to the child's `onRowsRendered` property. + * The `registerChild` callback should be set as the virtualized component's `ref`. + */ + children: (params: InfiniteLoaderChildProps) => ReactNode; + + /** + * Function responsible for tracking the loaded state of each row. + * Rows that are currently loading can be treated as loaded to avoid requesting them multiple times. + */ + isRowLoaded: (params: { index: number }) => boolean; + + /** + * Callback to be invoked when more rows must be loaded. + * The returned Promise should be resolved once row data has finished loading. + * It will be used to determine when to refresh the list with the newly-loaded data. + * This callback may be called multiple times in reaction to a single scroll event. + */ + loadMoreRows: (params: IndexRange) => Promise | void; + + /** + * Minimum number of rows to be loaded at a time. + * This property can be used to batch requests to reduce HTTP requests. + * + * @default 10 + */ + minimumBatchSize?: number; + + /** + * Number of rows in list; can be arbitrary high number if actual number is unknown. + * + * @default 0 + */ + rowCount?: number; + + /** + * Threshold at which to pre-fetch data. + * A threshold X means that data will start loading when a user scrolls within X rows. + * + * @default 15 + */ + threshold?: number; +}; + +/** + * Imperative API exposed by `InfiniteLoader` (e.g. via `useRef(null)`). + */ +export interface InfiniteLoaderImperativeAPI { + /** + * Reset any cached data about already-loaded rows. + * This method should be called if any/all loaded data needs to be refetched (e.g. a filtered list where the search criteria changes). + * If `autoReload` is true, the last loaded batch will be automatically reloaded. + */ + resetLoadMoreRowsCache(autoReload?: boolean): void; +} diff --git a/lib/components/InfiniteLoader/utils.ts b/lib/components/InfiniteLoader/utils.ts new file mode 100644 index 000000000..e70faf782 --- /dev/null +++ b/lib/components/InfiniteLoader/utils.ts @@ -0,0 +1,142 @@ +import type { + IndexRange, + InfiniteLoaderChildInstance, + InfiniteLoaderProps +} from "./types"; + +/** + * Determines if the specified start/stop range is visible based on the most recently rendered range. + */ +export function isRangeVisible({ + lastRenderedStartIndex, + lastRenderedStopIndex, + startIndex, + stopIndex +}: { + lastRenderedStartIndex: number; + lastRenderedStopIndex: number; + startIndex: number; + stopIndex: number; +}): boolean { + return !( + startIndex > lastRenderedStopIndex || stopIndex < lastRenderedStartIndex + ); +} + +/** + * Returns all of the ranges within a larger range that contain unloaded rows. + */ +export function scanForUnloadedRanges({ + isRowLoaded, + minimumBatchSize, + rowCount, + startIndex, + stopIndex +}: { + isRowLoaded: InfiniteLoaderProps["isRowLoaded"]; + minimumBatchSize: number; + rowCount: number; + startIndex: number; + stopIndex: number; +}): IndexRange[] { + const unloadedRanges: IndexRange[] = []; + + let rangeStartIndex: number | null = null; + let rangeStopIndex: number | null = null; + + for (let index = startIndex; index <= stopIndex; index++) { + const loaded = isRowLoaded({ index }); + + if (!loaded) { + rangeStopIndex = index; + if (rangeStartIndex === null) { + rangeStartIndex = index; + } + } else if (rangeStopIndex !== null) { + unloadedRanges.push({ + startIndex: rangeStartIndex!, + stopIndex: rangeStopIndex + }); + + rangeStartIndex = rangeStopIndex = null; + } + } + + // If :rangeStopIndex is not null it means we haven't ran out of unloaded rows. + // Scan forward to try filling our :minimumBatchSize. + if (rangeStopIndex !== null) { + const potentialStopIndex = Math.min( + Math.max(rangeStopIndex, rangeStartIndex! + minimumBatchSize - 1), + rowCount - 1 + ); + + for (let index = rangeStopIndex + 1; index <= potentialStopIndex; index++) { + if (!isRowLoaded({ index })) { + rangeStopIndex = index; + } else { + break; + } + } + + unloadedRanges.push({ + startIndex: rangeStartIndex!, + stopIndex: rangeStopIndex + }); + } + + // Check to see if our first range ended prematurely. + // In this case we should scan backwards to try filling our :minimumBatchSize. + if (unloadedRanges.length) { + const firstUnloadedRange = unloadedRanges[0]; + + while ( + firstUnloadedRange.stopIndex - firstUnloadedRange.startIndex + 1 < + minimumBatchSize && + firstUnloadedRange.startIndex > 0 + ) { + const index = firstUnloadedRange.startIndex - 1; + + if (!isRowLoaded({ index })) { + firstUnloadedRange.startIndex = index; + } else { + break; + } + } + } + + return unloadedRanges; +} + +/** + * Since RV components skip re-rendering when their props haven't changed (`PureComponent`/`memo`), + * a render must be forced once rows have loaded (even though props haven't changed). + * The registered child may be a class instance (e.g. a 9.x-style component) or a function component's imperative handle, + * and function components have no `forceUpdate()` method; so the first available method below is used: + * + * 1. `recomputeGridSize({ rowIndex })` (e.g. `Grid`, `List`, `Table`, `MultiGrid`) + * 2. `recomputeRowHeights(index)` (e.g. `List`, `Table`) + * 3. `forceUpdateGrid()` (e.g. `List`, `Table`) + * 4. `forceUpdate()` (any class component) + * + * The recompute methods are preferred because, while a Grid is scrolling, its cells may be cached; + * so it's important to invalidate that cache by recalculating sizes before forcing a re-render. + * (Calling `forceUpdate()` on a `List` or `Table` would not re-render the inner `Grid`.) + */ +export function forceUpdateReactVirtualizedComponent( + component: InfiniteLoaderChildInstance, + currentIndex: number = 0 +) { + if (typeof component.recomputeGridSize === "function") { + component.recomputeGridSize({ rowIndex: currentIndex }); + } else if (typeof component.recomputeRowHeights === "function") { + component.recomputeRowHeights(currentIndex); + } else if (typeof component.forceUpdateGrid === "function") { + component.forceUpdateGrid(); + } else if (typeof component.forceUpdate === "function") { + component.forceUpdate(); + } else if (process.env.NODE_ENV !== "production") { + console.warn( + "InfiniteLoader: the registered child does not implement recomputeGridSize(), recomputeRowHeights(), forceUpdateGrid() or forceUpdate(); it can't be re-rendered after rows have loaded." + ); + } +} diff --git a/lib/components/List/List.test.tsx b/lib/components/List/List.test.tsx new file mode 100644 index 000000000..a81e1b0b5 --- /dev/null +++ b/lib/components/List/List.test.tsx @@ -0,0 +1,1178 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Immutable from "immutable"; +import { fireEvent } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { + act, + createRef, + StrictMode, + useLayoutEffect, + type CSSProperties +} from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getObserverCount, + mockResizeObserver +} from "../../utils/test/mockResizeObserver"; +import { + findDOMNode as findDOMNodeUntyped, + render +} from "../../utils/test/render"; +import { + createScrollContainer, + mockWindowScrolling +} from "../../utils/test/scrollElement"; +import { CellMeasurer } from "../CellMeasurer/CellMeasurer"; +import { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import { accessibilityOverscanIndicesGetter } from "../Grid/accessibilityOverscanIndicesGetter"; +import { defaultOverscanIndicesGetter } from "../Grid/defaultOverscanIndicesGetter"; +import type { Alignment, OverscanIndicesGetterParams } from "../Grid/types"; +import { defaultListProps } from "./defaultListProps"; +import { List } from "./List"; +import type { + ListImperativeAPI, + ListProps, + RenderedRows, + RowRendererParams, + Scroll +} from "./types"; + +function findDOMNode( + instance: Parameters[0] +): HTMLDivElement { + return findDOMNodeUntyped(instance) as HTMLDivElement; +} + +describe("List", () => { + const array: string[] = []; + for (let i = 0; i < 100; i++) { + array.push(`Name ${i}`); + } + const names = Immutable.fromJS(array); + + // Override default behavior of overscanning by at least 1 (for accessibility) + // Because it makes for simple tests below + function overscanIndicesGetter({ + startIndex, + stopIndex + }: OverscanIndicesGetterParams) { + return { + overscanStartIndex: startIndex, + overscanStopIndex: stopIndex + }; + } + + function getMarkup(props: Partial = {}) { + function rowRenderer({ index, key, style }: RowRendererParams) { + return ( +
+ {names.get(index)} +
+ ); + } + + return ( + + ); + } + + function getInnerScrollContainerHeight() { + return document.body.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.height; + } + + function renderAndScrollToRow( + props: Partial, + params: { align?: Alignment; index: number } + ) { + const list = render(getMarkup(props)); + act(() => { + list.scrollToRow(params); + }); + return list; + } + + describe("number of rendered children", () => { + it("should render enough children to fill the view", () => { + const rendered = findDOMNode(render(getMarkup())); + expect(rendered.querySelectorAll(".listItem").length).toEqual(10); + }); + + it("should not render more children than available if the list is not filled", () => { + const rendered = findDOMNode(render(getMarkup({ rowCount: 5 }))); + expect(rendered.querySelectorAll(".listItem").length).toEqual(5); + }); + }); + + describe("scrollToPosition", () => { + it("should scroll to the top", () => { + const instance = render( + getMarkup({ + rowHeight: 10 + }) + ); + act(() => { + instance.scrollToPosition({ scrollTop: 100 }); + }); + const rendered = findDOMNode(instance); + expect(rendered.textContent).toContain("Name 10"); + expect(rendered.textContent).toContain("Name 19"); + }); + + it("should ignore scrollLeft", () => { + const instance = render(getMarkup()); + act(() => { + instance.scrollToPosition({ scrollLeft: 100 }); + }); + const rendered = findDOMNode(instance); + expect(rendered.scrollLeft).toEqual(0); + expect(rendered.scrollTop).toEqual(0); + act(() => { + instance.scrollToPosition({ scrollLeft: 100, scrollTop: 50 }); + }); + expect(rendered.scrollLeft).toEqual(0); + expect(rendered.scrollTop).toEqual(50); + }); + }); + + /** Tests scrolling via the imperative scrollToRow() method */ + describe("scrollToRow", () => { + it("should scroll to the top", () => { + const rendered = findDOMNode(renderAndScrollToRow({}, { index: 0 })); + expect(rendered.textContent).toContain("Name 0"); + }); + + it("should scroll down to the middle", () => { + const rendered = findDOMNode(renderAndScrollToRow({}, { index: 49 })); + // 100 items * 10 item height = 1,000 total item height + // 10 items can be visible at a time and :scrollTop is initially 0, + // So the minimum amount of scrolling leaves the 50th item at the bottom (just scrolled into view). + expect(rendered.textContent).toContain("Name 49"); + expect(rendered.scrollTop).toEqual(400); + }); + + it("should scroll to the bottom", () => { + const rendered = findDOMNode(renderAndScrollToRow({}, { index: 99 })); + // 100 height - 10 header = 90 available scroll space. + // 100 items * 10 item height = 1,000 total item height + // Target height for the last item then is 1000 - 90 + expect(rendered.textContent).toContain("Name 99"); + }); + + it('should scroll to the correct position for align "start"', () => { + const rendered = findDOMNode( + renderAndScrollToRow({}, { align: "start", index: 49 }) + ); + // 100 items * 10 item height = 1,000 total item height; 10 items can be visible at a time. + expect(rendered.textContent).toContain("Name 49"); + expect(rendered.textContent).toContain("Name 58"); + }); + + it('should scroll to the correct position for align "end"', () => { + const list = renderAndScrollToRow({}, { index: 99 }); + act(() => { + list.scrollToRow({ align: "end", index: 49 }); + }); + const rendered = findDOMNode(list); + // 100 items * 10 item height = 1,000 total item height; 10 items can be visible at a time. + expect(rendered.textContent).toContain("Name 40"); + expect(rendered.textContent).toContain("Name 49"); + }); + + it('should scroll to the correct position for align "center"', () => { + const list = renderAndScrollToRow({}, { index: 99 }); + act(() => { + list.scrollToRow({ align: "center", index: 49 }); + }); + const rendered = findDOMNode(list); + // 100 items * 10 item height = 1,000 total item height; 11 items can be visible at a time (the first and last item are only partially visible) + expect(rendered.textContent).toContain("Name 44"); + expect(rendered.textContent).toContain("Name 54"); + }); + + it("should support scrollToCell() (e.g. for ArrowKeyStepper)", () => { + const list = render(getMarkup()); + act(() => { + list.scrollToCell({ align: "start", columnIndex: 3, rowIndex: 49 }); + }); + const rendered = findDOMNode(list); + expect(rendered.scrollLeft).toEqual(0); + expect(rendered.scrollTop).toEqual(490); + }); + + it("should support getOffsetForRow()", () => { + const list = render(getMarkup()); + expect(list.getOffsetForRow({ index: 49 })).toEqual(400); + expect(list.getOffsetForRow({ align: "start", index: 49 })).toEqual(490); + expect(list.getOffsetForRow({ align: "center", index: 49 })).toEqual(445); + // The deprecated alignment param is still supported (align takes precedence) + expect(list.getOffsetForRow({ alignment: "start", index: 49 })).toEqual( + 490 + ); + expect( + list.getOffsetForRow({ align: "end", alignment: "start", index: 49 }) + ).toEqual(400); + // Defaults to the last row (and auto alignment), not to the row most recently scrolled to + act(() => { + list.scrollToRow({ align: "start", index: 20 }); + }); + const lastRowOffset = list.getOffsetForRow({ index: names.size - 1 }); + expect(list.getOffsetForRow({})).toEqual(lastRowOffset); + expect(list.getOffsetForRow()).toEqual(lastRowOffset); + }); + + it("should work in StrictMode", () => { + const ref = createRef(); + render( + +
{getMarkup({ ref } as Partial)}
+
+ ); + act(() => { + ref.current!.scrollToRow({ index: 49 }); + }); + const rendered = document.body.querySelector( + ".ReactVirtualized__List" + )!; + expect(rendered.scrollTop).toEqual(400); + expect(rendered.textContent).toContain("Name 49"); + }); + }); + + describe("initialScrollTop", () => { + it("should render and scroll to the initial offset on mount", () => { + const list = render( + getMarkup({ initialScrollTop: 200 }) + ); + const element = findDOMNode(list); + expect(element.scrollTop).toEqual(200); + expect(element.textContent).toContain("Name 20"); + expect(element.textContent).not.toContain("Name 0"); + }); + + it("should render the rows at the initial offset on the server, shifted into view", () => { + const html = renderToString(getMarkup({ initialScrollTop: 200 })); + expect(html).toContain("Name 20"); + expect(html).toContain("margin-top:-200px"); + }); + }); + + describe("property updates", () => { + it("should not keep the row scrolled to in view when :rowHeight changes", () => { + const list = renderAndScrollToRow({}, { index: 50 }); + expect(findDOMNode(list).scrollTop).toEqual(410); + // scrollToRow is a one-time scroll; making rows taller doesn't scroll back to row 50 + render(getMarkup({ rowHeight: 20 })); + expect(findDOMNode(list).scrollTop).toEqual(410); + expect(findDOMNode(list).textContent).not.toContain("Name 50"); + }); + + it("should update scroll position if size shrinks smaller than the current scroll", () => { + const list = renderAndScrollToRow({}, { index: 500 }); + render(getMarkup({ rowCount: 10 })); + expect(findDOMNode(list).textContent).toContain("Name 9"); + }); + }); + + describe("noRowsRenderer", () => { + it("should call :noRowsRenderer if :rowCount is 0", () => { + const rendered = findDOMNode( + render( + getMarkup({ + noRowsRenderer: () =>
No rows!
, + rowCount: 0 + }) + ) + ); + expect(rendered.textContent).toEqual("No rows!"); + }); + + it("should render an empty body if :rowCount is 0 and there is no :noRowsRenderer", () => { + const rendered = findDOMNode( + render( + getMarkup({ + rowCount: 0 + }) + ) + ); + expect(rendered.textContent).toEqual(""); + }); + }); + + describe("onRowsRendered", () => { + it("should call :onRowsRendered if at least one row is rendered", () => { + let startIndex: number | undefined, stopIndex: number | undefined; + render( + getMarkup({ + onRowsRendered: (params: RenderedRows) => + ({ startIndex, stopIndex } = params) + }) + ); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(9); + }); + + it("should not call :onRowsRendered unless the start or stop indices have changed", () => { + let numCalls = 0; + let startIndex: number | undefined; + let stopIndex: number | undefined; + const onRowsRendered = (params: RenderedRows) => { + startIndex = params.startIndex; + stopIndex = params.stopIndex; + numCalls++; + }; + findDOMNode(render(getMarkup({ onRowsRendered }))); + expect(numCalls).toEqual(1); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(9); + findDOMNode(render(getMarkup({ onRowsRendered }))); + expect(numCalls).toEqual(1); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(9); + }); + + it("should call :onRowsRendered if the start or stop indices have changed", () => { + let numCalls = 0; + let startIndex: number | undefined; + let stopIndex: number | undefined; + const onRowsRendered = (params: RenderedRows) => { + startIndex = params.startIndex; + stopIndex = params.stopIndex; + numCalls++; + }; + findDOMNode(render(getMarkup({ onRowsRendered }))); + expect(numCalls).toEqual(1); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(9); + findDOMNode( + render( + getMarkup({ + height: 50, + onRowsRendered + }) + ) + ); + expect(numCalls).toEqual(2); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(4); + }); + + it("should not call :onRowsRendered if no rows are rendered", () => { + let startIndex: number | undefined, stopIndex: number | undefined; + render( + getMarkup({ + height: 0, + onRowsRendered: (params: RenderedRows) => + ({ startIndex, stopIndex } = params) + }) + ); + expect(startIndex).toEqual(undefined); + expect(stopIndex).toEqual(undefined); + }); + }); + + describe("with a scrollElement", () => { + let unmockResizeObserver: () => void; + beforeEach(() => { + unmockResizeObserver = mockResizeObserver(); + }); + afterEach(() => { + render.unmount(); + unmockResizeObserver(); + }); + + // Renders the List within a scroll container (with a 100px viewport), below `contentOffset` pixels of other content + function renderInScrollContainer( + props: Partial = {}, + { + contentOffset = 0, + height = 100, + scrollTop = 0 + }: { contentOffset?: number; height?: number; scrollTop?: number } = {} + ) { + const container = createScrollContainer({ + contentOffset, + height, + width: 100 + }); + container.element.scrollTop = scrollTop; + const scrollTo = vi.spyOn(container.element, "scrollTo"); + const list = render( + getMarkup({ scrollElement: container.element, ...props }) + ); + return { container, list, scrollTo }; + } + + it("should render the rows within the viewport of the scroll element", () => { + let startIndex: number | undefined, stopIndex: number | undefined; + const { container, list } = renderInScrollContainer( + { + onRowsRendered: (params: RenderedRows) => + ({ startIndex, stopIndex } = params) + }, + { scrollTop: 100 } + ); + expect(startIndex).toEqual(10); + expect(stopIndex).toEqual(19); + expect(findDOMNode(list).style.height).toEqual("auto"); + + container.scroll(200); + expect(startIndex).toEqual(20); + expect(stopIndex).toEqual(29); + expect(findDOMNode(list).scrollTop).toEqual(0); + }); + + it("should measure its position within the scroll element", () => { + let startIndex: number | undefined; + const { container } = renderInScrollContainer( + { onRowsRendered: (params: RenderedRows) => ({ startIndex } = params) }, + { contentOffset: 150 } + ); + expect(startIndex).toEqual(0); + + container.scroll(250); + expect(startIndex).toEqual(10); + }); + + it("should scroll the scroll element with scrollToRow (#32)", () => { + const { list, scrollTo } = renderInScrollContainer(); + + act(() => { + list.scrollToRow({ index: 49 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 400); + expect(findDOMNode(list).textContent).toContain("Name 49"); + + act(() => { + list.scrollToRow({ align: "start", index: 20 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 200); + expect(findDOMNode(list).scrollTop).toEqual(0); + + // Already visible + scrollTo.mockClear(); + act(() => { + list.scrollToRow({ index: 25 }); + }); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("should scroll the scroll element with scrollToCell", () => { + const { list, scrollTo } = renderInScrollContainer(); + + act(() => { + list.scrollToCell({ align: "center", columnIndex: 0, rowIndex: 50 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 455); + }); + + it("should scroll the scroll element with scrollToPosition", () => { + const { list, scrollTo } = renderInScrollContainer( + {}, + { contentOffset: 100 } + ); + + act(() => { + list.scrollToPosition({ scrollTop: 300 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 400); + expect(findDOMNode(list).textContent).toContain("Name 30"); + }); + + it("should calculate getOffsetForRow relative to the viewport", () => { + const { list } = renderInScrollContainer({}, { scrollTop: 100 }); + expect(list.getOffsetForRow({ index: 5 })).toEqual(50); + expect(list.getOffsetForRow({ index: 15 })).toEqual(100); + }); + + it("should use the viewport height of the scroll element", () => { + let stopIndex: number | undefined; + renderInScrollContainer( + { + height: undefined, + onRowsRendered: (params: RenderedRows) => ({ stopIndex } = params) + }, + { height: 200 } + ); + expect(stopIndex).toEqual(19); + }); + + it("should start and stop being scrolled when the scrollElement changes from null to an element and back (#6)", () => { + let startIndex: number | undefined; + const onRowsRendered = (params: RenderedRows) => + ({ startIndex } = params); + const container = createScrollContainer({ height: 100 }); + container.element.scrollTop = 300; + + const list = render(getMarkup({ onRowsRendered, scrollElement: null })); + expect(findDOMNode(list).style.height).toEqual("auto"); + expect(startIndex).toEqual(0); + + render(getMarkup({ onRowsRendered, scrollElement: container.element })); + expect(startIndex).toEqual(30); + expect(getObserverCount(container.element)).toBe(1); + + render(getMarkup({ onRowsRendered, scrollElement: null })); + expect(startIndex).toEqual(0); + expect(getObserverCount(container.element)).toBe(0); + container.scroll(500); + expect(startIndex).toEqual(0); + + // Without a scrollElement, it scrolls itself again + render(getMarkup({ onRowsRendered })); + expect(findDOMNode(list).style.height).toEqual("100px"); + }); + + it("should ignore initialScrollTop", () => { + let startIndex: number | undefined; + const { list } = renderInScrollContainer({ + initialScrollTop: 200, + onRowsRendered: (params: RenderedRows) => ({ startIndex } = params) + }); + expect(startIndex).toEqual(0); + expect(findDOMNode(list).scrollTop).toEqual(0); + }); + + it('should be scrolled by the window (scrollElement="window")', () => { + let startIndex: number | undefined, stopIndex: number | undefined; + const windowScrolling = mockWindowScrolling({ innerHeight: 200 }); + const list = render( + getMarkup({ + onRowsRendered: (params: RenderedRows) => + ({ startIndex, stopIndex } = params), + scrollElement: "window" + }) + ); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(19); + + windowScrolling.scroll(500); + expect(startIndex).toEqual(50); + expect(document.body.style.pointerEvents).toEqual("none"); + + act(() => { + list.scrollToRow({ align: "start", index: 10 }); + }); + expect(window.scrollY).toEqual(100); + expect(startIndex).toEqual(10); + }); + + it("should work in StrictMode", () => { + let startIndex: number | undefined; + const ref = createRef(); + const container = createScrollContainer({ height: 100 }); + const scrollTo = vi.spyOn(container.element, "scrollTo"); + render( + +
+ {getMarkup({ + onRowsRendered: (params: RenderedRows) => + ({ startIndex } = params), + ref, + scrollElement: container.element + } as Partial)} +
+
+ ); + container.scroll(200); + expect(startIndex).toEqual(20); + + act(() => { + ref.current!.scrollToRow({ align: "start", index: 50 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 500); + expect(startIndex).toEqual(50); + }); + }); + + it("should warn in DEV if there is neither a height nor a scrollElement", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(getMarkup({ height: undefined })); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "List requires a height prop (unless a scrollElement is specified)" + ); + warn.mockRestore(); + }); + + describe("styles, classNames, and ids", () => { + it("should use the expected global CSS classNames", () => { + const node = findDOMNode(render(getMarkup())); + expect(node.className).toContain("ReactVirtualized__List"); + }); + + it("should use a custom :className if specified", () => { + const node = findDOMNode(render(getMarkup({ className: "foo" }))); + expect(node.className).toContain("foo"); + }); + + it("should use a custom :id if specified", () => { + const node = findDOMNode(render(getMarkup({ id: "bar" }))); + expect(node.getAttribute("id")).toEqual("bar"); + }); + + it("should use a custom :style if specified", () => { + const style = { backgroundColor: "red" }; + const rendered = findDOMNode(render(getMarkup({ style }))); + expect(rendered.style.backgroundColor).toEqual("red"); + }); + + it("should set the width of a row to be 100% by default", () => { + const rendered = findDOMNode(render(getMarkup())); + const cell = rendered.querySelector(".listItem")!; + expect(cell.style.width).toEqual("100%"); + }); + }); + + describe("overscanRowCount", () => { + it("should not overscan by default", () => { + const mock = vi.fn(); + mock.mockImplementation(overscanIndicesGetter); + + render( + getMarkup({ + overscanIndicesGetter: mock + }) + ); + expect(mock.mock.calls[0][0].overscanCellsCount).toEqual(0); + expect(mock.mock.calls[1][0].overscanCellsCount).toEqual(0); + }); + + it("should overscan the specified amount", () => { + const mock = vi.fn(); + mock.mockImplementation(overscanIndicesGetter); + + render( + getMarkup({ + overscanIndicesGetter: mock, + overscanRowCount: 10 + }) + ); + expect(mock.mock.calls[0][0].overscanCellsCount).toEqual(0); + expect(mock.mock.calls[1][0].overscanCellsCount).toEqual(10); + }); + }); + + describe("onScroll", () => { + it("should trigger callback when component initially mounts", () => { + const onScrollCalls: Scroll[] = []; + render( + getMarkup({ + onScroll: (params: Scroll) => onScrollCalls.push(params) + }) + ); + expect(onScrollCalls).toEqual([ + { + clientHeight: 100, + scrollHeight: 1000, + scrollTop: 0 + } + ]); + }); + + it("should trigger callback when component scrolls", () => { + const onScrollCalls: Scroll[] = []; + const rendered = render( + getMarkup({ + onScroll: (params: Scroll) => onScrollCalls.push(params) + }) + ); + fireEvent.scroll(findDOMNode(rendered), { + target: { scrollLeft: 0, scrollTop: 100 } + }); + expect(onScrollCalls[onScrollCalls.length - 1]).toEqual({ + clientHeight: 100, + scrollHeight: 1000, + scrollTop: 100 + }); + }); + }); + + describe("measureAllRows", () => { + it("should measure any unmeasured rows", () => { + const rowHeight = vi.fn(() => 20); + const props = { + estimatedRowSize: 15, + rowCount: 10, + rowHeight + }; + const rendered = render( + getMarkup({ ...props, height: 0, width: 0 }) + ); + expect(rowHeight).not.toHaveBeenCalled(); + act(() => { + rendered.measureAllRows(); + }); + expect(rowHeight).toHaveBeenCalledTimes(10); + + // All rows are measured (rather than just the 5 visible rows, with an estimated size for the others) + render(getMarkup({ ...props, height: 100, width: 100 })); + expect(getInnerScrollContainerHeight()).toEqual("200px"); + }); + + it("should estimate the size of rows that have not been measured", () => { + render( + getMarkup({ + estimatedRowSize: 15, + rowCount: 10, + rowHeight: () => 20 + }) + ); + // 5 visible (measured) rows * 20 + 5 estimated rows * 15 + expect(getInnerScrollContainerHeight()).toEqual("175px"); + }); + + it("should not throw if there are no rows", () => { + const rendered = render(getMarkup({ rowCount: 0 })); + expect(() => { + act(() => { + rendered.measureAllRows(); + }); + }).not.toThrow(); + }); + }); + + describe("recomputeRowHeights", () => { + it("should recompute row heights and other values when called", () => { + const indices: number[] = []; + const rowHeight = ({ index }: { index: number }) => { + indices.push(index); + return 10; + }; + const component = render( + getMarkup({ + rowHeight, + rowCount: 50 + }) + ); + + indices.splice(0); + act(() => { + component.recomputeRowHeights(); + }); + + // Only the rows required to fill the current viewport will be rendered + expect(indices[0]).toEqual(0); + expect(indices[indices.length - 1]).toEqual(9); + + indices.splice(0); + act(() => { + component.recomputeRowHeights(4); + }); + + expect(indices[0]).toEqual(4); + expect(indices[indices.length - 1]).toEqual(9); + }); + }); + + describe("forceUpdateGrid", () => { + it("should refresh inner Grid content when called", () => { + let marker = "a"; + function rowRenderer({ index, key, style }: RowRendererParams) { + return ( +
+ {index} + {marker} +
+ ); + } + const component = render(getMarkup({ rowRenderer })); + const node = findDOMNode(component); + expect(node.textContent).toContain("1a"); + marker = "b"; + act(() => { + component.forceUpdateGrid(); + }); + expect(node.textContent).toContain("1b"); + }); + }); + + describe("tabIndex", () => { + it("should be focusable by default", () => { + const rendered = findDOMNode(render(getMarkup())); + expect(rendered.tabIndex).toEqual(0); + }); + + it("should allow tabIndex to be overridden", () => { + const rendered = findDOMNode( + render( + getMarkup({ + tabIndex: -1 + }) + ) + ); + expect(rendered.tabIndex).toEqual(-1); + }); + }); + + it("should pass the cellRenderer an :isVisible flag", () => { + const rowRendererCalls: RowRendererParams[] = []; + function rowRenderer(props: RowRendererParams) { + rowRendererCalls.push(props); + return null; + } + findDOMNode( + render( + getMarkup({ + height: 50, + overscanIndicesGetter: defaultOverscanIndicesGetter, + overscanRowCount: 1, + rowHeight: 50, + rowRenderer + }) + ) + ); + expect(rowRendererCalls[0].isVisible).toEqual(true); + expect(rowRendererCalls[1].isVisible).toEqual(false); + }); + + it("should pass the List handle to the :rowRenderer as its :parent", () => { + const rowRenderer = vi.fn().mockReturnValue(null); + const list = render(getMarkup({ rowRenderer })); + expect(rowRenderer.mock.calls[0][0].parent).toBe(list); + }); + + describe("imperative handle", () => { + it("should be stable across renders", () => { + const list = render(getMarkup()); + const updated = render(getMarkup({ width: 200 })); + expect(updated).toBe(list); + }); + + it("should not throw if called after the List has unmounted", () => { + const list = render(getMarkup()); + render.unmount(); + expect(() => { + list.forceUpdateGrid(); + list.measureAllRows(); + list.recomputeRowHeights(); + list.recomputeGridSize(); + list.scrollToRow({ index: 10 }); + list.scrollToCell({ rowIndex: 10 }); + list.scrollToPosition({ scrollTop: 10 }); + }).not.toThrow(); + expect(list.getOffsetForRow({ index: 10 })).toEqual(0); + }); + }); + + describe("CellMeasurer", () => { + let measuredHeight = 25; + + // JSDom doesn't support measurements + const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetHeight" + )!; + function mockOffsetHeight() { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get: () => measuredHeight + }); + } + + afterEach(() => { + Object.defineProperty( + HTMLElement.prototype, + "offsetHeight", + originalOffsetHeight + ); + measuredHeight = 25; + }); + + function getCellMeasurerMarkup( + cache: CellMeasurerCache, + props: Partial = {} + ) { + const measureCallbacks: Array<() => void> = []; + function rowRenderer({ index, key, parent, style }: RowRendererParams) { + return ( + + {({ measure, registerChild }) => { + measureCallbacks[index] = measure; + return ( +
void} + style={style} + > + {names.get(index)} +
+ ); + }} +
+ ); + } + + return { + markup: getMarkup({ + deferredMeasurementCache: cache, + rowCount: 10, + rowHeight: cache.rowHeight, + rowRenderer, + ...props + }), + measureCallbacks + }; + } + + function getRowTops() { + return Array.from( + document.body.querySelectorAll(".listItem") + ).map((row) => row.style.top); + } + + it("should measure rows through the List handle (invalidateCellSizeAfterRender)", () => { + mockOffsetHeight(); + const cache = new CellMeasurerCache({ + defaultHeight: 10, + fixedWidth: true + }); + const { markup } = getCellMeasurerMarkup(cache); + const list = render(markup); + const invalidateCellSizeAfterRender = vi.spyOn( + list, + "invalidateCellSizeAfterRender" + ); + + expect(cache.getHeight(0, 0)).toEqual(25); + // Rows were re-positioned using their measured heights + expect(getRowTops().slice(0, 4)).toEqual(["0px", "25px", "50px", "75px"]); + // 4 visible (measured) rows * 25 + 6 estimated rows * 30 (estimatedRowSize) + expect(getInnerScrollContainerHeight()).toEqual("280px"); + + // Newly rendered (unmeasured) rows are reported to the List handle + expect(cache.has(9, 0)).toBe(true); + expect(cache.has(10, 0)).toBe(false); + render(getCellMeasurerMarkup(cache, { rowCount: 20 }).markup); + // Rows that haven't been measured yet are positioned using the cache's default height (10), + // so scrolling to row 19 (a one-time scroll) brings rows 10-13 into view + act(() => { + list.scrollToRow({ index: 19 }); + }); + expect(invalidateCellSizeAfterRender).toHaveBeenCalledWith({ + columnIndex: 0, + rowIndex: 10 + }); + expect(cache.getHeight(10, 0)).toEqual(25); + const row = Array.from( + document.body.querySelectorAll(".listItem") + ).find((row) => row.textContent === "Name 13")!; + expect(row.style.top).toEqual("325px"); + }); + + it("should re-measure rows through the List handle (recomputeGridSize)", () => { + mockOffsetHeight(); + const cache = new CellMeasurerCache({ + defaultHeight: 10, + fixedWidth: true + }); + const { markup, measureCallbacks } = getCellMeasurerMarkup(cache); + const list = render(markup); + const recomputeGridSize = vi.spyOn(list, "recomputeGridSize"); + expect(getRowTops().slice(0, 3)).toEqual(["0px", "25px", "50px"]); + + measuredHeight = 40; + act(() => { + measureCallbacks[0](); + }); + expect(recomputeGridSize).toHaveBeenCalledWith({ + columnIndex: 0, + rowIndex: 0 + }); + expect(cache.getHeight(0, 0)).toEqual(40); + expect(getRowTops().slice(0, 3)).toEqual(["0px", "40px", "65px"]); + }); + + it("should pass on a recomputeGridSize() call made by a row while the List is mounting", () => { + // Row 0 makes all rows taller as it mounts (before the List's inner Grid ref has been attached) + let rowHeight = 10; + function GrowingRow({ + index, + parent, + style + }: { + index: number; + parent: ListImperativeAPI; + style: CSSProperties; + }) { + useLayoutEffect(() => { + if (index === 0 && rowHeight === 10) { + rowHeight = 20; + parent.recomputeGridSize({ rowIndex: 0 }); + } + }, [index, parent]); + return ( +
+ {names.get(index)} +
+ ); + } + + render( + getMarkup({ + rowHeight: () => rowHeight, + rowRenderer: ({ index, key, parent, style }) => ( + + ) + }) + ); + const rows = document.body.querySelectorAll(".listItem"); + expect(rows[1].style.top).toEqual("20px"); + expect(rows[1].style.height).toEqual("20px"); + }); + + it("should measure rows in StrictMode", () => { + mockOffsetHeight(); + const cache = new CellMeasurerCache({ + defaultHeight: 10, + fixedWidth: true + }); + const { markup } = getCellMeasurerMarkup(cache); + render({markup}); + expect(cache.getHeight(0, 0)).toEqual(25); + expect(getRowTops().slice(0, 4)).toEqual(["0px", "25px", "50px", "75px"]); + // 4 visible (measured) rows * 25 + 6 estimated rows * 30 (estimatedRowSize) + expect(getInnerScrollContainerHeight()).toEqual("280px"); + }); + }); + + describe("pure", () => { + it("should not re-render unless props have changed", () => { + let rowRendererCalled = false; + function rowRenderer({ index, key, style }: RowRendererParams) { + rowRendererCalled = true; + return ( +
+ {index} +
+ ); + } + const markup = getMarkup({ rowRenderer }); + render(markup); + expect(rowRendererCalled).toEqual(true); + rowRendererCalled = false; + render(markup); + expect(rowRendererCalled).toEqual(false); + }); + }); + + it("should export its default props", () => { + expect(defaultListProps).toEqual({ + estimatedRowSize: 30, + noRowsRenderer: expect.any(Function), + onRowsRendered: expect.any(Function), + onScroll: expect.any(Function), + overscanIndicesGetter: accessibilityOverscanIndicesGetter, + overscanRowCount: 10, + role: "list", + style: {}, + tabIndex: 0 + }); + expect(Object.isFrozen(defaultListProps)).toBe(true); + }); + + describe("roles (#1657)", () => { + it("should render a list of list items by default", () => { + const rendered = findDOMNode(render(getMarkup())); + expect(rendered.getAttribute("role")).toEqual("list"); + expect(rendered.hasAttribute("aria-label")).toBe(false); + expect(rendered.hasAttribute("aria-readonly")).toBe(false); + expect( + rendered + .querySelector(".ReactVirtualized__Grid__innerScrollContainer")! + .getAttribute("role") + ).toEqual("presentation"); + const rows = rendered.querySelectorAll(".listItem"); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.getAttribute("role")).toEqual("listitem"); + } + }); + + it("should not override a role set by the row renderer", () => { + const rendered = findDOMNode( + render( + getMarkup({ + rowRenderer: ({ index, key, style }) => ( +
+ {index} +
+ ) + }) + ) + ); + expect(rendered.querySelector(".listItem")!.getAttribute("role")).toEqual( + "option" + ); + }); + + it("should support the aria-label, aria-readonly and containerRole props", () => { + const rendered = findDOMNode( + render( + getMarkup({ + "aria-label": "Users", + "aria-readonly": true, + containerRole: "none" + }) + ) + ); + expect(rendered.getAttribute("aria-label")).toEqual("Users"); + expect(rendered.getAttribute("aria-readonly")).toEqual("true"); + expect( + rendered + .querySelector(".ReactVirtualized__Grid__innerScrollContainer")! + .getAttribute("role") + ).toEqual("none"); + }); + + it('should use the version 9 roles with role="grid"', () => { + const rendered = findDOMNode(render(getMarkup({ role: "grid" }))); + expect(rendered.getAttribute("role")).toEqual("grid"); + expect(rendered.getAttribute("aria-label")).toEqual("grid"); + expect(rendered.getAttribute("aria-readonly")).toEqual("true"); + expect( + rendered + .querySelector(".ReactVirtualized__Grid__innerScrollContainer")! + .getAttribute("role") + ).toEqual("row"); + expect(rendered.querySelector(".listItem")!.getAttribute("role")).toEqual( + "gridcell" + ); + }); + }); + + it("should use the accessibility overscan indices getter by default", () => { + const rendered = findDOMNode( + render( + getMarkup({ overscanIndicesGetter: undefined, overscanRowCount: 0 }) + ) + ); + // At least one row is overscanned (for accessibility) + expect(rendered.querySelectorAll(".listItem").length).toEqual(11); + }); + + it("should set the width of the single-column inner Grid to auto", () => { + const rendered = findDOMNode(render(getMarkup())); + expect( + rendered.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.width + ).toEqual("auto"); + }); +}); diff --git a/lib/components/List/List.tsx b/lib/components/List/List.tsx new file mode 100644 index 000000000..255140be5 --- /dev/null +++ b/lib/components/List/List.tsx @@ -0,0 +1,235 @@ +import clsx from "clsx"; +import { + cloneElement, + forwardRef, + isValidElement, + memo, + useCallback, + useImperativeHandle, + useState, + type ReactElement +} from "react"; +import { createInnerGridRef } from "../../utils/createInnerGridRef"; +import { useMissingHeightWarning } from "../../utils/scrollElement/useMissingHeightWarning"; +import { Grid } from "../Grid/Grid"; +import type { + CellRendererParams, + GridImperativeAPI, + Scroll as GridScroll, + RenderedSection +} from "../Grid/types"; +import { defaultListProps } from "./defaultListProps"; +import type { ListImperativeAPI, ListProps } from "./types"; + +/** + * It is inefficient to create and manage a large list of DOM elements within a scrolling container + * if only a few of those elements are visible. The primary purpose of this component is to improve + * performance by only rendering the DOM nodes that a user is able to see based on their current + * scroll position. + * + * This component renders a virtualized list of elements with either fixed or dynamic heights. + */ +export const List = /* @__PURE__ */ memo( + /* @__PURE__ */ forwardRef( + function List(props, ref) { + const { + "aria-label": ariaLabel, + "aria-readonly": ariaReadonly, + className, + containerRole, + estimatedRowSize = defaultListProps.estimatedRowSize, + noRowsRenderer = defaultListProps.noRowsRenderer, + onRowsRendered = defaultListProps.onRowsRendered, + onScroll = defaultListProps.onScroll, + overscanIndicesGetter = defaultListProps.overscanIndicesGetter, + overscanRowCount = defaultListProps.overscanRowCount, + role = defaultListProps.role, + rowRenderer, + style = defaultListProps.style, + tabIndex = defaultListProps.tabIndex, + height, + scrollElement, + width, + ...rest + } = props; + + // The imperative handle is created once (it reads the inner Grid through a ref) + const [{ handle, setGridRef }] = useState(createListInstance); + + useImperativeHandle(ref, () => handle, [handle]); + + useMissingHeightWarning("List", height, scrollElement); + + // With role="list" (the default), the rows are list items within the list (#1657); + // otherwise (e.g. role="grid"), the Grid's roles and ARIA defaults apply. + const isList = role === "list"; + + const cellRenderer = useCallback( + ({ + isScrolling, + isVisible, + key, + rowIndex, + style: cellStyle + }: CellRendererParams) => { + // TRICKY The style object is sometimes cached by Grid. + // This prevents new style objects from bypassing shallowCompare(). + // However as of React 16, style props are auto-frozen (at least in dev mode) + // Check to make sure we can still modify the style before proceeding. + // https://github.com/facebook/react/commit/977357765b44af8ff0cfea327866861073095c12#commitcomment-20648713 + const widthDescriptor = Object.getOwnPropertyDescriptor( + cellStyle, + "width" + ); + if (widthDescriptor && widthDescriptor.writable) { + // By default, List cells should be 100% width. + // This prevents them from flowing under a scrollbar (if present). + cellStyle.width = "100%"; + } + + const row = rowRenderer({ + index: rowIndex, + isScrolling, + isVisible, + key, + parent: handle, + style: cellStyle + }); + + // (Grid gives a row without a role the "gridcell" role) + if ( + isList && + isValidElement<{ role?: string }>(row) && + !row.props.role + ) { + return cloneElement(row as ReactElement<{ role?: string }>, { + role: "listitem" + }); + } + return row; + }, + [handle, isList, rowRenderer] + ); + + const onGridScroll = useCallback( + ({ clientHeight, scrollHeight, scrollTop }: GridScroll) => { + onScroll({ clientHeight, scrollHeight, scrollTop }); + }, + [onScroll] + ); + + const onSectionRendered = useCallback( + ({ + rowOverscanStartIndex, + rowOverscanStopIndex, + rowStartIndex, + rowStopIndex + }: RenderedSection) => { + onRowsRendered({ + overscanStartIndex: rowOverscanStartIndex, + overscanStopIndex: rowOverscanStopIndex, + startIndex: rowStartIndex, + stopIndex: rowStopIndex + }); + }, + [onRowsRendered] + ); + + return ( + + ); + } + ) +); + +type ListInstance = { + handle: ListImperativeAPI; + setGridRef: (grid: GridImperativeAPI | null) => void; +}; + +// Creates the (stable) imperative handle of a List (every method delegates to the inner Grid) +// and the ref callback for the inner Grid. +function createListInstance(): ListInstance { + // (Keeps cell size invalidations requested by rows while the List is mounting; see createInnerGridRef) + const innerGrid = createInnerGridRef(); + const { getGrid } = innerGrid; + + const handle: ListImperativeAPI = { + forceUpdateGrid() { + getGrid()?.forceUpdate(); + }, + + getOffsetForRow({ align, alignment, index } = {}) { + const grid = getGrid(); + if (grid) { + const { scrollTop } = grid.getOffsetForCell({ + alignment: align ?? alignment, + columnIndex: 0, + rowIndex: index + }); + + return scrollTop; + } + return 0; + }, + + invalidateCellSizeAfterRender({ columnIndex, rowIndex }) { + innerGrid.invalidateCellSizeAfterRender({ columnIndex, rowIndex }); + }, + + measureAllRows() { + getGrid()?.measureAllCells(); + }, + + recomputeGridSize({ columnIndex = 0, rowIndex = 0 } = {}) { + innerGrid.recomputeGridSize({ columnIndex, rowIndex }); + }, + + recomputeRowHeights(index = 0) { + innerGrid.recomputeGridSize({ columnIndex: 0, rowIndex: index }); + }, + + scrollToCell({ align, columnIndex, rowIndex }) { + getGrid()?.scrollToCell({ align, columnIndex, rowIndex }); + }, + + scrollToPosition({ scrollTop }) { + getGrid()?.scrollToPosition({ scrollTop }); + }, + + scrollToRow({ align, index }) { + getGrid()?.scrollToCell({ align, columnIndex: 0, rowIndex: index }); + } + }; + + return { handle, setGridRef: innerGrid.setGridRef }; +} diff --git a/lib/components/List/defaultListProps.ts b/lib/components/List/defaultListProps.ts new file mode 100644 index 000000000..b3e243dea --- /dev/null +++ b/lib/components/List/defaultListProps.ts @@ -0,0 +1,33 @@ +import { accessibilityOverscanIndicesGetter } from "../Grid/accessibilityOverscanIndicesGetter"; +import type { ListProps } from "./types"; + +type DefaultedPropKeys = + | "estimatedRowSize" + | "noRowsRenderer" + | "onRowsRendered" + | "onScroll" + | "overscanIndicesGetter" + | "overscanRowCount" + | "role" + | "style" + | "tabIndex"; + +const noop = () => {}; + +/** + * Default values for `List`'s optional props. + * (The PURE annotations let bundlers drop this object if it isn't used.) + */ +export const defaultListProps: Readonly< + Required> +> = /* @__PURE__ */ Object.freeze({ + estimatedRowSize: 30, + noRowsRenderer: () => null, + onRowsRendered: noop, + onScroll: noop, + overscanIndicesGetter: accessibilityOverscanIndicesGetter, + overscanRowCount: 10, + role: "list", + style: /* @__PURE__ */ Object.freeze({}), + tabIndex: 0 +}); diff --git a/lib/components/List/types.ts b/lib/components/List/types.ts new file mode 100644 index 000000000..fa1ce8eb1 --- /dev/null +++ b/lib/components/List/types.ts @@ -0,0 +1,331 @@ +import type { CSSProperties } from "react"; +import type { + Alignment, + CellRenderer, + CellSize, + GridProps, + NoContentRenderer, + OverscanIndicesGetter, + CellPosition, + Scroll as GridScroll, + ScrollPosition +} from "../Grid/types"; +import type { ScrollElement } from "../../utils/scrollElement/types"; + +/** Parameters passed to a `List` `rowRenderer`. */ +export type RowRendererParams = { + /** Index of row. */ + index: number; + + /** The List is currently being scrolled. */ + isScrolling: boolean; + + /** This row is visible within the List (eg it is not an overscanned row). */ + isVisible: boolean; + + /** Unique key within array of rendered rows. */ + key: string; + + /** + * Reference to the parent `List` (its imperative handle). + * This value can be passed as-is to `CellMeasurer`. + */ + parent: ListImperativeAPI; + + /** + * Style object to be applied to row (to position it). + * This must be passed through to the rendered row element. + */ + style: CSSProperties; +}; + +/** + * Responsible for rendering a single row, given its index. + * Rows must be rendered with the `key` and `style` parameters provided. + */ +export type RowRenderer = ( + params: RowRendererParams +) => ReturnType; + +/** Parameters passed to the `List` `onRowsRendered` callback. */ +export type RenderedRows = { + overscanStartIndex: number; + overscanStopIndex: number; + startIndex: number; + stopIndex: number; +}; + +/** Parameters passed to the `List` `onScroll` callback. */ +export type Scroll = Pick< + GridScroll, + "clientHeight" | "scrollHeight" | "scrollTop" +>; + +/** + * `Grid` props that `List` sets itself (and so cannot be passed to `List`), + * that don't apply to a (vertical) `List`, + * or that `List` redefines with list-specific documentation. + */ +type OverriddenGridPropKeys = + | "aria-label" + | "aria-readonly" + | "autoContainerWidth" + | "cellRenderer" + | "className" + | "columnCount" + | "columnWidth" + | "containerRole" + | "estimatedColumnSize" + | "estimatedRowSize" + | "height" + | "id" + | "initialScrollLeft" + | "noContentRenderer" + | "onScroll" + | "onSectionRendered" + | "overscanColumnCount" + | "overscanIndicesGetter" + | "overscanRowCount" + | "role" + | "rowCount" + | "rowHeight" + | "scrollElement" + | "style" + | "tabIndex" + | "width"; + +/** + * `List` props. + * All other props are relayed to the inner `Grid` (see `GridProps`). + */ +export type ListProps = Omit & { + /** + * ARIA label for the list element. There is no default label. + * (With `role="grid"`, it defaults to `"grid"`, as in version 9.) + */ + "aria-label"?: string; + + /** + * ARIA readonly attribute for the list element. + * Not set by default (it doesn't apply to `role="list"`); with `role="grid"`, it defaults to `true`. + */ + "aria-readonly"?: boolean; + + /** + * ARIA role for the inner container of the rows. + * Defaults to `"presentation"` (so that the rows belong to the list), or to `"row"` with `role="grid"`. + */ + containerRole?: string; + + /** + * ARIA role for the list element. + * With the default `"list"`, rows that don't specify a `role` get `role="listitem"` (#1657). + * Pass `"grid"` for the version 9 roles (a `grid` element whose rows are `gridcell`s within a single `row`). + * @default "list" + */ + role?: string; + + /** + * Optional custom CSS class name to attach to root `List` element. + */ + className?: string; + + /** + * Used to estimate the total height of a `List` before all of its rows have actually been measured. + * The estimated total height is adjusted as rows are rendered. + * @default 30 + */ + estimatedRowSize?: number; + + /** + * Height constraint for list (determines how many actual rows are rendered). + * Required, unless a `scrollElement` is specified. + * + * With a `scrollElement`, the height of the scroll element's viewport is used instead; + * this prop is then only used until the scroll element has been measured (e.g. during server rendering). + */ + height?: number; + + /** + * Optional custom id to attach to root `List` element. + */ + id?: string; + + /** + * Callback used to render placeholder content when `rowCount` is 0. + * By default, nothing is rendered. + */ + noRowsRenderer?: NoContentRenderer; + + /** + * Callback invoked with information about the slice of rows that were just rendered: + * `({ overscanStartIndex: number, overscanStopIndex: number, startIndex: number, stopIndex: number }): void`. + */ + onRowsRendered?: (params: RenderedRows) => void; + + /** + * Callback invoked whenever the scroll offset changes within the inner scrollable region: + * `({ clientHeight: number, scrollHeight: number, scrollTop: number }): void`. + * This callback can be used to sync scrolling between lists, tables, or grids. + */ + onScroll?: (params: Scroll) => void; + + /** + * Responsible for calculating the number of rows to overscan before and after a specified range. + * By default (`accessibilityOverscanIndicesGetter`), at least one row is overscanned in each direction (for accessibility). + * See `Grid` `overscanIndicesGetter` for details. + */ + overscanIndicesGetter?: OverscanIndicesGetter; + + /** + * Number of rows to render above/below the visible bounds of the list. + * This can help reduce flickering during scrolling on certain browsers/devices. + * + * Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event. + * @default 10 + */ + overscanRowCount?: number; + + /** + * Number of rows in list. + */ + rowCount: number; + + /** + * Either a fixed row height (number) or a function that returns the height of a row given its index: `({ index: number }): number`. + * If function is used, specify `estimatedRowSize` for more consistent scrolling behavior. + */ + rowHeight: CellSize; + + /** + * Responsible for rendering a row given its index. + * It receives `index`, `isScrolling`, `isVisible`, `key`, `parent`, and `style` parameters. + * + * The `style` parameter specifies how the row is to be sized and positioned and must be passed through to the rendered row element. + * The `key` parameter must also be applied to the rendered element. + */ + rowRenderer: RowRenderer; + + /** + * Element that scrolls the `List` (instead of the `List` scrolling itself): `"window"`, or a scrollable DOM element + * (e.g. a container that the `List` is rendered within, along with other content). + * + * The `List` then renders at its full height, renders only the rows visible within the scroll element's viewport, + * and its scroll methods scroll the scroll element. + * Its position within the scroll element is measured when the scroll element scrolls or is resized, and when the `List` re-renders + * (e.g. call `forceUpdateGrid()` if content above the `List` changes size without the `List` re-rendering). + * Pass `null` while the scroll element isn't available yet (e.g. before a ref to it has been attached); + * the `List` then renders the rows at its top. + */ + scrollElement?: ScrollElement | null; + + /** + * Optional custom inline style to attach to root `List` element. + * @default {} + */ + style?: CSSProperties; + + /** + * Optional override of tab index default. + * @default 0 + */ + tabIndex?: number | null; + + /** + * Width of the list. + */ + width: number; +}; + +/** + * Public (imperative) methods of a `List` (its ref handle). + * The handle is also passed to `rowRenderer` as `parent` (e.g. for `CellMeasurer`). + */ +export interface ListImperativeAPI { + /** + * Forcefully re-render the inner `Grid` component. + * + * Re-rendering a `List` with the same props does not re-render its rows (both `List` and the inner `Grid` are memoized). + * Use this method if you want to manually trigger a re-render. + * This may be appropriate if the underlying row data has changed but the row sizes themselves have not. + */ + forceUpdateGrid(): void; + + /** + * Gets the vertical offset (`scrollTop`) for a given row and alignment (see `scrollToRow` for the `align` values). + * The alignment defaults to "auto"; an index of `-1` (the default) refers to the last row. + * + * `alignment` is a deprecated alias of `align` (`align` takes precedence if both are specified). + */ + getOffsetForRow(params?: { + align?: Alignment; + /** @deprecated Use `align` instead. */ + alignment?: Alignment; + index?: number; + }): number; + + /** + * Invalidate the size of a row (after the current render has been committed); see `Grid#invalidateCellSizeAfterRender`. + * This method is intended for `CellMeasurer`. + */ + invalidateCellSizeAfterRender(params: CellPosition): void; + + /** + * Pre-measure all rows in a `List`. + * + * Typically rows are only measured as needed and estimated heights are used for cells that have not yet been measured. + * This method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one). + */ + measureAllRows(): void; + + /** + * Recompute row heights and offsets after the specified index (defaults to 0). + * + * `List` has no way of knowing when its underlying list data has changed since it only receives a `rowHeight` property. + * If the `rowHeight` is a number it can compare before and after values but if it is a function that comparison is error prone. + * In the event that a dynamic `rowHeight` function is in use and the row heights have changed this function should be manually called by the "smart" container parent. + * + * This method will also force a render cycle (via `forceUpdate`) to ensure that the updated measurements are reflected in the rendered list. + */ + recomputeRowHeights(index?: number): void; + + /** + * Recompute row heights and offsets after the specified row index (defaults to 0); see `Grid#recomputeGridSize`. + * (The column index is ignored.) This method is intended for `CellMeasurer`; also see `recomputeRowHeights`. + */ + recomputeGridSize(params?: Partial): void; + + /** + * Ensure the specified row is visible (scrolling if necessary); the column index is ignored. + * This allows a `List` to be used with `ArrowKeyStepper`; also see `scrollToRow`. + */ + scrollToCell(params: { + align?: Alignment; + columnIndex?: number; + rowIndex?: number; + }): void; + + /** + * Scroll to the specified vertical offset; `scrollLeft` is ignored (lists don't scroll horizontally). + * Offsets are clamped to the scrollable range. This allows a `List` to be used with `ScrollSync`. + * Useful for animating position changes. + * + * If the `List` has a `scrollElement`, the scroll element (e.g. the window) is scrolled instead. + */ + scrollToPosition(params: ScrollPosition): void; + + /** + * Ensure the row at the specified index is visible (scrolling if necessary). + * This method can be used to safely scroll back to a row that a user has scrolled away from even if it was previously scrolled to. + * + * The `align` parameter (default "auto") controls the alignment of the row: + * "auto" scrolls the least amount possible to ensure that the specified row is fully visible + * (a row taller than the list is aligned to its start, so that its start is visible); + * "start" aligns the row to the top of the list and "end" to the bottom; "center" aligns it in the middle. + * This is a one-time scroll: the row is not kept in view afterwards (e.g. if the list's size or its row heights change). + * Rows that haven't been measured yet (e.g. by `CellMeasurer`) are positioned using their estimated heights. + * + * If the `List` has a `scrollElement`, the scroll element (e.g. the window) is scrolled instead. + */ + scrollToRow(params: { align?: Alignment; index: number }): void; +} diff --git a/lib/components/Masonry/Masonry.test.tsx b/lib/components/Masonry/Masonry.test.tsx new file mode 100644 index 000000000..106503d2b --- /dev/null +++ b/lib/components/Masonry/Masonry.test.tsx @@ -0,0 +1,1053 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent } from "@testing-library/react"; +import { act, createRef, StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + findDOMNode as findDOMNodeUtil, + render +} from "../../utils/test/render"; +import { CellMeasurer } from "../CellMeasurer/CellMeasurer"; +import { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import type { CellMeasurerCacheParams } from "../CellMeasurer/types"; +import { InfiniteLoader } from "../InfiniteLoader/InfiniteLoader"; +import { + getObserverCount, + mockResizeObserver +} from "../../utils/test/mockResizeObserver"; +import { createScrollContainer } from "../../utils/test/scrollElement"; +import { DEFAULT_SCROLLING_RESET_TIME_INTERVAL } from "./constants"; +import { createCellPositioner as createCellPositionerUtil } from "./createCellPositioner"; +import { defaultMasonryProps } from "./defaultMasonryProps"; +import { Masonry } from "./Masonry"; +import type { + MasonryCellRendererParams, + MasonryImperativeAPI, + MasonryProps +} from "./types"; + +const ALTERNATING_CELL_HEIGHTS = [100, 50, 100, 150]; +const CELL_SIZE_MULTIPLIER = 50; +const COLUMN_COUNT = 3; + +function findDOMNode(instance: any): any { + return findDOMNodeUtil(instance); +} + +function assertVisibleCells(rendered: any, text: string) { + expect( + Array.from(rendered.querySelectorAll(".cell")) + .map((node: any) => node.textContent) + .sort() + .join(",") + ).toEqual(text); +} + +function createCellMeasurerCache(props: CellMeasurerCacheParams = {}) { + return new CellMeasurerCache({ + defaultHeight: CELL_SIZE_MULTIPLIER, + defaultWidth: CELL_SIZE_MULTIPLIER, + fixedWidth: true, + keyMapper: (index: number) => index, + ...props + }); +} + +function createCellPositioner(cache: CellMeasurerCache) { + return createCellPositionerUtil({ + cellMeasurerCache: cache, + columnCount: COLUMN_COUNT, + columnWidth: CELL_SIZE_MULTIPLIER + }); +} + +function createCellRenderer( + cache: CellMeasurerCache, + renderCallback?: (index: number, params: MasonryCellRendererParams) => any +) { + renderCallback = + typeof renderCallback === "function" + ? renderCallback + : (index: number) => index; + + return function cellRenderer({ + index, + isScrolling, + key, + parent, + style + }: MasonryCellRendererParams) { + const height = + ALTERNATING_CELL_HEIGHTS[index % ALTERNATING_CELL_HEIGHTS.length]; + const width = CELL_SIZE_MULTIPLIER; + + return ( + +
{ + if (ref) { + // Accounts for the fact that JSDom doesn't support measurements. + Object.defineProperty(ref, "offsetHeight", { + configurable: true, + value: height + }); + Object.defineProperty(ref, "offsetWidth", { + configurable: true, + value: width + }); + } + }} + style={{ + ...style, + minHeight: height, + minWidth: width + }} + > + {renderCallback!(index, { index, isScrolling, key, parent, style })} +
+
+ ); + }; +} + +function getMarkup( + props: Partial & Record = {} +) { + const cellMeasurerCache = + (props.cellMeasurerCache as CellMeasurerCache) || createCellMeasurerCache(); + + return ( + )} + height={CELL_SIZE_MULTIPLIER * 2} + overscanByPixels={CELL_SIZE_MULTIPLIER} + width={CELL_SIZE_MULTIPLIER * COLUMN_COUNT} + {...props} + /> + ); +} + +function simulateScroll(masonry: any, scrollTop = 0) { + const masonryNode = findDOMNode(masonry); + masonryNode.scrollTop = scrollTop; + fireEvent.scroll(masonryNode); +} + +describe("Masonry", () => { + beforeEach(render.unmount); + + it("should warn in DEV if there is neither a height nor a scrollElement", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(getMarkup({ height: undefined })); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "Masonry requires a height prop (unless a scrollElement is specified)" + ); + warn.mockRestore(); + }); + + describe("layout and measuring", () => { + it("should measure only enough cells required for initial render", () => { + // avg cell size: CELL_SIZE_MULTIPLIER + // width: CELL_SIZE_MULTIPLIER * 3 + // height: CELL_SIZE_MULTIPLIER * 2 + // overcsan by: CELL_SIZE_MULTIPLIER + // Expected to measure 9 cells + const cellMeasurerCache = createCellMeasurerCache(); + render(getMarkup({ cellMeasurerCache })); + for (let i = 0; i <= 8; i++) { + expect(cellMeasurerCache.has(i)).toBe(true); + } + expect(cellMeasurerCache.has(9)).toBe(false); + }); + + it("should not measure cells while scrolling until they are needed", () => { + // Expected to measure 9 cells + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = vi + .fn() + .mockImplementation((index: number) => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback + ); + const rendered = findDOMNode( + render(getMarkup({ cellMeasurerCache, cellRenderer })) + ); + renderCallback.mockClear(); + // Scroll a little bit, but not so much to require re-measuring + simulateScroll(rendered, 51); + // Verify that render was only called enough times to fill view port (no extra for measuring) + expect(renderCallback).toHaveBeenCalledTimes(9); + }); + + it("should measure additional cells on scroll when it runs out of measured cells", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = vi + .fn() + .mockImplementation((index: number) => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback + ); + const rendered = findDOMNode( + render(getMarkup({ cellRenderer, cellMeasurerCache })) + ); + expect(cellMeasurerCache.has(9)).toBe(false); + + renderCallback.mockClear(); + + simulateScroll(rendered, 101); + expect(cellMeasurerCache.has(9)).toBe(true); + expect(cellMeasurerCache.has(10)).toBe(false); + }); + + // Masonry used to do a render pass for only unmeasured cells, + // But this resulting in removing (and later re-adding) measured cells from the DOM, + // Which was bad for performance. See GitHub issue #875 + it("should not remove previously-measured cells when measuring new ones", () => { + const log: number[] = []; + + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = (index: number) => { + log.push(index); + }; + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback + ); + + const rendered = findDOMNode( + render( + getMarkup({ + cellMeasurerCache, + cellRenderer + }) + ) + ); + + // Expected to have rendered twice: + // 1st time to measure 9 cells (b'c of esimated size) + // 2nd time to render and position 9 cells (b'c of actual size) + expect(log).toHaveLength(18); + + log.splice(0); + + simulateScroll(rendered, 101); + + // Expected to have rendered twice: + // 1st time to measure additional cells (based on estimated size) + // 2nd time to render and position with new cells + // The 1st render should also have included the pre-measured cells, + // To prevent them from being removed, recreated, and re-added to the DOM. + expect(log).toHaveLength(18); + }); + + it("should only render enough cells to fill the viewport", () => { + const rendered = findDOMNode( + render( + getMarkup({ + overscanByPixels: 0 + }) + ) + ); + assertVisibleCells(rendered, "0,1,2,3,4,5"); + simulateScroll(rendered, 51); + assertVisibleCells(rendered, "0,2,3,4,5,6"); + simulateScroll(rendered, 101); + assertVisibleCells(rendered, "3,4,5,6,7,8"); + simulateScroll(rendered, 1001); + assertVisibleCells(rendered, "30,31,32,33,34,35"); + }); + + it("should only render enough cells to fill the viewport plus overscanByPixels", () => { + const rendered = findDOMNode( + render( + getMarkup({ + overscanByPixels: 100 + }) + ) + ); + assertVisibleCells(rendered, "0,1,10,11,2,3,4,5,6,7,8,9"); + simulateScroll(rendered, 51); + assertVisibleCells(rendered, "0,1,10,11,2,3,4,5,6,7,8,9"); + simulateScroll(rendered, 101); + assertVisibleCells(rendered, "0,1,10,11,2,3,4,5,6,7,8,9"); + simulateScroll(rendered, 1001); + assertVisibleCells(rendered, "26,27,28,29,30,31,32,33,34,35,36,37"); + }); + + it("should still render correctly with a scrollElement", () => { + // Share instances between renders to avoid resetting state in ways we don't intend + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const container = createScrollContainer({ + height: CELL_SIZE_MULTIPLIER * 2, + width: CELL_SIZE_MULTIPLIER * COLUMN_COUNT + }); + + const masonry = render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + scrollElement: container.element + }) + ); + const rendered = findDOMNode(masonry); + expect(rendered.style.height).toBe("auto"); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + container.scroll(51); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + container.scroll(101); + assertVisibleCells(rendered, "0,2,3,4,5,6,7,8,9"); + container.scroll(1001); + assertVisibleCells(rendered, "27,29,30,31,32,33,34,35,36"); + expect(rendered.scrollTop).toBe(0); + + // Offsets above the Masonry (which is now 200px below the top of the viewport) + container.setContentOffset(200); + container.scroll(0); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + + // Without a scrollElement, it scrolls itself again + render(getMarkup({ cellMeasurerCache, cellPositioner })); + expect(rendered.style.height).toBe("100px"); + }); + + it("should set right instead of left in a cell styles for rtl row direction", () => { + // Share instances between renders to avoid resetting state in ways we don't intend + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + + const rendered = findDOMNode( + render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + rowDirection: "rtl" + }) + ) + ); + Array.from(rendered.querySelectorAll(".cell")).map((node: any) => { + expect(node.style.right).toMatch(/px/); + }); + }); + + it("should consider scroll only of the container element and not of any ancestor element", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderScrollableCell = (index: number) => ( +
+
{index}
+
+ ); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderScrollableCell + ); + + const rendered = findDOMNode( + render( + getMarkup({ + overscanByPixels: 0, + cellMeasurerCache, + cellRenderer + }) + ) + ); + assertVisibleCells(rendered, "0,1,2,3,4,5"); + const cellEl = rendered.querySelector("#scrollable-cell-1"); + fireEvent.scroll(cellEl, { target: { scrollTop: 100 } }); + assertVisibleCells(rendered, "0,1,2,3,4,5"); + }); + }); + + describe("recomputeCellPositions", () => { + it("should refresh all cell positions", () => { + // Share instances between renders to avoid resetting state in ways we don't intend + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = vi + .fn() + .mockImplementation(createCellPositioner(cellMeasurerCache)); + + let rendered = findDOMNode( + render( + getMarkup({ + cellMeasurerCache, + cellPositioner + }) + ) + ); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + + cellPositioner.mockImplementation((index: number) => ({ + left: 0, + top: index * CELL_SIZE_MULTIPLIER + })); + + const component = render( + getMarkup({ + cellMeasurerCache, + cellPositioner + }) + ); + rendered = findDOMNode(component); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + act(() => { + component.recomputeCellPositions(); + }); + assertVisibleCells(rendered, "0,1,2,3,4"); + }); + + it("should not reset measurement cache", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const component = render( + getMarkup({ cellMeasurerCache }) + ); + const rendered = findDOMNode(component); + simulateScroll(rendered, 101); + expect(cellMeasurerCache.has(9)).toBe(true); + simulateScroll(rendered, 0); + act(() => { + component.recomputeCellPositions(); + }); + for (let i = 0; i <= 9; i++) { + expect(cellMeasurerCache.has(i)).toBe(true); + } + }); + }); + + describe("cellCount", () => { + it("should not render cells that were removed when cellCount shrinks", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const renderedIndices: number[] = []; + const cellRenderer = createCellRenderer( + cellMeasurerCache, + (index: number) => { + renderedIndices.push(index); + return index; + } + ); + + let rendered = findDOMNode( + render( + getMarkup({ + cellCount: 20, + cellMeasurerCache, + cellPositioner, + cellRenderer + }) + ) + ); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + + renderedIndices.splice(0); + rendered = findDOMNode( + render( + getMarkup({ + cellCount: 3, + cellMeasurerCache, + cellPositioner, + cellRenderer + }) + ) + ); + assertVisibleCells(rendered, "0,1,2"); + expect(renderedIndices.length).toBeGreaterThan(0); + expect(renderedIndices.every((index) => index < 3)).toBe(true); + }); + + it("should not re-populate positions for removed cells in recomputeCellPositions", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = vi + .fn() + .mockImplementation(createCellPositioner(cellMeasurerCache)); + + render(getMarkup({ cellCount: 20, cellMeasurerCache, cellPositioner })); + + const component = render( + getMarkup({ cellCount: 3, cellMeasurerCache, cellPositioner }) + ); + + cellPositioner.mockClear(); + act(() => { + component.recomputeCellPositions(); + }); + expect(cellPositioner.mock.calls.map(([index]) => index)).toEqual([ + 0, 1, 2 + ]); + }); + }); + + describe("isScrolling", () => { + it("should be true for cellRenderer while scrolling is in progress", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = vi + .fn() + .mockImplementation((index: number) => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback + ); + const rendered = findDOMNode( + render(getMarkup({ cellMeasurerCache, cellRenderer })) + ); + renderCallback.mockClear(); + simulateScroll(rendered, 51); + expect(renderCallback.mock.calls[0][1].isScrolling).toEqual(true); + }); + + it("should be reset after a small debounce when scrolling stops", async () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = vi + .fn() + .mockImplementation((index: number) => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback + ); + const rendered = findDOMNode( + render(getMarkup({ cellMeasurerCache, cellRenderer })) + ); + simulateScroll(rendered, 51); + renderCallback.mockClear(); + // Jest silently ignored this assertion (it ran in an un-awaited setTimeout after the test had finished); + // Vitest reports it as an unhandled error, so wait for the debounce to complete instead. + // Updates scheduled within an async act() scope are only flushed when the scope exits, + // so wait for the debounce interval (plus some padding for the animation frame) inside of act(). + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL + 50) + ); + }); + await vi.waitFor(() => { + expect(renderCallback.mock.calls[0][1].isScrolling).toEqual(false); + }); + }); + }); + + describe("callbacks", () => { + it("should call onCellsRendered when rendered cells change", () => { + const onCellsRendered = vi.fn(); + const rendered = findDOMNode(render(getMarkup({ onCellsRendered }))); + expect(onCellsRendered.mock.calls).toEqual([ + [{ startIndex: 0, stopIndex: 8 }] + ]); + simulateScroll(rendered, 51); + expect(onCellsRendered.mock.calls).toEqual([ + [{ startIndex: 0, stopIndex: 8 }] + ]); + simulateScroll(rendered, 101); + expect(onCellsRendered.mock.calls).toEqual([ + [{ startIndex: 0, stopIndex: 8 }], + [{ startIndex: 0, stopIndex: 9 }] + ]); + }); + + it("should call onScroll when scroll position changes", () => { + const onScroll = vi.fn(); + const rendered = findDOMNode(render(getMarkup({ onScroll }))); + expect(onScroll.mock.calls).toEqual([ + [{ clientHeight: 100, scrollHeight: 16900, scrollTop: 0 }] + ]); + simulateScroll(rendered, 51); + expect(onScroll.mock.calls).toEqual([ + [{ clientHeight: 100, scrollHeight: 16900, scrollTop: 0 }], + [{ clientHeight: 100, scrollHeight: 16900, scrollTop: 51 }] + ]); + simulateScroll(rendered, 0); + expect(onScroll.mock.calls).toEqual([ + [{ clientHeight: 100, scrollHeight: 16900, scrollTop: 0 }], + [{ clientHeight: 100, scrollHeight: 16900, scrollTop: 51 }], + [{ clientHeight: 100, scrollHeight: 16900, scrollTop: 0 }] + ]); + }); + }); + + describe("keyMapper", () => { + it("should pass the correct key to rendered cells", () => { + const keyMapper = vi + .fn() + .mockImplementation((index: number) => `key:${index}`); + const cellRenderer = vi + .fn() + .mockImplementation( + ({ index, key, style }: MasonryCellRendererParams) => ( +
+ {index} +
+ ) + ); + findDOMNode(render(getMarkup({ cellRenderer, keyMapper }))); + expect(keyMapper).toHaveBeenCalled(); + expect(cellRenderer).toHaveBeenCalled(); + expect(cellRenderer.mock.calls[0][0].key).toEqual("key:0"); + }); + }); + + describe("with a scrollElement", () => { + let unmockResizeObserver: () => void; + beforeEach(() => { + unmockResizeObserver = mockResizeObserver(); + }); + afterEach(() => { + render.unmount(); + unmockResizeObserver(); + }); + + function createContainer(height = CELL_SIZE_MULTIPLIER * 2) { + return createScrollContainer({ + height, + width: CELL_SIZE_MULTIPLIER * COLUMN_COUNT + }); + } + + it("should set isScrolling while the scroll element is scrolled and reset it afterwards", async () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const renderCallback = vi + .fn() + .mockImplementation((index: number) => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback + ); + const container = createContainer(); + render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + cellRenderer, + scrollElement: container.element + }) + ); + expect(renderCallback.mock.lastCall![1].isScrolling).toBe(false); + + renderCallback.mockClear(); + container.scroll(51); + expect(renderCallback.mock.lastCall![1].isScrolling).toBe(true); + + await act(async () => { + await new Promise((resolve) => + setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL + 50) + ); + }); + expect(renderCallback.mock.lastCall![1].isScrolling).toBe(false); + }); + + it("should report the viewport's offset to onScroll", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const onScroll = vi.fn(); + const container = createContainer(); + render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + onScroll, + scrollElement: container.element + }) + ); + container.scroll(101); + expect(onScroll.mock.calls.map(([params]) => params.scrollTop)).toEqual([ + 0, 101 + ]); + }); + + it("should measure its position within the scroll element", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const container = createScrollContainer({ + contentOffset: 500, + height: CELL_SIZE_MULTIPLIER * 2, + width: CELL_SIZE_MULTIPLIER * COLUMN_COUNT + }); + container.element.scrollTop = 1501; + const masonry = render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + scrollElement: container.element + }) + ); + assertVisibleCells(findDOMNode(masonry), "27,29,30,31,32,33,34,35,36"); + }); + + it("should render and measure the cells within the viewport of the scroll element", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const onCellsRendered = vi.fn(); + // Twice as high as the height prop + const container = createContainer(CELL_SIZE_MULTIPLIER * 4); + const masonry = render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + onCellsRendered, + overscanByPixels: 0, + scrollElement: container.element + }) + ); + assertVisibleCells(findDOMNode(masonry), "0,1,2,3,4,5,6,7,8"); + for (let index = 0; index <= 8; index++) { + expect(cellMeasurerCache.has(index, 0)).toBe(true); + } + expect(cellMeasurerCache.has(9, 0)).toBe(false); + expect(onCellsRendered).toHaveBeenLastCalledWith({ + startIndex: 0, + stopIndex: 8 + }); + }); + + it("should start and stop being scrolled when the scrollElement changes from null to an element and back (#6)", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const container = createContainer(); + container.element.scrollTop = 1001; + const props = { cellMeasurerCache, cellPositioner }; + + const rendered = findDOMNode( + render(getMarkup({ ...props, scrollElement: null })) + ); + expect(rendered.style.height).toBe("auto"); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + + render(getMarkup({ ...props, scrollElement: container.element })); + assertVisibleCells(rendered, "27,29,30,31,32,33,34,35,36"); + expect(getObserverCount(container.element)).toBe(1); + + render(getMarkup({ ...props, scrollElement: null })); + expect(getObserverCount(container.element)).toBe(0); + expect(rendered.style.height).toBe("auto"); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + + container.scroll(1001); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + }); + + it("should work in StrictMode", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const ref = createRef(); + const container = createContainer(); + container.element.scrollTop = 1001; + render( + +
+ {getMarkup({ + cellMeasurerCache, + cellPositioner, + ref, + scrollElement: container.element + } as any)} +
+
+ ); + assertVisibleCells(container.element, "27,29,30,31,32,33,34,35,36"); + + container.scroll(0); + assertVisibleCells(container.element, "0,1,2,3,4,5,6,7,8"); + }); + }); + + describe("estimated total height", () => { + it("should not count cells that were removed when cellCount shrinks", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + + render(getMarkup({ cellCount: 20, cellMeasurerCache, cellPositioner })); + const rendered = findDOMNode( + render(getMarkup({ cellCount: 3, cellMeasurerCache, cellPositioner })) + ); + + // Cells 0, 1 and 2 are 100px, 50px and 100px tall (one per column) + const inner = rendered.querySelector( + ".ReactVirtualized__Masonry__innerScrollContainer" + ); + expect(inner.style.height).toBe("100px"); + }); + }); + + describe("imperative handle", () => { + it("should be stable and passed to cellRenderer as parent", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const parents = new Set(); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + (index, { parent }) => { + parents.add(parent); + return index; + } + ); + const ref = createRef(); + const props = { cellMeasurerCache, cellRenderer, ref }; + render(getMarkup(props)); + const handle = ref.current; + render(getMarkup({ ...props, height: 200 })); + expect(ref.current).toBe(handle); + expect(Array.from(parents)).toEqual([handle]); + }); + + it("should clear cell positions and re-position cells as they are rendered", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const component = render( + getMarkup({ cellMeasurerCache, cellPositioner }) + ); + const rendered = findDOMNode(component); + assertVisibleCells(rendered, "0,1,2,3,4,5,6,7,8"); + + cellPositioner.reset({ + columnCount: 1, + columnWidth: CELL_SIZE_MULTIPLIER + }); + // Cells are re-positioned as they are (re-)measured + cellMeasurerCache.clearAll(); + act(() => { + component.clearCellPositions(); + }); + // Cells 0 (100px), 1 (50px) and 2 are stacked in a single column; the viewport (100px) and overscan (50px) reach cell 2 + assertVisibleCells(rendered, "0,1,2"); + }); + + it("should re-render visible cells on forceUpdate and recomputeGridSize", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = vi + .fn() + .mockImplementation((index: number) => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback + ); + const props = { + cellMeasurerCache, + cellPositioner: createCellPositioner(cellMeasurerCache), + cellRenderer + }; + const component = render(getMarkup(props)); + + // Re-rendering with the same props doesn't re-render cells (memo) + renderCallback.mockClear(); + render(getMarkup(props)); + expect(renderCallback).not.toHaveBeenCalled(); + + act(() => { + component.forceUpdate(); + }); + expect(renderCallback).toHaveBeenCalledTimes(9); + + renderCallback.mockClear(); + act(() => { + component.recomputeGridSize({ rowIndex: 3 }); + }); + expect(renderCallback).toHaveBeenCalledTimes(9); + }); + + it("should position cells invalidated outside of a render", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = vi + .fn() + .mockImplementation(createCellPositioner(cellMeasurerCache)); + const component = render( + getMarkup({ cellMeasurerCache, cellPositioner }) + ); + cellPositioner.mockClear(); + act(() => { + component.invalidateCellSizeAfterRender({ rowIndex: 9 }); + }); + expect(cellPositioner.mock.calls.map(([index]) => index)).toEqual([9]); + }); + }); + + describe("InfiniteLoader", () => { + it("should re-render loaded cells when used as an InfiniteLoader child", async () => { + const cellMeasurerCache = createCellMeasurerCache(); + const loaded = new Set(); + const cellRenderer = createCellRenderer(cellMeasurerCache, (index) => + loaded.has(index) ? `loaded-${index}` : `loading-${index}` + ); + let resolveLoad: () => void = () => {}; + + render( + loaded.has(index)} + loadMoreRows={({ startIndex, stopIndex }) => + new Promise((resolve) => { + resolveLoad = () => { + for (let index = startIndex; index <= stopIndex; index++) { + loaded.add(index); + } + resolve(); + }; + }) + } + minimumBatchSize={1} + rowCount={100} + threshold={0} + > + {({ onRowsRendered, registerChild }) => + getMarkup({ + cellCount: 100, + cellMeasurerCache, + cellRenderer, + // InfiniteLoader expects a stopIndex (Masonry reports undefined if it renders no cells) + onCellsRendered: + onRowsRendered as MasonryProps["onCellsRendered"], + ref: registerChild + }) + } + + ); + + const container = document.querySelector(".ReactVirtualized__Masonry")!; + expect(container.textContent).toContain("loading-0"); + + await act(async () => { + resolveLoad(); + }); + expect(container.textContent).toContain("loaded-0"); + expect(container.textContent).not.toContain("loading-"); + }); + }); + + describe("CellMeasurer", () => { + it("should re-render after a cell has been re-measured", () => { + const cellMeasurerCache = createCellMeasurerCache(); + let measureCell0: () => void = () => {}; + let cell0Height = 100; + const cellRenderer = ({ + index, + key, + parent, + style + }: MasonryCellRendererParams) => ( + + {({ measure, registerChild }) => { + if (index === 0) { + measureCell0 = measure; + } + return ( +
{ + if (ref) { + Object.defineProperty(ref, "offsetHeight", { + configurable: true, + get: () => (index === 0 ? cell0Height : 50) + }); + Object.defineProperty(ref, "offsetWidth", { + configurable: true, + value: CELL_SIZE_MULTIPLIER + }); + } + registerChild(ref); + }} + style={style} + > + {index} +
+ ); + }} +
+ ); + const rendered = findDOMNode( + render(getMarkup({ cellMeasurerCache, cellRenderer })) + ); + const getCell0 = () => + Array.from(rendered.querySelectorAll(".cell")).find( + (node: any) => node.textContent === "0" + ) as HTMLElement; + expect(getCell0().style.height).toBe("100px"); + + cell0Height = 150; + act(() => { + measureCell0(); + }); + expect(cellMeasurerCache.getHeight(0)).toBe(150); + expect(getCell0().style.height).toBe("150px"); + }); + }); + + describe("StrictMode", () => { + it("should measure, position and scroll cells", () => { + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + const onCellsRendered = vi.fn(); + const onScroll = vi.fn(); + const ref = createRef(); + render( + + {getMarkup({ + cellMeasurerCache, + cellPositioner, + onCellsRendered, + onScroll, + overscanByPixels: 0, + ref + })} + + ); + const rendered = document.querySelector(".ReactVirtualized__Masonry")!; + assertVisibleCells(rendered, "0,1,2,3,4,5"); + expect(onCellsRendered.mock.calls).toEqual([ + [{ startIndex: 0, stopIndex: 5 }] + ]); + expect(onScroll).toHaveBeenCalledTimes(1); + + simulateScroll(rendered, 101); + assertVisibleCells(rendered, "3,4,5,6,7,8"); + simulateScroll(rendered, 1001); + assertVisibleCells(rendered, "30,31,32,33,34,35"); + + cellPositioner.reset({ + columnCount: COLUMN_COUNT, + columnWidth: CELL_SIZE_MULTIPLIER + }); + act(() => { + ref.current!.recomputeCellPositions(); + }); + assertVisibleCells(rendered, "30,31,32,33,34,35"); + }); + }); + + describe("DOM output", () => { + it("should render class names, ARIA attributes and styles", () => { + const rendered = findDOMNode( + render( + getMarkup({ + "aria-label": "Photos", + className: "custom", + id: "masonry", + style: { backgroundColor: "red" } + }) + ) + ); + expect(rendered.className).toBe("ReactVirtualized__Masonry custom"); + expect(rendered.getAttribute("aria-label")).toBe("Photos"); + expect(rendered.getAttribute("id")).toBe("masonry"); + expect(rendered.getAttribute("role")).toBe(defaultMasonryProps.role); + expect(rendered.getAttribute("tabindex")).toBe("0"); + expect(rendered.style.backgroundColor).toBe("red"); + expect(rendered.style.height).toBe("100px"); + expect(rendered.style.width).toBe("150px"); + }); + + it("should export frozen default props", () => { + expect(Object.isFrozen(defaultMasonryProps)).toBe(true); + expect(defaultMasonryProps.overscanByPixels).toBe(20); + expect(defaultMasonryProps.scrollingResetTimeInterval).toBe(150); + }); + }); +}); diff --git a/lib/components/Masonry/Masonry.tsx b/lib/components/Masonry/Masonry.tsx new file mode 100644 index 000000000..8e845ed03 --- /dev/null +++ b/lib/components/Masonry/Masonry.tsx @@ -0,0 +1,621 @@ +import clsx from "clsx"; +import { + forwardRef, + memo, + useEffect, + useImperativeHandle, + useInsertionEffect, + useReducer, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, + type UIEvent +} from "react"; +import { + cancelAnimationTimeout, + requestAnimationTimeout, + type AnimationTimeoutId +} from "../../utils/requestAnimationTimeout"; +import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect"; +import type { ScrollElementLinkTarget } from "../../utils/scrollElement/types"; +import { useMissingHeightWarning } from "../../utils/scrollElement/useMissingHeightWarning"; +import { useScrollElementLink } from "../../utils/scrollElement/useScrollElementLink"; +import { defaultMasonryProps } from "./defaultMasonryProps"; +import { PositionCache } from "./PositionCache"; +import type { + MasonryCellMeasurerCache, + MasonryImperativeAPI, + MasonryProps, + Positioner +} from "./types"; + +type State = { + isScrolling: boolean; + + // Scroll offset of the Masonry's own scrolling container + scrollTop: number; + + // The viewport of the scroll element the Masonry is linked to (its scrollElement prop; see useScrollElementLink), or null if not linked. + // While linked, the Masonry renders at its full height and is scrolled by the scroll element. + linkedViewport: LinkedViewport | null; +}; + +type LinkedViewport = { + // Height of the scroll element's viewport + height: number; + // Scroll offset of the scroll element, relative to the top of the Masonry (and at least 0) + scrollTop: number; +}; + +// Values from the most recently committed render, used by the (stable) imperative handle and event handlers +type LatestValues = { + cellCount: number; + cellMeasurerCache: MasonryCellMeasurerCache; + cellPositioner: Positioner; + height: number; + scrollingResetTimeInterval: number; + width: number; +}; + +type MasonryInternals = { + debounceResetIsScrollingId: AnimationTimeoutId | null; + element: HTMLDivElement | null; + invalidateOnUpdateStartIndex: number | null; + invalidateOnUpdateStopIndex: number | null; + latest: LatestValues; + // Mirrors State.linkedViewport (updated by the link target methods, during commit or event handlers) + linkedViewport: LinkedViewport | null; + onScrollMemoized: number | undefined; + positionCache: PositionCache; + startIndexMemoized: number | null; + stopIndexMemoized: number | null | undefined; +}; + +type MasonryInstance = { + handle: MasonryImperativeAPI; + linkTarget: ScrollElementLinkTarget; + internals: MasonryInternals; + onScroll: (event: UIEvent) => void; + setElementRef: (element: HTMLDivElement | null) => void; +}; + +const increment = (value: number) => value + 1; + +/** + * This component efficiently displays arbitrarily positioned cells using windowing techniques. + * Cell position is determined by an injected `cellPositioner` property. + * Windowing is vertical; this component does not support horizontal scrolling. + * + * Rendering occurs in two phases: + * 1) First pass uses estimated cell sizes (provided by the cache) to determine how many cells to measure in a batch. + * Batch size is chosen using a fast, naive layout algorithm that stacks images in order until the viewport has been filled. + * After measurement is complete (once the render has been committed) this component evaluates positioned cells + * in order to determine if another measurement pass is required (eg if actual cell sizes were less than estimated sizes). + * All measurements are permanently cached (keyed by `keyMapper`) for performance purposes. + * 2) Second pass uses the external `cellPositioner` to layout cells. + * At this time the positioner has access to cached size measurements for all cells. + * The positions it returns are cached by Masonry for fast access later. + * Phase one is repeated if the user scrolls beyond the current layout's bounds. + * If the layout is invalidated due to eg a resize, cached positions can be cleared using `recomputeCellPositions()`. + * + * Animation constraints: + * Simple animations are supported (eg translate/slide into place on initial reveal). + * More complex animations are not (eg flying from one position to another on resize). + * + * Layout constraints: + * This component supports multi-column layout. + * The height of each item may vary. + * The width of each item must not exceed the width of the column it is "in". + * The left position of all items within a column must align. + * (Items may not span multiple columns.) + */ +export const Masonry = /* @__PURE__ */ memo( + /* @__PURE__ */ forwardRef( + function Masonry(props, ref) { + const { + "aria-label": ariaLabel, + cellCount, + cellMeasurerCache, + cellPositioner, + cellRenderer, + className, + height: heightProp, + id, + keyMapper = defaultMasonryProps.keyMapper, + onCellsRendered = defaultMasonryProps.onCellsRendered, + onScroll = defaultMasonryProps.onScroll, + overscanByPixels = defaultMasonryProps.overscanByPixels, + role = defaultMasonryProps.role, + rowDirection = defaultMasonryProps.rowDirection, + scrollElement, + scrollingResetTimeInterval = defaultMasonryProps.scrollingResetTimeInterval, + style = defaultMasonryProps.style, + tabIndex = defaultMasonryProps.tabIndex, + width + } = props; + + const [state, setState] = useState(() => ({ + isScrolling: false, + linkedViewport: null, + scrollTop: 0 + })); + const [, forceUpdate] = useReducer(increment, 0); + + // A Masonry with a scrollElement is scrolled by that element (e.g. the window) rather than scrolling itself: + // it renders at its full height, and renders the cells within the scroll element's viewport. + // Until the scroll element has been measured (e.g. during server rendering), the height prop is used as the viewport height. + const linked = scrollElement !== undefined; + const { linkedViewport } = state; + const height = + linkedViewport !== null ? linkedViewport.height : (heightProp ?? 0); + + // The instance (and its stable imperative handle) is created once + const [ + { + handle, + internals, + linkTarget, + onScroll: onScrollEvent, + setElementRef + } + ] = useState(() => + createMasonryInstance( + { + cellCount, + cellMeasurerCache, + cellPositioner, + height, + scrollingResetTimeInterval, + width + }, + setState, + forceUpdate + ) + ); + + useImperativeHandle(ref, () => handle, [handle]); + + useMissingHeightWarning("Masonry", heightProp, scrollElement); + + useScrollElementLink( + scrollElement, + linkTarget, + scrollingResetTimeInterval + ); + + const latest: LatestValues = { + cellCount, + cellMeasurerCache, + cellPositioner, + height, + scrollingResetTimeInterval, + width + }; + useInsertionEffect(() => { + internals.latest = latest; + }); + + const { isScrolling } = state; + const scrollTop = linked + ? (linkedViewport?.scrollTop ?? 0) + : state.scrollTop; + + const positionCache = internals.positionCache; + + const children: ReactNode[] = []; + + const estimatedTotalHeight = getEstimatedTotalHeight( + positionCache, + cellCount, + cellMeasurerCache, + width + ); + + const shortestColumnSize = positionCache.shortestColumnSize; + const measuredCellCount = positionCache.count; + + let startIndex = 0; + let stopIndex: number | undefined; + + positionCache.range( + Math.max(0, scrollTop - overscanByPixels), + height + overscanByPixels * 2, + (index: number, left: number, top: number) => { + // The position cache may contain cells that have since been removed (cellCount shrank) + if (index >= cellCount) { + return; + } + + if (typeof stopIndex === "undefined") { + startIndex = index; + stopIndex = index; + } else { + startIndex = Math.min(startIndex, index); + stopIndex = Math.max(stopIndex, index); + } + + children.push( + cellRenderer({ + index, + isScrolling, + key: keyMapper(index), + parent: handle, + style: { + height: cellMeasurerCache.getHeight(index), + [rowDirection === "ltr" ? "left" : "right"]: left, + position: "absolute", + top, + width: cellMeasurerCache.getWidth(index) + } + }) + ); + } + ); + + // We need to measure additional cells for this layout + if ( + shortestColumnSize < scrollTop + height + overscanByPixels && + measuredCellCount < cellCount + ) { + const batchSize = Math.min( + cellCount - measuredCellCount, + Math.ceil( + (((scrollTop + height + overscanByPixels - shortestColumnSize) / + cellMeasurerCache.defaultHeight) * + width) / + cellMeasurerCache.defaultWidth + ) + ); + + for ( + let index = measuredCellCount; + index < measuredCellCount + batchSize; + index++ + ) { + stopIndex = index; + + children.push( + cellRenderer({ + index: index, + isScrolling, + key: keyMapper(index), + parent: handle, + style: { + width: cellMeasurerCache.getWidth(index) + } + }) + ); + } + } + + // Equivalent of the 9.x componentDidMount/componentDidUpdate + useIsomorphicLayoutEffect(() => { + // Query external layout logic for position of newly-measured cells + if (typeof internals.invalidateOnUpdateStartIndex === "number") { + const invalidateStartIndex = internals.invalidateOnUpdateStartIndex; + const invalidateStopIndex = internals.invalidateOnUpdateStopIndex!; + + internals.invalidateOnUpdateStartIndex = null; + internals.invalidateOnUpdateStopIndex = null; + + populatePositionCache( + internals.positionCache, + cellMeasurerCache, + cellPositioner, + invalidateStartIndex, + invalidateStopIndex + ); + + forceUpdate(); + } + + if (internals.onScrollMemoized !== scrollTop) { + internals.onScrollMemoized = scrollTop; + + if (onScroll) { + onScroll({ + clientHeight: height, + scrollHeight: getEstimatedTotalHeight( + internals.positionCache, + cellCount, + cellMeasurerCache, + width + ), + scrollTop + }); + } + } + + if ( + internals.startIndexMemoized !== startIndex || + internals.stopIndexMemoized !== stopIndex + ) { + internals.startIndexMemoized = startIndex; + internals.stopIndexMemoized = stopIndex; + + if (onCellsRendered) { + onCellsRendered({ startIndex, stopIndex }); + } + } + }); + + useEffect( + () => () => { + if (internals.debounceResetIsScrollingId) { + cancelAnimationTimeout(internals.debounceResetIsScrollingId); + internals.debounceResetIsScrollingId = null; + } + }, + [internals] + ); + + return ( +
+
+ {children} +
+
+ ); + } + ) +); + +function getEstimatedTotalHeight( + positionCache: PositionCache, + cellCount: number, + cellMeasurerCache: MasonryCellMeasurerCache, + width: number +) { + const estimatedColumnCount = Math.max( + 1, + Math.floor(width / cellMeasurerCache.defaultWidth) + ); + + return positionCache.estimateTotalHeight( + cellCount, + estimatedColumnCount, + cellMeasurerCache.defaultHeight + ); +} + +function populatePositionCache( + positionCache: PositionCache, + cellMeasurerCache: MasonryCellMeasurerCache, + cellPositioner: Positioner, + startIndex: number, + stopIndex: number +) { + for (let index = startIndex; index <= stopIndex; index++) { + const { left, top } = cellPositioner(index); + + positionCache.setPosition( + index, + left, + top, + cellMeasurerCache.getHeight(index) + ); + } +} + +// Creates the (stable) imperative handle of a Masonry, its scroll event handler and its mutable internals. +function createMasonryInstance( + initialValues: LatestValues, + setState: Dispatch>, + forceUpdate: () => void +): MasonryInstance { + const internals: MasonryInternals = { + debounceResetIsScrollingId: null, + element: null, + invalidateOnUpdateStartIndex: null, + invalidateOnUpdateStopIndex: null, + latest: initialValues, + linkedViewport: null, + onScrollMemoized: undefined, + positionCache: new PositionCache(), + startIndexMemoized: null, + stopIndexMemoized: null + }; + + const debounceResetIsScrollingCallback = () => { + internals.debounceResetIsScrollingId = null; + + setState((prevState) => + prevState.isScrolling ? { ...prevState, isScrolling: false } : prevState + ); + }; + + const debounceResetIsScrolling = () => { + if (internals.debounceResetIsScrollingId) { + cancelAnimationTimeout(internals.debounceResetIsScrollingId); + } + + internals.debounceResetIsScrollingId = requestAnimationTimeout( + debounceResetIsScrollingCallback, + internals.latest.scrollingResetTimeInterval + ); + }; + + const onScroll = (event: UIEvent) => { + const { cellCount, cellMeasurerCache, height, width } = internals.latest; + + const eventScrollTop = event.currentTarget.scrollTop; + + // When this component is shrunk drastically, React dispatches a series of back-to-back scroll events, + // Gradually converging on a scrollTop that is within the bounds of the new, smaller height. + // This causes a series of rapid renders that is slow for long lists. + // We can avoid that by doing some simple bounds checking to ensure that scroll offsets never exceed their bounds. + const scrollTop = Math.min( + Math.max( + 0, + getEstimatedTotalHeight( + internals.positionCache, + cellCount, + cellMeasurerCache, + width + ) - height + ), + eventScrollTop + ); + + // On iOS, we can arrive at negative offsets by swiping past the start or end. + // Avoid re-rendering in this case as it can cause problems; see #532 for more. + if (eventScrollTop !== scrollTop) { + return; + } + + // Prevent pointer events from interrupting a smooth scroll + debounceResetIsScrolling(); + + // Certain devices (like Apple touchpad) rapid-fire duplicate events. + // Don't force a re-render if this is the case. + setState((prevState) => + prevState.scrollTop !== scrollTop + ? { ...prevState, isScrolling: true, scrollTop } + : prevState + ); + }; + + // Linked to the Masonry's scroll element (if it has one) by useScrollElementLink. + // (A Masonry has no scroll methods, so the link source isn't used.) + const linkTarget: ScrollElementLinkTarget = { + getElement() { + return internals.element; + }, + + link(source, viewport) { + const linkedViewport = + source !== null + ? { + height: viewport.height, + scrollTop: Math.max(0, viewport.scrollTop) + } + : null; + internals.linkedViewport = linkedViewport; + setState((prevState) => ({ ...prevState, linkedViewport })); + }, + + setViewport(viewport) { + const prevViewport = internals.linkedViewport; + if (prevViewport === null) { + return; + } + + const linkedViewport = { + height: viewport.height, + scrollTop: Math.max(0, viewport.scrollTop) + }; + if ( + linkedViewport.height === prevViewport.height && + linkedViewport.scrollTop === prevViewport.scrollTop + ) { + return; + } + internals.linkedViewport = linkedViewport; + + // A change of the scroll offset marks the Masonry as scrolling (like a scroll event of its own container would) + const scrolled = linkedViewport.scrollTop !== prevViewport.scrollTop; + if (scrolled) { + debounceResetIsScrolling(); + } + setState((prevState) => ({ + ...prevState, + isScrolling: prevState.isScrolling || scrolled, + linkedViewport + })); + } + }; + + const handle: MasonryImperativeAPI = { + clearCellPositions() { + internals.positionCache = new PositionCache(); + forceUpdate(); + }, + + forceUpdate() { + forceUpdate(); + }, + + // HACK This method signature was intended for Grid + invalidateCellSizeAfterRender({ rowIndex: index }) { + if (internals.invalidateOnUpdateStartIndex === null) { + internals.invalidateOnUpdateStartIndex = index; + internals.invalidateOnUpdateStopIndex = index; + } else { + internals.invalidateOnUpdateStartIndex = Math.min( + internals.invalidateOnUpdateStartIndex, + index + ); + internals.invalidateOnUpdateStopIndex = Math.max( + internals.invalidateOnUpdateStopIndex!, + index + ); + } + + // The invalidated cells are positioned once the (next) render has been committed. + // (Within a commit this re-render is batched with the one the layout effect schedules.) + forceUpdate(); + }, + + recomputeCellPositions() { + const { cellCount, cellMeasurerCache, cellPositioner } = internals.latest; + + // Don't re-populate positions for cells that have been removed (cellCount shrank) + const stopIndex = Math.min(internals.positionCache.count, cellCount) - 1; + + const positionCache = new PositionCache(); + populatePositionCache( + positionCache, + cellMeasurerCache, + cellPositioner, + 0, + stopIndex + ); + internals.positionCache = positionCache; + + forceUpdate(); + }, + + recomputeGridSize() { + forceUpdate(); + } + }; + + const setElementRef = (element: HTMLDivElement | null) => { + internals.element = element; + }; + + return { handle, internals, linkTarget, onScroll, setElementRef }; +} diff --git a/lib/components/Masonry/PositionCache.test.ts b/lib/components/Masonry/PositionCache.test.ts new file mode 100644 index 000000000..3d972ee18 --- /dev/null +++ b/lib/components/Masonry/PositionCache.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { PositionCache } from "./PositionCache"; + +describe("PositionCache", () => { + describe("shortestColumnSize", () => { + it("should return 0 if no positions have been set", () => { + const positionCache = new PositionCache(); + expect(positionCache.shortestColumnSize).toBe(0); + }); + + it("should return the height of the shortest column", () => { + const positionCache = new PositionCache(); + positionCache.setPosition(0, 0, 0, 300); + positionCache.setPosition(1, 100, 0, 200); + positionCache.setPosition(2, 200, 0, 500); + expect(positionCache.shortestColumnSize).toBe(200); + }); + + it("should not treat a column with a height of 0 as unset", () => { + const positionCache = new PositionCache(); + positionCache.setPosition(0, 0, 0, 300); + positionCache.setPosition(1, 100, 0, 0); + positionCache.setPosition(2, 200, 0, 500); + expect(positionCache.shortestColumnSize).toBe(0); + }); + }); + + describe("estimateTotalHeight", () => { + it("should add the estimated height of unmeasured cells to the tallest column", () => { + const positionCache = new PositionCache(); + positionCache.setPosition(0, 0, 0, 300); + positionCache.setPosition(1, 100, 0, 200); + // 4 unmeasured cells in 2 columns of 50px each + expect(positionCache.estimateTotalHeight(6, 2, 50)).toBe(400); + }); + + it("should only count the cells that remain after cellCount shrinks", () => { + const positionCache = new PositionCache(); + positionCache.setPosition(0, 0, 0, 300); + positionCache.setPosition(1, 100, 0, 200); + positionCache.setPosition(2, 100, 200, 500); + positionCache.setPosition(3, 0, 300, 100); + expect(positionCache.estimateTotalHeight(4, 2, 50)).toBe(700); + expect(positionCache.estimateTotalHeight(2, 2, 50)).toBe(300); + expect(positionCache.estimateTotalHeight(0, 2, 50)).toBe(0); + }); + }); +}); diff --git a/source/Masonry/PositionCache.js b/lib/components/Masonry/PositionCache.ts similarity index 57% rename from source/Masonry/PositionCache.js rename to lib/components/Masonry/PositionCache.ts index cc7801040..3f1755824 100644 --- a/source/Masonry/PositionCache.js +++ b/lib/components/Masonry/PositionCache.ts @@ -1,27 +1,42 @@ -/** @flow */ -import createIntervalTree from '../vendor/intervalTree'; +import { createIntervalTree } from "../../vendor/intervalTree"; type RenderCallback = (index: number, left: number, top: number) => void; // Position cache requirements: // O(log(n)) lookup of cells to render for a given viewport size // O(1) lookup of shortest measured column (so we know when to enter phase 1) -export default class PositionCache { +export class PositionCache { // Tracks the height of each column - _columnSizeMap: {[x: number]: number} = {}; + _columnSizeMap: { [x: number]: number } = {}; // Store tops and bottoms of each cell for fast intersection lookup. _intervalTree = createIntervalTree(); // Maps cell index to x coordinates for quick lookup. - _leftMap: {[index: number]: number} = {}; + _leftMap: { [index: number]: number } = {}; + + // Maps cell index to the y coordinate of its bottom edge. + _bottomMap: { [index: number]: number } = {}; estimateTotalHeight( cellCount: number, columnCount: number, - defaultCellHeight: number, + defaultCellHeight: number ): number { const unmeasuredCellCount = cellCount - this.count; + if (unmeasuredCellCount < 0) { + // Some of the cached cells have since been removed (cellCount shrank); + // only the remaining cells (all of which have been positioned) contribute to the height. + let size = 0; + for (let index = 0; index < cellCount; index++) { + const bottom = this._bottomMap[index]; + if (bottom !== undefined && bottom > size) { + size = bottom; + } + } + return size; + } + return ( this.tallestColumnSize + Math.ceil(unmeasuredCellCount / columnCount) * defaultCellHeight @@ -32,18 +47,20 @@ export default class PositionCache { range( scrollTop: number, clientHeight: number, - renderCallback: RenderCallback, + renderCallback: RenderCallback ): void { this._intervalTree.queryInterval( scrollTop, scrollTop + clientHeight, - ([top, _, index]) => renderCallback(index, this._leftMap[index], top), + ([top, , index]) => + renderCallback(index as number, this._leftMap[index as number], top) ); } setPosition(index: number, left: number, top: number, height: number): void { this._intervalTree.insert([top, top + height, index]); this._leftMap[index] = left; + this._bottomMap[index] = top + height; const columnSizeMap = this._columnSizeMap; const columnHeight = columnSizeMap[left]; @@ -61,14 +78,14 @@ export default class PositionCache { get shortestColumnSize(): number { const columnSizeMap = this._columnSizeMap; - let size = 0; + let size = Infinity; - for (let i in columnSizeMap) { - let height = columnSizeMap[(i: any)]; - size = size === 0 ? height : Math.min(size, height); + for (const i in columnSizeMap) { + const height = columnSizeMap[i as unknown as number]; + size = Math.min(size, height); } - return size; + return size === Infinity ? 0 : size; } get tallestColumnSize(): number { @@ -76,8 +93,8 @@ export default class PositionCache { let size = 0; - for (let i in columnSizeMap) { - let height = columnSizeMap[(i: any)]; + for (const i in columnSizeMap) { + const height = columnSizeMap[i as unknown as number]; size = Math.max(size, height); } diff --git a/lib/components/Masonry/constants.ts b/lib/components/Masonry/constants.ts new file mode 100644 index 000000000..61832c640 --- /dev/null +++ b/lib/components/Masonry/constants.ts @@ -0,0 +1,5 @@ +/** + * Specifies the number of milliseconds during which to disable pointer events while a scroll is in progress. + * This improves performance and makes scrolling smoother. + */ +export const DEFAULT_SCROLLING_RESET_TIME_INTERVAL = 150; diff --git a/lib/components/Masonry/createCellPositioner.test.ts b/lib/components/Masonry/createCellPositioner.test.ts new file mode 100644 index 000000000..1554f38fa --- /dev/null +++ b/lib/components/Masonry/createCellPositioner.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import { createCellPositioner } from "./createCellPositioner"; + +describe("createCellPositioner", () => { + function createCache() { + return new CellMeasurerCache({ + defaultHeight: 50, + defaultWidth: 80, + fixedWidth: true + }); + } + + it("should default spacer to 0", () => { + const cellPositioner = createCellPositioner({ + cellMeasurerCache: createCache(), + columnCount: 3, + columnWidth: 80 + }); + expect(cellPositioner(0)).toEqual({ left: 0, top: 0 }); + expect(cellPositioner(1)).toEqual({ left: 80, top: 0 }); + expect(cellPositioner(2)).toEqual({ left: 160, top: 0 }); + expect(cellPositioner(3)).toEqual({ left: 0, top: 50 }); + }); + + it("should default spacer to 0 when reset without a spacer", () => { + const cellPositioner = createCellPositioner({ + cellMeasurerCache: createCache(), + columnCount: 2, + columnWidth: 100, + spacer: 10 + }); + cellPositioner.reset({ columnCount: 3, columnWidth: 80 }); + expect(cellPositioner(0)).toEqual({ left: 0, top: 0 }); + expect(cellPositioner(1)).toEqual({ left: 80, top: 0 }); + expect(cellPositioner(2)).toEqual({ left: 160, top: 0 }); + expect(cellPositioner(3)).toEqual({ left: 0, top: 50 }); + }); + + it("should use the spacer passed to reset", () => { + const cellPositioner = createCellPositioner({ + cellMeasurerCache: createCache(), + columnCount: 2, + columnWidth: 100 + }); + cellPositioner.reset({ columnCount: 3, columnWidth: 80, spacer: 10 }); + expect(cellPositioner(0)).toEqual({ left: 0, top: 0 }); + expect(cellPositioner(1)).toEqual({ left: 90, top: 0 }); + expect(cellPositioner(2)).toEqual({ left: 180, top: 0 }); + expect(cellPositioner(3)).toEqual({ left: 0, top: 60 }); + }); +}); diff --git a/source/Masonry/createCellPositioner.js b/lib/components/Masonry/createCellPositioner.ts similarity index 63% rename from source/Masonry/createCellPositioner.js rename to lib/components/Masonry/createCellPositioner.ts index 86a8ba26b..bff8d6dd8 100644 --- a/source/Masonry/createCellPositioner.js +++ b/lib/components/Masonry/createCellPositioner.ts @@ -1,30 +1,24 @@ -/** @flow */ -import type {CellMeasurerCache, Positioner} from './Masonry'; - -type createCellPositionerParams = { - cellMeasurerCache: CellMeasurerCache, - columnCount: number, - columnWidth: number, - spacer?: number, -}; - -type resetParams = { - columnCount: number, - columnWidth: number, - spacer?: number, -}; - -export default function createCellPositioner({ +import type { + CellPositioner, + CellPositionerResetParams, + CreateCellPositionerParams, + Position +} from "./types"; + +/** + * Creates a simple `Masonry` cell positioner that always inserts a cell into the shortest column. + */ +export function createCellPositioner({ cellMeasurerCache, columnCount, columnWidth, - spacer = 0, -}: createCellPositionerParams): Positioner { - let columnHeights; + spacer = 0 +}: CreateCellPositionerParams): CellPositioner { + let columnHeights: number[]; initOrResetDerivedValues(); - function cellPositioner(index) { + function cellPositioner(index: number): Position { // Find the shortest column and use it. let columnIndex = 0; for (let i = 1; i < columnHeights.length; i++) { @@ -41,7 +35,7 @@ export default function createCellPositioner({ return { left, - top, + top }; } @@ -54,10 +48,10 @@ export default function createCellPositioner({ } } - function reset(params: resetParams): void { + function reset(params: CellPositionerResetParams): void { columnCount = params.columnCount; columnWidth = params.columnWidth; - spacer = params.spacer; + spacer = params.spacer ?? 0; initOrResetDerivedValues(); } diff --git a/lib/components/Masonry/defaultMasonryProps.ts b/lib/components/Masonry/defaultMasonryProps.ts new file mode 100644 index 000000000..f9d7e3edb --- /dev/null +++ b/lib/components/Masonry/defaultMasonryProps.ts @@ -0,0 +1,33 @@ +import { DEFAULT_SCROLLING_RESET_TIME_INTERVAL } from "./constants"; +import type { MasonryProps } from "./types"; + +type DefaultedPropKeys = + | "keyMapper" + | "onCellsRendered" + | "onScroll" + | "overscanByPixels" + | "role" + | "rowDirection" + | "scrollingResetTimeInterval" + | "style" + | "tabIndex"; + +const noop = () => {}; + +/** + * Default values for `Masonry`'s optional props. + * (The PURE annotations let bundlers drop this object if it isn't used.) + */ +export const defaultMasonryProps: Readonly<{ + [Key in DefaultedPropKeys]-?: NonNullable; +}> = /* @__PURE__ */ Object.freeze({ + keyMapper: (index: number) => index, + onCellsRendered: noop, + onScroll: noop, + overscanByPixels: 20, + role: "grid", + rowDirection: "ltr", + scrollingResetTimeInterval: DEFAULT_SCROLLING_RESET_TIME_INTERVAL, + style: /* @__PURE__ */ Object.freeze({}), + tabIndex: 0 +}); diff --git a/lib/components/Masonry/types.ts b/lib/components/Masonry/types.ts new file mode 100644 index 000000000..f89c7a6b1 --- /dev/null +++ b/lib/components/Masonry/types.ts @@ -0,0 +1,326 @@ +import type { CSSProperties, Key, ReactNode } from "react"; +import type { ScrollElement } from "../../utils/scrollElement/types"; +import type { CellMeasureCache } from "../CellMeasurer/types"; + +/** + * Cache of cell measurements used by `Masonry` (typically an instance of `CellMeasurerCache`). + * Default sizes help `Masonry` decide how many cells to batch-measure. + */ +export type MasonryCellMeasurerCache = Pick< + CellMeasureCache, + "getHeight" | "getWidth" +> & { + /** Height reported for cells that have not yet been measured. */ + readonly defaultHeight: number; + + /** Width reported for cells that have not yet been measured. */ + readonly defaultWidth: number; +}; + +/** + * Position of a cell within the `Masonry` layout. + */ +export type Position = { + /** Offset (in pixels) from the left (or right, for `rtl` row direction) edge of the container. */ + left: number; + + /** Offset (in pixels) from the top edge of the container. */ + top: number; +}; + +/** + * Positions a cell given its index. + */ +export type Positioner = (index: number) => Position; + +/** + * Configuration parameters for `createCellPositioner` (exported publicly as `createMasonryCellPositioner`). + */ +export type CreateCellPositionerParams = { + /** Contains cell measurements (eg item height). */ + cellMeasurerCache: MasonryCellMeasurerCache; + + /** Number of columns to use in layout. */ + columnCount: number; + + /** Column width. */ + columnWidth: number; + + /** Empty space between columns; defaults to 0. */ + spacer?: number; +}; + +/** + * Parameters for the `reset` method of a positioner created by `createCellPositioner`. + */ +export type CellPositionerResetParams = { + /** Number of columns to use in layout. */ + columnCount: number; + + /** Column width. */ + columnWidth: number; + + /** + * Empty space between columns. + * Defaults to 0 (like `createCellPositioner`). + */ + spacer?: number; +}; + +/** + * Positioner returned by `createCellPositioner`. + */ +export type CellPositioner = Positioner & { + /** + * Updates the configuration settings (eg after a window resize) and resets the layout. + * Call `recomputeCellPositions()` on the `Masonry` instance afterward. + */ + reset: (params: CellPositionerResetParams) => void; +}; + +/** + * Parameters passed to the `Masonry` `cellRenderer` prop. + */ +export type MasonryCellRendererParams = { + /** Index of item within the collection. */ + index: number; + + /** The `Masonry` is currently being scrolled. */ + isScrolling: boolean; + + /** Unique key within array of cells. */ + key: Key; + + /** + * Reference to the parent `Masonry` (its imperative handle). + * This value should be passed as-is to `CellMeasurer`. + */ + parent: MasonryImperativeAPI; + + /** + * Style object to be applied to cell (to position it). + * This must be passed through to the rendered cell element. + */ + style: CSSProperties; +}; + +/** + * Responsible for rendering a single cell given its index. + */ +export type MasonryCellRenderer = ( + params: MasonryCellRendererParams +) => ReactNode; + +/** + * Maps an index to a unique id to store cached measurement and position info for a cell. + */ +export type MasonryKeyMapper = (index: number) => Key; + +/** + * Parameters passed to the `Masonry` `onCellsRendered` callback. + */ +export type MasonryOnCellsRenderedParams = { + /** Index of the first rendered cell (0 if no cells were rendered). */ + startIndex: number; + + /** Index of the last rendered cell (undefined if no cells were rendered). */ + stopIndex: number | undefined; +}; + +/** + * Callback invoked with information about the cells that were most recently rendered. + */ +export type MasonryOnCellsRenderedCallback = ( + params: MasonryOnCellsRenderedParams +) => void; + +/** + * Parameters passed to the `Masonry` `onScroll` callback. + */ +export type MasonryOnScrollParams = { + /** Height of the visible area (the `height` prop). */ + clientHeight: number; + + /** Estimated total height of all cells. */ + scrollHeight: number; + + /** Current vertical scroll offset. */ + scrollTop: number; +}; + +/** + * Callback invoked whenever the scroll offset changes within the inner scrollable region. + */ +export type MasonryOnScrollCallback = (params: MasonryOnScrollParams) => void; + +export type MasonryProps = { + /** Optional ARIA label to attach to root `Masonry` element. */ + "aria-label"?: string; + + /** Total number of items. */ + cellCount: number; + + /** + * Caches item measurements (typically an instance of `CellMeasurerCache`). + * Default sizes help `Masonry` decide how many images to batch-measure. + */ + cellMeasurerCache: MasonryCellMeasurerCache; + + /** + * Positions a cell given an index: `(index: number) => ({ left: number, top: number })`. + * See `createMasonryCellPositioner` for a built-in positioner. + */ + cellPositioner: Positioner; + + /** + * Responsible for rendering a cell given an index: + * `({ index: number, isScrolling: boolean, key: Key, parent: MasonryImperativeAPI, style: CSSProperties }) => ReactNode`. + * The `style` param must be passed through to the rendered cell element. + * The cell should typically be wrapped in a `CellMeasurer`. + */ + cellRenderer: MasonryCellRenderer; + + /** Optional custom CSS class name to attach to root `Masonry` element. */ + className?: string | null; + + /** + * Height of the component; this value determines the number of visible items. + * Required, unless a `scrollElement` is specified. + * + * With a `scrollElement`, the height of the scroll element's viewport is used instead; + * this prop is then only used until the scroll element has been measured (e.g. during server rendering). + */ + height?: number; + + /** Optional custom id to attach to root `Masonry` element. */ + id?: string | null; + + /** + * Maps an index to a unique id to store cached measurement and position info for a cell. + * This prevents eg cached measurements from being invalidated when a collection is re-ordered. + * Defaults to the identity function (`index => index`). + */ + keyMapper?: MasonryKeyMapper; + + /** + * Callback invoked with information about the cells that were most recently rendered. + * This callback is only invoked when visible cells have changed: + * `({ startIndex: number, stopIndex: number }): void`. + * Does nothing by default. + */ + onCellsRendered?: MasonryOnCellsRenderedCallback | null; + + /** + * Callback invoked whenever the scroll offset changes within the inner scrollable region: + * `({ clientHeight: number, scrollHeight: number, scrollTop: number }): void`. + * Does nothing by default. + */ + onScroll?: MasonryOnScrollCallback | null; + + /** + * Render this many additional pixels above and below the viewport. + * This helps reduce flicker when a user scrolls quickly. + * @default 20 + */ + overscanByPixels?: number; + + /** + * Optional override of ARIA role default. + * @default "grid" + */ + role?: string; + + /** + * Row direction of items; can be "ltr" or "rtl". + * @default "ltr" + */ + rowDirection?: string; + + /** + * Element that scrolls the `Masonry` (instead of the `Masonry` scrolling itself): `"window"`, or a scrollable DOM element + * (e.g. a container that the `Masonry` is rendered within, along with other content). + * + * The `Masonry` then renders at its full height, and renders only the cells visible within the scroll element's viewport. + * Its position within the scroll element is measured when the scroll element scrolls or is resized, and when the `Masonry` re-renders + * (e.g. call `forceUpdate()` if content above the `Masonry` changes size without the `Masonry` re-rendering). + * Pass `null` while the scroll element isn't available yet (e.g. before a ref to it has been attached); + * the `Masonry` then renders the cells at its top. + */ + scrollElement?: ScrollElement | null; + + /** + * Wait this amount of time (in milliseconds) after the last scroll event before resetting `pointer-events` + * (and the `isScrolling` parameter passed to `cellRenderer`). + * @default 150 + */ + scrollingResetTimeInterval?: number; + + /** + * Optional custom inline style to attach to root `Masonry` element. + * @default {} + */ + style?: CSSProperties; + + /** + * Optional override of tab index default. + * @default 0 + */ + tabIndex?: number; + + /** Width of the component; this value determines the number of visible items. */ + width: number; +}; + +/** + * Identifies a cell within a `Masonry` (for the `CellMeasurer` interop methods). + * Only `rowIndex` (the cell's index) is used; `columnIndex` is accepted (and ignored) for compatibility with `Grid`. + */ +export type MasonryCellPosition = { + columnIndex?: number; + rowIndex: number; +}; + +/** + * Imperative API of a `Masonry` (accessed using a ref, eg `useRef(null)`). + * This is also the `parent` value passed to `cellRenderer` (for `CellMeasurer`). + */ +export interface MasonryImperativeAPI { + /** + * Clears the internal position cache and re-renders. + * Cells are positioned again (by the `cellPositioner`) as they are rendered. + */ + clearCellPositions(): void; + + /** + * Forcefully re-renders the `Masonry` (and its visible cells). + * + * Re-rendering a `Masonry` with the same props does not re-render its cells (it is memoized). + * Use this method if the underlying cell data has changed but the props have not. + */ + forceUpdate(): void; + + /** + * Notifies the `Masonry` that the specified cell has been measured for the first time (while rendering); + * the cell is positioned (by the `cellPositioner`) once the current render has been committed, then the `Masonry` re-renders. + * Only `rowIndex` (the cell's index) is used. + * This method is intended for `CellMeasurer`. + */ + invalidateCellSizeAfterRender(params: MasonryCellPosition): void; + + /** + * Resets the internal position cache, synchronously re-computes the positions of all measured cells + * (by calling the `cellPositioner` for each of them, in index order), then re-renders. + * Reset the `cellPositioner` (e.g. with its `reset` method) before calling this method. + */ + recomputeCellPositions(): void; + + /** + * Re-renders the `Masonry`, e.g. after more cells have been loaded (this method is called by `InfiniteLoader`) + * or a cell has been re-measured (it is called by `CellMeasurer`'s `measure` function). + * Parameters (the index of the first changed cell, as `rowIndex`) are accepted for compatibility with `Grid` and ignored. + * + * Cached cell positions are not changed: to re-position cells after their sizes have changed, + * reset the `cellPositioner` and call `recomputeCellPositions()`. + */ + recomputeGridSize(params?: Partial): void; +} diff --git a/lib/components/MultiGrid/CellMeasurerCacheDecorator.test.ts b/lib/components/MultiGrid/CellMeasurerCacheDecorator.test.ts new file mode 100644 index 000000000..5b99e29f0 --- /dev/null +++ b/lib/components/MultiGrid/CellMeasurerCacheDecorator.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import { CellMeasurerCacheDecorator } from "./CellMeasurerCacheDecorator"; + +describe("CellMeasurerCacheDecorator", () => { + function createDecorator() { + const cellMeasurerCache = new CellMeasurerCache({ + defaultHeight: 25, + defaultWidth: 50 + }); + cellMeasurerCache.set(3, 2, 100, 40); + + return new CellMeasurerCacheDecorator({ + cellMeasurerCache, + columnIndexOffset: 2, + rowIndexOffset: 3 + }); + } + + it("should return the (offset) column width", () => { + const decorator = createDecorator(); + expect(decorator.columnWidth({ index: 0 })).toBe(100); + expect(decorator.columnWidth({ index: 1 })).toBe(50); + }); + + it("should return the (offset) row height", () => { + const decorator = createDecorator(); + expect(decorator.rowHeight({ index: 0 })).toBe(40); + expect(decorator.rowHeight({ index: 1 })).toBe(25); + }); +}); diff --git a/source/MultiGrid/CellMeasurerCacheDecorator.js b/lib/components/MultiGrid/CellMeasurerCacheDecorator.ts similarity index 57% rename from source/MultiGrid/CellMeasurerCacheDecorator.js rename to lib/components/MultiGrid/CellMeasurerCacheDecorator.ts index ac70c4af8..2a2d6bf26 100644 --- a/source/MultiGrid/CellMeasurerCacheDecorator.js +++ b/lib/components/MultiGrid/CellMeasurerCacheDecorator.ts @@ -1,29 +1,31 @@ -/** @flow */ -import {CellMeasurerCache} from '../CellMeasurer'; +import type { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import type { CellMeasureCache } from "../CellMeasurer/types"; type CellMeasurerCacheDecoratorParams = { - cellMeasurerCache: CellMeasurerCache, - columnIndexOffset: number, - rowIndexOffset: number, + cellMeasurerCache: CellMeasurerCache; + columnIndexOffset?: number; + rowIndexOffset?: number; }; type IndexParam = { - index: number, + index: number; }; /** * Caches measurements for a given cell. */ -export default class CellMeasurerCacheDecorator { +export class CellMeasurerCacheDecorator implements CellMeasureCache { _cellMeasurerCache: CellMeasurerCache; _columnIndexOffset: number; _rowIndexOffset: number; - constructor(params: CellMeasurerCacheDecoratorParams = {}) { + constructor( + params: CellMeasurerCacheDecoratorParams = {} as CellMeasurerCacheDecoratorParams + ) { const { cellMeasurerCache, columnIndexOffset = 0, - rowIndexOffset = 0, + rowIndexOffset = 0 } = params; this._cellMeasurerCache = cellMeasurerCache; @@ -34,7 +36,7 @@ export default class CellMeasurerCacheDecorator { clear(rowIndex: number, columnIndex: number): void { this._cellMeasurerCache.clear( rowIndex + this._rowIndexOffset, - columnIndex + this._columnIndexOffset, + columnIndex + this._columnIndexOffset ); } @@ -42,9 +44,9 @@ export default class CellMeasurerCacheDecorator { this._cellMeasurerCache.clearAll(); } - columnWidth = ({index}: IndexParam) => { - this._cellMeasurerCache.columnWidth({ - index: index + this._columnIndexOffset, + columnWidth = ({ index }: IndexParam): number => { + return this._cellMeasurerCache.columnWidth({ + index: index + this._columnIndexOffset }); }; @@ -64,30 +66,30 @@ export default class CellMeasurerCacheDecorator { return this._cellMeasurerCache.hasFixedWidth(); } - getHeight(rowIndex: number, columnIndex: ?number = 0): ?number { + getHeight(rowIndex: number, columnIndex: number = 0): number { return this._cellMeasurerCache.getHeight( rowIndex + this._rowIndexOffset, - columnIndex + this._columnIndexOffset, + columnIndex + this._columnIndexOffset ); } - getWidth(rowIndex: number, columnIndex: ?number = 0): ?number { + getWidth(rowIndex: number, columnIndex: number = 0): number { return this._cellMeasurerCache.getWidth( rowIndex + this._rowIndexOffset, - columnIndex + this._columnIndexOffset, + columnIndex + this._columnIndexOffset ); } - has(rowIndex: number, columnIndex: ?number = 0): boolean { + has(rowIndex: number, columnIndex: number = 0): boolean { return this._cellMeasurerCache.has( rowIndex + this._rowIndexOffset, - columnIndex + this._columnIndexOffset, + columnIndex + this._columnIndexOffset ); } - rowHeight = ({index}: IndexParam) => { - this._cellMeasurerCache.rowHeight({ - index: index + this._rowIndexOffset, + rowHeight = ({ index }: IndexParam): number => { + return this._cellMeasurerCache.rowHeight({ + index: index + this._rowIndexOffset }); }; @@ -95,13 +97,13 @@ export default class CellMeasurerCacheDecorator { rowIndex: number, columnIndex: number, width: number, - height: number, + height: number ): void { this._cellMeasurerCache.set( rowIndex + this._rowIndexOffset, columnIndex + this._columnIndexOffset, - (width: number), - (height: number), + width, + height ); } } diff --git a/lib/components/MultiGrid/MultiGrid.test.tsx b/lib/components/MultiGrid/MultiGrid.test.tsx new file mode 100644 index 000000000..3d9d9f228 --- /dev/null +++ b/lib/components/MultiGrid/MultiGrid.test.tsx @@ -0,0 +1,1325 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent } from "@testing-library/react"; +import { act, createRef, StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + findDOMNode as findDOMNodeUntyped, + render +} from "../../utils/test/render"; +import { CellMeasurer } from "../CellMeasurer/CellMeasurer"; +import { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import type { CellRendererParams } from "../Grid/types"; +import { MultiGrid } from "./MultiGrid"; +import type { MultiGridImperativeAPI, MultiGridProps } from "./types"; + +function findDOMNode( + instance: Parameters[0] +): HTMLDivElement { + return findDOMNodeUntyped(instance) as HTMLDivElement; +} + +// These tests only focus on what MultiGrid does specifically. +// The inner Grid component is tested in depth elsewhere. +describe("MultiGrid", () => { + function defaultCellRenderer({ + columnIndex, + key, + rowIndex, + style + }: Pick) { + return ( +
+ {`row:${rowIndex}, column:${columnIndex}`} +
+ ); + } + + function getMarkup(props: Partial & { ref?: any } = {}) { + return ( + + ); + } + + describe("fixed columns and rows", () => { + // #1351 + it("should not throw when columnCount drops to fixedColumnCount (with variable column widths)", () => { + const props = { + columnWidth: ({ index }: { index: number }) => [45, 120][index], + fixedColumnCount: 1, + fixedRowCount: 1, + rowCount: 1 + }; + render(getMarkup({ ...props, columnCount: 2 })); + let rendered: HTMLElement | null = null; + expect(() => { + rendered = findDOMNode(render(getMarkup({ ...props, columnCount: 1 }))); + }).not.toThrow(); + expect(rendered!.textContent).toContain("row:0, column:0"); + expect(rendered!.textContent).not.toContain("column:1"); + + // And back + rendered = findDOMNode(render(getMarkup({ ...props, columnCount: 2 }))); + expect(rendered.textContent).toContain("row:0, column:1"); + }); + + it("should render 4 Grids when configured for fixed columns and rows", () => { + const rendered = findDOMNode( + render( + getMarkup({ + fixedColumnCount: 1, + fixedRowCount: 1 + }) + ) + ); + const grids = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(grids.length).toEqual(4); + const [topLeft, topRight, bottomLeft, bottomRight] = grids; + expect(topLeft.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(topLeft.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(topRight.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(topRight.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(bottomLeft.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(bottomLeft.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(bottomRight.style.getPropertyValue("overflow-x")).toEqual("auto"); + expect(bottomRight.style.getPropertyValue("overflow-y")).toEqual("auto"); + }); + + it("should render 2 Grids when configured for fixed columns only", () => { + const rendered = findDOMNode( + render( + getMarkup({ + fixedColumnCount: 1, + fixedRowCount: 0 + }) + ) + ); + const grids = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(grids.length).toEqual(2); + const [bottomLeft, bottomRight] = grids; + expect(bottomLeft.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(bottomLeft.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(bottomRight.style.getPropertyValue("overflow-x")).toEqual("auto"); + expect(bottomRight.style.getPropertyValue("overflow-y")).toEqual("auto"); + }); + + it("should render 2 Grids when configured for fixed rows only", () => { + const rendered = findDOMNode( + render( + getMarkup({ + fixedColumnCount: 0, + fixedRowCount: 1 + }) + ) + ); + const grids = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(grids.length).toEqual(2); + const [topRight, bottomRight] = grids; + expect(topRight.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(topRight.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(bottomRight.style.getPropertyValue("overflow-x")).toEqual("auto"); + expect(bottomRight.style.getPropertyValue("overflow-y")).toEqual("auto"); + }); + + it("should render 1 Grid when configured for neither fixed columns and rows", () => { + const rendered = findDOMNode( + render( + getMarkup({ + fixedColumnCount: 0, + fixedRowCount: 0 + }) + ) + ); + const grids = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(grids.length).toEqual(1); + const [bottomRight] = grids; + expect(bottomRight.style.getPropertyValue("overflow-x")).toEqual("auto"); + expect(bottomRight.style.getPropertyValue("overflow-y")).toEqual("auto"); + }); + + it("should adjust the number of Grids when fixed column or row counts change", () => { + let rendered = findDOMNode( + render( + getMarkup({ + fixedColumnCount: 2, + fixedRowCount: 1 + }) + ) + ); + expect( + rendered.querySelectorAll(".ReactVirtualized__Grid").length + ).toEqual(4); + rendered = findDOMNode( + render( + getMarkup({ + fixedColumnCount: 0, + fixedRowCount: 0 + }) + ) + ); + expect( + rendered.querySelectorAll(".ReactVirtualized__Grid").length + ).toEqual(1); + rendered = findDOMNode( + render( + getMarkup({ + fixedColumnCount: 0, + fixedRowCount: 2 + }) + ) + ); + expect( + rendered.querySelectorAll(".ReactVirtualized__Grid").length + ).toEqual(2); + }); + + it("should allow scrolling of fixed Grids when configured for fixed columns and rows with scroll interaction", () => { + const rendered = findDOMNode( + render( + getMarkup({ + enableFixedColumnScroll: true, + enableFixedRowScroll: true, + fixedColumnCount: 1, + fixedRowCount: 1 + }) + ) + ); + const grids = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(grids.length).toEqual(4); + const [topLeft, topRight, bottomLeft, bottomRight] = grids; + expect(topLeft.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(topLeft.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(topRight.style.getPropertyValue("overflow-x")).toEqual("auto"); + expect(topRight.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(bottomLeft.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(bottomLeft.style.getPropertyValue("overflow-y")).toEqual("auto"); + expect(bottomRight.style.getPropertyValue("overflow-x")).toEqual("auto"); + expect(bottomRight.style.getPropertyValue("overflow-y")).toEqual("auto"); + }); + + // Bug #8 + it("should update the fixed Grids' overflow when enableFixedColumnScroll or enableFixedRowScroll change", () => { + const props = { fixedColumnCount: 1, fixedRowCount: 1 }; + const getOverflow = () => { + const [, topRight, bottomLeft] = + document.body.querySelectorAll( + ".ReactVirtualized__Grid" + ); + return { + bottomLeftY: bottomLeft.style.overflowY, + topRightX: topRight.style.overflowX + }; + }; + + render(getMarkup(props)); + expect(getOverflow()).toEqual({ + bottomLeftY: "hidden", + topRightX: "hidden" + }); + + render(getMarkup({ ...props, enableFixedColumnScroll: true })); + expect(getOverflow()).toEqual({ + bottomLeftY: "auto", + topRightX: "hidden" + }); + + render( + getMarkup({ + ...props, + enableFixedColumnScroll: true, + enableFixedRowScroll: true + }) + ); + expect(getOverflow()).toEqual({ + bottomLeftY: "auto", + topRightX: "auto" + }); + + render(getMarkup(props)); + expect(getOverflow()).toEqual({ + bottomLeftY: "hidden", + topRightX: "hidden" + }); + }); + }); + + // Bug #29 + describe("scrollbar spacers", () => { + function getScrollbarSize20() { + return 20; + } + + // Sizes of the inner Grids' content (once all cells have been measured) + function getInnerSizes(multiGrid: MultiGridImperativeAPI) { + act(() => { + multiGrid.measureAllCells(); + multiGrid.forceUpdateGrids(); + }); + const [, topRight, bottomLeft, bottomRight] = + document.body.querySelectorAll( + ".ReactVirtualized__Grid__innerScrollContainer" + ); + return { + bottomLeftHeight: parseInt(bottomLeft.style.height, 10), + bottomRightHeight: parseInt(bottomRight.style.height, 10), + bottomRightWidth: parseInt(bottomRight.style.width, 10), + topRightWidth: parseInt(topRight.style.width, 10) + }; + } + + it("should give the bottom-left Grid an extra row for the main Grid's horizontal scrollbar", () => { + const onScrollbarPresenceChange = vi.fn(); + const multiGrid = render( + getMarkup({ + columnCount: 50, + fixedColumnCount: 2, + fixedRowCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + // 9 scrollable rows (180px) fit into the bottom Grids (280px), even with a horizontal scrollbar + rowCount: 10 + }) + ); + expect(onScrollbarPresenceChange).toHaveBeenLastCalledWith({ + horizontal: true, + size: 20, + vertical: false + }); + + // The fixed Grids can be scrolled exactly as far as the main Grid + const sizes = getInnerSizes(multiGrid); + expect(sizes.bottomRightHeight).toEqual(180); + expect(sizes.bottomLeftHeight).toEqual(sizes.bottomRightHeight + 20); + expect(sizes.topRightWidth).toEqual(sizes.bottomRightWidth); + }); + + it("should give the top-right Grid an extra column for the main Grid's vertical scrollbar", () => { + const onScrollbarPresenceChange = vi.fn(); + const multiGrid = render( + getMarkup({ + // 3 scrollable columns (150px) fit into the right Grids (300px), even with a vertical scrollbar + columnCount: 5, + fixedColumnCount: 2, + fixedRowCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100 + }) + ); + expect(onScrollbarPresenceChange).toHaveBeenLastCalledWith({ + horizontal: false, + size: 20, + vertical: true + }); + + const sizes = getInnerSizes(multiGrid); + expect(sizes.bottomRightWidth).toEqual(150); + expect(sizes.topRightWidth).toEqual(sizes.bottomRightWidth + 20); + expect(sizes.bottomLeftHeight).toEqual(sizes.bottomRightHeight); + }); + + it("should add both extras if the main Grid has both scrollbars", () => { + const multiGrid = render( + getMarkup({ + fixedColumnCount: 2, + fixedRowCount: 1, + getScrollbarSize: getScrollbarSize20 + }) + ); + const sizes = getInnerSizes(multiGrid); + expect(sizes.bottomLeftHeight).toEqual(sizes.bottomRightHeight + 20); + expect(sizes.topRightWidth).toEqual(sizes.bottomRightWidth + 20); + }); + }); + + describe("hideTopRightGridScrollbar, hideBottomLeftGridScrollbar should hide the scrollbars", () => { + function getScrollbarSize20() { + return 20; + } + it("should add scroll wrappers to hide scroll bar when configured for fixed columns and rows with scroll interaction", () => { + const rendered = findDOMNode( + render( + getMarkup({ + enableFixedColumnScroll: true, + enableFixedRowScroll: true, + fixedColumnCount: 1, + fixedRowCount: 1, + hideTopRightGridScrollbar: true, + hideBottomLeftGridScrollbar: true, + getScrollbarSize: getScrollbarSize20 + }) + ) + ); + let wrappers = rendered.querySelectorAll( + ".TopRightGrid_ScrollWrapper" + ); + expect(wrappers.length).toEqual(1); + const [topRightWrapper] = wrappers; + wrappers = rendered.querySelectorAll( + ".BottomLeftGrid_ScrollWrapper" + ); + expect(wrappers.length).toEqual(1); + const [bottomLeftWrapper] = wrappers; + + expect(topRightWrapper.style.getPropertyValue("overflow-x")).toEqual( + "hidden" + ); + expect(topRightWrapper.style.getPropertyValue("overflow-y")).toEqual( + "hidden" + ); + expect(bottomLeftWrapper.style.getPropertyValue("overflow-x")).toEqual( + "hidden" + ); + expect(bottomLeftWrapper.style.getPropertyValue("overflow-y")).toEqual( + "hidden" + ); + + expect(topRightWrapper.style.getPropertyValue("height")).toEqual("20px"); + expect(bottomLeftWrapper.style.getPropertyValue("height")).toEqual( + "280px" + ); + expect(topRightWrapper.style.getPropertyValue("width")).toEqual("350px"); + expect(bottomLeftWrapper.style.getPropertyValue("width")).toEqual("50px"); + + const grids = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(grids.length).toEqual(4); + const [topLeft, topRight, bottomLeft, bottomRight] = grids; + expect(topLeft.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(topLeft.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(topRight.style.getPropertyValue("overflow-x")).toEqual("auto"); + expect(topRight.style.getPropertyValue("overflow-y")).toEqual("hidden"); + expect(topRight.style.getPropertyValue("height")).toEqual("40px"); + expect(bottomLeft.style.getPropertyValue("overflow-x")).toEqual("hidden"); + expect(bottomLeft.style.getPropertyValue("overflow-y")).toEqual("auto"); + expect(bottomLeft.style.getPropertyValue("width")).toEqual("70px"); + expect(bottomRight.style.getPropertyValue("overflow-x")).toEqual("auto"); + expect(bottomRight.style.getPropertyValue("overflow-y")).toEqual("auto"); + }); + }); + + describe("#recomputeGridSize", () => { + // Bug #7 + it("should recompute (and re-render) the MultiGrid's own layout", () => { + let fixedRowHeight = 75; + let fixedColumnWidth = 100; + + function variableRowHeight({ index }: { index: number }) { + if (index === 0) { + return fixedRowHeight; + } + return 20; + } + function variableColumnWidth({ index }: { index: number }) { + if (index === 0) { + return fixedColumnWidth; + } + return 50; + } + + const multiGrid = render( + getMarkup({ + fixedColumnCount: 1, + fixedRowCount: 1, + rowHeight: variableRowHeight, + columnWidth: variableColumnWidth + }) + ); + const rendered = findDOMNode(multiGrid); + + const grids = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(grids.length).toEqual(4); + const [topLeft, topRight, bottomLeft, bottomRight] = grids; + expect(topLeft.style.getPropertyValue("height")).toEqual("75px"); + expect(topRight.style.getPropertyValue("height")).toEqual("75px"); + expect(bottomLeft.style.getPropertyValue("height")).toEqual("225px"); + expect(bottomRight.style.getPropertyValue("height")).toEqual("225px"); + + expect(topLeft.style.getPropertyValue("width")).toEqual("100px"); + expect(topRight.style.getPropertyValue("width")).toEqual("300px"); + expect(bottomLeft.style.getPropertyValue("width")).toEqual("100px"); + expect(bottomRight.style.getPropertyValue("width")).toEqual("300px"); + + fixedRowHeight = 125; + fixedColumnWidth = 75; + act(() => { + multiGrid.recomputeGridSize(); + }); + + // No forceUpdate() is needed + const topContainer = rendered.firstElementChild as HTMLElement; + expect(topContainer.style.height).toEqual("125px"); + + const gridsAfter = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(gridsAfter.length).toEqual(4); + const [topLeftAfter, topRightAfter, bottomLeftAfter, bottomRightAfter] = + gridsAfter; + expect(topLeftAfter.style.getPropertyValue("height")).toEqual("125px"); + expect(topRightAfter.style.getPropertyValue("height")).toEqual("125px"); + expect(bottomLeftAfter.style.getPropertyValue("height")).toEqual("175px"); + expect(bottomRightAfter.style.getPropertyValue("height")).toEqual( + "175px" + ); + + expect(topLeftAfter.style.getPropertyValue("width")).toEqual("75px"); + expect(topRightAfter.style.getPropertyValue("width")).toEqual("325px"); + expect(bottomLeftAfter.style.getPropertyValue("width")).toEqual("75px"); + expect(bottomRightAfter.style.getPropertyValue("width")).toEqual("325px"); + expect(topRightAfter.style.left).toEqual("75px"); + expect(bottomRightAfter.style.left).toEqual("75px"); + + // The cells are repositioned as well + const cell = Array.from( + bottomRightAfter.querySelectorAll(".gridItem") + ).find((cell) => cell.textContent === "row:1, column:1")!; + expect(cell.style.top).toEqual("0px"); + expect(cell.style.left).toEqual("0px"); + const fixedCell = topLeftAfter.querySelector(".gridItem")!; + expect(fixedCell.style.height).toEqual("125px"); + expect(fixedCell.style.width).toEqual("75px"); + }); + + it("should update the cell sizes when a numeric columnWidth or rowHeight changes", () => { + render(getMarkup({ fixedColumnCount: 1, fixedRowCount: 1 })); + render( + getMarkup({ + columnWidth: 100, + fixedColumnCount: 1, + fixedRowCount: 1, + rowHeight: 40 + }) + ); + const cells = Array.from( + document.body.querySelectorAll(".gridItem") + ); + for (const text of [ + "row:0, column:0", + "row:0, column:2", + "row:2, column:0", + "row:2, column:2" + ]) { + const cell = cells.find((cell) => cell.textContent === text)!; + expect(cell).toBeDefined(); + expect(cell.style.width).toEqual("100px"); + expect(cell.style.height).toEqual("40px"); + } + const cell = cells.find( + (cell) => cell.textContent === "row:2, column:2" + )!; + expect(cell.style.left).toEqual("100px"); + expect(cell.style.top).toEqual("40px"); + }); + }); + + describe("scrollToCell", () => { + it("should adjust :scrollLeft for the main Grid when a column is scrolled to", () => { + const multiGrid = render( + getMarkup({ + columnWidth: 50, + fixedColumnCount: 2 + }) + ); + act(() => { + multiGrid.scrollToCell({ align: "start", columnIndex: 19 }); + }); + const grids = findDOMNode(multiGrid).querySelectorAll( + ".ReactVirtualized__Grid" + ); + // 20th column, less 2 for the fixed-column Grid, 50px column width + expect(grids[3].scrollLeft).toEqual(850); + // The top-right Grid is kept in sync + expect(grids[1].scrollLeft).toEqual(850); + }); + + it("should adjust :scrollTop for the main Grid when a row is scrolled to", () => { + const multiGrid = render( + getMarkup({ + fixedRowCount: 1, + rowHeight: 50 + }) + ); + act(() => { + multiGrid.scrollToCell({ align: "start", rowIndex: 19 }); + }); + const grids = findDOMNode(multiGrid).querySelectorAll( + ".ReactVirtualized__Grid" + ); + // 20th row, less 1 for the fixed-row Grid, 50px row width + expect(grids[3].scrollTop).toEqual(900); + // The bottom-left Grid is kept in sync + expect(grids[2].scrollTop).toEqual(900); + }); + + it("should not scroll an axis to a fixed column or row", () => { + const multiGrid = render( + getMarkup({ + fixedRowCount: 1, + rowHeight: 50 + }) + ); + act(() => { + multiGrid.scrollToCell({ + align: "start", + columnIndex: 1, + rowIndex: 19 + }); + }); + const grids = findDOMNode(multiGrid).querySelectorAll( + ".ReactVirtualized__Grid" + ); + expect(grids[3].scrollLeft).toEqual(0); + expect(grids[3].scrollTop).toEqual(900); + }); + }); + + describe("#measureAllCells", () => { + it("should not throw if there are no rows or columns", () => { + const multiGrid = render(getMarkup({ columnCount: 0, rowCount: 0 })); + expect(() => { + act(() => { + multiGrid.measureAllCells(); + }); + }).not.toThrow(); + }); + + it("should not throw if all rows and columns are fixed", () => { + const multiGrid = render( + getMarkup({ + columnCount: 2, + fixedColumnCount: 2, + fixedRowCount: 1, + rowCount: 1 + }) + ); + expect(() => { + act(() => { + multiGrid.measureAllCells(); + }); + }).not.toThrow(); + }); + }); + + describe("#forceUpdateGrids", () => { + it("should call forceUpdate() on inner Grids", () => { + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation(({ key }: CellRendererParams) => ( +
+ )); + + const rendered = render( + getMarkup({ + cellRenderer, + columnCount: 2, + fixedColumnCount: 1, + fixedRowCount: 1, + rowCount: 2 + }) + ); + + expect(cellRenderer.mock.calls).toHaveLength(4); + + cellRenderer.mockReset(); + act(() => { + rendered.forceUpdateGrids(); + }); + + expect(cellRenderer.mock.calls).toHaveLength(4); + }); + }); + + describe("#invalidateCellSizeAfterRender", () => { + it("should call invalidateCellSizeAfterRender() on inner Grids", () => { + const cellRenderer = vi.fn(); + cellRenderer.mockImplementation(({ key }: CellRendererParams) => ( +
+ )); + + const rendered = render( + getMarkup({ + cellRenderer, + columnCount: 2, + fixedColumnCount: 1, + fixedRowCount: 1, + rowCount: 2 + }) + ); + + cellRenderer.mockReset(); + act(() => { + // (The invalidated cells are recomputed after the next commit, which is scheduled automatically) + rendered.invalidateCellSizeAfterRender({ + columnIndex: 0, + rowIndex: 0 + }); + }); + + expect(cellRenderer.mock.calls).toHaveLength(4); + }); + + // Bug #21 + it("should specify itself as the :parent for the cells of all quadrants", () => { + const parents = new Map(); + function cellRenderer({ + columnIndex, + key, + parent, + rowIndex + }: CellRendererParams) { + parents.set(`${rowIndex}-${columnIndex}`, parent); + return
; + } + + const rendered = render( + getMarkup({ + cellRenderer, + columnCount: 2, + fixedColumnCount: 1, + fixedRowCount: 1, + rowCount: 2 + }) + ); + + expect(Array.from(parents.keys()).sort()).toEqual([ + "0-0", + "0-1", + "1-0", + "1-1" + ]); + for (const parent of parents.values()) { + expect(parent).toBe(rendered); + } + }); + }); + + describe("styles", () => { + it("should support custom style for the outer MultiGrid wrapper element", () => { + const rendered = findDOMNode( + render( + getMarkup({ + style: { backgroundColor: "black" } + }) + ) + ); + expect(rendered.style.backgroundColor).toEqual("black"); + }); + + it("should support custom styles for each Grid", () => { + const rendered = findDOMNode( + render( + getMarkup({ + fixedColumnCount: 2, + fixedRowCount: 1, + styleBottomLeftGrid: { backgroundColor: "green" }, + styleBottomRightGrid: { backgroundColor: "red" }, + styleTopLeftGrid: { backgroundColor: "blue" }, + styleTopRightGrid: { backgroundColor: "purple" } + }) + ) + ); + const grids = rendered.querySelectorAll( + ".ReactVirtualized__Grid" + ); + const topLeftGrid = grids[0]; + const topRightGrid = grids[1]; + const bottomLeftGrid = grids[2]; + const bottomRightGrid = grids[3]; + expect(topLeftGrid.style.backgroundColor).toEqual("blue"); + expect(topRightGrid.style.backgroundColor).toEqual("purple"); + expect(bottomLeftGrid.style.backgroundColor).toEqual("green"); + expect(bottomRightGrid.style.backgroundColor).toEqual("red"); + }); + }); + describe("scroll syncing", () => { + function getGrids(multiGrid: MultiGridImperativeAPI) { + const [topLeftGrid, topRightGrid, bottomLeftGrid, bottomRightGrid] = + findDOMNode(multiGrid).querySelectorAll( + ".ReactVirtualized__Grid" + ); + return { topLeftGrid, topRightGrid, bottomLeftGrid, bottomRightGrid }; + } + + it("should apply the initial scroll offsets to the quadrants that scroll along each axis", () => { + const multiGrid = render( + getMarkup({ + columnWidth: 50, + fixedColumnCount: 2, + fixedRowCount: 1, + initialScrollLeft: 300, + initialScrollTop: 400, + rowHeight: 20 + }) + ); + const { topLeftGrid, topRightGrid, bottomLeftGrid, bottomRightGrid } = + getGrids(multiGrid); + expect(bottomRightGrid.scrollLeft).toEqual(300); + expect(bottomRightGrid.scrollTop).toEqual(400); + expect(topRightGrid.scrollLeft).toEqual(300); + expect(topRightGrid.scrollTop).toEqual(0); + expect(bottomLeftGrid.scrollLeft).toEqual(0); + expect(bottomLeftGrid.scrollTop).toEqual(400); + expect(topLeftGrid.scrollLeft).toEqual(0); + expect(topLeftGrid.scrollTop).toEqual(0); + }); + + it("should adjust :scrollLeft for top-right and main grids when scrollToPosition is used", () => { + const multiGrid = render( + getMarkup({ + columnWidth: 50, + fixedColumnCount: 2 + }) + ); + act(() => { + multiGrid.scrollToPosition({ scrollLeft: 850 }); + }); + const { topRightGrid, bottomRightGrid } = getGrids(multiGrid); + expect(topRightGrid.scrollLeft).toEqual(850); + expect(bottomRightGrid.scrollLeft).toEqual(850); + }); + + it("should adjust :scrollTop for bottom-left and main grids when scrollToPosition is used", () => { + const multiGrid = render( + getMarkup({ + columnWidth: 50, + fixedColumnCount: 2 + }) + ); + act(() => { + multiGrid.scrollToPosition({ scrollTop: 500 }); + }); + const { bottomLeftGrid, bottomRightGrid } = getGrids(multiGrid); + expect(bottomLeftGrid.scrollTop).toEqual(500); + expect(bottomRightGrid.scrollTop).toEqual(500); + }); + + it("should adjust :scrollTop and :scrollLeft when scrollToPosition is called again", () => { + const multiGrid = render(getMarkup()); + act(() => { + multiGrid.scrollToPosition({ scrollLeft: 100, scrollTop: 100 }); + }); + act(() => { + multiGrid.scrollToPosition({ scrollLeft: 900, scrollTop: 750 }); + }); + const { topRightGrid, bottomLeftGrid, bottomRightGrid } = + getGrids(multiGrid); + expect(topRightGrid.scrollLeft).toEqual(900); + expect(bottomRightGrid.scrollLeft).toEqual(900); + expect(bottomLeftGrid.scrollTop).toEqual(750); + expect(bottomRightGrid.scrollTop).toEqual(750); + }); + + it("should sync the fixed Grids in the same update when the main Grid is scrolled", () => { + const onScroll = vi.fn(); + const multiGrid = render(getMarkup({ onScroll })); + const { topRightGrid, bottomLeftGrid, bottomRightGrid } = + getGrids(multiGrid); + + // A single (synchronous) update renders all quadrants at the new offsets + fireEvent.scroll(bottomRightGrid, { + target: { scrollLeft: 300, scrollTop: 400 } + }); + expect(topRightGrid.scrollLeft).toEqual(300); + expect(bottomLeftGrid.scrollTop).toEqual(400); + expect(bottomRightGrid.textContent).toContain("row:21, column:8"); + expect(topRightGrid.textContent).toContain("row:0, column:8"); + expect(bottomLeftGrid.textContent).toContain("row:21, column:0"); + expect(onScroll).toHaveBeenLastCalledWith( + expect.objectContaining({ scrollLeft: 300, scrollTop: 400 }) + ); + }); + + it("should sync the main Grid when a fixed Grid is scrolled (with scroll interaction)", () => { + const multiGrid = render( + getMarkup({ + enableFixedColumnScroll: true, + enableFixedRowScroll: true + }) + ); + const { topRightGrid, bottomLeftGrid, bottomRightGrid } = + getGrids(multiGrid); + + fireEvent.scroll(topRightGrid, { + target: { scrollLeft: 300, scrollTop: 0 } + }); + expect(bottomRightGrid.scrollLeft).toEqual(300); + expect(bottomRightGrid.scrollTop).toEqual(0); + + fireEvent.scroll(bottomLeftGrid, { + target: { scrollLeft: 0, scrollTop: 400 } + }); + expect(bottomRightGrid.scrollLeft).toEqual(300); + expect(bottomRightGrid.scrollTop).toEqual(400); + expect(topRightGrid.scrollLeft).toEqual(300); + }); + + it("should keep the quadrants in sync (without scrolling) when the size changes after scrollToCell", () => { + const multiGrid = render(getMarkup()); + act(() => { + multiGrid.scrollToCell({ columnIndex: 30, rowIndex: 50 }); + }); + const before = getGrids(multiGrid).bottomRightGrid; + const { scrollLeft, scrollTop } = before; + render(getMarkup({ height: 200, width: 300 })); + const { topRightGrid, bottomLeftGrid, bottomRightGrid } = + getGrids(multiGrid); + // scrollToCell is a one-time scroll; the offsets aren't adjusted afterwards + expect(bottomRightGrid.scrollTop).toEqual(scrollTop); + expect(bottomRightGrid.scrollLeft).toEqual(scrollLeft); + expect(bottomLeftGrid.scrollTop).toEqual(bottomRightGrid.scrollTop); + expect(topRightGrid.scrollLeft).toEqual(bottomRightGrid.scrollLeft); + }); + + it("should sync a fixed Grid that is added after scrolling", () => { + const multiGrid = render( + getMarkup({ fixedColumnCount: 0, fixedRowCount: 0 }) + ); + act(() => { + multiGrid.scrollToPosition({ scrollLeft: 300, scrollTop: 400 }); + }); + render(getMarkup({ fixedColumnCount: 2, fixedRowCount: 1 })); + const { topRightGrid, bottomLeftGrid } = getGrids(multiGrid); + expect(topRightGrid.scrollLeft).toEqual(300); + expect(bottomLeftGrid.scrollTop).toEqual(400); + }); + + it("should sync the fixed Grids in StrictMode", () => { + const ref = createRef(); + render( + +
{getMarkup({ ref })}
+
+ ); + act(() => { + ref.current!.scrollToPosition({ scrollLeft: 300, scrollTop: 400 }); + }); + const [, topRightGrid, bottomLeftGrid, bottomRightGrid] = + document.body.querySelectorAll(".ReactVirtualized__Grid"); + expect(bottomRightGrid.scrollLeft).toEqual(300); + expect(bottomRightGrid.scrollTop).toEqual(400); + expect(topRightGrid.scrollLeft).toEqual(300); + expect(bottomLeftGrid.scrollTop).toEqual(400); + }); + + it("should not crash when decreasing :rowCount", () => { + const multiGrid = render(getMarkup()); + act(() => { + multiGrid.scrollToPosition({ scrollTop: 1000 }); + }); + render( + getMarkup({ + rowCount: 2 + }) + ); + expect(document.body.textContent).toContain("row:1, column:0"); + expect(document.body.textContent).not.toContain("row:2,"); + }); + + it("should not crash when decreasing :columnCount", () => { + render(getMarkup()); + render( + getMarkup({ + columnCount: 3 + }) + ); + expect(document.body.textContent).toContain("row:0, column:2"); + expect(document.body.textContent).not.toContain("column:3"); + }); + }); + + describe("deferredMeasurementCache", () => { + function getDeferredMeasurementCache() { + const deferredMeasurementCache: any = new CellMeasurerCache({ + fixedHeight: true, + fixedWidth: true + }); + + deferredMeasurementCache._columnIndices = {}; + deferredMeasurementCache._rowIndices = {}; + deferredMeasurementCache.has = ( + rowIndex: number, + columnIndex: number + ) => { + deferredMeasurementCache._columnIndices[columnIndex] = columnIndex; + deferredMeasurementCache._rowIndices[rowIndex] = rowIndex; + return true; + }; + + return deferredMeasurementCache; + } + + it("should wrap top-right and bottom-right deferredMeasurementCache if fixedColumnCount is > 0", () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + render( + getMarkup({ + deferredMeasurementCache: deferredMeasurementCache, + columnCount: 3, + fixedColumnCount: 1, + fixedRowCount: 0, + rowCount: 1 + }) + ); + + expect(Object.keys(deferredMeasurementCache._columnIndices)).toEqual([ + "0", + "1", + "2" + ]); + }); + + it("should not wrap top-right and bottom-right deferredMeasurementCache if fixedColumnCount is 0", () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + render( + getMarkup({ + deferredMeasurementCache: deferredMeasurementCache, + columnCount: 2, + fixedColumnCount: 0, + fixedRowCount: 0, + rowCount: 1 + }) + ); + + expect(Object.keys(deferredMeasurementCache._columnIndices)).toEqual([ + "0", + "1" + ]); + }); + + it("should wrap bottom-left and bottom-right deferredMeasurementCache if fixedRowCount is > 0", () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + render( + getMarkup({ + deferredMeasurementCache: deferredMeasurementCache, + columnCount: 1, + fixedColumnCount: 0, + fixedRowCount: 1, + rowCount: 3 + }) + ); + + expect(Object.keys(deferredMeasurementCache._rowIndices)).toEqual([ + "0", + "1", + "2" + ]); + }); + + // Bug #20 + it("should update the wrappers when fixedColumnCount or fixedRowCount change", () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + const props = { + deferredMeasurementCache, + columnCount: 4, + rowCount: 4 + }; + render(getMarkup({ ...props, fixedColumnCount: 1, fixedRowCount: 1 })); + expect(Object.keys(deferredMeasurementCache._columnIndices)).toEqual([ + "0", + "1", + "2", + "3" + ]); + + deferredMeasurementCache._columnIndices = {}; + deferredMeasurementCache._rowIndices = {}; + render(getMarkup({ ...props, fixedColumnCount: 2, fixedRowCount: 2 })); + // (With stale offsets, the last column and row would not be queried) + expect(Object.keys(deferredMeasurementCache._columnIndices)).toEqual([ + "0", + "1", + "2", + "3" + ]); + expect(Object.keys(deferredMeasurementCache._rowIndices)).toEqual([ + "0", + "1", + "2", + "3" + ]); + }); + + // Bug #20 + it("should update the wrappers when deferredMeasurementCache changes", () => { + function getDynamicWidthCache() { + const cache = getDeferredMeasurementCache(); + cache.hasFixedWidth = () => false; + return cache; + } + const props = { + columnCount: 4, + fixedColumnCount: 1, + fixedRowCount: 0, + rowCount: 1 + }; + + const cacheA = getDynamicWidthCache(); + render(getMarkup({ ...props, deferredMeasurementCache: cacheA })); + expect(Object.keys(cacheA._columnIndices)).toEqual(["0", "1", "2", "3"]); + + const cacheB = getDynamicWidthCache(); + cacheA._columnIndices = {}; + render(getMarkup({ ...props, deferredMeasurementCache: cacheB })); + expect(Object.keys(cacheA._columnIndices)).toEqual([]); + expect(Object.keys(cacheB._columnIndices)).toEqual(["0", "1", "2", "3"]); + }); + + it("should not wrap bottom-left and bottom-right deferredMeasurementCache if fixedRowCount is 0", () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + render( + getMarkup({ + deferredMeasurementCache: deferredMeasurementCache, + columnCount: 1, + fixedColumnCount: 0, + fixedRowCount: 0, + rowCount: 2 + }) + ); + + expect(Object.keys(deferredMeasurementCache._rowIndices)).toEqual([ + "0", + "1" + ]); + }); + }); + + describe("CellMeasurer", () => { + // jsdom doesn't measure elements; a cell reports the width specified by its data-width attribute instead + let originalOffsetWidth: PropertyDescriptor | undefined; + beforeEach(() => { + originalOffsetWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetWidth" + ); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get(this: HTMLElement) { + return Number(this.dataset.width ?? 0); + } + }); + }); + afterEach(() => { + Object.defineProperty( + HTMLElement.prototype, + "offsetWidth", + originalOffsetWidth! + ); + }); + + const getColumnWidth = (columnIndex: number) => 60 + (columnIndex % 3) * 10; + + function renderMeasuredMultiGrid() { + const cache = new CellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 50, + fixedHeight: true + }); + + function cellRenderer({ + columnIndex, + key, + parent, + rowIndex, + style + }: CellRendererParams) { + return ( + +
+ {`row:${rowIndex}, column:${columnIndex}`} +
+
+ ); + } + + const ref = createRef(); + render( + +
+ {getMarkup({ + cellRenderer, + columnWidth: cache.columnWidth, + deferredMeasurementCache: cache, + fixedColumnCount: 1, + fixedRowCount: 1, + ref + })} +
+
+ ); + return ref; + } + + // Checks that the cells of all quadrants have their measured widths, and that the columns of the top and bottom Grids line up + function expectMeasuredColumns() { + const [topLeft, topRight, bottomLeft, bottomRight] = + document.body.querySelectorAll(".ReactVirtualized__Grid"); + + expect(topLeft.style.width).toEqual(`${getColumnWidth(0)}px`); + expect(bottomLeft.style.width).toEqual(`${getColumnWidth(0)}px`); + expect(topRight.style.left).toEqual(`${getColumnWidth(0)}px`); + expect(bottomRight.style.left).toEqual(`${getColumnWidth(0)}px`); + + const topRightLefts = new Map(); + for (const grid of [topLeft, topRight, bottomLeft, bottomRight]) { + const cells = grid.querySelectorAll(".gridItem"); + expect(cells.length).toBeGreaterThan(0); + for (const cell of cells) { + const [, row, column] = cell.textContent!.match( + /row:(\d+), column:(\d+)/ + )!; + const columnIndex = Number(column); + expect(cell.style.width).toEqual(`${getColumnWidth(columnIndex)}px`); + if (grid === topRight) { + topRightLefts.set(columnIndex, cell.style.left); + } else if (grid === bottomRight && topRightLefts.has(columnIndex)) { + expect(cell.style.left).toEqual(topRightLefts.get(columnIndex)); + } + expect(Number(row)).toBeGreaterThanOrEqual(0); + } + } + expect(topRight.scrollLeft).toEqual(bottomRight.scrollLeft); + } + + it("should measure the cells of all quadrants (in StrictMode)", () => { + renderMeasuredMultiGrid(); + expectMeasuredColumns(); + }); + + it("should apply the measurements of cells rendered while scrolling (in StrictMode)", () => { + const ref = renderMeasuredMultiGrid(); + + // The newly rendered cells are rendered (and measured) by the inner Grids only + act(() => { + ref.current!.scrollToPosition({ scrollLeft: 1500 }); + }); + const bottomRight = document.body.querySelectorAll( + ".ReactVirtualized__Grid" + )[3]; + expect(bottomRight.textContent).toContain("column:30"); + expectMeasuredColumns(); + }); + }); + + describe("onScrollbarPresenceChange", () => { + function getScrollbarSize20() { + return 20; + } + + it("should not trigger on-mount if scrollbars are hidden", () => { + const onScrollbarPresenceChange = vi.fn(); + + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1 + }) + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + }); + + it("should trigger on-mount if scrollbars are visible", () => { + const onScrollbarPresenceChange = vi.fn(); + + render( + getMarkup({ + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100 + }) + ); + expect(onScrollbarPresenceChange).toHaveBeenCalled(); + + const args = onScrollbarPresenceChange.mock.calls[0][0]; + expect(args.horizontal).toBe(true); + expect(args.size).toBe(getScrollbarSize20()); + expect(args.vertical).toBe(true); + }); + + it("should trigger on-update if scrollbar visibility has changed", () => { + const onScrollbarPresenceChange = vi.fn(); + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1 + }) + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + + render( + getMarkup({ + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100 + }) + ); + expect(onScrollbarPresenceChange).toHaveBeenCalled(); + + const args = onScrollbarPresenceChange.mock.calls[0][0]; + expect(args.horizontal).toBe(true); + expect(args.size).toBe(getScrollbarSize20()); + expect(args.vertical).toBe(true); + }); + + it("should not trigger on-update if scrollbar visibility does not change", () => { + const onScrollbarPresenceChange = vi.fn(); + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1 + }) + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + render( + getMarkup({ + columnCount: 2, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 2 + }) + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lib/components/MultiGrid/MultiGrid.tsx b/lib/components/MultiGrid/MultiGrid.tsx new file mode 100644 index 000000000..030c6b9cf --- /dev/null +++ b/lib/components/MultiGrid/MultiGrid.tsx @@ -0,0 +1,877 @@ +import { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useInsertionEffect, + useMemo, + useReducer, + useState, + type CSSProperties, + type Dispatch, + type SetStateAction +} from "react"; +import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect"; +import type { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import type { CellMeasureCache } from "../CellMeasurer/types"; +import { Grid } from "../Grid/Grid"; +import type { + CellRendererParams, + CellSize, + GridImperativeAPI, + Scroll, + ScrollbarPresenceChange +} from "../Grid/types"; +import { CellMeasurerCacheDecorator } from "./CellMeasurerCacheDecorator"; +import { defaultMultiGridProps } from "./defaultMultiGridProps"; +import type { MultiGridImperativeAPI, MultiGridProps } from "./types"; + +const SCROLLBAR_SIZE_BUFFER = 20; + +type ScrollInfo = Pick; + +// Scrollbars of the bottom-right Grid (as reported by its onScrollbarPresenceChange) +type ScrollbarState = { + scrollbarSize: number; + showHorizontalScrollbar: boolean; + showVerticalScrollbar: boolean; +}; + +const INITIAL_SCROLLBAR_STATE: ScrollbarState = { + scrollbarSize: 0, + showHorizontalScrollbar: false, + showVerticalScrollbar: false +}; + +// Props read by the imperative methods and the inner Grids' callbacks. +// They are updated in an insertion effect, so they are current when these are called during the commit phase +// (e.g. by a CellMeasurer cell or an inner Grid, whose effects run before the MultiGrid's own layout effects). +type LatestValues = Pick< + MultiGridProps, + "onScroll" | "onScrollbarPresenceChange" +> & { + fixedColumnCount: number; + fixedRowCount: number; +}; + +// Mutable, per-instance values (the equivalent of the instance fields of the 9.x class component). +// They are only modified by the imperative methods, the inner Grids' callbacks and effects (never during render). +type MultiGridInternals = { + bottomLeftGrid: GridImperativeAPI | null; + bottomRightGrid: GridImperativeAPI | null; + topLeftGrid: GridImperativeAPI | null; + topRightGrid: GridImperativeAPI | null; + + // Cell sizes of the most recently committed render (to detect numeric size changes) + committedSizes: { columnWidth: CellSize; rowHeight: CellSize } | null; + + deferredInvalidateColumnIndex: number | null; + deferredInvalidateRowIndex: number | null; + + latest: LatestValues; + + // The most recently requested scrollbar state (including an update that has not been rendered yet) + scrollbarState: ScrollbarState; + + // The scroll offsets the quadrants are kept in sync with: the bottom-right Grid's offsets + // (or the top-right/bottom-left Grid's, if those are scrolled by the user; see enableFixedRowScroll/enableFixedColumnScroll). + // These are updated by scroll events, so they aren't state: changing them doesn't re-render MultiGrid itself; + // the quadrants are scrolled through their imperative handles instead. + scrollLeft: number; + scrollTop: number; +}; + +type MultiGridInstance = { + handle: MultiGridImperativeAPI; + internals: MultiGridInternals; + onCommit: (columnWidth: CellSize, rowHeight: CellSize) => void; + onScroll: (scrollInfo: ScrollInfo) => void; + onScrollbarPresenceChange: (params: ScrollbarPresenceChange) => void; + onScrollLeft: (scrollInfo: ScrollInfo) => void; + onScrollTop: (scrollInfo: ScrollInfo) => void; + setBottomLeftGrid: (grid: GridImperativeAPI | null) => void; + setBottomRightGrid: (grid: GridImperativeAPI | null) => void; + setTopLeftGrid: (grid: GridImperativeAPI | null) => void; + setTopRightGrid: (grid: GridImperativeAPI | null) => void; +}; + +const increment = (value: number) => value + 1; + +function isNumericSizeChange(prevSize: CellSize, nextSize: CellSize) { + // Function sizes aren't compared (inline functions would cause infinite loops); + // in that case the recompute methods should be used to inform of changes (as with Grid). + return ( + (typeof prevSize === "number" || typeof nextSize === "number") && + prevSize !== nextSize + ); +} + +// Total size of the first `count` columns (or rows) +function getFixedCellsSize(size: CellSize, count: number): number { + if (typeof size === "function") { + let total = 0; + for (let index = 0; index < count; index++) { + total += size({ index }); + } + return total; + } + return size * count; +} + +function createMultiGridInstance( + initialLatest: LatestValues, + setScrollbarState: Dispatch>, + forceUpdate: () => void +): MultiGridInstance { + const internals: MultiGridInternals = { + bottomLeftGrid: null, + bottomRightGrid: null, + topLeftGrid: null, + topRightGrid: null, + committedSizes: null, + deferredInvalidateColumnIndex: null, + deferredInvalidateRowIndex: null, + latest: initialLatest, + scrollbarState: INITIAL_SCROLLBAR_STATE, + scrollLeft: 0, + scrollTop: 0 + }; + + // Calls recomputeGridSize() on all child Grids (adjusting the indices for the fixed rows and columns) + const recomputeGrids = (columnIndex: number, rowIndex: number) => { + const { fixedColumnCount, fixedRowCount } = internals.latest; + + const adjustedColumnIndex = Math.max(0, columnIndex - fixedColumnCount); + const adjustedRowIndex = Math.max(0, rowIndex - fixedRowCount); + + internals.bottomLeftGrid?.recomputeGridSize({ + columnIndex, + rowIndex: adjustedRowIndex + }); + internals.bottomRightGrid?.recomputeGridSize({ + columnIndex: adjustedColumnIndex, + rowIndex: adjustedRowIndex + }); + internals.topLeftGrid?.recomputeGridSize({ + columnIndex, + rowIndex + }); + internals.topRightGrid?.recomputeGridSize({ + columnIndex: adjustedColumnIndex, + rowIndex + }); + }; + + // Scrolls the fixed Grids to the current scroll offsets (horizontally for the top-right Grid, vertically for the bottom-left Grid). + // Grid's scrollToPosition() is a no-op for a Grid that is already there. + // (The bottom-right Grid is not scrolled here: it reports its own offsets, and may have a pending scroll, e.g. to keep a cell in view.) + const syncFixedGridScrollPositions = () => { + const { scrollLeft, scrollTop } = internals; + + internals.topRightGrid?.scrollToPosition({ scrollLeft }); + internals.bottomLeftGrid?.scrollToPosition({ scrollTop }); + }; + + const handle: MultiGridImperativeAPI = { + forceUpdateGrids() { + internals.bottomLeftGrid?.forceUpdate(); + internals.bottomRightGrid?.forceUpdate(); + internals.topLeftGrid?.forceUpdate(); + internals.topRightGrid?.forceUpdate(); + }, + + invalidateCellSizeAfterRender({ columnIndex = 0, rowIndex = 0 } = {}) { + internals.deferredInvalidateColumnIndex = + typeof internals.deferredInvalidateColumnIndex === "number" + ? Math.min(internals.deferredInvalidateColumnIndex, columnIndex) + : columnIndex; + internals.deferredInvalidateRowIndex = + typeof internals.deferredInvalidateRowIndex === "number" + ? Math.min(internals.deferredInvalidateRowIndex, rowIndex) + : rowIndex; + + // The invalidated sizes are recomputed after the MultiGrid's next commit (see onCommit). + // The cell may have been rendered by an inner Grid alone (e.g. while scrolling), so schedule a MultiGrid update. + // (This is called during the commit phase, so the update is applied before the browser paints.) + forceUpdate(); + }, + + measureAllCells() { + internals.bottomLeftGrid?.measureAllCells(); + internals.bottomRightGrid?.measureAllCells(); + internals.topLeftGrid?.measureAllCells(); + internals.topRightGrid?.measureAllCells(); + }, + + recomputeGridSize({ columnIndex = 0, rowIndex = 0 } = {}) { + recomputeGrids(columnIndex, rowIndex); + + // Re-render the MultiGrid itself, recomputing the size of the fixed rows and columns (bug #7) + forceUpdate(); + }, + + scrollToCell({ align, columnIndex, rowIndex }) { + const { fixedColumnCount, fixedRowCount } = internals.latest; + + // Fixed cells are always visible (in the corresponding axis) + const adjustedColumnIndex = + columnIndex !== undefined && columnIndex >= fixedColumnCount + ? columnIndex - fixedColumnCount + : undefined; + const adjustedRowIndex = + rowIndex !== undefined && rowIndex >= fixedRowCount + ? rowIndex - fixedRowCount + : undefined; + + // The other quadrants are synced when the bottom-right Grid reports its new offsets (via onScroll) + internals.bottomRightGrid?.scrollToCell({ + align, + columnIndex: adjustedColumnIndex, + rowIndex: adjustedRowIndex + }); + }, + + scrollToPosition({ scrollLeft, scrollTop }) { + internals.bottomRightGrid?.scrollToPosition({ scrollLeft, scrollTop }); + } + }; + + // Called after every commit of the MultiGrid (the equivalent of the 9.x componentDidMount/componentDidUpdate) + const onCommit = (columnWidth: CellSize, rowHeight: CellSize) => { + // Most of the inner Grids receive wrapped (function) cell sizes, which Grid can't compare; + // so a change of a numeric size is passed on by recomputing them (as Grid does for its own numeric sizes). + const committedSizes = internals.committedSizes; + internals.committedSizes = { columnWidth, rowHeight }; + if ( + committedSizes !== null && + (isNumericSizeChange(committedSizes.columnWidth, columnWidth) || + isNumericSizeChange(committedSizes.rowHeight, rowHeight)) + ) { + recomputeGrids(0, 0); + } + + // Cell sizes invalidated by CellMeasurer (see invalidateCellSizeAfterRender) + if (typeof internals.deferredInvalidateColumnIndex === "number") { + const columnIndex = internals.deferredInvalidateColumnIndex; + // Always set alongside the column index (by invalidateCellSizeAfterRender) + const rowIndex = internals.deferredInvalidateRowIndex as number; + + internals.deferredInvalidateColumnIndex = null; + internals.deferredInvalidateRowIndex = null; + + handle.recomputeGridSize({ columnIndex, rowIndex }); + } + + // E.g. a fixed Grid that has just been added (because the fixed row or column count changed) + syncFixedGridScrollPositions(); + }; + + // The bottom-right Grid has been scrolled + const onScroll = (scrollInfo: ScrollInfo) => { + const { scrollLeft, scrollTop } = scrollInfo; + + internals.scrollLeft = scrollLeft; + internals.scrollTop = scrollTop; + + // Scroll the fixed Grids in the same batch of updates as the one that scrolled (so they don't lag behind). + // (This is a no-op for a Grid that reported the new offsets.) + syncFixedGridScrollPositions(); + + const { onScroll } = internals.latest; + if (onScroll) { + // The full Scroll object is passed when invoked by the bottom-right Grid; + // only scrollLeft and scrollTop are passed when invoked via onScrollLeft or onScrollTop + onScroll(scrollInfo as Scroll); + } + }; + + const onScrollbarPresenceChange = ({ + horizontal, + size, + vertical + }: ScrollbarPresenceChange) => { + const { showHorizontalScrollbar, showVerticalScrollbar } = + internals.scrollbarState; + + if ( + horizontal !== showHorizontalScrollbar || + vertical !== showVerticalScrollbar + ) { + const scrollbarState: ScrollbarState = { + scrollbarSize: size, + showHorizontalScrollbar: horizontal, + showVerticalScrollbar: vertical + }; + internals.scrollbarState = scrollbarState; + setScrollbarState(scrollbarState); + + const { onScrollbarPresenceChange } = internals.latest; + if (typeof onScrollbarPresenceChange === "function") { + onScrollbarPresenceChange({ + horizontal, + size, + vertical + }); + } + } + }; + + // The top-right Grid has been scrolled by the user (enableFixedRowScroll) + const onScrollLeft = ({ scrollLeft }: ScrollInfo) => { + internals.bottomRightGrid?.scrollToPosition({ scrollLeft }); + onScroll({ + scrollLeft, + scrollTop: internals.scrollTop + }); + }; + + // The bottom-left Grid has been scrolled by the user (enableFixedColumnScroll) + const onScrollTop = ({ scrollTop }: ScrollInfo) => { + internals.bottomRightGrid?.scrollToPosition({ scrollTop }); + onScroll({ + scrollLeft: internals.scrollLeft, + scrollTop + }); + }; + + return { + handle, + internals, + onCommit, + onScroll, + onScrollbarPresenceChange, + onScrollLeft, + onScrollTop, + setBottomLeftGrid: (grid) => { + internals.bottomLeftGrid = grid; + }, + setBottomRightGrid: (grid) => { + internals.bottomRightGrid = grid; + }, + setTopLeftGrid: (grid) => { + internals.topLeftGrid = grid; + }, + setTopRightGrid: (grid) => { + internals.topRightGrid = grid; + } + }; +} + +// The CellMeasurerCache (wrapper) used by each of the inner Grids that don't start at the first row and column. +// (The wrappers translate the inner Grids' indices to indices of the full grid.) +function createDeferredMeasurementCaches( + deferredMeasurementCache: CellMeasureCache | undefined, + fixedColumnCount: number, + fixedRowCount: number +) { + if (!deferredMeasurementCache) { + return { + bottomLeftGrid: undefined, + bottomRightGrid: undefined, + topRightGrid: undefined + }; + } + + // CellMeasurerCacheDecorator relies on CellMeasurerCache-specific methods (eg clearAll) + const cellMeasurerCache = deferredMeasurementCache as CellMeasurerCache; + + return { + bottomLeftGrid: + fixedRowCount > 0 + ? new CellMeasurerCacheDecorator({ + cellMeasurerCache, + columnIndexOffset: 0, + rowIndexOffset: fixedRowCount + }) + : deferredMeasurementCache, + bottomRightGrid: + fixedColumnCount > 0 || fixedRowCount > 0 + ? new CellMeasurerCacheDecorator({ + cellMeasurerCache, + columnIndexOffset: fixedColumnCount, + rowIndexOffset: fixedRowCount + }) + : deferredMeasurementCache, + topRightGrid: + fixedColumnCount > 0 + ? new CellMeasurerCacheDecorator({ + cellMeasurerCache, + columnIndexOffset: fixedColumnCount, + rowIndexOffset: 0 + }) + : deferredMeasurementCache + }; +} + +/** + * Renders 1, 2, or 4 Grids depending on configuration. + * A main (body) Grid will always be rendered. + * Optionally, 1-2 Grids for sticky header rows will also be rendered. + * If no sticky columns, only 1 sticky header Grid will be rendered. + * If sticky columns, 2 sticky header Grids will be rendered. + * + * Scrolling is done through the imperative handle (`ref`): see `scrollToCell` and `scrollToPosition`. + */ +export const MultiGrid = /* @__PURE__ */ memo( + /* @__PURE__ */ forwardRef( + function MultiGrid(props, ref) { + const { + cellRenderer, + classNameBottomLeftGrid = defaultMultiGridProps.classNameBottomLeftGrid, + classNameBottomRightGrid = defaultMultiGridProps.classNameBottomRightGrid, + classNameTopLeftGrid = defaultMultiGridProps.classNameTopLeftGrid, + classNameTopRightGrid = defaultMultiGridProps.classNameTopRightGrid, + columnCount, + columnWidth, + deferredMeasurementCache, + enableFixedColumnScroll = defaultMultiGridProps.enableFixedColumnScroll, + enableFixedRowScroll = defaultMultiGridProps.enableFixedRowScroll, + fixedColumnCount = defaultMultiGridProps.fixedColumnCount, + fixedRowCount = defaultMultiGridProps.fixedRowCount, + height, + hideBottomLeftGridScrollbar = defaultMultiGridProps.hideBottomLeftGridScrollbar, + hideTopRightGridScrollbar = defaultMultiGridProps.hideTopRightGridScrollbar, + // The initial offsets only apply to the quadrants that scroll along the corresponding axis + initialScrollLeft, + initialScrollTop, + onScroll, + onScrollbarPresenceChange, + onSectionRendered, + rowCount, + rowHeight, + style = defaultMultiGridProps.style, + styleBottomLeftGrid = defaultMultiGridProps.styleBottomLeftGrid, + styleBottomRightGrid = defaultMultiGridProps.styleBottomRightGrid, + styleTopLeftGrid = defaultMultiGridProps.styleTopLeftGrid, + styleTopRightGrid = defaultMultiGridProps.styleTopRightGrid, + width, + // The remaining (Grid) props are relayed to all of the inner Grids + ...gridProps + } = props; + + // Incremented to re-render the MultiGrid and recompute the size of its fixed rows and columns (see recomputeGridSize) + const [layoutVersion, forceUpdate] = useReducer(increment, 0); + const [scrollbarState, setScrollbarState] = useState( + INITIAL_SCROLLBAR_STATE + ); + + const latest: LatestValues = { + fixedColumnCount, + fixedRowCount, + onScroll, + onScrollbarPresenceChange + }; + + // The instance (and its stable imperative handle) is created once + const [ + { + handle, + internals, + onCommit, + onScroll: onScrollBottomRightGrid, + onScrollbarPresenceChange: onScrollbarPresenceChangeBottomRightGrid, + onScrollLeft, + onScrollTop, + setBottomLeftGrid, + setBottomRightGrid, + setTopLeftGrid, + setTopRightGrid + } + ] = useState(() => + createMultiGridInstance(latest, setScrollbarState, forceUpdate) + ); + + useImperativeHandle(ref, () => handle, [handle]); + + useInsertionEffect(() => { + internals.latest = latest; + }); + + const { scrollbarSize, showHorizontalScrollbar, showVerticalScrollbar } = + scrollbarState; + + // The size of the fixed columns and rows. + // Function sizes are (only) re-read when they change or when recomputeGridSize() is called (see layoutVersion). + const leftGridWidth = useMemo( + () => getFixedCellsSize(columnWidth, fixedColumnCount), + // eslint-disable-next-line react-hooks/exhaustive-deps + [columnWidth, fixedColumnCount, layoutVersion] + ); + const topGridHeight = useMemo( + () => getFixedCellsSize(rowHeight, fixedRowCount), + // eslint-disable-next-line react-hooks/exhaustive-deps + [rowHeight, fixedRowCount, layoutVersion] + ); + const bottomGridHeight = height - topGridHeight; + const rightGridWidth = width - leftGridWidth; + + // The fixed Grids need some extra room to scroll as far as the bottom-right Grid (which may have scrollbars): + // the bottom-left Grid gets an extra row (as tall as the bottom-right Grid's horizontal scrollbar), + // and the top-right Grid gets an extra column (as wide as the bottom-right Grid's vertical scrollbar). + // (The bottom-right Grid's horizontal scrollbar shortens its scrollable height, and vice versa; bug #29.) + // If the bottom-right Grid has no scrollbar, the extra space is overflow:hidden anyway. + const additionalRowCount = showHorizontalScrollbar ? 1 : 0; + const additionalColumnCount = showVerticalScrollbar ? 1 : 0; + + // Inline styles are memoized so that they don't defeat the inner Grids' memo(). + const containerOuterStyle = useMemo( + () => ({ + height, + overflow: "visible", // Let :focus outline show through + width, + ...style + }), + [height, style, width] + ); + const containerTopStyle = useMemo( + () => ({ + height: topGridHeight, + position: "relative", + width + }), + [topGridHeight, width] + ); + const containerBottomStyle = useMemo( + () => ({ + height: bottomGridHeight, + overflow: "visible", // Let :focus outline show through + position: "relative", + width + }), + [bottomGridHeight, width] + ); + // (Bug #8: these follow changes to enableFixedColumnScroll and enableFixedRowScroll) + const bottomLeftGridStyle = useMemo( + () => ({ + left: 0, + overflowX: "hidden", + overflowY: enableFixedColumnScroll ? "auto" : "hidden", + position: "absolute", + ...styleBottomLeftGrid + }), + [enableFixedColumnScroll, styleBottomLeftGrid] + ); + const bottomRightGridStyle = useMemo( + () => ({ + left: leftGridWidth, + position: "absolute", + ...styleBottomRightGrid + }), + [leftGridWidth, styleBottomRightGrid] + ); + const topLeftGridStyle = useMemo( + () => ({ + left: 0, + overflowX: "hidden", + overflowY: "hidden", + position: "absolute", + top: 0, + ...styleTopLeftGrid + }), + [styleTopLeftGrid] + ); + const topRightGridStyle = useMemo( + () => ({ + left: leftGridWidth, + overflowX: enableFixedRowScroll ? "auto" : "hidden", + overflowY: "hidden", + position: "absolute", + top: 0, + ...styleTopRightGrid + }), + [enableFixedRowScroll, leftGridWidth, styleTopRightGrid] + ); + const topRightGridInnerStyle = useMemo( + () => + hideTopRightGridScrollbar + ? { ...topRightGridStyle, left: 0 } + : topRightGridStyle, + [hideTopRightGridScrollbar, topRightGridStyle] + ); + + // Bug #20: the wrappers (and their index offsets) follow changes to the cache and the fixed row and column counts + const deferredMeasurementCaches = useMemo( + () => + createDeferredMeasurementCaches( + deferredMeasurementCache, + fixedColumnCount, + fixedRowCount + ), + [deferredMeasurementCache, fixedColumnCount, fixedRowCount] + ); + + // Cell sizes of the right and bottom Grids (whose indices are offset by the fixed columns and rows) + const columnWidthRightGrid = useCallback( + ({ index }: { index: number }): number => { + // The extra column (see additionalColumnCount) + if ( + showVerticalScrollbar && + index === columnCount - fixedColumnCount + ) { + return scrollbarSize; + } + + return typeof columnWidth === "function" + ? columnWidth({ index: index + fixedColumnCount }) + : columnWidth; + }, + [ + columnCount, + columnWidth, + fixedColumnCount, + scrollbarSize, + showVerticalScrollbar + ] + ); + const rowHeightBottomGrid = useCallback( + ({ index }: { index: number }): number => { + // The extra row (see additionalRowCount) + if (showHorizontalScrollbar && index === rowCount - fixedRowCount) { + return scrollbarSize; + } + + return typeof rowHeight === "function" + ? rowHeight({ index: index + fixedRowCount }) + : rowHeight; + }, + [ + fixedRowCount, + rowCount, + rowHeight, + scrollbarSize, + showHorizontalScrollbar + ] + ); + + // Cell renderers of the inner Grids. + // Indices are translated to indices of the full grid, and the MultiGrid's handle is passed as the parent (for CellMeasurer). + const cellRendererTopLeftGrid = useCallback( + (params: CellRendererParams) => + cellRenderer({ + ...params, + // Bug #21: like the other quadrants (rather than the inner Grid) + parent: handle + }), + [cellRenderer, handle] + ); + const cellRendererTopRightGrid = useCallback( + ({ columnIndex, ...rest }: CellRendererParams) => { + if (columnIndex === columnCount - fixedColumnCount) { + return ( +
+ ); + } else { + return cellRenderer({ + ...rest, + columnIndex: columnIndex + fixedColumnCount, + parent: handle + }); + } + }, + [cellRenderer, columnCount, fixedColumnCount, handle] + ); + const cellRendererBottomLeftGrid = useCallback( + ({ rowIndex, ...rest }: CellRendererParams) => { + if (rowIndex === rowCount - fixedRowCount) { + return ( +
+ ); + } else { + return cellRenderer({ + ...rest, + parent: handle, + rowIndex: rowIndex + fixedRowCount + }); + } + }, + [cellRenderer, fixedRowCount, handle, rowCount] + ); + const cellRendererBottomRightGrid = useCallback( + ({ columnIndex, rowIndex, ...rest }: CellRendererParams) => + cellRenderer({ + ...rest, + columnIndex: columnIndex + fixedColumnCount, + parent: handle, + rowIndex: rowIndex + fixedRowCount + }), + [cellRenderer, fixedColumnCount, fixedRowCount, handle] + ); + + // Equivalent of the 9.x componentDidMount/componentDidUpdate + useIsomorphicLayoutEffect(() => { + onCommit(columnWidth, rowHeight); + }); + + // Don't render any of our Grids if there are no cells. + // This mirrors what Grid does, + // And prevents us from recording inaccurate measurements when used with CellMeasurer. + if (width === 0 || height === 0) { + return null; + } + + let topLeftGrid = null; + if (fixedColumnCount && fixedRowCount) { + topLeftGrid = ( + + ); + } + + let topRightGrid = null; + if (fixedRowCount) { + const additionalHeight = showHorizontalScrollbar ? scrollbarSize : 0; + + topRightGrid = ( + + ); + + if (hideTopRightGridScrollbar) { + topRightGrid = ( +
+ {topRightGrid} +
+ ); + } + } + + let bottomLeftGrid = null; + if (fixedColumnCount) { + // The bottom-left Grid's own vertical scrollbar (see enableFixedColumnScroll) + const additionalWidth = showVerticalScrollbar ? scrollbarSize : 0; + + bottomLeftGrid = ( + + ); + + if (hideBottomLeftGridScrollbar) { + bottomLeftGrid = ( +
+ {bottomLeftGrid} +
+ ); + } + } + + const bottomRightGrid = ( + + ); + + return ( +
+
+ {topLeftGrid} + {topRightGrid} +
+
+ {bottomLeftGrid} + {bottomRightGrid} +
+
+ ); + } + ) +); diff --git a/lib/components/MultiGrid/defaultMultiGridProps.ts b/lib/components/MultiGrid/defaultMultiGridProps.ts new file mode 100644 index 000000000..16b529f61 --- /dev/null +++ b/lib/components/MultiGrid/defaultMultiGridProps.ts @@ -0,0 +1,42 @@ +import type { MultiGridProps } from "./types"; + +type DefaultedPropKeys = + | "classNameBottomLeftGrid" + | "classNameBottomRightGrid" + | "classNameTopLeftGrid" + | "classNameTopRightGrid" + | "enableFixedColumnScroll" + | "enableFixedRowScroll" + | "fixedColumnCount" + | "fixedRowCount" + | "hideBottomLeftGridScrollbar" + | "hideTopRightGridScrollbar" + | "style" + | "styleBottomLeftGrid" + | "styleBottomRightGrid" + | "styleTopLeftGrid" + | "styleTopRightGrid"; + +/** + * Default values for `MultiGrid`'s optional props. + * (Props that are relayed to the inner `Grid`s default to the values in `defaultGridProps`.) + */ +export const defaultMultiGridProps: Readonly< + Required> +> = /* @__PURE__ */ Object.freeze({ + classNameBottomLeftGrid: "", + classNameBottomRightGrid: "", + classNameTopLeftGrid: "", + classNameTopRightGrid: "", + enableFixedColumnScroll: false, + enableFixedRowScroll: false, + fixedColumnCount: 0, + fixedRowCount: 0, + hideBottomLeftGridScrollbar: false, + hideTopRightGridScrollbar: false, + style: /* @__PURE__ */ Object.freeze({}), + styleBottomLeftGrid: /* @__PURE__ */ Object.freeze({}), + styleBottomRightGrid: /* @__PURE__ */ Object.freeze({}), + styleTopLeftGrid: /* @__PURE__ */ Object.freeze({}), + styleTopRightGrid: /* @__PURE__ */ Object.freeze({}) +}); diff --git a/lib/components/MultiGrid/types.ts b/lib/components/MultiGrid/types.ts new file mode 100644 index 000000000..6932f3eec --- /dev/null +++ b/lib/components/MultiGrid/types.ts @@ -0,0 +1,170 @@ +import type { CSSProperties } from "react"; +import type { + Alignment, + CellPosition, + GridProps, + ScrollbarPresenceChange, + ScrollPosition +} from "../Grid/types"; + +/** + * `MultiGrid` accepts most `Grid` props (most of which, eg `cellRenderer`, are relayed to all child `Grid`s; + * some, eg `columnCount` and `rowCount`, are adjusted slightly to support fixed rows and columns), + * plus the following props. + * + * A `MultiGrid` always scrolls itself (it doesn't support a `scrollElement`). + * To scroll a `MultiGrid`, use its imperative `scrollToCell` or `scrollToPosition` methods. + */ +export type MultiGridProps = Omit< + GridProps, + "height" | "onScrollbarPresenceChange" | "scrollElement" | "style" +> & { + /** Height of the `MultiGrid` (including its fixed rows); determines the number of visible (vs virtualized) rows. */ + height: number; + + /** + * Optional custom className to attach to bottom-left `Grid` element. + * @default "" + */ + classNameBottomLeftGrid?: string; + + /** + * Optional custom className to attach to bottom-right `Grid` element. + * @default "" + */ + classNameBottomRightGrid?: string; + + /** + * Optional custom className to attach to top-left `Grid` element. + * @default "" + */ + classNameTopLeftGrid?: string; + + /** + * Optional custom className to attach to top-right `Grid` element. + * @default "" + */ + classNameTopRightGrid?: string; + + /** + * Fixed columns can be actively scrolled (e.g. with a mouse wheel while the cursor is over them). + * @default false + */ + enableFixedColumnScroll?: boolean; + + /** + * Fixed rows can be actively scrolled (e.g. with a mouse wheel while the cursor is over them). + * @default false + */ + enableFixedRowScroll?: boolean; + + /** + * Number of fixed columns. + * @default 0 + */ + fixedColumnCount?: number; + + /** + * Number of fixed rows. + * @default 0 + */ + fixedRowCount?: number; + + /** + * Optionally hides the bottom-left `Grid` scrollbar by adding an additional wrapper. + * Only useful if `enableFixedColumnScroll` is set to `true`. + * @default false + */ + hideBottomLeftGridScrollbar?: boolean; + + /** + * Optionally hides the top-right `Grid` scrollbar by adding an additional wrapper. + * Only useful if `enableFixedRowScroll` is set to `true`. + * @default false + */ + hideTopRightGridScrollbar?: boolean; + + /** + * Called whenever a horizontal or vertical scrollbar is added or removed from the bottom-right `Grid`: + * `({ horizontal: boolean, size: number, vertical: boolean }): void`. + */ + onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void; + + /** + * Optional custom inline style to attach to root `MultiGrid` element. + * @default {} + */ + style?: CSSProperties; + + /** + * Optional custom inline style to attach to bottom-left `Grid` element. + * @default {} + */ + styleBottomLeftGrid?: CSSProperties; + + /** + * Optional custom inline style to attach to bottom-right `Grid` element. + * @default {} + */ + styleBottomRightGrid?: CSSProperties; + + /** + * Optional custom inline style to attach to top-left `Grid` element. + * @default {} + */ + styleTopLeftGrid?: CSSProperties; + + /** + * Optional custom inline style to attach to top-right `Grid` element. + * @default {} + */ + styleTopRightGrid?: CSSProperties; +}; + +/** Public (imperative) methods of a `MultiGrid` instance. */ +export interface MultiGridImperativeAPI { + /** + * Pass-thru that calls `forceUpdate` on all child `Grid`s. + */ + forceUpdateGrids(): void; + + /** + * Invalidate the size of a cell (after the current render has been committed); see `Grid#invalidateCellSizeAfterRender`. + * The invalidated cells (of all child `Grid`s) are recomputed, and the `MultiGrid` re-rendered, once the current render has been committed. + * This method is intended for `CellMeasurer`. + */ + invalidateCellSizeAfterRender(params?: Partial): void; + + /** + * Pass-thru that calls `measureAllCells` on all child `Grid`s. + */ + measureAllCells(): void; + + /** + * Pass-thru that calls `recomputeGridSize` on all child `Grid`s + * (adjusting the specified indices for fixed rows and columns). + * Both indices default to 0. + * + * This also recomputes the `MultiGrid`'s own layout (the height of the fixed rows and the width of the fixed columns) + * and re-renders it. + */ + recomputeGridSize(params?: { columnIndex?: number; rowIndex?: number }): void; + + /** + * Ensure the specified column and/or row is visible (scrolling if necessary); see `Grid#scrollToCell`. + * Indices include the fixed columns and rows (which are always visible, so they are not scrolled to). + * The fixed rows and columns are kept in sync with the scrolled section. + */ + scrollToCell(params: { + align?: Alignment; + columnIndex?: number; + rowIndex?: number; + }): void; + + /** + * Scroll to the specified offset(s) of the scrollable (bottom-right) section; see `Grid#scrollToPosition`. + * The fixed rows and columns are kept in sync with it. + * This allows a `MultiGrid` to be used with `ScrollSync`. + */ + scrollToPosition(params: ScrollPosition): void; +} diff --git a/lib/components/ScrollSync/ScrollSync.test.tsx b/lib/components/ScrollSync/ScrollSync.test.tsx new file mode 100644 index 000000000..67ff4a3af --- /dev/null +++ b/lib/components/ScrollSync/ScrollSync.test.tsx @@ -0,0 +1,518 @@ +import { + act, + forwardRef, + StrictMode, + useImperativeHandle, + type ReactNode +} from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render } from "../../utils/test/render"; +import { Grid } from "../Grid/Grid"; +import type { CellRendererParams, GridImperativeAPI } from "../Grid/types"; +import { ScrollSync } from "./ScrollSync"; +import type { + ScrollSyncChild, + ScrollSyncChildProps, + ScrollSyncScrollParams +} from "./types"; + +function ChildComponent({ + clientHeight, + clientWidth, + scrollHeight, + scrollLeft, + scrollTop, + scrollWidth +}: Partial) { + return ( +
+ {`clientHeight:${clientHeight}`} + {`clientWidth:${clientWidth}`} + {`scrollHeight:${scrollHeight}`} + {`scrollLeft:${scrollLeft}`} + {`scrollTop:${scrollTop}`} + {`scrollWidth:${scrollWidth}`} +
+ ); +} + +function getTextContent() { + return document.querySelector('[data-testid="values"]')!.textContent; +} + +function scrollParams( + params: Partial = {} +): ScrollSyncScrollParams { + return { + clientHeight: 100, + clientWidth: 100, + scrollHeight: 1000, + scrollLeft: 0, + scrollTop: 0, + scrollWidth: 1000, + ...params + }; +} + +/** Minimal registered child; optionally echoes the new offset through onScroll (like Grid does). */ +const MockChild = forwardRef< + ScrollSyncChild, + { + onScroll?: (params: ScrollSyncScrollParams) => void; + scrollToPosition: ScrollSyncChild["scrollToPosition"]; + } +>(function MockChild({ onScroll, scrollToPosition }, ref) { + useImperativeHandle( + ref, + () => ({ + scrollToPosition: (position) => { + scrollToPosition(position); + onScroll?.(scrollParams(position)); + } + }), + [onScroll, scrollToPosition] + ); + return null; +}); + +describe("ScrollSync", () => { + it("should pass through an initial value of 0 for :scrollLeft and :scrollTop", () => { + render( + {(params) => } + ); + expect(getTextContent()).toContain("clientHeight:0"); + expect(getTextContent()).toContain("clientWidth:0"); + expect(getTextContent()).toContain("scrollHeight:0"); + expect(getTextContent()).toContain("scrollLeft:0"); + expect(getTextContent()).toContain("scrollTop:0"); + expect(getTextContent()).toContain("scrollWidth:0"); + }); + + it("should update :scrollLeft and :scrollTop when :onScroll is called", () => { + let onScroll: ((params: ScrollSyncScrollParams) => void) | undefined; + render( + + {(params) => { + onScroll = params.onScroll; + return ; + }} + + ); + act(() => { + onScroll!({ + clientHeight: 400, + clientWidth: 200, + scrollHeight: 1000, + scrollLeft: 50, + scrollTop: 100, + scrollWidth: 500 + }); + }); + expect(getTextContent()).toContain("clientHeight:400"); + expect(getTextContent()).toContain("clientWidth:200"); + expect(getTextContent()).toContain("scrollHeight:1000"); + expect(getTextContent()).toContain("scrollLeft:50"); + expect(getTextContent()).toContain("scrollTop:100"); + expect(getTextContent()).toContain("scrollWidth:500"); + }); + + it("should return a stable ref callback per key from :registerChild", () => { + const callbacks: Array> = + []; + let onScroll: ((params: ScrollSyncScrollParams) => void) | undefined; + render( + + {(params) => { + onScroll = params.onScroll; + callbacks.push(params.registerChild("a"), params.registerChild("b")); + return null; + }} + + ); + act(() => onScroll!(scrollParams({ scrollTop: 10 }))); + expect(callbacks.length).toBe(4); + expect(callbacks[0]).toBe(callbacks[2]); + expect(callbacks[1]).toBe(callbacks[3]); + expect(callbacks[0]).not.toBe(callbacks[1]); + }); + + it("should throw if a child without a scrollToPosition() method is registered", () => { + let registerChild: ScrollSyncChildProps["registerChild"] | undefined; + render( + + {(params) => { + registerChild = params.registerChild; + return null; + }} + + ); + expect(() => registerChild!("a")({} as unknown as ScrollSyncChild)).toThrow( + "Unexpected child type registered" + ); + }); + + describe("registerChild", () => { + const ROW_HEIGHT = 50; + const COLUMN_WIDTH = 50; + + function cellRenderer({ + columnIndex, + key, + rowIndex, + style + }: CellRendererParams) { + return ( +
+ {`${rowIndex}:${columnIndex}`} +
+ ); + } + + function getCells(id: string) { + return Array.from( + document.querySelectorAll(`[data-testid="${id}"] .cell`) + ).map((cell) => cell.textContent); + } + + function renderSpreadsheet({ + bodyRef, + headerRef, + leftRef, + strictMode = false + }: { + bodyRef?: (grid: GridImperativeAPI | null) => void; + headerRef?: (grid: GridImperativeAPI | null) => void; + leftRef?: (grid: GridImperativeAPI | null) => void; + strictMode?: boolean; + } = {}) { + let latestParams: ScrollSyncChildProps | undefined; + const onBodyScroll = vi.fn(); + const onHeaderScroll = vi.fn(); + + const element: ReactNode = ( + + {(params) => { + latestParams = params; + const { onScroll, registerChild } = params; + return ( + <> + +
+ { + onHeaderScroll(scrollParams); + onScroll(scrollParams); + }} + overscanColumnCount={0} + overscanRowCount={0} + ref={(grid: GridImperativeAPI | null) => { + headerRef?.(grid); + return registerChild("header")(grid); + }} + rowCount={1} + rowHeight={ROW_HEIGHT} + width={100} + /> +
+
+ { + leftRef?.(grid); + return registerChild("left")(grid); + }} + rowCount={20} + rowHeight={ROW_HEIGHT} + width={COLUMN_WIDTH} + /> +
+
+ { + onBodyScroll(scrollParams); + onScroll(scrollParams); + }} + overscanColumnCount={0} + overscanRowCount={0} + ref={(grid: GridImperativeAPI | null) => { + bodyRef?.(grid); + return registerChild("body")(grid); + }} + rowCount={20} + rowHeight={ROW_HEIGHT} + width={100} + /> +
+ + ); + }} +
+ ); + + render( +
{strictMode ? {element} : element}
+ ); + + return { + getParams: () => latestParams!, + onBodyScroll, + onHeaderScroll + }; + } + + it.each([false, true])( + "should scroll the other registered children when a registered child scrolls (strict mode: %s)", + (strictMode) => { + let body: GridImperativeAPI | null = null; + let header: GridImperativeAPI | null = null; + const { onBodyScroll, onHeaderScroll } = renderSpreadsheet({ + bodyRef: (grid) => { + body = grid; + }, + headerRef: (grid) => { + header = grid; + }, + strictMode + }); + + expect(getCells("header")).toEqual(["0:0", "0:1"]); + expect(getCells("left")).toEqual(["0:0", "1:0"]); + + const scrollToPosition = vi.spyOn(header!, "scrollToPosition"); + onBodyScroll.mockClear(); + onHeaderScroll.mockClear(); + + // Simulate the user scrolling the body Grid + act(() => { + body!.handleScrollEvent({ scrollLeft: 100, scrollTop: 150 }); + }); + + expect(getCells("body")).toEqual(["3:2", "3:3", "4:2", "4:3"]); + expect(getCells("header")).toEqual(["0:2", "0:3"]); + expect(getCells("left")).toEqual(["3:0", "4:0"]); + + expect(getTextContent()).toContain("scrollLeft:100"); + expect(getTextContent()).toContain("scrollTop:150"); + // Size values are those of the scrolled (body) Grid, not of the header's echo + expect(getTextContent()).toContain("clientHeight:100"); + expect(getTextContent()).toContain("scrollHeight:1000"); + + // No feedback loop: the header was synced once and its echo was not propagated back to the body + expect(scrollToPosition).toHaveBeenCalledTimes(1); + expect(scrollToPosition).toHaveBeenCalledWith({ + scrollLeft: 100, + scrollTop: 150 + }); + expect(onBodyScroll).toHaveBeenCalledTimes(1); + expect(onHeaderScroll).toHaveBeenCalledTimes(1); + } + ); + + it("should only sync the axes that changed", () => { + let body: GridImperativeAPI | null = null; + let left: GridImperativeAPI | null = null; + renderSpreadsheet({ + bodyRef: (grid) => { + body = grid; + }, + leftRef: (grid) => { + left = grid; + } + }); + + const scrollToPosition = vi.spyOn(left!, "scrollToPosition"); + + act(() => { + body!.handleScrollEvent({ scrollLeft: 0, scrollTop: 150 }); + }); + expect(scrollToPosition).toHaveBeenLastCalledWith({ scrollTop: 150 }); + + act(() => { + body!.handleScrollEvent({ scrollLeft: 100, scrollTop: 150 }); + }); + expect(scrollToPosition).toHaveBeenLastCalledWith({ scrollLeft: 100 }); + expect(scrollToPosition).toHaveBeenCalledTimes(2); + }); + + it("should not propagate an offset a synced child reports after clamping it", () => { + let body: GridImperativeAPI | null = null; + let header: GridImperativeAPI | null = null; + const { getParams } = renderSpreadsheet({ + bodyRef: (grid) => { + body = grid; + }, + headerRef: (grid) => { + header = grid; + } + }); + + act(() => { + body!.handleScrollEvent({ scrollLeft: 100, scrollTop: 150 }); + }); + + const bodyScrollToPosition = vi.spyOn(body!, "scrollToPosition"); + + // The header can't scroll vertically; the next time it handles a (horizontal) scroll event + // it reports a scrollTop of 0, which must not scroll the body back to the top. + act(() => { + header!.handleScrollEvent({ scrollLeft: 100, scrollTop: 0 }); + }); + expect(bodyScrollToPosition).not.toHaveBeenCalled(); + expect(getParams().scrollTop).toBe(150); + expect(getCells("body")).toEqual(["3:2", "3:3", "4:2", "4:3"]); + + // But horizontal scrolling of the header is still synced + act(() => { + header!.handleScrollEvent({ scrollLeft: 200, scrollTop: 0 }); + }); + expect(bodyScrollToPosition).toHaveBeenCalledWith({ scrollLeft: 200 }); + expect(getParams().scrollLeft).toBe(200); + expect(getParams().scrollTop).toBe(150); + expect(getCells("body")).toEqual(["3:4", "3:5", "4:4", "4:5"]); + }); + + it("should not loop when children echo synchronously", () => { + const a = vi.fn(); + const b = vi.fn(); + let params: ScrollSyncChildProps | undefined; + render( + + {(p) => { + params = p; + return ( + <> + + + + ); + }} + + ); + + act(() => params!.onScroll(scrollParams({ scrollTop: 10 }))); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + expect(a).toHaveBeenCalledWith({ scrollTop: 10 }); + expect(params!.scrollTop).toBe(10); + }); + + it("should stop syncing a registered child after it unmounts", () => { + const a = vi.fn(); + const b = vi.fn(); + let params: ScrollSyncChildProps | undefined; + const renderChildren = (showB: boolean) => + render( + + {(p) => { + params = p; + return ( + <> + + {showB && ( + + )} + + ); + }} + + ); + + renderChildren(true); + act(() => params!.onScroll(scrollParams({ scrollTop: 10 }))); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + + renderChildren(false); + act(() => params!.onScroll(scrollParams({ scrollTop: 20 }))); + expect(a).toHaveBeenCalledTimes(2); + expect(b).toHaveBeenCalledTimes(1); + }); + + it("should sync a child registered under an existing key (or registered late) to the current offset", () => { + const first = vi.fn(); + const second = vi.fn(); + let params: ScrollSyncChildProps | undefined; + const renderChild = (scrollToPosition: typeof first) => + render( + + {(p) => { + params = p; + return ( + + ); + }} + + ); + + renderChild(first); + // No sync is needed when the offset is still 0 + expect(first).not.toHaveBeenCalled(); + + act(() => + params!.onScroll(scrollParams({ scrollLeft: 5, scrollTop: 10 })) + ); + expect(first).toHaveBeenCalledTimes(1); + + // Replace the child registered under key "a" + renderChild(second); + expect(second).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledWith({ scrollLeft: 5, scrollTop: 10 }); + + act(() => + params!.onScroll(scrollParams({ scrollLeft: 5, scrollTop: 20 })) + ); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(2); + expect(second).toHaveBeenLastCalledWith({ scrollTop: 20 }); + }); + + it("should keep registered children in StrictMode", () => { + const a = vi.fn(); + let params: ScrollSyncChildProps | undefined; + render( + + + {(p) => { + params = p; + return ( + + ); + }} + + + ); + + act(() => params!.onScroll(scrollParams({ scrollTop: 10 }))); + expect(a).toHaveBeenCalledTimes(1); + expect(a).toHaveBeenCalledWith({ scrollTop: 10 }); + }); + }); +}); diff --git a/lib/components/ScrollSync/ScrollSync.tsx b/lib/components/ScrollSync/ScrollSync.tsx new file mode 100644 index 000000000..54c91adba --- /dev/null +++ b/lib/components/ScrollSync/ScrollSync.tsx @@ -0,0 +1,174 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import type { + ScrollSyncChild, + ScrollSyncProps, + ScrollSyncScrollParams +} from "./types"; + +type ScrollSyncRefCallback = ( + child: ScrollSyncChild | null +) => void | (() => void); + +type ScrollOffsets = { scrollLeft: number; scrollTop: number }; + +const INITIAL_STATE: ScrollSyncScrollParams = { + clientHeight: 0, + clientWidth: 0, + scrollHeight: 0, + scrollLeft: 0, + scrollTop: 0, + scrollWidth: 0 +}; + +/** + * HOC that simplifies the process of synchronizing scrolling between two or more virtualized components. + */ +export const ScrollSync = /* @__PURE__ */ memo(function ScrollSync({ + children +}: ScrollSyncProps) { + const [state, setState] = useState(INITIAL_STATE); + + // Registered children, by key + const childrenRef = useRef>(new Map()); + + // Stable ref callbacks, by key + const refCallbacksRef = useRef>(new Map()); + + // The child most recently registered under each key (not cleared when it is detached) + const lastRegisteredRef = useRef>(new Map()); + + // The offsets the registered children have been synced to + const syncedRef = useRef({ scrollLeft: 0, scrollTop: 0 }); + + // Whether any registered child has been scrolled by ScrollSync yet + const hasSyncedRef = useRef(false); + + const onScroll = useCallback((params: ScrollSyncScrollParams) => { + const synced = syncedRef.current; + const hasSynced = hasSyncedRef.current; + + // Once children have been synced, a reported offset that matches the synced one is a child echoing the + // offset ScrollSync just scrolled it to; ignore it so that its size values don't replace the scrolled child's. + if ( + hasSynced && + params.scrollLeft === synced.scrollLeft && + params.scrollTop === synced.scrollTop + ) { + return; + } + + // A synced child that can't scroll as far as the synced offset (e.g. a header row that only scrolls horizontally) + // reports its clamped offset (its maximum) the next time it scrolls; don't propagate that back to the other children. + const isClamped = ( + offset: number, + syncedOffset: number, + scrollSize: number, + clientSize: number + ) => + hasSynced && offset < syncedOffset && offset >= scrollSize - clientSize; + + // (List and Table only report a scrollTop) + const leftChanged = + typeof params.scrollLeft === "number" && + params.scrollLeft !== synced.scrollLeft && + !isClamped( + params.scrollLeft, + synced.scrollLeft, + params.scrollWidth, + params.clientWidth + ); + const topChanged = + typeof params.scrollTop === "number" && + params.scrollTop !== synced.scrollTop && + !isClamped( + params.scrollTop, + synced.scrollTop, + params.scrollHeight, + params.clientHeight + ); + + if (hasSynced && !leftChanged && !topChanged) { + return; + } + + const scrollLeft = leftChanged ? params.scrollLeft : synced.scrollLeft; + const scrollTop = topChanged ? params.scrollTop : synced.scrollTop; + + syncedRef.current = { scrollLeft, scrollTop }; + + setState({ + clientHeight: params.clientHeight, + clientWidth: params.clientWidth, + scrollHeight: params.scrollHeight, + scrollLeft, + scrollTop, + scrollWidth: params.scrollWidth + }); + + if (leftChanged || topChanged) { + const position: { scrollLeft?: number; scrollTop?: number } = {}; + if (leftChanged) { + position.scrollLeft = scrollLeft; + } + if (topChanged) { + position.scrollTop = scrollTop; + } + + childrenRef.current.forEach((child) => { + hasSyncedRef.current = true; + + // The child that reported this offset is already there, so this is a no-op for it + child.scrollToPosition(position); + }); + } + }, []); + + const registerChild = useCallback((key: string) => { + let refCallback = refCallbacksRef.current.get(key); + if (!refCallback) { + refCallback = (child: ScrollSyncChild | null) => { + const map = childrenRef.current; + if (child) { + if (typeof child.scrollToPosition !== "function") { + throw Error( + "Unexpected child type registered; only components with a scrollToPosition() method (e.g. Grid) are supported." + ); + } + + map.set(key, child); + + // Bring a child registered after scrolling has happened in line with the others + // (unless it's the same child being re-attached, e.g. because of an inline ref callback or StrictMode). + const isNewChild = lastRegisteredRef.current.get(key) !== child; + lastRegisteredRef.current.set(key, child); + + const { scrollLeft, scrollTop } = syncedRef.current; + if (isNewChild && (scrollLeft !== 0 || scrollTop !== 0)) { + hasSyncedRef.current = true; + child.scrollToPosition({ scrollLeft, scrollTop }); + } + + // React 19 calls this cleanup function (instead of calling the ref with null) when the child unmounts; + // only unregister the child if it hasn't been replaced by another one registered under the same key. + return () => { + if (map.get(key) === child) { + map.delete(key); + } + }; + } else { + // React 18 + map.delete(key); + } + }; + refCallbacksRef.current.set(key, refCallback); + } + return refCallback; + }, []); + + const childProps = useMemo( + () => ({ ...state, onScroll, registerChild }), + [onScroll, registerChild, state] + ); + + return children(childProps); +}); diff --git a/lib/components/ScrollSync/types.ts b/lib/components/ScrollSync/types.ts new file mode 100644 index 000000000..6b2972c29 --- /dev/null +++ b/lib/components/ScrollSync/types.ts @@ -0,0 +1,90 @@ +import type { ReactNode } from "react"; + +/** + * Scroll and size values tracked by `ScrollSync`. + * This is also the shape of the object accepted by the `onScroll` callback. + */ +export type ScrollSyncScrollParams = { + /** Height of the visible portion of the `Grid` (or other scroll-synced component). */ + clientHeight: number; + + /** Width of the visible portion of the `Grid` (or other scroll-synced component). */ + clientWidth: number; + + /** Total height of all rows in the `Grid` (or other scroll-synced component). */ + scrollHeight: number; + + /** The current scroll-left offset. */ + scrollLeft: number; + + /** The current scroll-top offset. */ + scrollTop: number; + + /** Total width of all columns in the `Grid` (or other scroll-synced component). */ + scrollWidth: number; +}; + +/** + * A component that can be registered with `ScrollSync` (via `registerChild`), e.g. a `Grid`. + * `ScrollSync` scrolls it by calling its `scrollToPosition` method. + */ +export type ScrollSyncChild = { + scrollToPosition(params: { scrollLeft?: number; scrollTop?: number }): void; +}; + +/** + * Parameters passed to the `ScrollSync` children render function. + */ +export type ScrollSyncChildProps = { + /** Height of the visible portion of the `Grid` (or other scroll-synced component). */ + clientHeight: number; + + /** Width of the visible portion of the `Grid` (or other scroll-synced component). */ + clientWidth: number; + + /** + * This function should be passed through (as the `onScroll` prop) to every registered child that the user can scroll. + * Whenever it reports a new scroll offset, the other registered children are scrolled to match it + * and the render function is called again with the updated scroll and size values. + */ + onScroll: (params: ScrollSyncScrollParams) => void; + + /** + * Returns a ref callback that registers a virtualized child (e.g. a `Grid`) under the specified key: + * ``. + * + * When a scroll offset is reported through `onScroll`, `ScrollSync` scrolls all registered children to it + * (only the axes that changed are synced). + * A child registered after scrolling has happened is scrolled to the current offset. + * + * The returned callback is stable for a given key, so it can be passed inline during render. + * Registering a different component under the same key replaces the previous one. + */ + registerChild: ( + key: string + ) => (child: ScrollSyncChild | null) => void | (() => void); + + /** Total height of all rows in the `Grid` (or other scroll-synced component). */ + scrollHeight: number; + + /** The current scroll-left offset. */ + scrollLeft: number; + + /** The current scroll-top offset. */ + scrollTop: number; + + /** Total width of all columns in the `Grid` (or other scroll-synced component). */ + scrollWidth: number; +}; + +export type ScrollSyncProps = { + /** + * Function responsible for rendering 2 or more virtualized components. + * It is passed the current scroll and size values (`clientHeight`, `clientWidth`, `scrollHeight`, `scrollLeft`, `scrollTop`, `scrollWidth`), + * an `onScroll` callback that should be passed through to the virtualized children that the user can scroll, + * and a `registerChild` function that returns a ref callback for each virtualized child that should be kept in sync + * (e.g. ``). + * The scroll and size values can also be used for custom rendering (e.g. shading based on the scroll offset). + */ + children: (params: ScrollSyncChildProps) => ReactNode; +}; diff --git a/lib/components/Table/Column.test.tsx b/lib/components/Table/Column.test.tsx new file mode 100644 index 000000000..133dad0fe --- /dev/null +++ b/lib/components/Table/Column.test.tsx @@ -0,0 +1,122 @@ +import Immutable from "immutable"; +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import { defaultCellDataGetter } from "./defaultCellDataGetter"; +import { defaultCellRenderer } from "./defaultCellRenderer"; +import { defaultHeaderRenderer } from "./defaultHeaderRenderer"; +import type { TableCellProps } from "./types"; + +describe("Column", () => { + const rowData = Immutable.Map({ + foo: "Foo", + bar: 1 + }); + + describe("defaultCellDataGetter", () => { + it("should return a value for specified attributes", () => { + expect( + defaultCellDataGetter({ + dataKey: "foo", + rowData + }) + ).toEqual("Foo"); + expect( + defaultCellDataGetter({ + dataKey: "bar", + rowData + }) + ).toEqual(1); + }); + + it("should return undefined for missing attributes", () => { + expect( + defaultCellDataGetter({ + dataKey: "baz", + rowData + }) + ).toEqual(undefined); + }); + }); + + describe("defaultCellRenderer", () => { + it("should render a value for specified attributes", () => { + expect( + defaultCellRenderer({ + cellData: "Foo", + dataKey: "foo", + rowData, + rowIndex: 0 + } as TableCellProps) + ).toEqual("Foo"); + expect( + defaultCellRenderer({ + cellData: 1, + dataKey: "bar", + rowData, + rowIndex: 0 + } as TableCellProps) + ).toEqual("1"); + }); + + it("should render empty string for null or missing attributes", () => { + expect( + defaultCellRenderer({ + cellData: null, + dataKey: "baz", + rowData, + rowIndex: 0 + } as TableCellProps) + ).toEqual(""); + expect( + defaultCellRenderer({ + cellData: undefined, + dataKey: "baz", + rowData, + rowIndex: 0 + } as TableCellProps) + ).toEqual(""); + }); + }); + + describe("defaultHeaderRenderer", () => { + it("should render a value for specified attributes", () => { + expect( + ( + defaultHeaderRenderer({ + dataKey: "foo", + label: "squirrel" + })[0].props as { children?: ReactNode } + ).children + ).toEqual("squirrel"); + + const label =
Rabbit
; + expect( + ( + defaultHeaderRenderer({ + dataKey: "bar", + label: label + })[0].props as { children?: ReactNode } + ).children + ).toEqual(label); + }); + + it("should render empty string for null or missing attributes", () => { + expect( + ( + defaultHeaderRenderer({ + dataKey: "foo", + label: null + })[0].props as { children?: ReactNode } + ).children + ).toBeNull(); + expect( + ( + defaultHeaderRenderer({ + dataKey: "bar", + label: undefined + })[0].props as { children?: ReactNode } + ).children + ).toBeUndefined(); + }); + }); +}); diff --git a/lib/components/Table/Column.tsx b/lib/components/Table/Column.tsx new file mode 100644 index 000000000..988d6b0f9 --- /dev/null +++ b/lib/components/Table/Column.tsx @@ -0,0 +1,15 @@ +import type { ColumnProps } from "./types"; + +/** + * Describes the header and cell contents of a table column. + * + * This component does not render anything itself; + * `Table` reads the props of its children to render headers and cells + * (applying `defaultColumnProps` to each child's props). + * Components that render a `Column` (e.g. `const MyColumn = (props) => `) can be used as `Table` children too; + * their props are read the same way (the wrapper component itself is never rendered). + */ +export function Column(props: ColumnProps): null { + void props; + return null; +} diff --git a/source/Table/SortDirection.js b/lib/components/Table/SortDirection.ts similarity index 56% rename from source/Table/SortDirection.js rename to lib/components/Table/SortDirection.ts index 485285c18..999dee842 100644 --- a/source/Table/SortDirection.js +++ b/lib/components/Table/SortDirection.ts @@ -1,15 +1,18 @@ -const SortDirection = { +export type { SortDirectionType } from "./types"; + +/** + * Specifies which direction `Table` data is currently sorted in. + */ +export const SortDirection = { /** * Sort items in ascending order. * This means arranging from the lowest value to the highest (e.g. a-z, 0-9). */ - ASC: 'ASC', + ASC: "ASC", /** * Sort items in descending order. * This means arranging from the highest value to the lowest (e.g. z-a, 9-0). */ - DESC: 'DESC', -}; - -export default SortDirection; + DESC: "DESC" +} as const; diff --git a/lib/components/Table/SortIndicator.tsx b/lib/components/Table/SortIndicator.tsx new file mode 100644 index 000000000..1608836fa --- /dev/null +++ b/lib/components/Table/SortIndicator.tsx @@ -0,0 +1,26 @@ +import clsx from "clsx"; +import { SortDirection } from "./SortDirection"; +import type { SortIndicatorProps } from "./types"; + +/** + * Displayed beside a header to indicate that a Table is currently sorted by this column. + */ +export function SortIndicator({ sortDirection }: SortIndicatorProps) { + const classNames = clsx("ReactVirtualized__Table__sortableHeaderIcon", { + "ReactVirtualized__Table__sortableHeaderIcon--ASC": + sortDirection === SortDirection.ASC, + "ReactVirtualized__Table__sortableHeaderIcon--DESC": + sortDirection === SortDirection.DESC + }); + + return ( + + {sortDirection === SortDirection.ASC ? ( + + ) : ( + + )} + + + ); +} diff --git a/lib/components/Table/Table.test.tsx b/lib/components/Table/Table.test.tsx new file mode 100644 index 000000000..21aa9ef42 --- /dev/null +++ b/lib/components/Table/Table.test.tsx @@ -0,0 +1,2361 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Immutable from "immutable"; +import { fireEvent } from "@testing-library/react"; +import { renderToString } from "react-dom/server"; +import { + act, + createRef, + StrictMode, + useLayoutEffect, + useState, + type CSSProperties, + type ReactElement, + type ReactNode +} from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getObserverCount, + mockResizeObserver +} from "../../utils/test/mockResizeObserver"; +import { createScrollContainer } from "../../utils/test/scrollElement"; +import { + findDOMNode as findDOMNodeUntyped, + render +} from "../../utils/test/render"; +import { CellMeasurer } from "../CellMeasurer/CellMeasurer"; +import { CellMeasurerCache } from "../CellMeasurer/CellMeasurerCache"; +import type { OverscanIndicesGetterParams } from "../Grid/types"; +import { Column } from "./Column"; +import { createMultiSort } from "./createMultiSort"; +import { SortDirection } from "./SortDirection"; +import { Table } from "./Table"; +import type { + ColumnProps, + MultiSortCallbackParams, + TableImperativeAPI, + SortDirectionType, + TableCellProps, + TableHeaderProps, + TableRowsRenderedParams, + TableScrollParams, + TableProps +} from "./types"; + +function findDOMNode(instance: unknown): HTMLDivElement { + return findDOMNodeUntyped(instance) as HTMLDivElement; +} + +// The inner Grid's scrolling container (of the most recently rendered Table) +function getGridElement(): HTMLDivElement { + return document.querySelector(".ReactVirtualized__Grid")!; +} + +// The total height of the inner Grid's rows (of the most recently rendered Table) +function getTotalRowsHeight(): number { + return Number.parseInt( + document.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.height, + 10 + ); +} + +type GetMarkupParams = Partial> & { + cellDataGetter?: ColumnProps["cellDataGetter"]; + cellRenderer?: ColumnProps["cellRenderer"]; + columnData?: any; + columnID?: string; + columnStyle?: CSSProperties; + columnHeaderStyle?: CSSProperties; + defaultSortDirection?: SortDirectionType; + disableSort?: boolean; + headerRenderer?: ColumnProps["headerRenderer"]; + label?: ReactNode; + maxWidth?: number; + minWidth?: number; + onRowClick?: TableProps["onRowClick"] | null; +}; + +describe("Table", () => { + const array: { id: number; name: string; email: string }[] = []; + for (let i = 0; i < 100; i++) { + array.push({ + id: i, + name: `Name ${i}`, + email: `user-${i}@treasure-data.com` + }); + } + const list: any = Immutable.fromJS(array); + + // Works with an Immutable List of Maps + function immutableRowGetter({ index }: { index: number }) { + return list.get(index); + } + + // Works with an Array of Objects + function vanillaRowGetter({ index }: { index: number }) { + return array[index]; + } + + // Override default behavior of overscanning by at least 1 (for accessibility) + // Because it makes for simple tests below + function overscanIndicesGetter({ + startIndex, + stopIndex + }: OverscanIndicesGetterParams) { + return { + overscanStartIndex: startIndex, + overscanStopIndex: stopIndex + }; + } + + function getMarkup({ + cellDataGetter, + cellRenderer, + columnData = { data: 123 }, + columnID, + columnStyle, + columnHeaderStyle, + disableSort = false, + headerRenderer, + maxWidth, + minWidth, + defaultSortDirection, + label, + ...flexTableProps + }: GetMarkupParams = {}) { + return ( + )} + > + + + {false} + {true} + {null} + {undefined} +
+ ); + } + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Table no longer validates its children at runtime (propTypes were replaced by TypeScript types), + // so these tests verify how children are rendered instead. + describe("children", () => { + function renderWithChildren(children: ReactNode) { + return findDOMNode( + render( + + {children} +
+ ) + ); + } + + it("should accept Column children", () => { + const children = []; + const rendered = renderWithChildren(children); + expect(rendered.getAttribute("aria-colcount")).toEqual("1"); + const cells = rendered.querySelectorAll( + ".ReactVirtualized__Table__rowColumn" + ); + expect(cells.length).toEqual(1); + expect(cells[0].textContent).toEqual("Name 0"); + }); + + it("should apply the Column defaults to a plain Column (without renderers)", () => { + // Regression test for #1870/#1885 (React 19 no longer applies class defaultProps when creating elements) + const rendered = renderWithChildren( + + ); + const header = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!; + expect(header.textContent).toEqual("Name"); + expect(header.style.flex).toEqual("0 1 100px"); + const cells = rendered.querySelectorAll( + ".ReactVirtualized__Table__rowColumn" + ); + expect(cells.length).toEqual(1); + expect(cells[0].textContent).toEqual("Name 0"); + expect(cells[0].title).toEqual("Name 0"); + }); + + it("should accept components that wrap Column as children", () => { + const sort = vi.fn(); + const MyColumn = (props: ColumnProps) => ; + + const rendered = findDOMNode( + render( + + + `Email: ${cellData}`} + dataKey="email" + width={40} + /> +
+ ) + ); + expect(rendered.getAttribute("aria-colcount")).toEqual("2"); + const headers = rendered.querySelectorAll( + ".ReactVirtualized__Table__headerColumn" + ); + expect(headers.length).toEqual(2); + expect(headers[0].textContent).toEqual("Name"); + expect(headers[0].style.flex).toEqual("0 1 60px"); + const cells = rendered.querySelectorAll( + ".ReactVirtualized__Table__rowColumn" + ); + expect(cells.length).toEqual(2); + expect(cells[0].textContent).toEqual("Name 0"); + expect(cells[1].textContent).toEqual("Email: user-0@treasure-data.com"); + + // The default sort direction applies too + act(() => { + headers[0].click(); + }); + expect(sort).toHaveBeenCalledTimes(1); + expect(sort.mock.calls[0][0].sortBy).toEqual("name"); + expect(sort.mock.calls[0][0].sortDirection).toEqual(SortDirection.ASC); + }); + + it("should accept falsy children to allow easier dynamic showing/hiding of columns", () => { + const children = [ + false, + , + null + ]; + const rendered = renderWithChildren(children); + expect(rendered.getAttribute("aria-colcount")).toEqual("1"); + expect( + rendered.querySelectorAll( + ".ReactVirtualized__Table__headerColumn" + ).length + ).toEqual(1); + }); + }); + + describe("height", () => { + it("should subtract header row height from the inner Grid height if headers are enabled", () => { + const rendered = findDOMNode( + render( + getMarkup({ + headerHeight: 10, + overscanRowCount: 0, + rowHeight: 20, + height: 50 + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + expect(rows.length).toEqual(2); + }); + + it("should not subtract header row height from the inner Grid height if headers are disabled", () => { + const rendered = findDOMNode( + render( + getMarkup({ + disableHeader: true, + headerHeight: 10, + overscanRowCount: 0, + rowHeight: 20, + height: 50 + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + expect(rows.length).toEqual(3); + }); + }); + + describe("initial rendering", () => { + // Ensure that both Immutable Lists of Maps and Arrays of Objects are supported + const useImmutable = [true, false]; + useImmutable.forEach((useImmutable) => { + it("should render the correct number of rows", () => { + const rendered = findDOMNode( + render( + getMarkup({ + rowGetter: useImmutable ? immutableRowGetter : vanillaRowGetter + }) + ) + ); + // 100px height should fit 1 header (20px) and 8 rows (10px each) - + expect( + rendered.querySelectorAll( + ".ReactVirtualized__Table__headerRow" + ).length + ).toEqual(1); + expect( + rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ).length + ).toEqual(8); + }); + + it("should render the expected headers", () => { + const rendered = findDOMNode( + render( + getMarkup({ + rowGetter: useImmutable ? immutableRowGetter : vanillaRowGetter + }) + ) + ); + const columns = rendered.querySelectorAll( + ".ReactVirtualized__Table__headerColumn" + ); + expect(columns.length).toEqual(2); + expect(columns[0].textContent).toEqual("Name"); + expect(columns[1].textContent).toEqual("Email"); + }); + + it("should render the expected rows and columns", () => { + const rendered = findDOMNode( + render( + getMarkup({ + rowGetter: useImmutable ? immutableRowGetter : vanillaRowGetter, + headerHeight: 10, + rowHeight: 20, + height: 50 + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + expect(rows.length).toEqual(2); + Array.from(rows).forEach((row, index) => { + const rowData = list.get(index); + const columns = row.querySelectorAll( + ".ReactVirtualized__Table__rowColumn" + ); + expect(columns.length).toEqual(2); + expect(columns[0].textContent).toEqual(rowData.get("name")); + expect(columns[1].textContent).toEqual(rowData.get("email")); + }); + }); + }); + + it("should support a :rowHeight function", () => { + const rowHeight = ({ index }: { index: number }) => 10 + index * 10; + const rendered = findDOMNode( + render( + getMarkup({ + rowHeight, + rowCount: 3 + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + Array.from(rows).forEach((row, index) => { + expect(Number.parseInt(row.style.height, 10)).toEqual( + rowHeight({ index }) + ); + }); + }); + + it("should support :minWidth and :maxWidth values for a column", () => { + const rendered = findDOMNode( + render( + getMarkup({ + maxWidth: 75, + minWidth: 25, + rowCount: 1 + }) + ) + ); + const columns = rendered.querySelectorAll( + ".ReactVirtualized__Table__rowColumn" + ); + const emailColumn = columns[1]; + expect(Number.parseInt(emailColumn.style.maxWidth, 10)).toEqual(75); + expect(Number.parseInt(emailColumn.style.minWidth, 10)).toEqual(25); + }); + }); + + describe("initialScrollTop", () => { + function getRowIndices(element: Element) { + return Array.from( + element.querySelectorAll(".ReactVirtualized__Table__row") + ).map((row) => Number(row.getAttribute("aria-rowindex")) - 1); + } + + it("should render and scroll to the initial offset on mount", () => { + const table = render( + getMarkup({ initialScrollTop: 200 }) + ); + const element = findDOMNode(table); + expect( + element.querySelector(".ReactVirtualized__Table__Grid")!.scrollTop + ).toEqual(200); + expect(getRowIndices(element)[0]).toEqual(20); + }); + + it("should render the rows at the initial offset on the server", () => { + const container = document.createElement("div"); + container.innerHTML = renderToString( + getMarkup({ initialScrollTop: 200 }) + ); + expect(getRowIndices(container)[0]).toEqual(20); + expect( + container.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.marginTop + ).toEqual("-200px"); + }); + }); + + describe("measureAllRows", () => { + it("should measure any unmeasured rows", () => { + const rendered = render( + getMarkup({ + estimatedRowSize: 15, + rowCount: 10, + rowHeight: () => 20 + }) + ); + expect(getTotalRowsHeight()).toEqual(170); + act(() => { + rendered.measureAllRows(); + }); + act(() => { + rendered.forceUpdateGrid(); + }); + expect(getTotalRowsHeight()).toEqual(200); + }); + + it("should not throw if there are no rows", () => { + const rendered = render(getMarkup({ rowCount: 0 })); + expect(() => { + act(() => { + rendered.measureAllRows(); + }); + }).not.toThrow(); + }); + }); + + describe("recomputeRowHeights", () => { + it("should recompute row heights and other values when called", () => { + const indices: number[] = []; + const rowHeight = ({ index }: { index: number }) => { + indices.push(index); + return 10; + }; + const component = render( + getMarkup({ + rowHeight, + rowCount: 50 + }) + ); + + indices.splice(0); + act(() => { + component.recomputeRowHeights(); + }); + + // Only the rows required to fill the current viewport will be rendered + expect(indices[0]).toEqual(0); + expect(indices[indices.length - 1]).toEqual(7); + + indices.splice(0); + act(() => { + component.recomputeRowHeights(4); + }); + + expect(indices[0]).toEqual(4); + expect(indices[indices.length - 1]).toEqual(7); + }); + }); + + describe("forceUpdateGrid", () => { + it("should refresh inner Grid content when called", () => { + let marker = "a"; + function cellRenderer({ rowIndex }: TableCellProps) { + return `${rowIndex}${marker}`; + } + const component = render(getMarkup({ cellRenderer })); + const node = findDOMNode(component); + expect(node.textContent).toContain("1a"); + marker = "b"; + act(() => { + component.forceUpdateGrid(); + }); + expect(node.textContent).toContain("1b"); + }); + }); + + describe("custom getter functions", () => { + it("should use a custom cellDataGetter if specified", () => { + const rendered = findDOMNode( + render( + getMarkup({ + cellDataGetter: ({ dataKey, rowData }) => + `Custom ${dataKey} for row ${rowData.get("id")}` + }) + ) + ); + const nameColumns = rendered.querySelectorAll( + ".ReactVirtualized__Table__rowColumn:first-of-type" + ); + Array.from(nameColumns).forEach((nameColumn, index) => { + expect(nameColumn.textContent).toEqual(`Custom name for row ${index}`); + }); + }); + + it("should use a custom cellRenderer if specified", () => { + const rendered = findDOMNode( + render( + getMarkup({ + cellRenderer: ({ cellData }) => `Custom ${cellData}` + }) + ) + ); + const nameColumns = rendered.querySelectorAll( + ".ReactVirtualized__Table__rowColumn:first-of-type" + ); + Array.from(nameColumns).forEach((nameColumn, index) => { + const rowData = list.get(index); + expect(nameColumn.textContent).toEqual(`Custom ${rowData.get("name")}`); + }); + }); + + it("should set the rendered cell content as the cell :title if it is a string", () => { + const rendered = findDOMNode( + render( + getMarkup({ + cellRenderer: () => "Custom" + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__rowColumn:first-of-type" + )!; + expect(nameColumn.getAttribute("title")).toContain("Custom"); + }); + + it("should not set a cell :title if the rendered cell content is not a string", () => { + const rendered = findDOMNode( + render( + getMarkup({ + cellRenderer: () =>
Custom
+ }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__rowColumn:first-of-type" + )!; + expect(nameColumn.getAttribute("title")).toEqual(null); + }); + + it("should set the rendered header label as header :title if it is a string", () => { + const rendered = findDOMNode( + render( + getMarkup({ + label: "Custom" + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerTruncatedText:first-of-type" + )!; + expect(nameColumn.getAttribute("title")).toContain("Custom"); + }); + + it("should not set a header :title if the rendered header label is not a string", () => { + const rendered = findDOMNode( + render( + getMarkup({ + label:
Custom
+ }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerTruncatedText:first-of-type" + )!; + expect(nameColumn.getAttribute("title")).toEqual(null); + }); + }); + + // #1725 + describe("column keys", () => { + function Stateful({ value }: { value: string }) { + // Keeps the value it was mounted with + const [initialValue] = useState(value); + return {initialValue}; + } + + function getMarkupWithColumns(dataKeys: string[]) { + const rows = [{ a: "A", b: "B" }]; + return ( + rows[index]} + rowHeight={20} + width={200} + > + {dataKeys.map((dataKey) => ( + } + dataKey={dataKey} + key={dataKey} + label={dataKey} + width={100} + /> + ))} +
+ ); + } + + it("should move the cells of a column with it when columns are reordered", () => { + render(getMarkupWithColumns(["a", "b"])); + const headers = Array.from( + document.body.querySelectorAll(".ReactVirtualized__Table__headerColumn") + ); + + render(getMarkupWithColumns(["b", "a"])); + const cells = Array.from( + document.body.querySelectorAll(".ReactVirtualized__Table__rowColumn") + ).map((cell) => cell.textContent); + // (Keyed by index, each cell would keep the state of the column previously at its position: "A", "B") + expect(cells).toEqual(["B", "A"]); + + // The header elements are moved (not re-created) + const reorderedHeaders = Array.from( + document.body.querySelectorAll(".ReactVirtualized__Table__headerColumn") + ); + expect(reorderedHeaders[0]).toBe(headers[1]); + expect(reorderedHeaders[1]).toBe(headers[0]); + }); + + it("should key columns without a key by their position", () => { + const rows = [{ a: "A", b: "B" }]; + render( + rows[index]} + rowHeight={20} + width={200} + > + + {null} + +
+ ); + const cells = Array.from( + document.body.querySelectorAll(".ReactVirtualized__Table__rowColumn") + ).map((cell) => cell.textContent); + expect(cells).toEqual(["A", "B"]); + }); + }); + + describe("sorting", () => { + // #1585 + it("should re-render when the sort criteria of createMultiSort are stored in React state", () => { + function MultiSortTable() { + // The sort criteria are stored in separate state values, as passed to the callback (not copied) + const [sortBy, setSortBy] = useState(["name"]); + const [sortDirection, setSortDirection] = useState< + MultiSortCallbackParams["sortDirection"] + >({ name: "ASC" }); + const [multiSort] = useState(() => + createMultiSort( + ({ sortBy, sortDirection }) => { + setSortBy(sortBy); + setSortDirection(sortDirection); + }, + { defaultSortBy: ["name"] } + ) + ); + const sortState = { sortBy, sortDirection }; + return ( + + {["name", "email"].map((dataKey) => ( + + `${dataKey}:${sortState.sortDirection[dataKey] ?? "-"}:${ + sortState.sortBy.indexOf(dataKey) + 1 + }` + } + key={dataKey} + width={100} + /> + ))} +
+ ); + } + + render(); + const headers = () => + Array.from( + document.body.querySelectorAll( + ".ReactVirtualized__Table__headerColumn" + ) + ); + const headerTexts = () => headers().map((header) => header.textContent); + expect(headerTexts()).toEqual(["name:ASC:1", "email:-:0"]); + + fireEvent.click(headers()[0]); + expect(headerTexts()).toEqual(["name:DESC:1", "email:-:0"]); + + fireEvent.click(headers()[1], { shiftKey: true }); + expect(headerTexts()).toEqual(["name:DESC:1", "email:ASC:2"]); + + fireEvent.click(headers()[0], { ctrlKey: true }); + expect(headerTexts()).toEqual(["name:-:0", "email:ASC:1"]); + }); + + it("should not render sort indicators if no sort function is provided", () => { + const rendered = findDOMNode(render(getMarkup())); + const nameColumn = rendered.querySelectorAll( + ".ReactVirtualized__Table__headerColumn:first-of-type" + ); + + expect((nameColumn as any).className || "").not.toContain( + "ReactVirtualized__Table__sortableHeaderColumn" + ); + }); + + it("should not render sort indicators for non-sortable columns", () => { + const rendered = findDOMNode( + render( + getMarkup({ + disableSort: true, + sort: () => {} + }) + ) + ); + const nameColumn = rendered.querySelectorAll( + ".ReactVirtualized__Table__headerColumn:first-of-type" + ); + + expect((nameColumn as any).className || "").not.toContain( + "ReactVirtualized__Table__sortableHeaderColumn" + ); + expect( + rendered.querySelectorAll( + ".ReactVirtualized__Table__sortableHeaderColumn" + ).length + ).toEqual(1); // Email only + }); + + it("should render sortable column headers as sortable", () => { + const rendered = findDOMNode( + render( + getMarkup({ + sort: () => {} + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + expect(nameColumn.className).toContain( + "ReactVirtualized__Table__sortableHeaderColumn" + ); + expect( + rendered.querySelectorAll( + ".ReactVirtualized__Table__sortableHeaderColumn" + ).length + ).toEqual(2); // Email and Name + }); + + it("should render the correct sort indicator by the current sort-by column", () => { + const sortDirections = [SortDirection.ASC, SortDirection.DESC]; + sortDirections.forEach((sortDirection) => { + const rendered = findDOMNode( + render( + getMarkup({ + sort: () => {}, + sortBy: "name", + sortDirection + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + expect( + nameColumn.querySelector( + ".ReactVirtualized__Table__sortableHeaderIcon" + )! + ).not.toEqual(null); + expect( + nameColumn.querySelector( + `.ReactVirtualized__Table__sortableHeaderIcon--${sortDirection}` + )! + ).not.toEqual(null); + }); + }); + + it("should call sort with the correct arguments when the current sort-by column header is clicked", () => { + const sortDirections = [SortDirection.ASC, SortDirection.DESC]; + sortDirections.forEach((sortDirection) => { + const sortCalls: any[] = []; + const rendered = findDOMNode( + render( + getMarkup({ + sort: ({ sortBy, sortDirection }) => + sortCalls.push({ sortBy, sortDirection }), + sortBy: "name", + sortDirection + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + fireEvent.click(nameColumn); + expect(sortCalls.length).toEqual(1); + + const { sortBy, sortDirection: newSortDirection } = sortCalls[0]; + const expectedSortDirection = + sortDirection === SortDirection.ASC + ? SortDirection.DESC + : SortDirection.ASC; + expect(sortBy).toEqual("name"); + expect(newSortDirection).toEqual(expectedSortDirection); + }); + }); + + it("should call sort with the correct arguments when a new sort-by column header is clicked", () => { + const sortCalls: any[] = []; + const rendered = findDOMNode( + render( + getMarkup({ + sort: ({ sortBy, sortDirection }) => + sortCalls.push({ sortBy, sortDirection }), + sortBy: "email", + sortDirection: SortDirection.ASC + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + fireEvent.click(nameColumn); + expect(sortCalls.length).toEqual(1); + + const { sortBy, sortDirection } = sortCalls[0]; + expect(sortBy).toEqual("name"); + expect(sortDirection).toEqual(SortDirection.ASC); + }); + + it("should call sort when a column header is activated via ENTER or SPACE key", () => { + const sortCalls: any[] = []; + const rendered = findDOMNode( + render( + getMarkup({ + sort: ({ sortBy, sortDirection }) => + sortCalls.push({ sortBy, sortDirection }), + sortBy: "name" + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + expect(sortCalls.length).toEqual(0); + fireEvent.keyDown(nameColumn, { key: " " }); + expect(sortCalls.length).toEqual(1); + fireEvent.keyDown(nameColumn, { key: "Enter" }); + expect(sortCalls.length).toEqual(2); + fireEvent.keyDown(nameColumn, { key: "F" }); + expect(sortCalls.length).toEqual(2); + }); + + it("should honor the default sort order on first click of the column", () => { + const sortDirections = [SortDirection.ASC, SortDirection.DESC]; + sortDirections.forEach((sortDirection) => { + const sortCalls: any[] = []; + const rendered = findDOMNode( + render( + getMarkup({ + sort: ({ sortBy, sortDirection }) => + sortCalls.push({ sortBy, sortDirection }), + defaultSortDirection: sortDirection + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + fireEvent.click(nameColumn); + expect(sortCalls.length).toEqual(1); + + const { sortBy, sortDirection: newSortDirection } = sortCalls[0]; + expect(sortBy).toEqual("name"); + expect(newSortDirection).toEqual(sortDirection); + }); + }); + }); + + describe("headerRowRenderer", () => { + it("should render a custom header row if one is provided", () => { + const headerRowRenderer = vi.fn().mockReturnValue(
foo bar
); + const rendered = findDOMNode( + render( + getMarkup({ + headerHeight: 33, + headerRowRenderer, + rowClassName: "someRowClass" + }) + ) + ); + expect(rendered.textContent).toContain("foo bar"); + expect(headerRowRenderer).toHaveBeenCalled(); + const params = headerRowRenderer.mock.calls[0][0]; + expect(params.className).toContain("someRowClass"); + expect(params.columns).toHaveLength(2); + expect(params.style.height).toBe(33); + }); + }); + + describe("headerRenderer", () => { + it("should render a custom header if one is provided", () => { + const columnData = { foo: "foo", bar: "bar" }; + const headerRendererCalls: TableHeaderProps[] = []; + const rendered = findDOMNode( + render( + getMarkup({ + columnData, + headerRenderer: (params) => { + headerRendererCalls.push(params); + return "custom header"; + }, + sortBy: "name", + sortDirection: SortDirection.ASC + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + expect(nameColumn.textContent).toContain("custom header"); + expect(headerRendererCalls.length).toBeTruthy(); + + const headerRendererCall = headerRendererCalls[0]; + expect(headerRendererCall.columnData).toEqual(columnData); + expect(headerRendererCall.dataKey).toEqual("name"); + expect(headerRendererCall.disableSort).toEqual(false); + expect(headerRendererCall.label).toEqual("Name"); + expect(headerRendererCall.sortBy).toEqual("name"); + expect(headerRendererCall.sortDirection).toEqual(SortDirection.ASC); + }); + + it("should honor sort for custom headers", () => { + const sortCalls: any[] = []; + const rendered = findDOMNode( + render( + getMarkup({ + headerRenderer: () => "custom header", + sort: ({ sortBy, sortDirection }) => + sortCalls.push([sortBy, sortDirection]), + sortBy: "name", + sortDirection: SortDirection.ASC + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + fireEvent.click(nameColumn); + + expect(sortCalls.length).toEqual(1); + const sortCall = sortCalls[0]; + expect(sortCall[0]).toEqual("name"); + expect(sortCall[1]).toEqual(SortDirection.DESC); + }); + + it("should honor :onHeaderClick for custom header", () => { + const columnData = { foo: "foo", bar: "bar" }; + const onHeaderClick = vi.fn(); + const rendered = findDOMNode( + render( + getMarkup({ + columnData, + headerRenderer: () => "custom header", + onHeaderClick + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + fireEvent.click(nameColumn); + + expect(onHeaderClick).toHaveBeenCalledTimes(1); + const params = onHeaderClick.mock.calls[0][0]; + expect(params.dataKey).toEqual("name"); + expect(params.columnData).toEqual(columnData); + expect(params.event.type).toEqual("click"); + }); + }); + + describe("noRowsRenderer", () => { + it("should call :noRowsRenderer if :rowCount is 0", () => { + render( + getMarkup({ + noRowsRenderer: () =>
No rows!
, + rowCount: 0 + }) + ); + const bodyDOMNode = getGridElement(); + expect(bodyDOMNode.textContent).toEqual("No rows!"); + }); + + it("should render an empty body if :rowCount is 0 and there is no :noRowsRenderer", () => { + render( + getMarkup({ + rowCount: 0 + }) + ); + const bodyDOMNode = getGridElement(); + expect(bodyDOMNode.textContent).toEqual(""); + }); + }); + + describe("onColumnClick", () => { + it("should call :onColumnClick with the correct arguments when a column is clicked", () => { + const onColumnClick = vi.fn(); + const rendered = findDOMNode( + render( + getMarkup({ + onColumnClick + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__rowColumn:first-of-type" + )!; + + fireEvent.click(nameColumn); + + expect(onColumnClick).toHaveBeenCalledTimes(1); + const params = onColumnClick.mock.calls[0][0]; + expect(params.dataKey).toEqual("name"); + expect(params.columnData.data).toEqual(123); + expect(params.event.type).toEqual("click"); + }); + }); + + describe("onHeaderClick", () => { + it("should call :onHeaderClick with the correct arguments when a column header is clicked and sorting is disabled", () => { + const onHeaderClick = vi.fn(); + const rendered = findDOMNode( + render( + getMarkup({ + disableSort: true, + onHeaderClick + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + fireEvent.click(nameColumn); + + expect(onHeaderClick).toHaveBeenCalledTimes(1); + const params = onHeaderClick.mock.calls[0][0]; + expect(params.dataKey).toEqual("name"); + expect(params.columnData.data).toEqual(123); + expect(params.event.type).toEqual("click"); + }); + + it("should call :onHeaderClick with the correct arguments when a column header is clicked and sorting is enabled", () => { + const onHeaderClick = vi.fn(); + const rendered = findDOMNode( + render( + getMarkup({ + disableSort: false, + onHeaderClick + }) + ) + ); + const nameColumn = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn:first-of-type" + )!; + + fireEvent.click(nameColumn); + + expect(onHeaderClick).toHaveBeenCalledTimes(1); + const params = onHeaderClick.mock.calls[0][0]; + expect(params.dataKey).toEqual("name"); + expect(params.columnData.data).toEqual(123); + expect(params.event.type).toEqual("click"); + }); + }); + + describe("onRowClick", () => { + it("should call :onRowClick with the correct :rowIndex when a row is clicked", () => { + const onRowClick = vi.fn(); + const rendered = findDOMNode( + render( + getMarkup({ + onRowClick + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + fireEvent.click(rows[0]); + fireEvent.click(rows[3]); + expect(onRowClick).toHaveBeenCalledTimes(2); + expect(onRowClick.mock.calls.map((call) => call[0].index)).toEqual([ + 0, 3 + ]); + }); + }); + + describe("onRowDoubleClick", () => { + it("should call :onRowDoubleClick with the correct :rowIndex when a row is clicked", () => { + const onRowDoubleClick = vi.fn(); + const rendered = findDOMNode( + render( + getMarkup({ + onRowDoubleClick + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + fireEvent.dblClick(rows[0]); + fireEvent.dblClick(rows[3]); + expect(onRowDoubleClick).toHaveBeenCalledTimes(2); + expect(onRowDoubleClick.mock.calls.map((call) => call[0].index)).toEqual([ + 0, 3 + ]); + }); + }); + + describe("onRowRightClick", () => { + it("should call :onRowRightClick with the correct :rowIndex when a row is right-clicked", () => { + const onRowRightClick = vi.fn(); + const rendered = findDOMNode( + render( + getMarkup({ + onRowRightClick + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + fireEvent.contextMenu(rows[0]); + fireEvent.contextMenu(rows[3]); + expect(onRowRightClick).toHaveBeenCalledTimes(2); + expect(onRowRightClick.mock.calls.map((call) => call[0].index)).toEqual([ + 0, 3 + ]); + }); + }); + + describe("onRowMouseOver/Out", () => { + it("should call :onRowMouseOver and :onRowMouseOut with the correct :rowIndex when the mouse is moved over rows", () => { + const onRowMouseOver = vi.fn(); + const onRowMouseOut = vi.fn(); + const rendered = findDOMNode( + render( + getMarkup({ + onRowMouseOver, + onRowMouseOut + }) + ) + ); + + const simulateMouseOver = (from: HTMLElement, to: HTMLElement) => { + fireEvent.mouseOut(from, { relatedTarget: to }); + fireEvent.mouseOver(to, { relatedTarget: from }); + }; + + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + + simulateMouseOver(rows[0], rows[1]); + simulateMouseOver(rows[1], rows[2]); + simulateMouseOver(rows[2], rows[3]); + + expect(onRowMouseOver).toHaveBeenCalled(); + expect(onRowMouseOut).toHaveBeenCalled(); + expect(onRowMouseOver.mock.calls.map((call) => call[0].index)).toEqual([ + 1, 2, 3 + ]); + expect(onRowMouseOut.mock.calls.map((call) => call[0].index)).toEqual([ + 0, 1, 2 + ]); + }); + }); + + describe("rowClassName", () => { + it("should render a static classname given :rowClassName as a string", () => { + const staticClassName = "staticClass"; + const rendered = findDOMNode( + render( + getMarkup({ + rowClassName: staticClassName + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + Array.from(rows).forEach((row) => { + expect(row.className).toContain(staticClassName); + }); + }); + + it("should render dynamic classname given :rowClassName as a function", () => { + const rendered = findDOMNode( + render( + getMarkup({ + rowClassName: ({ index }) => (index % 2 === 0 ? "even" : "odd") + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + Array.from(rows).forEach((row, index) => { + if (index % 2 === 0) { + expect(row.className).toContain("even"); + expect(row.className).not.toContain("odd"); + } else { + expect(row.className).toContain("odd"); + expect(row.className).not.toContain("even"); + } + }); + }); + }); + + describe("onRowsRendered", () => { + it("should call :onRowsRendered at least one row is rendered", () => { + let startIndex, stopIndex; + render( + getMarkup({ + onRowsRendered: (params) => ({ startIndex, stopIndex } = params) + }) + ); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(7); + }); + + it("should not call :onRowsRendered unless the start or stop indices have changed", () => { + let numCalls = 0; + let startIndex; + let stopIndex; + const onRowsRendered = (params: TableRowsRenderedParams) => { + startIndex = params.startIndex; + stopIndex = params.stopIndex; + numCalls++; + }; + render(getMarkup({ onRowsRendered })); + expect(numCalls).toEqual(1); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(7); + render(getMarkup({ onRowsRendered })); + expect(numCalls).toEqual(1); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(7); + }); + + it("should call :onRowsRendered if the start or stop indices have changed", () => { + let numCalls = 0; + let startIndex; + let stopIndex; + const onRowsRendered = (params: TableRowsRenderedParams) => { + startIndex = params.startIndex; + stopIndex = params.stopIndex; + numCalls++; + }; + render(getMarkup({ onRowsRendered })); + expect(numCalls).toEqual(1); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(7); + render( + getMarkup({ + height: 50, + onRowsRendered + }) + ); + expect(numCalls).toEqual(2); + expect(startIndex).toEqual(0); + expect(stopIndex).toEqual(2); + }); + + it("should not call :onRowsRendered if no rows are rendered", () => { + let startIndex, stopIndex; + render( + getMarkup({ + height: 0, + onRowsRendered: (params) => ({ startIndex, stopIndex } = params) + }) + ); + expect(startIndex).toEqual(undefined); + expect(stopIndex).toEqual(undefined); + }); + }); + + describe("with a scrollElement", () => { + let unmockResizeObserver: () => void; + beforeEach(() => { + unmockResizeObserver = mockResizeObserver(); + }); + afterEach(() => { + render.unmount(); + unmockResizeObserver(); + }); + + // Renders the Table within a scroll container (with a 100px viewport), below `contentOffset` pixels of other content + function renderInScrollContainer( + props: GetMarkupParams = {}, + { + contentOffset = 0, + height = 100, + scrollTop = 0 + }: { contentOffset?: number; height?: number; scrollTop?: number } = {} + ) { + const container = createScrollContainer({ + contentOffset, + height, + width: 100 + }); + container.element.scrollTop = scrollTop; + const scrollTo = vi.spyOn(container.element, "scrollTo"); + const markup = (moreProps: GetMarkupParams = {}) => + getMarkup({ scrollElement: container.element, ...props, ...moreProps }); + const table = render(markup()); + return { container, markup, scrollTo, table }; + } + + it("should render the rows within the viewport of the scroll element", () => { + let startIndex, stopIndex; + // The rows start below the header (20px) + const { container } = renderInScrollContainer( + { onRowsRendered: (params) => ({ startIndex, stopIndex } = params) }, + { scrollTop: 100 } + ); + // The rows may fill the whole viewport (100px), since the header is scrolled along with them + expect(startIndex).toEqual(8); + expect(stopIndex).toEqual(17); + expect(getGridElement().style.height).toEqual("auto"); + + container.scroll(180); + expect(startIndex).toEqual(16); + expect(stopIndex).toEqual(25); + }); + + it("should measure its position from the inner Grid (below the header)", () => { + let startIndex; + const { container } = renderInScrollContainer( + { onRowsRendered: (params) => ({ startIndex } = params) }, + { contentOffset: 50 } + ); + expect(startIndex).toEqual(0); + + // The rows start 70px from the top of the content (50px + the 20px header) + container.scroll(170); + expect(startIndex).toEqual(10); + + // Without a header, the rows start right at the top of the Table + render( + getMarkup({ + disableHeader: true, + onRowsRendered: (params) => ({ startIndex } = params), + scrollElement: container.element + }) + ); + expect(startIndex).toEqual(12); + }); + + it("should scroll the scroll element with scrollToRow, scrollToCell and scrollToPosition (#32)", () => { + const { scrollTo, table } = renderInScrollContainer(); + + act(() => { + table.scrollToRow({ index: 49 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 400 + 20); + + act(() => { + table.scrollToCell({ align: "start", columnIndex: 0, rowIndex: 20 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 200 + 20); + + act(() => { + table.scrollToPosition({ scrollTop: 300 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 300 + 20); + expect(getGridElement().scrollTop).toEqual(0); + expect(getGridElement().textContent).toContain("Name 30"); + }); + + it("should use the height without the header once the scrollElement is removed", () => { + let stopIndex; + const { markup } = renderInScrollContainer({ + onRowsRendered: (params) => ({ stopIndex } = params) + }); + expect(stopIndex).toEqual(9); + render(markup({ scrollElement: undefined })); + expect(stopIndex).toEqual(7); + expect(getGridElement().style.height).toEqual("80px"); + }); + + it("should use the viewport height of the scroll element (and the height prop until it is available)", () => { + let stopIndex; + const container = createScrollContainer({ height: 200 }); + render( + getMarkup({ + onRowsRendered: (params) => ({ stopIndex } = params), + scrollElement: null + }) + ); + expect(stopIndex).toEqual(9); + + render( + getMarkup({ + height: undefined, + onRowsRendered: (params) => ({ stopIndex } = params), + scrollElement: container.element + }) + ); + expect(stopIndex).toEqual(19); + expect(getObserverCount(container.element)).toBe(1); + }); + + it("should work in StrictMode", () => { + let startIndex; + const ref = createRef(); + const container = createScrollContainer({ height: 100 }); + const scrollTo = vi.spyOn(container.element, "scrollTo"); + render( + +
+ {getMarkup({ + onRowsRendered: (params) => ({ startIndex } = params), + ref, + scrollElement: container.element + } as GetMarkupParams)} +
+
+ ); + container.scroll(220); + expect(startIndex).toEqual(20); + + act(() => { + ref.current!.scrollToRow({ align: "start", index: 50 }); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 520); + expect(startIndex).toEqual(50); + }); + }); + + describe("imperative scrolling", () => { + it("should support scrollToRow()", () => { + let startIndex, stopIndex; + const table = render( + getMarkup({ + onRowsRendered: (params) => ({ startIndex, stopIndex } = params) + }) + ); + act(() => { + table.scrollToRow({ index: 49 }); + }); + // 80px (100px - 20px header) / 10px rows = 8 rows + expect(getGridElement().scrollTop).toEqual(420); + expect(stopIndex).toEqual(49); + + act(() => { + table.scrollToRow({ align: "start", index: 20 }); + }); + expect(getGridElement().scrollTop).toEqual(200); + expect(startIndex).toEqual(20); + }); + + it("should support scrollToCell() (e.g. for ArrowKeyStepper)", () => { + const table = render(getMarkup()); + act(() => { + table.scrollToCell({ align: "center", columnIndex: 2, rowIndex: 50 }); + }); + expect(getGridElement().scrollLeft).toEqual(0); + expect(getGridElement().scrollTop).toEqual(465); + }); + + it("should support scrollToPosition()", () => { + const table = render(getMarkup()); + act(() => { + table.scrollToPosition({ scrollLeft: 50, scrollTop: 100 }); + }); + expect(getGridElement().scrollLeft).toEqual(0); + expect(getGridElement().scrollTop).toEqual(100); + }); + + it("should support getOffsetForRow()", () => { + const table = render(getMarkup()); + expect(table.getOffsetForRow({ alignment: "start", index: 20 })).toEqual( + 200 + ); + expect(table.getOffsetForRow({ index: 49 })).toEqual(420); + expect(table.getOffsetForRow({ align: "end", index: 20 })).toEqual(130); + expect(table.getOffsetForRow({ align: "center", index: 20 })).toEqual( + 165 + ); + }); + + it("should work in StrictMode", () => { + const ref = createRef(); + render( + +
{getMarkup({ ref } as GetMarkupParams)}
+
+ ); + act(() => { + ref.current!.scrollToRow({ index: 49 }); + }); + expect(getGridElement().scrollTop).toEqual(420); + }); + }); + + it("should warn in DEV if there is neither a height nor a scrollElement", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(getMarkup({ height: undefined } as Partial)); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "Table requires a height prop (unless a scrollElement is specified)" + ); + warn.mockRestore(); + }); + + describe("styles, classNames, and ids", () => { + it("should relay Grid props (e.g. containerStyle and containerProps) to the inner Grid", () => { + const node = findDOMNode( + render( + getMarkup({ + containerProps: { title: "container" }, + containerStyle: { backgroundColor: "red" } + }) + ) + ); + // containerProps are spread onto the Grid element, containerStyle onto its inner container + const grid = node.querySelector( + ".ReactVirtualized__Table__Grid" + )!; + expect(grid.getAttribute("title")).toEqual("container"); + const innerScrollContainer = node.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!; + expect(innerScrollContainer.style.backgroundColor).toEqual("red"); + }); + + it("should use the expected global CSS classNames", () => { + const node = findDOMNode( + render( + getMarkup({ + sort: () => {}, + sortBy: "name", + sortDirection: SortDirection.ASC + }) + ) + ); + expect(node.className).toEqual("ReactVirtualized__Table"); + expect( + node.querySelector(".ReactVirtualized__Table__headerRow")! + ).toBeTruthy(); + expect( + node.querySelector(".ReactVirtualized__Table__rowColumn")! + ).toBeTruthy(); + expect( + node.querySelector( + ".ReactVirtualized__Table__headerColumn" + )! + ).toBeTruthy(); + expect( + node.querySelector(".ReactVirtualized__Table__row")! + ).toBeTruthy(); + expect( + node.querySelector( + ".ReactVirtualized__Table__sortableHeaderColumn" + )! + ).toBeTruthy(); + expect( + node.querySelector( + ".ReactVirtualized__Table__sortableHeaderIcon" + )! + ).toBeTruthy(); + }); + + it("should use a custom :className if specified", () => { + const node = findDOMNode( + render( + getMarkup({ + className: "foo", + headerClassName: "bar", + rowClassName: "baz" + }) + ) + ); + expect(node.className).toContain("foo"); + expect(node.querySelectorAll(".bar").length).toEqual(2); + expect(node.querySelectorAll(".baz").length).toEqual(9); + }); + + it("should use a custom :id if specified", () => { + const node = findDOMNode(render(getMarkup({ id: "bar" }))); + expect(node.getAttribute("id")).toEqual("bar"); + }); + + it("should not set :id on the inner Grid", () => { + const node = findDOMNode(render(getMarkup({ id: "bar" }))); + const grid = node.querySelector(".ReactVirtualized__Grid")!; + expect(grid.getAttribute("id")).not.toEqual("bar"); + }); + + it("should use custom :styles if specified", () => { + const columnStyle = { backgroundColor: "red", overflow: "visible" }; + const headerStyle = { backgroundColor: "blue" }; + const columnHeaderStyle = { color: "yellow" }; + const rowStyle = { backgroundColor: "green" }; + const style = { backgroundColor: "orange" }; + const node = findDOMNode( + render( + getMarkup({ + columnStyle, + headerStyle, + columnHeaderStyle, + rowStyle, + style + }) + ) + ); + expect( + node.querySelector(".ReactVirtualized__Table__rowColumn")! + .style.backgroundColor + ).toEqual("red"); + expect( + node.querySelector(".ReactVirtualized__Table__rowColumn")! + .style.overflow + ).toEqual("visible"); + expect( + node.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!.style.backgroundColor + ).toEqual("blue"); + expect( + node.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!.style.color + ).toEqual("yellow"); + expect( + node.querySelector(".ReactVirtualized__Table__row")!.style + .backgroundColor + ).toEqual("green"); + expect(node.style.backgroundColor).toEqual("orange"); + }); + + it("should render dynamic style given :rowStyle as a function", () => { + const rendered = findDOMNode( + render( + getMarkup({ + rowStyle: ({ index }) => + index % 2 === 0 + ? { backgroundColor: "red" } + : { backgroundColor: "green" } + }) + ) + ); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + Array.from(rows).forEach((row, index) => { + if (index % 2 === 0) { + expect(row.style.backgroundColor).toEqual("red"); + } else { + expect(row.style.backgroundColor).toEqual("green"); + } + }); + }); + + it("should pass :gridClassName and :gridStyle to the inner Grid", () => { + const rendered = findDOMNode( + render( + getMarkup({ + gridClassName: "foo", + gridStyle: { backgroundColor: "red" } + }) + ) + ); + const grid = rendered.querySelector( + ".ReactVirtualized__Grid" + )!; + expect(grid.className).toContain("foo"); + expect(grid.style.backgroundColor).toEqual("red"); + }); + }); + + describe("overscanRowCount", () => { + it("should not overscan by default", () => { + const mock = vi.fn(); + mock.mockImplementation(overscanIndicesGetter); + + render( + getMarkup({ + overscanIndicesGetter: mock + }) + ); + expect(mock.mock.calls[0][0].overscanCellsCount).toEqual(0); + expect(mock.mock.calls[1][0].overscanCellsCount).toEqual(0); + }); + + it("should overscan the specified amount", () => { + const mock = vi.fn(); + mock.mockImplementation(overscanIndicesGetter); + + render( + getMarkup({ + overscanIndicesGetter: mock, + overscanRowCount: 10 + }) + ); + expect(mock.mock.calls[0][0].overscanCellsCount).toEqual(0); + expect(mock.mock.calls[1][0].overscanCellsCount).toEqual(10); + }); + }); + + describe("onScroll", () => { + it("should trigger callback when component initially mounts", () => { + const onScrollCalls: TableScrollParams[] = []; + render( + getMarkup({ + onScroll: (params) => onScrollCalls.push(params) + }) + ); + expect(onScrollCalls).toEqual([ + { + clientHeight: 80, + scrollHeight: 1000, + scrollTop: 0 + } + ]); + }); + + it("should trigger callback when component scrolls", () => { + const onScrollCalls: TableScrollParams[] = []; + render( + getMarkup({ + onScroll: (params) => onScrollCalls.push(params) + }) + ); + fireEvent.scroll(getGridElement(), { + target: { + scrollLeft: 0, + scrollTop: 100 + } + }); + expect(onScrollCalls.length).toEqual(2); + expect(onScrollCalls[1]).toEqual({ + clientHeight: 80, + scrollHeight: 1000, + scrollTop: 100 + }); + }); + }); + + describe("a11y properties", () => { + it("should set aria role on the table", () => { + const node = findDOMNode(render(getMarkup())); + expect(node.getAttribute("role")).toEqual("grid"); + }); + + it("should set aria col/row count on the table", () => { + const node = findDOMNode(render(getMarkup())); + expect(node.getAttribute("aria-colcount")).toEqual("2"); + expect(node.getAttribute("aria-rowcount")).toEqual(`${list.size}`); + }); + + it("should pass down aria labels on the table", () => { + const node = findDOMNode( + render( + getMarkup({ + "aria-label": "my-table-label", + "aria-labelledby": "my-table-label-id" + }) + ) + ); + expect(node.getAttribute("aria-label")).toEqual("my-table-label"); + expect(node.getAttribute("aria-labelledby")).toEqual("my-table-label-id"); + }); + + it("should set aria role on the header row", () => { + const rendered = findDOMNode(render(getMarkup())); + const row = rendered.querySelector( + ".ReactVirtualized__Table__headerRow" + )!; + expect(row.getAttribute("role")).toEqual("row"); + }); + + it("should set appropriate aria role on the grid", () => { + const rendered = findDOMNode(render(getMarkup())); + const grid = rendered.querySelector( + ".ReactVirtualized__Table__Grid" + )!; + expect(grid.getAttribute("role")).toEqual("rowgroup"); + }); + + it("should set aria role on a row", () => { + const rendered = findDOMNode(render(getMarkup())); + const row = rendered.querySelector( + ".ReactVirtualized__Table__row" + )!; + expect(row.getAttribute("role")).toEqual("row"); + }); + + it("should set aria rowindex on a row", () => { + const rendered = findDOMNode(render(getMarkup())); + const rows = rendered.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + expect(rows[0].getAttribute("aria-rowindex")).toEqual("1"); + expect(rows[1].getAttribute("aria-rowindex")).toEqual("2"); + }); + + it("should set aria role on a cell", () => { + const rendered = findDOMNode(render(getMarkup())); + const cell = rendered.querySelector( + ".ReactVirtualized__Table__rowColumn" + )!; + expect(cell.getAttribute("role")).toEqual("gridcell"); + }); + + it("should set aria colindex on a cell", () => { + const rendered = findDOMNode(render(getMarkup())); + const cells = rendered.querySelectorAll( + ".ReactVirtualized__Table__rowColumn" + ); + expect(cells[0].getAttribute("aria-colindex")).toEqual("1"); + expect(cells[1].getAttribute("aria-colindex")).toEqual("2"); + }); + + it("should set aria-describedby on a cell when the column has an id", () => { + const columnID = "column-header-test"; + const rendered = findDOMNode( + render( + getMarkup({ + columnID + }) + ) + ); + const cell = rendered.querySelector( + ".ReactVirtualized__Table__rowColumn" + )!; + expect(cell.getAttribute("aria-describedby")).toEqual(columnID); + }); + + it("should attach a11y properties to a row if :onRowClick is specified", () => { + const rendered = findDOMNode( + render( + getMarkup({ + onRowClick: () => {} + }) + ) + ); + const row = rendered.querySelector( + ".ReactVirtualized__Table__row" + )!; + expect(row.getAttribute("aria-label")).toEqual("row"); + expect(row.tabIndex).toEqual(0); + }); + + it("should not attach a11y properties to a row if no :onRowClick is specified", () => { + const rendered = findDOMNode( + render( + getMarkup({ + onRowClick: null + }) + ) + ); + const row = rendered.querySelector( + ".ReactVirtualized__Table__row" + )!; + expect(row.getAttribute("aria-label")).toEqual(null); + expect(row.tabIndex).toEqual(-1); + }); + + it("should set aria role on a header column", () => { + const rendered = findDOMNode(render(getMarkup())); + const header = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!; + expect(header.getAttribute("role")).toEqual("columnheader"); + }); + + it("should set aria-sort ascending on a header column if the column is sorted ascending", () => { + const rendered = findDOMNode( + render( + getMarkup({ + sortBy: "name", + sortDirection: SortDirection.ASC + }) + ) + ); + const header = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!; + expect(header.getAttribute("aria-sort")).toEqual("ascending"); + }); + + it("should set aria-sort descending on a header column if the column is sorted descending", () => { + const rendered = findDOMNode( + render( + getMarkup({ + sortBy: "name", + sortDirection: SortDirection.DESC + }) + ) + ); + const header = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!; + expect(header.getAttribute("aria-sort")).toEqual("descending"); + }); + + it('should set aria-sort to "none" if the column is sortable but not the current sort', () => { + const rendered = findDOMNode( + render(getMarkup({ disableSort: true, sort: vi.fn() })) + ); + const headers = rendered.querySelectorAll( + ".ReactVirtualized__Table__headerColumn" + ); + // the first column is not sortable + expect(headers[0].getAttribute("aria-sort")).toBe(null); + // the second column is sortable + expect(headers[1].getAttribute("aria-sort")).toEqual("none"); + }); + + // #1291 + it("should not set aria-sort on a column that isn't sortable, even if it has an onHeaderClick handler", () => { + const rendered = findDOMNode( + render( + getMarkup({ + disableSort: true, + onHeaderClick: vi.fn(), + sort: vi.fn() + }) + ) + ); + const headers = rendered.querySelectorAll( + ".ReactVirtualized__Table__headerColumn" + ); + // The first column isn't sortable (but its header can still be clicked) + expect(headers[0].getAttribute("aria-sort")).toBe(null); + expect(headers[0].getAttribute("tabindex")).toEqual("0"); + // The second column is sortable + expect(headers[1].getAttribute("aria-sort")).toEqual("none"); + }); + + it("should set id on a header column when the column has an id", () => { + const columnID = "column-header-test"; + const rendered = findDOMNode( + render( + getMarkup({ + columnID + }) + ) + ); + const header = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!; + expect(header.getAttribute("id")).toEqual(columnID); + }); + + it("should attach a11y properties to a header column if sort is enabled", () => { + const rendered = findDOMNode( + render( + getMarkup({ + disableSort: false, + sort: () => {} + }) + ) + ); + const row = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!; + expect(row.getAttribute("aria-label")).toEqual("Name"); + expect(row.tabIndex).toEqual(0); + }); + + it("should not attach a11y properties to a header column if sort is not enabled", () => { + const rendered = findDOMNode( + render( + getMarkup({ + disableSort: true + }) + ) + ); + const row = rendered.querySelector( + ".ReactVirtualized__Table__headerColumn" + )!; + expect(row.getAttribute("aria-label")).toEqual(null); + expect(row.tabIndex).toEqual(-1); + }); + }); + + describe("tabIndex", () => { + it("should be focusable by default", () => { + const rendered = findDOMNode(render(getMarkup())); + expect( + rendered.querySelector(".ReactVirtualized__Grid")!.tabIndex + ).toEqual(0); + }); + + it("should allow tabIndex to be overridden", () => { + const rendered = findDOMNode( + render( + getMarkup({ + tabIndex: -1 + }) + ) + ); + expect( + rendered.querySelector(".ReactVirtualized__Grid")!.tabIndex + ).toEqual(-1); + }); + }); + + describe("pure", () => { + it("should not re-render unless props have changed", () => { + let headerRendererCalled = false; + let cellRendererCalled = false; + function headerRenderer() { + headerRendererCalled = true; + return "foo"; + } + function cellRenderer() { + cellRendererCalled = true; + return "foo"; + } + const markup = getMarkup({ + headerRenderer, + cellRenderer + }); + render(markup); + expect(headerRendererCalled).toEqual(true); + expect(cellRendererCalled).toEqual(true); + headerRendererCalled = false; + cellRendererCalled = false; + render(markup); + expect(headerRendererCalled).toEqual(false); + expect(cellRendererCalled).toEqual(false); + }); + + it("should re-render both the Table and the inner Grid whenever an external property changes", () => { + let headerRendererCalled = false; + let cellRendererCalled = false; + function headerRenderer() { + headerRendererCalled = true; + return "foo"; + } + function cellRenderer() { + cellRendererCalled = true; + return "foo"; + } + const initialProperties: GetMarkupParams = { + cellRenderer, + estimatedRowSize: 15, + headerRenderer, + overscanRowCount: 1, + rowHeight: 15, + rowCount: 20, + sortBy: "name", + sortDirection: SortDirection.ASC, + tabIndex: null + }; + const changedProperties: GetMarkupParams = { + estimatedRowSize: 10, + overscanRowCount: 0, + rowHeight: 10, + rowCount: 10, + sortBy: "email", + sortDirection: SortDirection.DESC, + tabIndex: 1 + }; + Object.entries(changedProperties).forEach(([key, value]) => { + render.unmount(); // Reset + render(getMarkup(initialProperties)); + headerRendererCalled = true; + cellRendererCalled = false; + render( + getMarkup({ + ...initialProperties, + [key]: value + }) + ); + expect(headerRendererCalled).toEqual(true); + expect(cellRendererCalled).toEqual(true); + }); + }); + }); + + it("should set the width of the single-column inner Grid to auto", () => { + const rendered = findDOMNode(render(getMarkup())); + expect( + rendered.querySelector( + ".ReactVirtualized__Grid__innerScrollContainer" + )!.style.width + ).toEqual("auto"); + }); + + it("should pass the Table handle as the :parent param to the Column :cellRenderer", () => { + const cellRenderer = vi.fn().mockReturnValue(null); + const table = render(getMarkup({ cellRenderer })); + expect(cellRenderer.mock.calls[0][0].parent).toBe(table); + }); + + describe("CellMeasurer", () => { + function renderWithCellMeasurer( + cache: CellMeasurerCache, + wrap: (markup: ReactElement) => ReactElement = (markup) => markup + ) { + // The cell's height is "measured" from its content (jsdom has no layout) + vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation( + function (this: HTMLElement) { + return this.getAttribute("data-height") + ? Number(this.getAttribute("data-height")) + : 0; + } + ); + + const ref = createRef(); + render( + wrap( + + ( + +
{cellData}
+
+ )} + dataKey="name" + width={100} + /> +
+ ) + ); + return ref; + } + + it("should measure cells rendered by a CellMeasurer (the Table handle is the parent)", () => { + const cache = new CellMeasurerCache({ + defaultHeight: 10, + fixedWidth: true + }); + const ref = renderWithCellMeasurer(cache); + expect(cache.getHeight(0, 0)).toEqual(40); + expect(cache.getHeight(1, 0)).toEqual(20); + + // The Grid has been updated with the measured sizes + const rows = document.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + expect(rows[0].style.height).toEqual("40px"); + expect(rows[1].style.height).toEqual("20px"); + expect(rows[1].style.top).toEqual("40px"); + expect( + ref.current!.getOffsetForRow({ align: "start", index: 2 }) + ).toEqual(60); + }); + + it("should pass on a recomputeGridSize() call made by a cell while the Table is mounting", () => { + // The first cell makes all rows taller as it mounts (before the Table's inner Grid ref has been attached) + let rowHeight = 10; + function GrowingCell({ + parent, + rowIndex + }: { + parent: TableImperativeAPI; + rowIndex: number; + }) { + useLayoutEffect(() => { + if (rowIndex === 0 && rowHeight === 10) { + rowHeight = 20; + parent.recomputeGridSize({ rowIndex: 0 }); + } + }, [parent, rowIndex]); + return {rowIndex}; + } + + render( + rowHeight} + width={100} + > + ( + + )} + dataKey="name" + label="Name" + width={50} + /> +
+ ); + const rows = document.body.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + expect(rows[1].style.top).toEqual("20px"); + expect(rows[1].style.height).toEqual("20px"); + }); + + it("should measure cells rendered by a CellMeasurer in StrictMode", () => { + const cache = new CellMeasurerCache({ + defaultHeight: 10, + fixedWidth: true + }); + renderWithCellMeasurer(cache, (markup) => ( + {markup} + )); + const rows = document.querySelectorAll( + ".ReactVirtualized__Table__row" + ); + expect(rows[0].style.height).toEqual("40px"); + expect(rows[1].style.top).toEqual("40px"); + }); + }); + + describe("StrictMode", () => { + it("should render headers, rows, and handle sorting in StrictMode", () => { + const sort = vi.fn(); + const ref = createRef(); + render( + + {getMarkup({ + ref, + sort, + sortBy: "name", + sortDirection: SortDirection.ASC + } as GetMarkupParams)} + + ); + const headers = document.querySelectorAll( + ".ReactVirtualized__Table__headerColumn" + ); + expect(headers.length).toEqual(2); + expect(headers[0].getAttribute("aria-sort")).toEqual("ascending"); + expect( + document.querySelectorAll(".ReactVirtualized__Table__row").length + ).toEqual(8); + + act(() => { + headers[0].click(); + }); + expect(sort).toHaveBeenCalledTimes(1); + expect(sort.mock.calls[0][0].sortDirection).toEqual(SortDirection.DESC); + + act(() => { + ref.current!.scrollToRow({ align: "start", index: 10 }); + }); + expect(getGridElement().scrollTop).toEqual(100); + }); + }); +}); diff --git a/lib/components/Table/Table.tsx b/lib/components/Table/Table.tsx new file mode 100644 index 000000000..fa688854c --- /dev/null +++ b/lib/components/Table/Table.tsx @@ -0,0 +1,636 @@ +import clsx from "clsx"; +import { + Children, + forwardRef, + isValidElement, + memo, + useCallback, + useImperativeHandle, + useInsertionEffect, + useMemo, + useState, + type AriaAttributes, + type CSSProperties, + type KeyboardEvent, + type MouseEvent, + type ReactNode +} from "react"; +import { + createInnerGridRef, + type InnerGridRef +} from "../../utils/createInnerGridRef"; +import { useMissingHeightWarning } from "../../utils/scrollElement/useMissingHeightWarning"; +import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect"; +import { Grid } from "../Grid/Grid"; +import type { + CellRenderer, + CellRendererParams, + GridProps, + RenderedSection, + Scroll +} from "../Grid/types"; +import { + defaultColumnProps, + type ColumnPropsWithDefaults +} from "./defaultColumnProps"; +import { defaultTableProps } from "./defaultTableProps"; +import { SortDirection } from "./SortDirection"; +import type { + TableImperativeAPI, + TableProps, + TableRowsRenderedParams, + TableScrollParams +} from "./types"; + +/** + * Returns a child element's props with `defaultColumnProps` applied (to the props that are undefined). + * + * `Table` reads the props of its children without rendering them, + * so the defaults are applied to any child element (not only `Column` elements); + * this allows components that wrap `Column` to be used as columns as well. + */ +function getColumnProps(child: ReactNode): ColumnPropsWithDefaults { + const props = ( + isValidElement(child) ? child.props : {} + ) as ColumnPropsWithDefaults; + + const resolved: Record = { ...props }; + for (const key in defaultColumnProps) { + if (resolved[key] === undefined) { + resolved[key] = + defaultColumnProps[key as keyof typeof defaultColumnProps]; + } + } + + return resolved as ColumnPropsWithDefaults; +} + +/** Determines the flex-shrink, flex-grow, and width values for a cell (header or column). */ +function getFlexStyleForColumn( + column: ColumnPropsWithDefaults, + customStyle: CSSProperties = {} +): CSSProperties { + const flexValue = `${column.flexGrow} ${column.flexShrink} ${column.width}px`; + + const style: CSSProperties = { + ...customStyle, + flex: flexValue, + msFlex: flexValue, + WebkitFlex: flexValue + }; + + if (column.maxWidth) { + style.maxWidth = column.maxWidth; + } + + if (column.minWidth) { + style.minWidth = column.minWidth; + } + + return style; +} + +// Callbacks read by the (stable) Grid event handlers. +// They are updated in an insertion effect, so they are current when the Grid calls them during its commit phase. +type LatestCallbacks = { + onRowsRendered: (params: TableRowsRenderedParams) => void; + onScroll: (params: TableScrollParams) => void; +}; + +// Mutable, per-instance values; only modified by effects, ref callbacks, and the Grid's cell renderer (see below). +type TableInternals = { + // The inner Grid (keeps cell size invalidations requested by cells while the Table is mounting; see createInnerGridRef) + innerGrid: InnerGridRef; + + // The inner Grid's scrolling container element + gridElement: HTMLDivElement | null; + + latest: LatestCallbacks; +}; + +function createTableInstance(latest: LatestCallbacks) { + const internals: TableInternals = { + gridElement: null, + innerGrid: createInnerGridRef(), + latest + }; + + const { getGrid } = internals.innerGrid; + + const handle: TableImperativeAPI = { + forceUpdateGrid() { + getGrid()?.forceUpdate(); + }, + + getOffsetForRow({ align, alignment, index } = {}) { + const grid = getGrid(); + if (grid) { + const { scrollTop } = grid.getOffsetForCell({ + alignment: align ?? alignment, + rowIndex: index + }); + + return scrollTop; + } + return 0; + }, + + getScrollbarWidth() { + const element = internals.gridElement; + if (element) { + const clientWidth = element.clientWidth || 0; + const offsetWidth = element.offsetWidth || 0; + return offsetWidth - clientWidth; + } + + return 0; + }, + + invalidateCellSizeAfterRender({ columnIndex, rowIndex }) { + internals.innerGrid.invalidateCellSizeAfterRender({ + columnIndex, + rowIndex + }); + }, + + measureAllRows() { + getGrid()?.measureAllCells(); + }, + + recomputeGridSize({ columnIndex = 0, rowIndex = 0 } = {}) { + internals.innerGrid.recomputeGridSize({ columnIndex, rowIndex }); + }, + + recomputeRowHeights(index = 0) { + internals.innerGrid.recomputeGridSize({ + columnIndex: 0, + rowIndex: index + }); + }, + + scrollToCell({ align, columnIndex, rowIndex }) { + getGrid()?.scrollToCell({ align, columnIndex, rowIndex }); + }, + + scrollToPosition({ scrollTop }) { + getGrid()?.scrollToPosition({ scrollTop }); + }, + + scrollToRow({ align, index }) { + getGrid()?.scrollToCell({ align, columnIndex: 0, rowIndex: index }); + } + }; + + const onScroll = ({ clientHeight, scrollHeight, scrollTop }: Scroll) => { + internals.latest.onScroll({ clientHeight, scrollHeight, scrollTop }); + }; + + const onSectionRendered = ({ + rowOverscanStartIndex, + rowOverscanStopIndex, + rowStartIndex, + rowStopIndex + }: RenderedSection) => { + internals.latest.onRowsRendered({ + overscanStartIndex: rowOverscanStartIndex, + overscanStopIndex: rowOverscanStopIndex, + startIndex: rowStartIndex, + stopIndex: rowStopIndex + }); + }; + + const setGridRef = internals.innerGrid.setGridRef; + + const setGridElementRef = (element: HTMLDivElement | null) => { + internals.gridElement = element; + }; + + return { + handle, + internals, + onScroll, + onSectionRendered, + setGridElementRef, + setGridRef + }; +} + +/** + * Table component with fixed headers and virtualized rows for improved performance with large data sets. + * This component expects explicit width, height, and padding parameters. + * + * Columns are described by `Column` children (or components that render a `Column`). + * Scrolling is done through the imperative handle (`ref`): see `scrollToRow`, `scrollToCell`, and `scrollToPosition`. + */ +export const Table = /* @__PURE__ */ memo( + /* @__PURE__ */ forwardRef( + function Table(props, ref) { + const { + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + children, + className, + disableHeader = defaultTableProps.disableHeader, + estimatedRowSize = defaultTableProps.estimatedRowSize, + gridClassName, + gridStyle, + headerClassName, + headerHeight = defaultTableProps.headerHeight, + headerRowRenderer = defaultTableProps.headerRowRenderer, + headerStyle = defaultTableProps.headerStyle, + height, + id, + noRowsRenderer = defaultTableProps.noRowsRenderer, + onColumnClick, + onHeaderClick, + onRowClick, + onRowDoubleClick, + onRowMouseOut, + onRowMouseOver, + onRowRightClick, + onRowsRendered = defaultTableProps.onRowsRendered, + onScroll = defaultTableProps.onScroll, + overscanIndicesGetter = defaultTableProps.overscanIndicesGetter, + overscanRowCount = defaultTableProps.overscanRowCount, + rowClassName, + rowCount, + rowGetter, + rowHeight, + rowRenderer = defaultTableProps.rowRenderer, + rowStyle = defaultTableProps.rowStyle, + scrollElement, + sort, + sortBy, + sortDirection, + style = defaultTableProps.style, + width + } = props; + + const [scrollbarWidth, setScrollbarWidth] = useState(0); + + // The instance (and its stable imperative handle) is created once + const [ + { + handle, + internals, + onScroll: onGridScroll, + onSectionRendered, + setGridElementRef, + setGridRef + } + ] = useState(() => createTableInstance({ onRowsRendered, onScroll })); + + useImperativeHandle(ref, () => handle, [handle]); + + useMissingHeightWarning("Table", height, scrollElement); + + useInsertionEffect(() => { + internals.latest = { onRowsRendered, onScroll }; + }); + + // Equivalent of the 9.x componentDidMount/componentDidUpdate: + // the header row is padded by the width of the inner Grid's vertical scrollbar (if any). + // (Setting the same value again does not re-render.) + useIsomorphicLayoutEffect(() => { + setScrollbarWidth(handle.getScrollbarWidth()); + }); + + // Column props (with defaults applied) and the cached column flex styles (shared by all rows); + // these are recomputed only when the children change. + // Header and row cells are keyed by their Column's key (Children.toArray provides a unique key for every child, + // based on its own key if it has one), so that the cells of a column move with it when columns are reordered (#1725). + const [columns, columnKeys] = useMemo(() => { + const columnElements = Children.toArray(children); + return [ + columnElements.map(getColumnProps), + columnElements.map((child, index) => + isValidElement(child) && child.key !== null + ? String(child.key) + : String(index) + ) + ]; + }, [children]); + const cachedColumnStyles = useMemo( + () => + columns.map( + (column): CSSProperties => ({ + overflow: "hidden", + ...getFlexStyleForColumn( + column, + column.style || defaultColumnProps.style + ) + }) + ), + [columns] + ); + + const createRow = useCallback( + ({ + rowIndex: index, + isScrolling, + key, + style: cellStyle + }: CellRendererParams): ReturnType => { + const rowClass = + typeof rowClassName === "function" + ? rowClassName({ index }) + : rowClassName; + const rowStyleObject = + typeof rowStyle === "function" ? rowStyle({ index }) : rowStyle; + const rowData = rowGetter({ index }); + + const renderedColumns = columns.map((column, columnIndex) => { + const { + cellDataGetter, + cellRenderer, + className: columnClassName, + columnData, + dataKey, + id: columnId + } = column; + + const cellData = cellDataGetter({ columnData, dataKey, rowData }); + const renderedCell = cellRenderer({ + cellData, + columnData, + columnIndex, + dataKey, + isScrolling, + // CellMeasurer interop: the Table handle delegates to the inner Grid + parent: handle, + rowData, + rowIndex: index + }); + + const onClick = (event: MouseEvent) => { + if (onColumnClick) { + onColumnClick({ columnData, dataKey, event }); + } + }; + + const title = + typeof renderedCell === "string" ? renderedCell : null; + + return ( +
+ {renderedCell} +
+ ); + }); + + const flattenedStyle = { + ...cellStyle, + height: + typeof rowHeight === "function" + ? rowHeight({ index }) + : rowHeight, + overflow: "hidden", + paddingRight: scrollbarWidth, + ...rowStyleObject + }; + + return rowRenderer({ + className: clsx("ReactVirtualized__Table__row", rowClass), + columns: renderedColumns, + index, + isScrolling, + key, + onRowClick, + onRowDoubleClick, + onRowRightClick, + onRowMouseOver, + onRowMouseOut, + rowData, + style: flattenedStyle + }) as ReturnType; + }, + [ + cachedColumnStyles, + columnKeys, + columns, + handle, + onColumnClick, + onRowClick, + onRowDoubleClick, + onRowMouseOut, + onRowMouseOver, + onRowRightClick, + rowClassName, + rowGetter, + rowHeight, + rowRenderer, + rowStyle, + scrollbarWidth + ] + ); + + const createHeader = (column: ColumnPropsWithDefaults, index: number) => { + const { + columnData, + dataKey, + defaultSortDirection, + disableSort, + headerRenderer, + id: columnId, + label + } = column; + const sortEnabled = !disableSort && sort; + + const classNames = clsx( + "ReactVirtualized__Table__headerColumn", + headerClassName, + column.headerClassName, + { + ReactVirtualized__Table__sortableHeaderColumn: sortEnabled + } + ); + const headerColumnStyle = getFlexStyleForColumn(column, { + ...headerStyle, + ...column.headerStyle + }); + + const renderedHeader = headerRenderer({ + columnData, + dataKey, + disableSort, + label, + sortBy, + sortDirection + }); + + let headerOnClick: + | (( + event: + | MouseEvent + | KeyboardEvent + ) => void) + | undefined, + headerOnKeyDown: + | ((event: KeyboardEvent) => void) + | undefined, + headerTabIndex: number | undefined, + headerAriaSort: AriaAttributes["aria-sort"], + headerAriaLabel: string | undefined; + + if (sortEnabled || onHeaderClick) { + // If this is a sortable header, clicking it should update the table data's sorting. + const isFirstTimeSort = sortBy !== dataKey; + + // If this is the firstTime sort of this column, use the column default sort order. + // Otherwise, invert the direction of the sort. + const newSortDirection = isFirstTimeSort + ? defaultSortDirection + : sortDirection === SortDirection.DESC + ? SortDirection.ASC + : SortDirection.DESC; + + const onClick = ( + event: MouseEvent | KeyboardEvent + ) => { + if (sortEnabled) { + sort({ + defaultSortDirection, + event, + sortBy: dataKey, + sortDirection: newSortDirection + }); + } + if (onHeaderClick) { + onHeaderClick({ columnData, dataKey, event }); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + onClick(event); + } + }; + + // Label may be a non-string node; this matches the original implementation. + headerAriaLabel = (column["aria-label"] || label || dataKey) as + | string + | undefined; + // Only sortable columns are announced as sortable ("none" = sortable, but not sorted) (#1291) + if (sortEnabled) { + headerAriaSort = "none"; + } + headerTabIndex = 0; + headerOnClick = onClick; + headerOnKeyDown = onKeyDown; + } + + // (The data may be sorted by a column even if the user can't change the sort order) + if (sortBy === dataKey) { + headerAriaSort = + sortDirection === SortDirection.ASC ? "ascending" : "descending"; + } + + return ( +
+ {renderedHeader} +
+ ); + }; + + // A Table with a scrollElement is scrolled by the scroll element (e.g. the window), along with its header; + // so the rows may fill the whole viewport (whose height is measured by the inner Grid, which receives the scrollElement). + const availableRowsHeight = + scrollElement !== undefined + ? height + : disableHeader + ? (height ?? 0) + : (height ?? 0) - headerHeight; + + const rowClass = + typeof rowClassName === "function" + ? rowClassName({ index: -1 }) + : rowClassName; + const rowStyleObject = + typeof rowStyle === "function" ? rowStyle({ index: -1 }) : rowStyle; + + // All Table props are passed through to the inner Grid (as in 9.x), including props that have nothing to do with Grid + // (e.g. sortBy and sortDirection): Grid is memoized and won't re-render unless its props change, + // so any prop that should trigger a re-render of the rows must be specified here to avoid a stale display. + // The same goes for scrollbarWidth (which is not a Grid prop). + // "aria-readonly" is null (not undefined) so that Grid's default value is not used. + const gridProps = { + ...props, + "aria-readonly": null, + estimatedRowSize, + overscanIndicesGetter, + overscanRowCount, + scrollbarWidth + } as unknown as GridProps; + + return ( +
+ {!disableHeader && + headerRowRenderer({ + className: clsx("ReactVirtualized__Table__headerRow", rowClass), + columns: columns.map(createHeader), + style: { + height: headerHeight, + overflow: "hidden", + paddingRight: scrollbarWidth, + width: width, + ...rowStyleObject + } + })} + + +
+ ); + } + ) +); diff --git a/lib/components/Table/createMultiSort.test.ts b/lib/components/Table/createMultiSort.test.ts new file mode 100644 index 000000000..59f8e745b --- /dev/null +++ b/lib/components/Table/createMultiSort.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMultiSort } from "./createMultiSort"; +import type { MultiSortReturn, SortDirectionType } from "./types"; + +describe("createMultiSort", () => { + function simulate( + sort: MultiSortReturn["sort"], + dataKey: string, + eventModifier = "", + defaultSortDirection: SortDirectionType = "ASC" + ) { + sort({ + defaultSortDirection, + event: { + ctrlKey: eventModifier === "control", + metaKey: eventModifier === "meta", + shiftKey: eventModifier === "shift" + }, + sortBy: dataKey + }); + } + + it("errors if the user did not specify a sort callback", () => { + expect(createMultiSort).toThrow(); + }); + + it("sets the correct default values", () => { + const multiSort = createMultiSort(vi.fn(), { + defaultSortBy: ["a", "b"], + defaultSortDirection: { + a: "ASC", + b: "DESC" + } + }); + expect(multiSort.sortBy).toEqual(["a", "b"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + expect(multiSort.sortDirection.b).toBe("DESC"); + }); + + it("sets the correct default sparse values", () => { + const multiSort = createMultiSort(vi.fn(), { + defaultSortBy: ["a", "b"] + }); + expect(multiSort.sortBy).toEqual(["a", "b"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + expect(multiSort.sortDirection.b).toBe("ASC"); + }); + + it("does not mutate the defaultSortBy array", () => { + const defaultSortBy = ["a"]; + const multiSortA = createMultiSort(vi.fn(), { defaultSortBy }); + const multiSortB = createMultiSort(vi.fn(), { defaultSortBy }); + + simulate(multiSortA.sort, "b", "shift"); + expect(multiSortA.sortBy).toEqual(["a", "b"]); + simulate(multiSortA.sort, "a", "control"); + expect(multiSortA.sortBy).toEqual(["b"]); + simulate(multiSortA.sort, "c"); + expect(multiSortA.sortBy).toEqual(["c"]); + + expect(defaultSortBy).toEqual(["a"]); + expect(multiSortB.sortBy).toEqual(["a"]); + }); + + describe("immutability (#1585)", () => { + it("creates new sortBy and sortDirection values whenever the sort criteria change", () => { + const sortCallback = vi.fn(); + const multiSort = createMultiSort(sortCallback, { + defaultSortBy: ["a"] + }); + const initial = { + sortBy: multiSort.sortBy, + sortDirection: multiSort.sortDirection + }; + + // Each kind of change (click, shift + click, control + click) + const changes: [string, string][] = [ + ["b", "shift"], + ["b", "shift"], + ["a", "control"], + ["c", ""], + ["c", ""] + ]; + const seen = [initial]; + changes.forEach(([dataKey, modifier]) => { + simulate(multiSort.sort, dataKey, modifier); + const { sortBy, sortDirection } = sortCallback.mock.lastCall![0]; + seen.forEach((previous) => { + expect(sortDirection).not.toBe(previous.sortDirection); + }); + // (The returned object reflects the latest values) + expect(multiSort.sortBy).toBe(sortBy); + expect(multiSort.sortDirection).toBe(sortDirection); + seen.push({ sortBy, sortDirection }); + }); + + // Previously passed values are never mutated + expect(seen.map(({ sortBy }) => sortBy)).toEqual([ + ["a"], + ["a", "b"], + ["a", "b"], + ["b"], + ["c"], + ["c"] + ]); + expect(seen.map(({ sortDirection }) => sortDirection)).toEqual([ + { a: "ASC" }, + { a: "ASC", b: "ASC" }, + { a: "ASC", b: "DESC" }, + { b: "DESC" }, + { c: "ASC" }, + { c: "DESC" } + ]); + }); + + it("keeps the same values if a control + click doesn't change the sort criteria", () => { + const sortCallback = vi.fn(); + const multiSort = createMultiSort(sortCallback, { + defaultSortBy: ["a"] + }); + const { sortBy, sortDirection } = multiSort; + simulate(multiSort.sort, "b", "control"); + expect(sortCallback.mock.lastCall![0].sortBy).toBe(sortBy); + expect(sortCallback.mock.lastCall![0].sortDirection).toBe(sortDirection); + }); + }); + + describe("on click", () => { + it("sets the correct default value for a field", () => { + const multiSort = createMultiSort(vi.fn()); + + simulate(multiSort.sort, "a"); + expect(multiSort.sortBy).toEqual(["a"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + + simulate(multiSort.sort, "b", "", "DESC"); + expect(multiSort.sortBy).toEqual(["b"]); + expect(multiSort.sortDirection.b).toBe("DESC"); + }); + + it("toggles a field value", () => { + const multiSort = createMultiSort(vi.fn()); + + simulate(multiSort.sort, "a"); + expect(multiSort.sortBy).toEqual(["a"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + + simulate(multiSort.sort, "a"); + expect(multiSort.sortBy).toEqual(["a"]); + expect(multiSort.sortDirection.a).toBe("DESC"); + + simulate(multiSort.sort, "b", "", "DESC"); + expect(multiSort.sortBy).toEqual(["b"]); + expect(multiSort.sortDirection.b).toBe("DESC"); + + simulate(multiSort.sort, "b", "", "DESC"); + expect(multiSort.sortBy).toEqual(["b"]); + expect(multiSort.sortDirection.b).toBe("ASC"); + }); + + it("resets sort-by fields", () => { + const multiSort = createMultiSort(vi.fn(), { + defaultSortBy: ["a", "b"] + }); + expect(multiSort.sortBy).toEqual(["a", "b"]); + + simulate(multiSort.sort, "a"); + expect(multiSort.sortBy).toEqual(["a"]); + }); + + it("resets sort-direction fields", () => { + const multiSort = createMultiSort(vi.fn(), { + defaultSortBy: ["a", "b"], + defaultSortDirection: { + a: "DESC", + b: "ASC" + } + }); + expect(multiSort.sortBy).toEqual(["a", "b"]); + expect(multiSort.sortDirection.a).toEqual("DESC"); + expect(multiSort.sortDirection.b).toEqual("ASC"); + + simulate(multiSort.sort, "a"); + expect(multiSort.sortBy).toEqual(["a"]); + expect(multiSort.sortDirection.a).toEqual("ASC"); + expect(multiSort.sortDirection.b).toEqual(undefined); + + simulate(multiSort.sort, "b"); + expect(multiSort.sortBy).toEqual(["b"]); + expect(multiSort.sortDirection.a).toEqual(undefined); + expect(multiSort.sortDirection.b).toEqual("ASC"); + }); + }); + + describe("on shift click", () => { + it("appends a field to the sort by list", () => { + const multiSort = createMultiSort(vi.fn()); + + simulate(multiSort.sort, "a"); + expect(multiSort.sortBy).toEqual(["a"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + + simulate(multiSort.sort, "b", "shift"); + expect(multiSort.sortBy).toEqual(["a", "b"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + expect(multiSort.sortDirection.b).toBe("ASC"); + }); + + it("toggles an appended field value", () => { + const multiSort = createMultiSort(vi.fn()); + + simulate(multiSort.sort, "a"); + expect(multiSort.sortBy).toEqual(["a"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + + simulate(multiSort.sort, "b", "shift"); + expect(multiSort.sortBy).toEqual(["a", "b"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + expect(multiSort.sortDirection.b).toBe("ASC"); + + simulate(multiSort.sort, "a", "shift"); + expect(multiSort.sortBy).toEqual(["a", "b"]); + expect(multiSort.sortDirection.a).toBe("DESC"); + expect(multiSort.sortDirection.b).toBe("ASC"); + + simulate(multiSort.sort, "a", "shift"); + expect(multiSort.sortBy).toEqual(["a", "b"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + expect(multiSort.sortDirection.b).toBe("ASC"); + }); + + it("able to shift+click more than once", () => { + const multiSort = createMultiSort(vi.fn()); + + simulate(multiSort.sort, "a"); + expect(multiSort.sortBy).toEqual(["a"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + + simulate(multiSort.sort, "b", "shift"); + expect(multiSort.sortBy).toEqual(["a", "b"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + expect(multiSort.sortDirection.b).toBe("ASC"); + + simulate(multiSort.sort, "b"); + expect(multiSort.sortBy).toEqual(["b"]); + expect(multiSort.sortDirection.b).toBe("DESC"); + + simulate(multiSort.sort, "a", "shift"); + expect(multiSort.sortBy).toEqual(["b", "a"]); + expect(multiSort.sortDirection.a).toBe("ASC"); + expect(multiSort.sortDirection.b).toBe("DESC"); + }); + }); + + ["control", "meta"].forEach((modifier) => { + describe(`${modifier} click`, () => { + it("removes a field from the sort by list", () => { + const multiSort = createMultiSort(vi.fn(), { + defaultSortBy: ["a", "b"] + }); + expect(multiSort.sortBy).toEqual(["a", "b"]); + + simulate(multiSort.sort, "a", modifier); + expect(multiSort.sortBy).toEqual(["b"]); + + simulate(multiSort.sort, "b", modifier); + expect(multiSort.sortBy).toEqual([]); + }); + + it("ignores fields not in the list on control click", () => { + const multiSort = createMultiSort(vi.fn(), { + defaultSortBy: ["a", "b"] + }); + expect(multiSort.sortBy).toEqual(["a", "b"]); + + simulate(multiSort.sort, "c", modifier); + expect(multiSort.sortBy).toEqual(["a", "b"]); + }); + }); + }); +}); diff --git a/lib/components/Table/createMultiSort.ts b/lib/components/Table/createMultiSort.ts new file mode 100644 index 000000000..b102ccf3c --- /dev/null +++ b/lib/components/Table/createMultiSort.ts @@ -0,0 +1,98 @@ +import type { + MultiSortCallback, + MultiSortOptions, + MultiSortParams, + MultiSortReturn, + SortDirectionMap +} from "./types"; + +/** + * Creates sort state (and a `sort` function compatible with the `Table` `sort` prop) + * for sorting a `Table` by multiple fields. + * + * - Click sorts by a single field (toggling its direction if it was already sorted). + * - SHIFT + click appends a field to the existing sort criteria (or toggles its direction). + * - CONTROL/META + click removes a field from the sort criteria. + * + * Whenever the sort criteria change, new `sortBy` and `sortDirection` values are created (previous values are never mutated), + * so they can be stored in React state (e.g. with `useState`) as passed to `sortCallback`. + * + * @param sortCallback Notified (with the updated `sortBy` and `sortDirection` values) whenever the sort criteria change. + * @param options Optional default sort-by values. + */ +export function createMultiSort( + sortCallback: MultiSortCallback, + { defaultSortBy, defaultSortDirection = {} }: MultiSortOptions = {} +): MultiSortReturn { + if (!sortCallback) { + throw Error(`Required parameter "sortCallback" not specified`); + } + + // The sort criteria are replaced (never mutated) when they change, + // so that they can be stored in React state (e.g. with useState) and compared by reference. + // (This also leaves the caller's defaultSortBy array unchanged.) + let sortBy: string[] = defaultSortBy ? [...defaultSortBy] : []; + let sortDirection: SortDirectionMap = {}; + sortBy.forEach((dataKey) => { + sortDirection[dataKey] = + defaultSortDirection[dataKey] !== undefined + ? defaultSortDirection[dataKey] + : "ASC"; + }); + + const toggle = (direction: SortDirectionMap[string]) => + direction === "ASC" ? "DESC" : "ASC"; + + function sort({ + defaultSortDirection, + event, + sortBy: dataKey + }: MultiSortParams): void { + if (event.shiftKey) { + // Shift + click appends a column to existing criteria (or toggles its direction) + if (sortDirection[dataKey] !== undefined) { + sortDirection = { + ...sortDirection, + [dataKey]: toggle(sortDirection[dataKey]) + }; + } else { + sortBy = [...sortBy, dataKey]; + sortDirection = { ...sortDirection, [dataKey]: defaultSortDirection }; + } + } else if (event.ctrlKey || event.metaKey) { + // Control + click removes column from sort (if present) + if (sortBy.includes(dataKey)) { + sortBy = sortBy.filter((key) => key !== dataKey); + sortDirection = { ...sortDirection }; + delete sortDirection[dataKey]; + } + } else { + // Sort by the selected key only. + // If it is already selected, reverse its sort direction; else, use the default direction. + sortDirection = { + [dataKey]: + sortDirection[dataKey] !== undefined + ? toggle(sortDirection[dataKey]) + : defaultSortDirection + }; + sortBy = [dataKey]; + } + + // Notify application code + sortCallback({ + sortBy, + sortDirection + }); + } + + return { + sort, + // (Getters, since the sort criteria are replaced when they change) + get sortBy() { + return sortBy; + }, + get sortDirection() { + return sortDirection; + } + }; +} diff --git a/source/Table/defaultCellDataGetter.js b/lib/components/Table/defaultCellDataGetter.ts similarity index 62% rename from source/Table/defaultCellDataGetter.js rename to lib/components/Table/defaultCellDataGetter.ts index e08a72ef0..ea5e36de7 100644 --- a/source/Table/defaultCellDataGetter.js +++ b/lib/components/Table/defaultCellDataGetter.ts @@ -1,16 +1,15 @@ -/** @flow */ -import type {CellDataGetterParams} from './types'; +import type { TableCellDataGetterParams } from "./types"; /** * Default accessor for returning a cell value for a given attribute. * This function expects to operate on either a vanilla Object or an Immutable Map. * You should override the column's cellDataGetter if your data is some other type of object. */ -export default function defaultCellDataGetter({ +export function defaultCellDataGetter({ dataKey, - rowData, -}: CellDataGetterParams) { - if (typeof rowData.get === 'function') { + rowData +}: Pick) { + if (typeof rowData.get === "function") { return rowData.get(dataKey); } else { return rowData[dataKey]; diff --git a/source/Table/defaultCellRenderer.js b/lib/components/Table/defaultCellRenderer.ts similarity index 59% rename from source/Table/defaultCellRenderer.js rename to lib/components/Table/defaultCellRenderer.ts index 626794752..a67e973b9 100644 --- a/source/Table/defaultCellRenderer.js +++ b/lib/components/Table/defaultCellRenderer.ts @@ -1,15 +1,14 @@ -/** @flow */ -import type {CellRendererParams} from './types'; +import type { TableCellProps } from "./types"; /** * Default cell renderer that displays an attribute as a simple string * You should override the column's cellRenderer if your data is some other type of object. */ -export default function defaultCellRenderer({ - cellData, -}: CellRendererParams): string { +export function defaultCellRenderer({ + cellData +}: Pick): string { if (cellData == null) { - return ''; + return ""; } else { return String(cellData); } diff --git a/lib/components/Table/defaultColumnProps.ts b/lib/components/Table/defaultColumnProps.ts new file mode 100644 index 000000000..6e62f132c --- /dev/null +++ b/lib/components/Table/defaultColumnProps.ts @@ -0,0 +1,35 @@ +import { defaultCellDataGetter } from "./defaultCellDataGetter"; +import { defaultCellRenderer } from "./defaultCellRenderer"; +import { defaultHeaderRenderer } from "./defaultHeaderRenderer"; +import { SortDirection } from "./SortDirection"; +import type { ColumnProps } from "./types"; + +type DefaultedPropKeys = + | "cellDataGetter" + | "cellRenderer" + | "defaultSortDirection" + | "flexGrow" + | "flexShrink" + | "headerRenderer" + | "style"; + +/** Column props, with the props that have default values marked as required. */ +export type ColumnPropsWithDefaults = ColumnProps & + Required>; + +/** + * Default values for `Column`'s optional props. + * `Table` applies these to the props of each of its child elements (so wrapper components work as columns too). + * (The PURE annotations let bundlers drop this object if it isn't used.) + */ +export const defaultColumnProps: Readonly< + Required> +> = /* @__PURE__ */ Object.freeze({ + cellDataGetter: defaultCellDataGetter, + cellRenderer: defaultCellRenderer, + defaultSortDirection: SortDirection.ASC, + flexGrow: 0, + flexShrink: 1, + headerRenderer: defaultHeaderRenderer, + style: /* @__PURE__ */ Object.freeze({}) +}); diff --git a/lib/components/Table/defaultHeaderRenderer.tsx b/lib/components/Table/defaultHeaderRenderer.tsx new file mode 100644 index 000000000..e810fb467 --- /dev/null +++ b/lib/components/Table/defaultHeaderRenderer.tsx @@ -0,0 +1,36 @@ +import type { ReactElement } from "react"; +import { SortIndicator } from "./SortIndicator"; +import type { TableHeaderProps } from "./types"; + +/** + * Default table header renderer. + */ +export function defaultHeaderRenderer({ + dataKey, + label, + sortBy, + sortDirection +}: Pick< + TableHeaderProps, + "dataKey" | "label" | "sortBy" | "sortDirection" +>): ReactElement[] { + const showSortIndicator = sortBy === dataKey; + const children = [ + + {label} + + ]; + + if (showSortIndicator) { + children.push( + + ); + } + + return children; +} diff --git a/lib/components/Table/defaultHeaderRowRenderer.tsx b/lib/components/Table/defaultHeaderRowRenderer.tsx new file mode 100644 index 000000000..d7651c2de --- /dev/null +++ b/lib/components/Table/defaultHeaderRowRenderer.tsx @@ -0,0 +1,16 @@ +import type { TableHeaderRowProps } from "./types"; + +/** + * Default table header row renderer. + */ +export function defaultHeaderRowRenderer({ + className, + columns, + style +}: TableHeaderRowProps) { + return ( +
+ {columns} +
+ ); +} diff --git a/lib/components/Table/defaultRowRenderer.tsx b/lib/components/Table/defaultRowRenderer.tsx new file mode 100644 index 000000000..234cbd7c3 --- /dev/null +++ b/lib/components/Table/defaultRowRenderer.tsx @@ -0,0 +1,66 @@ +import type { HTMLAttributes } from "react"; +import type { TableRowProps } from "./types"; + +/** + * Default row renderer for Table. + */ +export function defaultRowRenderer({ + className, + columns, + index, + key, + onRowClick, + onRowDoubleClick, + onRowMouseOut, + onRowMouseOver, + onRowRightClick, + rowData, + style +}: TableRowProps) { + const a11yProps: HTMLAttributes = { + "aria-rowindex": index + 1 + }; + + if ( + onRowClick || + onRowDoubleClick || + onRowMouseOut || + onRowMouseOver || + onRowRightClick + ) { + a11yProps["aria-label"] = "row"; + a11yProps.tabIndex = 0; + + if (onRowClick) { + a11yProps.onClick = (event) => onRowClick({ event, index, rowData }); + } + if (onRowDoubleClick) { + a11yProps.onDoubleClick = (event) => + onRowDoubleClick({ event, index, rowData }); + } + if (onRowMouseOut) { + a11yProps.onMouseOut = (event) => + onRowMouseOut({ event, index, rowData }); + } + if (onRowMouseOver) { + a11yProps.onMouseOver = (event) => + onRowMouseOver({ event, index, rowData }); + } + if (onRowRightClick) { + a11yProps.onContextMenu = (event) => + onRowRightClick({ event, index, rowData }); + } + } + + return ( +
+ {columns} +
+ ); +} diff --git a/lib/components/Table/defaultTableProps.ts b/lib/components/Table/defaultTableProps.ts new file mode 100644 index 000000000..933bcbeea --- /dev/null +++ b/lib/components/Table/defaultTableProps.ts @@ -0,0 +1,41 @@ +import { accessibilityOverscanIndicesGetter } from "../Grid/accessibilityOverscanIndicesGetter"; +import { defaultHeaderRowRenderer } from "./defaultHeaderRowRenderer"; +import { defaultRowRenderer } from "./defaultRowRenderer"; +import type { TableProps } from "./types"; + +type DefaultedPropKeys = + | "disableHeader" + | "estimatedRowSize" + | "headerHeight" + | "headerRowRenderer" + | "headerStyle" + | "noRowsRenderer" + | "onRowsRendered" + | "onScroll" + | "overscanIndicesGetter" + | "overscanRowCount" + | "rowRenderer" + | "rowStyle" + | "style"; + +/** + * Default values for `Table`'s optional props. + * (The PURE annotations let bundlers drop this object if it isn't used.) + */ +export const defaultTableProps: Readonly< + Required> +> = /* @__PURE__ */ Object.freeze({ + disableHeader: false, + estimatedRowSize: 30, + headerHeight: 0, + headerRowRenderer: defaultHeaderRowRenderer, + headerStyle: /* @__PURE__ */ Object.freeze({}), + noRowsRenderer: () => null, + onRowsRendered: () => null, + onScroll: () => null, + overscanIndicesGetter: accessibilityOverscanIndicesGetter, + overscanRowCount: 10, + rowRenderer: defaultRowRenderer, + rowStyle: /* @__PURE__ */ Object.freeze({}), + style: /* @__PURE__ */ Object.freeze({}) +}); diff --git a/lib/components/Table/types.ts b/lib/components/Table/types.ts new file mode 100644 index 000000000..2a82c293b --- /dev/null +++ b/lib/components/Table/types.ts @@ -0,0 +1,771 @@ +import type { + CSSProperties, + KeyboardEvent, + MouseEvent, + ReactNode +} from "react"; +import type { + CellMeasureCache, + CellMeasurerParent +} from "../CellMeasurer/types"; +import type { + Alignment, + CellPosition, + CellSize, + GridProps, + OverscanIndicesGetter, + ScrollPosition +} from "../Grid/types"; +import type { ScrollElement } from "../../utils/scrollElement/types"; + +/** + * Specifies which direction `Table` data is sorted in: + * - `"ASC"`: ascending order; from the lowest value to the highest (e.g. a-z, 0-9). + * - `"DESC"`: descending order; from the highest value to the lowest (e.g. z-a, 9-0). + */ +export type SortDirectionType = "ASC" | "DESC"; + +/** Parameters passed to a `Column` `cellDataGetter`. */ +export type TableCellDataGetterParams = { + /** Additional data passed to this column (the `Column` `columnData` prop). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columnData?: any; + + /** Uniquely identifies the row-data attribute corresponding to this cell. */ + dataKey: string; + + /** Data for the current row (as returned by the `Table` `rowGetter`). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rowData: any; +}; + +/** Returns a cell's data, given its `dataKey` and row data. */ +export type TableCellDataGetter = ( + params: TableCellDataGetterParams + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => any; + +/** Parameters passed to a `Column` `cellRenderer`. */ +export type TableCellProps = { + /** Cell data (as returned by the column's `cellDataGetter`). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cellData: any; + + /** Additional data passed to this column (the `Column` `columnData` prop). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columnData?: any; + + /** Index of the column being rendered. */ + columnIndex: number; + + /** Uniquely identifies the row-data attribute corresponding to this cell. */ + dataKey: string; + + /** The `Table` is currently being scrolled. */ + isScrolling: boolean; + + /** + * Reference to the `Table` (its imperative handle), which delegates the `CellMeasurer` methods to the inner `Grid`. + * This value can be passed as-is to `CellMeasurer`. + */ + parent: CellMeasurerParent; + + /** Data for the current row (as returned by the `Table` `rowGetter`). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rowData: any; + + /** Index of the row being rendered. */ + rowIndex: number; +}; + +/** Renders a cell's contents. */ +export type TableCellRenderer = (params: TableCellProps) => ReactNode; + +/** Parameters passed to a `Column` `headerRenderer`. */ +export type TableHeaderProps = { + /** Additional data passed to this column (the `Column` `columnData` prop). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columnData?: any; + + /** Uniquely identifies the row-data attribute corresponding to this column. */ + dataKey: string; + + /** Sorting has been disabled for this column. */ + disableSort?: boolean; + + /** Header label for this column. */ + label?: ReactNode; + + /** Table data is currently sorted by this `dataKey` (if it is sorted at all). */ + sortBy?: string; + + /** Table data is currently sorted in this direction (if it is sorted at all). */ + sortDirection?: SortDirectionType; +}; + +/** Renders a column header's contents. */ +export type TableHeaderRenderer = (params: TableHeaderProps) => ReactNode; + +/** Parameters passed to a `Table` `headerRowRenderer`. */ +export type TableHeaderRowProps = { + /** Header row class name. */ + className: string; + + /** Array of rendered column headers. */ + columns: ReactNode[]; + + /** Header row style object. */ + style: CSSProperties; +}; + +/** Renders the header row of a `Table`. */ +export type TableHeaderRowRenderer = (params: TableHeaderRowProps) => ReactNode; + +/** Parameters passed to `Table` row mouse event callbacks (e.g. `onRowClick`). */ +export type TableRowMouseEventHandlerParams = { + /** The DOM event. */ + event: MouseEvent; + + /** Index of the row. */ + index: number; + + /** Data for the row (as returned by the `Table` `rowGetter`). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rowData: any; +}; + +/** Callback invoked in response to a mouse event on a `Table` row. */ +export type TableRowMouseEventHandler = ( + params: TableRowMouseEventHandlerParams +) => void; + +/** Parameters passed to a `Table` `rowRenderer`. */ +export type TableRowProps = { + /** Row-level class name. */ + className: string; + + /** Array of rendered cells (one per column). */ + columns: ReactNode[]; + + /** Row index. */ + index: number; + + /** The `Table` is currently being scrolled. */ + isScrolling: boolean; + + /** Unique key within array of rendered rows. */ + key: string; + + /** Optional row `onClick` handler. */ + onRowClick?: TableRowMouseEventHandler; + + /** Optional row `onDoubleClick` handler. */ + onRowDoubleClick?: TableRowMouseEventHandler; + + /** Optional row `onMouseOut` handler. */ + onRowMouseOut?: TableRowMouseEventHandler; + + /** Optional row `onMouseOver` handler. */ + onRowMouseOver?: TableRowMouseEventHandler; + + /** Optional row `onContextMenu` (right-click) handler. */ + onRowRightClick?: TableRowMouseEventHandler; + + /** Row data (as returned by the `Table` `rowGetter`). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rowData: any; + + /** Row-level style object; this must be applied to the rendered row element. */ + style: CSSProperties; +}; + +/** Renders a `Table` row, given its rendered columns. */ +export type TableRowRenderer = (params: TableRowProps) => ReactNode; + +/** Parameters passed to the `Table` `onColumnClick` and `onHeaderClick` callbacks. */ +export type TableColumnEventHandlerParams = { + /** Additional data passed to this column (the `Column` `columnData` prop). */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columnData: any; + + /** Uniquely identifies the row-data attribute corresponding to this column. */ + dataKey: string; + + /** The DOM event (a keyboard event if a header was activated via the ENTER or SPACE key). */ + event: MouseEvent | KeyboardEvent; +}; + +/** Parameters passed to the `Table` `sort` callback. */ +export type TableSortParams = { + /** Default sort direction of the clicked column. */ + defaultSortDirection: SortDirectionType; + + /** The DOM event (a keyboard event if the header was activated via the ENTER or SPACE key). */ + event: MouseEvent | KeyboardEvent; + + /** `dataKey` of the clicked column. */ + sortBy: string; + + /** + * New sort direction: the column's `defaultSortDirection` if the column is not currently sorted, + * else the opposite of the current `sortDirection`. + */ + sortDirection: SortDirectionType; +}; + +/** Parameters passed to the `Table` `onRowsRendered` callback. */ +export type TableRowsRenderedParams = { + /** Index of the first rendered row (including overscanned rows). */ + overscanStartIndex: number; + + /** Index of the last rendered row (including overscanned rows). */ + overscanStopIndex: number; + + /** Index of the first visible row. */ + startIndex: number; + + /** Index of the last visible row. */ + stopIndex: number; +}; + +/** Parameters passed to the `Table` `onScroll` callback. */ +export type TableScrollParams = { + clientHeight: number; + scrollHeight: number; + scrollTop: number; +}; + +type TableOwnProps = { + /** ARIA label set on the outer `Table` element. */ + "aria-label"?: string; + + /** ARIA labelledby attribute set on the outer `Table` element. */ + "aria-labelledby"?: string; + + /** + * One or more `Column`s describing the data displayed in this table. + * Falsy children are ignored, to allow easier dynamic showing/hiding of columns. + * + * `Table` reads the props of its children (with `defaultColumnProps` applied) without rendering them, + * so components that wrap `Column` (e.g. `const MyColumn = (props: ColumnProps) => `) can be used as children too. + * (Subclassing `Column` is no longer supported.) + */ + children?: ReactNode; + + /** Optional custom CSS class name to attach to root `Table` element. */ + className?: string; + + /** + * If `CellMeasurer` is used to measure this Table's cells, this should be a pointer to its `CellMeasurerCache`. + * It is passed through to the inner `Grid`. + */ + deferredMeasurementCache?: CellMeasureCache; + + /** + * Do not render the table header (only the rows). + * @default false + */ + disableHeader?: boolean; + + /** + * Used to estimate the total height of a `Table` before all of its rows have actually been measured. + * The estimated total height is adjusted as rows are rendered. + * @default 30 + */ + estimatedRowSize?: number; + + /** Optional custom CSS class name to attach to inner `Grid` element. */ + gridClassName?: string; + + /** Optional inline style to attach to inner `Grid` element. */ + gridStyle?: CSSProperties; + + /** Optional CSS class to apply to all column headers. */ + headerClassName?: string; + + /** + * Fixed height of header row. + * @default 0 + */ + headerHeight?: number; + + /** + * Responsible for rendering the table header row given an array of columns: + * `({ className: string, columns: ReactNode[], style: CSSProperties }): ReactNode`. + * + * This is an advanced property. + * It is useful for situations where you require additional hooks into `Table` to render additional custom UI elements. + * You may want to start by forking the `defaultTableHeaderRowRenderer` function (the default). + */ + headerRowRenderer?: TableHeaderRowRenderer; + + /** + * Optional custom inline style to attach to table header columns. + * @default {} + */ + headerStyle?: CSSProperties; + + /** + * Fixed/available height for the outer DOM element. + * Required, unless a `scrollElement` is specified. + * + * With a `scrollElement`, the height of the scroll element's viewport is used instead; + * this prop is then only used until the scroll element has been measured (e.g. during server rendering). + */ + height?: number; + + /** Optional custom id to attach to root `Table` element. */ + id?: string; + + /** + * Vertical scroll offset to initially render (and scroll to) the `Table` at. + * Only the initial value is used: changing this prop later has no effect (use `scrollToPosition` to scroll). + * This also works with server rendering: the rows at this offset are rendered on the server, + * and they stay in place when the page is hydrated. + * Ignored if a `scrollElement` is specified (which then determines the vertical offset). + */ + initialScrollTop?: number; + + /** Optional renderer to be used in place of table body rows when `rowCount` is 0. */ + noRowsRenderer?: () => ReactNode; + + /** + * Callback invoked when a user clicks on a table column (cell): + * `({ columnData: any, dataKey: string, event: Event }): void`. + */ + onColumnClick?: (params: TableColumnEventHandlerParams) => void; + + /** + * Callback invoked when a user clicks on (or activates via the ENTER or SPACE key) a table header: + * `({ columnData: any, dataKey: string, event: Event }): void`. + */ + onHeaderClick?: (params: TableColumnEventHandlerParams) => void; + + /** + * Callback invoked when a user clicks on a table row: + * `({ event: Event, index: number, rowData: any }): void`. + */ + onRowClick?: TableRowMouseEventHandler; + + /** + * Callback invoked when a user double-clicks on a table row: + * `({ event: Event, index: number, rowData: any }): void`. + */ + onRowDoubleClick?: TableRowMouseEventHandler; + + /** + * Callback invoked when the mouse leaves a table row: + * `({ event: Event, index: number, rowData: any }): void`. + */ + onRowMouseOut?: TableRowMouseEventHandler; + + /** + * Callback invoked when a user moves the mouse over a table row: + * `({ event: Event, index: number, rowData: any }): void`. + */ + onRowMouseOver?: TableRowMouseEventHandler; + + /** + * Callback invoked when a user right-clicks on a table row: + * `({ event: Event, index: number, rowData: any }): void`. + */ + onRowRightClick?: TableRowMouseEventHandler; + + /** + * Callback invoked with information about the slice of rows that were just rendered: + * `({ overscanStartIndex: number, overscanStopIndex: number, startIndex: number, stopIndex: number }): void`. + */ + onRowsRendered?: (params: TableRowsRenderedParams) => void; + + /** + * Callback invoked whenever the scroll offset changes within the inner scrollable region: + * `({ clientHeight: number, scrollHeight: number, scrollTop: number }): void`. + * This callback can be used to sync scrolling between lists, tables, or grids. + */ + onScroll?: (params: TableScrollParams) => void; + + /** + * Responsible for calculating the number of rows to overscan before and after a specified range (see `Grid` `overscanIndicesGetter`). + * By default (`accessibilityOverscanIndicesGetter`), `Table` overscans by at least one row in each direction (for accessibility). + */ + overscanIndicesGetter?: OverscanIndicesGetter; + + /** + * Number of rows to render above/below the visible bounds of the list. + * This can help reduce flickering during scrolling on certain browsers/devices. + * @default 10 + */ + overscanRowCount?: number; + + /** + * Optional CSS class to apply to all table rows (including the header row). + * This value may be either a static string or a function with the signature `({ index: number }): string`. + * Note that for the header row an index of `-1` is provided. + */ + rowClassName?: string | ((params: { index: number }) => string); + + /** Number of rows in table. */ + rowCount: number; + + /** + * Callback responsible for returning a data row given an index: + * `({ index: number }): any`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rowGetter: (params: { index: number }) => any; + + /** + * Either a fixed row height (number) or a function that returns the height of a row given its index: + * `({ index: number }): number`. + */ + rowHeight: CellSize; + + /** + * Responsible for rendering a table row given an array of columns. + * + * This is an advanced property. + * It is useful for situations where you require additional hooks into `Table` (eg integration with a library like `react-sortable-hoc`). + * The easiest way to override it is to decorate (or fork) the `defaultTableRowRenderer` function (the default). + */ + rowRenderer?: TableRowRenderer; + + /** + * Optional custom inline style to attach to table rows (including the header row). + * This value may be either a style object or a function with the signature `({ index: number }): CSSProperties`. + * Note that for the header row an index of `-1` is provided. + * @default {} + */ + rowStyle?: CSSProperties | ((params: { index: number }) => CSSProperties); + + /** + * Sort function to be called if a sortable header is clicked: + * `({ defaultSortDirection: SortDirection, event: Event, sortBy: string, sortDirection: SortDirection }): void`. + */ + sort?: (params: TableSortParams) => void; + + /** Data is currently sorted by this `dataKey` (if it is sorted at all). */ + sortBy?: string; + + /** Data is currently sorted in this direction (if it is sorted at all). */ + sortDirection?: SortDirectionType; + + /** + * Element that scrolls the `Table` (instead of the `Table` scrolling itself): `"window"`, or a scrollable DOM element + * (e.g. a container that the `Table` is rendered within, along with other content). + * + * The `Table` then renders all of its rows at their full height (below its header, which scrolls along with them), renders only the rows visible within the scroll element's viewport, + * and its scroll methods scroll the scroll element. + * Its position within the scroll element is measured when the scroll element scrolls or is resized, and when the `Table` re-renders + * (e.g. call `forceUpdateGrid()` if content above the `Table` changes size without the `Table` re-rendering). + * Pass `null` while the scroll element isn't available yet (e.g. before a ref to it has been attached); + * the `Table` then renders the rows at its top. + */ + scrollElement?: ScrollElement | null; + + /** + * Optional custom inline style to attach to root `Table` element. + * @default {} + */ + style?: CSSProperties; + + /** + * Optional override of inner `Grid` tab index default. + * @default 0 + */ + tabIndex?: number | null; + + /** Width of the table. */ + width: number; +}; + +// Grid props that Table sets on its inner Grid itself, or that don't apply to its single column of rows +type OverriddenGridPropKeys = + | "aria-readonly" + | "autoContainerWidth" + | "cellRenderer" + | "columnCount" + | "columnWidth" + | "elementRef" + | "estimatedColumnSize" + | "initialScrollLeft" + | "noContentRenderer" + | "onSectionRendered" + | "overscanColumnCount" + | "role"; + +/** + * `Table` props. + * All other props are relayed to the inner `Grid` (see `GridProps`). + */ +export type TableProps = Omit< + GridProps, + OverriddenGridPropKeys | keyof TableOwnProps +> & + TableOwnProps; + +/** Public (imperative) methods of a `Table` instance. */ +export interface TableImperativeAPI { + /** + * Forcefully re-render the inner `Grid` component. + * + * Calling `forceUpdate` on `Table` may not re-render the inner `Grid` since it uses `shallowCompare` as a performance optimization. + * Use this method if you want to manually trigger a re-render. + * This may be appropriate if the underlying row data has changed but the row sizes themselves have not. + */ + forceUpdateGrid(): void; + + /** + * Gets offset for a given row and alignment (`align`; `alignment` is a deprecated alias). + * The alignment defaults to "auto"; an index of `-1` (the default) refers to the last row. + */ + getOffsetForRow(params?: { + align?: Alignment; + /** @deprecated Use `align` instead. */ + alignment?: Alignment; + index?: number; + }): number; + + /** + * Gets the scrollbar width used to pad the table-header. + */ + getScrollbarWidth(): number; + + /** + * Invalidate the size of a row (after the current render has been committed); see `Grid#invalidateCellSizeAfterRender`. + * This method is intended for `CellMeasurer`. + */ + invalidateCellSizeAfterRender(params: CellPosition): void; + + /** + * Pre-measure all rows in a `Table`. + * + * Typically rows are only measured as needed and estimated heights are used for cells that have not yet been measured. + * This method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one). + */ + measureAllRows(): void; + + /** + * Recompute row heights and offsets after the specified index (defaults to 0). + * + * `Table` has no way of knowing when its underlying list data has changed since it only receives a `rowHeight` property. + * If the `rowHeight` is a number it can compare before and after values but if it is a function that comparison is error prone. + * In the event that a dynamic `rowHeight` function is in use and the row heights have changed this function should be manually called by the "smart" container parent. + * + * This method will also force a render cycle (via `forceUpdate`) to ensure that the updated measurements are reflected in the rendered table. + */ + recomputeRowHeights(index?: number): void; + + /** + * Recompute row heights and offsets after the specified row index (defaults to 0); see `Grid#recomputeGridSize`. + * (The column index is ignored.) This method is intended for `CellMeasurer`; also see `recomputeRowHeights`. + */ + recomputeGridSize(params?: Partial): void; + + /** + * Ensure the specified row is visible (scrolling if necessary); the column index is ignored. + * This allows a `Table` to be used with `ArrowKeyStepper`; also see `scrollToRow`. + */ + scrollToCell(params: { + align?: Alignment; + columnIndex?: number; + rowIndex?: number; + }): void; + + /** + * Scroll to the specified vertical offset; `scrollLeft` is ignored (tables don't scroll horizontally). + * Offsets are clamped to the scrollable range. This allows a `Table` to be used with `ScrollSync`. + * Useful for animating position changes. + * + * If the `Table` has a `scrollElement`, the scroll element (e.g. the window) is scrolled instead. + */ + scrollToPosition(params: ScrollPosition): void; + + /** + * Ensure the row at the specified index is visible (scrolling if necessary). + * This method can be used to safely scroll back to a row that a user has scrolled away from even if it was previously scrolled to. + * + * The `align` parameter (default "auto") controls the alignment of the row: + * "auto" scrolls the least amount possible to ensure that the specified row is fully visible + * (a row taller than the table is aligned to its start, so that its start is visible); + * "start" aligns the row to the top of the table and "end" to the bottom; "center" aligns it in the middle. + * This is a one-time scroll: the row is not kept in view afterwards (e.g. if the table's size or its row heights change). + * Rows that haven't been measured yet (e.g. by `CellMeasurer`) are positioned using their estimated heights. + * + * If the `Table` has a `scrollElement`, the scroll element (e.g. the window) is scrolled instead. + */ + scrollToRow(params: { align?: Alignment; index: number }): void; +} + +/** + * Props of a `Column`. + * `Table` reads these from the props of each of its child elements (applying `defaultColumnProps`), + * so a component that renders a `Column` and accepts these props can be used as a column as well. + */ +export type ColumnProps = { + /** Optional aria-label value to set on the column header. */ + "aria-label"?: string; + + /** + * Callback responsible for returning a cell's data, given its `dataKey`: + * `({ columnData: any, dataKey: string, rowData: any }): any`. + * + * The default `cellDataGetter` expects to operate on either a vanilla Object or a Map-like object with a `get` method. + * You should override it if your data is calculated or requires any custom processing. + * Defaults to `defaultTableCellDataGetter`. + */ + cellDataGetter?: TableCellDataGetter; + + /** + * Callback responsible for rendering a cell's contents: + * `({ cellData: any, columnData: any, columnIndex: number, dataKey: string, isScrolling: boolean, parent: TableImperativeAPI, rowData: any, rowIndex: number }): ReactNode`. + * + * The default `cellRenderer` (`defaultTableCellRenderer`) displays an attribute as a simple string. + * You should override it if your data is some other type of object or requires custom formatting. + */ + cellRenderer?: TableCellRenderer; + + /** Optional CSS class to apply to the rendered cell container. */ + className?: string; + + /** + * Additional data passed to this column's `cellDataGetter`, `cellRenderer`, and `headerRenderer`. + * Use this object to relay action-creators or relational data. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + columnData?: any; + + /** + * Uniquely identifies the row-data attribute corresponding to this cell + * (eg this might be "name" in an array of user objects). + */ + dataKey: string; + + /** + * Default sort order when clicked for the first time. + * @default "ASC" + */ + defaultSortDirection?: SortDirectionType; + + /** If sort is enabled for the table at large, disable it for this column. */ + disableSort?: boolean; + + /** + * Flex grow style. + * @default 0 + */ + flexGrow?: number; + + /** + * Flex shrink style. + * @default 1 + */ + flexShrink?: number; + + /** Optional CSS class to apply to this column's header. */ + headerClassName?: string; + + /** + * Optional callback responsible for rendering a column's header contents: + * `({ columnData: any, dataKey: string, disableSort: boolean, label: ReactNode, sortBy: string, sortDirection: SortDirection }): ReactNode`. + * + * The default `headerRenderer` (`defaultTableHeaderRenderer`) displays the column `label` along with a sort indicator if the column is sort-enabled and active. + */ + headerRenderer?: TableHeaderRenderer; + + /** Optional inline style to apply to this column's header. */ + headerStyle?: CSSProperties; + + /** Optional id to set on the column header; used for the cells' `aria-describedby`. */ + id?: string; + + /** Header label for this column. */ + label?: ReactNode; + + /** Maximum width of column; this property will only be used if `flexGrow` is greater than 0. */ + maxWidth?: number; + + /** Minimum width of column. */ + minWidth?: number; + + /** + * Optional inline style to apply to the rendered cell container. + * @default {} + */ + style?: CSSProperties; + + /** + * Flex basis (width) for this column; + * This value can grow or shrink based on `flexGrow` and `flexShrink` properties. + */ + width: number; +}; + +export type SortIndicatorProps = { + /** Direction the column is currently sorted in. */ + sortDirection?: SortDirectionType; +}; + +/** Maps a `dataKey` to the direction it is sorted in. */ +export type SortDirectionMap = { [dataKey: string]: SortDirectionType }; + +/** Parameters passed to the `sort` function returned by `createMultiSort` (compatible with the `Table` `sort` prop). */ +export type MultiSortParams = { + /** Sort direction to use if the column is not already sorted. */ + defaultSortDirection: SortDirectionType; + + /** + * The event that triggered the sort. + * SHIFT + click appends a column to the existing sort criteria (or toggles its direction); + * CONTROL/META + click removes a column from the sort criteria. + */ + event: { ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }; + + /** `dataKey` of the column that was clicked. */ + sortBy: string; +}; + +/** Parameters passed to the `createMultiSort` `sortCallback`. */ +export type MultiSortCallbackParams = { + /** Ordered array of fields to sort by, in order of importance. */ + sortBy: string[]; + + /** Map of field name to "ASC" or "DESC" direction. */ + sortDirection: SortDirectionMap; +}; + +/** Callback notified (by `createMultiSort`) whenever the sort criteria change. */ +export type MultiSortCallback = (params: MultiSortCallbackParams) => void; + +/** Options accepted by `createMultiSort`. */ +export type MultiSortOptions = { + /** + * Fields to initially sort by, in order of importance. + * (The array is copied; it isn't mutated.) + */ + defaultSortBy?: string[] | null; + + /** Initial sort direction of each `defaultSortBy` field; fields not specified default to "ASC". */ + defaultSortDirection?: SortDirectionMap; +}; + +/** Value returned by `createMultiSort`. */ +export type MultiSortReturn = { + /** + * Sort property to be passed to the `Table` component. + * This function replaces the `sortBy` and `sortDirection` values (and passes the new values to the sort callback). + */ + sort: (params: MultiSortParams) => void; + + /** + * Specifies the fields currently responsible for sorting data, + * In order of importance. + * A new array is created whenever the sort criteria change (it isn't mutated), so read it from this object when needed. + */ + readonly sortBy: string[]; + + /** + * Specifies the direction a specific field is being sorted in. + * A new object is created whenever the sort criteria change (it isn't mutated), so read it from this object when needed. + */ + readonly sortDirection: SortDirectionMap; +}; diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 000000000..6c0e533de --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,110 @@ +export { ArrowKeyStepper } from "./components/ArrowKeyStepper/ArrowKeyStepper"; +export { defaultArrowKeyStepperProps } from "./components/ArrowKeyStepper/defaultArrowKeyStepperProps"; +export { + type ArrowKeyStepperChild, + type ArrowKeyStepperChildProps, + type ArrowKeyStepperImperativeAPI, + type ArrowKeyStepperProps, + type ScrollIndices +} from "./components/ArrowKeyStepper/types"; +export { CellMeasurer } from "./components/CellMeasurer/CellMeasurer"; +export { CellMeasurerCache } from "./components/CellMeasurer/CellMeasurerCache"; +export { + type CellMeasurerCacheAPI, + type CellMeasurerCacheParams, + type CellMeasurerChildFunction, + type CellMeasurerChildParams, + type CellMeasurerProps +} from "./components/CellMeasurer/types"; +export { ColumnSizer } from "./components/ColumnSizer/ColumnSizer"; +export { type ColumnSizerProps } from "./components/ColumnSizer/types"; +export { accessibilityOverscanIndicesGetter } from "./components/Grid/accessibilityOverscanIndicesGetter"; +export { defaultCellRangeRenderer } from "./components/Grid/defaultCellRangeRenderer"; +export { defaultGridProps } from "./components/Grid/defaultGridProps"; +export { defaultOverscanIndicesGetter } from "./components/Grid/defaultOverscanIndicesGetter"; +export { Grid } from "./components/Grid/Grid"; +export { + type Alignment, + type CellPosition, + type CellRangeRenderer, + type CellRangeRendererParams, + type CellRenderer, + type CellRendererParams, + type CellSize, + type GridImperativeAPI, + type GridProps, + type NoContentRenderer, + type OverscanIndicesGetter, + type RenderedSection, + type Scroll, + type ScrollbarPresenceChange, + type ScrollPosition +} from "./components/Grid/types"; +export { defaultInfiniteLoaderProps } from "./components/InfiniteLoader/defaultInfiniteLoaderProps"; +export { InfiniteLoader } from "./components/InfiniteLoader/InfiniteLoader"; +export { + type InfiniteLoaderChildInstance, + type InfiniteLoaderImperativeAPI, + type InfiniteLoaderProps +} from "./components/InfiniteLoader/types"; +export { defaultListProps } from "./components/List/defaultListProps"; +export { List } from "./components/List/List"; +export { + type ListImperativeAPI, + type ListProps, + type RowRendererParams +} from "./components/List/types"; +export { defaultMasonryProps } from "./components/Masonry/defaultMasonryProps"; +export { createCellPositioner as createMasonryCellPositioner } from "./components/Masonry/createCellPositioner"; +export { Masonry } from "./components/Masonry/Masonry"; +export { + type MasonryImperativeAPI, + type MasonryProps +} from "./components/Masonry/types"; +export { defaultMultiGridProps } from "./components/MultiGrid/defaultMultiGridProps"; +export { MultiGrid } from "./components/MultiGrid/MultiGrid"; +export { + type MultiGridImperativeAPI, + type MultiGridProps +} from "./components/MultiGrid/types"; +export { ScrollSync } from "./components/ScrollSync/ScrollSync"; +export { + type ScrollSyncChild, + type ScrollSyncChildProps, + type ScrollSyncProps, + type ScrollSyncScrollParams +} from "./components/ScrollSync/types"; +export { Column } from "./components/Table/Column"; +export { createMultiSort as createTableMultiSort } from "./components/Table/createMultiSort"; +export { defaultCellDataGetter as defaultTableCellDataGetter } from "./components/Table/defaultCellDataGetter"; +export { defaultCellRenderer as defaultTableCellRenderer } from "./components/Table/defaultCellRenderer"; +export { defaultColumnProps } from "./components/Table/defaultColumnProps"; +export { defaultHeaderRenderer as defaultTableHeaderRenderer } from "./components/Table/defaultHeaderRenderer"; +export { defaultHeaderRowRenderer as defaultTableHeaderRowRenderer } from "./components/Table/defaultHeaderRowRenderer"; +export { defaultRowRenderer as defaultTableRowRenderer } from "./components/Table/defaultRowRenderer"; +export { defaultTableProps } from "./components/Table/defaultTableProps"; +export { SortDirection } from "./components/Table/SortDirection"; +export { SortIndicator } from "./components/Table/SortIndicator"; +export { Table } from "./components/Table/Table"; +export { + type ColumnProps, + type MultiSortOptions, + type MultiSortParams, + type MultiSortReturn, + type SortDirectionMap, + type SortDirectionType, + type TableCellDataGetter, + type TableCellDataGetterParams, + type TableCellProps, + type TableCellRenderer, + type TableHeaderProps, + type TableHeaderRenderer, + type TableHeaderRowProps, + type TableHeaderRowRenderer, + type TableImperativeAPI, + type TableProps, + type TableRowProps, + type TableRowRenderer, + type TableSortParams +} from "./components/Table/types"; +export { type ScrollElement } from "./utils/scrollElement/types"; diff --git a/source/styles.css b/lib/styles.css similarity index 91% rename from source/styles.css rename to lib/styles.css index 0ef05e4e4..1b2c192b7 100644 --- a/source/styles.css +++ b/lib/styles.css @@ -1,11 +1,3 @@ -/* Collection default theme */ - -.ReactVirtualized__Collection { -} - -.ReactVirtualized__Collection__innerScrollContainer { -} - /* Grid default theme */ .ReactVirtualized__Grid { diff --git a/source/utils/TestHelper.js b/lib/utils/TestHelper.ts similarity index 74% rename from source/utils/TestHelper.js rename to lib/utils/TestHelper.ts index 80869401f..6d35f39d7 100644 --- a/source/utils/TestHelper.js +++ b/lib/utils/TestHelper.ts @@ -1,4 +1,4 @@ -import initCellMetadata from './initCellMetadata'; +import { initCellMetadata } from "./initCellMetadata"; // Default cell sizes and offsets for use in below tests export function getCellMetadata() { @@ -11,10 +11,10 @@ export function getCellMetadata() { 30, // 5: 50..70 20, // 6: 70..100 10, // 7: 80..110 - 30, // 8: 110..110 (max) + 30 // 8: 110..110 (max) ]; return initCellMetadata({ cellCount: cellSizes.length, - size: ({index}) => cellSizes[index], + size: ({ index }) => cellSizes[index] }); } diff --git a/lib/utils/animationFrame.ts b/lib/utils/animationFrame.ts new file mode 100644 index 000000000..256c71085 --- /dev/null +++ b/lib/utils/animationFrame.ts @@ -0,0 +1,53 @@ +type Callback = (timestamp: number) => void; +type CancelAnimationFrame = (requestId: number) => void; +type RequestAnimationFrame = (callback: Callback) => number; + +type AnimationFrameHost = { + requestAnimationFrame?: RequestAnimationFrame; + webkitRequestAnimationFrame?: RequestAnimationFrame; + mozRequestAnimationFrame?: RequestAnimationFrame; + oRequestAnimationFrame?: RequestAnimationFrame; + msRequestAnimationFrame?: RequestAnimationFrame; + cancelAnimationFrame?: CancelAnimationFrame; + webkitCancelAnimationFrame?: CancelAnimationFrame; + mozCancelAnimationFrame?: CancelAnimationFrame; + oCancelAnimationFrame?: CancelAnimationFrame; + msCancelAnimationFrame?: CancelAnimationFrame; + setTimeout: (callback: Callback, delay: number) => number; + clearTimeout: (id: number) => void; +}; + +// Properly handle server-side rendering. +let win: AnimationFrameHost; +if (typeof window !== "undefined") { + win = window as unknown as AnimationFrameHost; +} else if (typeof self !== "undefined") { + win = self as unknown as AnimationFrameHost; +} else { + win = {} as AnimationFrameHost; +} + +// requestAnimationFrame() shim by Paul Irish +// http://paulirish.com/2011/requestanimationframe-for-smart-animating/ +const request: RequestAnimationFrame = + win.requestAnimationFrame || + win.webkitRequestAnimationFrame || + win.mozRequestAnimationFrame || + win.oRequestAnimationFrame || + win.msRequestAnimationFrame || + function (callback: Callback): number { + return win.setTimeout(callback, 1000 / 60); + }; + +const cancel: CancelAnimationFrame = + win.cancelAnimationFrame || + win.webkitCancelAnimationFrame || + win.mozCancelAnimationFrame || + win.oCancelAnimationFrame || + win.msCancelAnimationFrame || + function (id: number) { + win.clearTimeout(id); + }; + +export const raf: RequestAnimationFrame = request; +export const caf: CancelAnimationFrame = cancel; diff --git a/source/utils/createCallbackMemoizer.jest.js b/lib/utils/createCallbackMemoizer.test.ts similarity index 66% rename from source/utils/createCallbackMemoizer.jest.js rename to lib/utils/createCallbackMemoizer.test.ts index 96ca9ec87..06591b4a5 100644 --- a/source/utils/createCallbackMemoizer.jest.js +++ b/lib/utils/createCallbackMemoizer.test.ts @@ -1,12 +1,20 @@ -import createCallbackMemoizer from './createCallbackMemoizer'; +import { describe, expect, it } from "vitest"; +import { createCallbackMemoizer } from "./createCallbackMemoizer"; -describe('createCallbackMemoizer', () => { +type RowsRenderedParams = { + overscanStartIndex?: number; + overscanStopIndex?: number; + startIndex?: number; + stopIndex?: number; +}; + +describe("createCallbackMemoizer", () => { function OnRowsRendered() { let numCalls = 0; - let overscanStartIndex; - let overscanStopIndex; - let startIndex; - let stopIndex; + let overscanStartIndex: number | undefined; + let overscanStopIndex: number | undefined; + let startIndex: number | undefined; + let stopIndex: number | undefined; return { numCalls: () => numCalls, @@ -14,76 +22,76 @@ describe('createCallbackMemoizer', () => { overscanStopIndex: () => overscanStopIndex, startIndex: () => startIndex, stopIndex: () => stopIndex, - update: params => { + update: (params: RowsRenderedParams) => { overscanStartIndex = params.overscanStartIndex; overscanStopIndex = params.overscanStopIndex; startIndex = params.startIndex; stopIndex = params.stopIndex; numCalls++; - }, + } }; } - it('should not call onRowsRendered if startIndex or stopIndex are invalid', () => { - const util = new OnRowsRendered(); + it("should not call onRowsRendered if startIndex or stopIndex are invalid", () => { + const util = OnRowsRendered(); const helper = createCallbackMemoizer(); helper({ callback: util.update, indices: { startIndex: 0, - stopIndex: undefined, - }, + stopIndex: undefined + } }); expect(util.numCalls()).toEqual(0); helper({ callback: util.update, indices: { startIndex: undefined, - stopIndex: 0, - }, + stopIndex: 0 + } }); expect(util.numCalls()).toEqual(0); }); - it('should call onRowsRendered if startIndex and stopIndex are valid', () => { - const util = new OnRowsRendered(); + it("should call onRowsRendered if startIndex and stopIndex are valid", () => { + const util = OnRowsRendered(); const helper = createCallbackMemoizer(); helper({ callback: util.update, indices: { startIndex: 0, - stopIndex: 1, - }, + stopIndex: 1 + } }); expect(util.numCalls()).toEqual(1); expect(util.startIndex()).toEqual(0); expect(util.stopIndex()).toEqual(1); }); - it('should call onRowsRendered if startIndex and stopIndex are invalid but :requireAllKeys is false', () => { - const util = new OnRowsRendered(); + it("should call onRowsRendered if startIndex and stopIndex are invalid but :requireAllKeys is false", () => { + const util = OnRowsRendered(); const helper = createCallbackMemoizer(false); helper({ callback: util.update, indices: { startIndex: undefined, - stopIndex: 1, - }, + stopIndex: 1 + } }); expect(util.numCalls()).toEqual(1); expect(util.startIndex()).toEqual(undefined); expect(util.stopIndex()).toEqual(1); }); - it('should not call onRowsRendered if startIndex or stopIndex have not changed', () => { - const util = new OnRowsRendered(); + it("should not call onRowsRendered if startIndex or stopIndex have not changed", () => { + const util = OnRowsRendered(); const helper = createCallbackMemoizer(); helper({ callback: util.update, indices: { startIndex: 0, - stopIndex: 1, - }, + stopIndex: 1 + } }); expect(util.numCalls()).toEqual(1); expect(util.startIndex()).toEqual(0); @@ -92,21 +100,21 @@ describe('createCallbackMemoizer', () => { callback: util.update, indices: { startIndex: 0, - stopIndex: 1, - }, + stopIndex: 1 + } }); expect(util.numCalls()).toEqual(1); }); - it('should not call onRowsRendered if startIndex or stopIndex have changed', () => { - const util = new OnRowsRendered(); + it("should not call onRowsRendered if startIndex or stopIndex have changed", () => { + const util = OnRowsRendered(); const helper = createCallbackMemoizer(); helper({ callback: util.update, indices: { startIndex: 0, - stopIndex: 1, - }, + stopIndex: 1 + } }); expect(util.numCalls()).toEqual(1); expect(util.startIndex()).toEqual(0); @@ -115,8 +123,8 @@ describe('createCallbackMemoizer', () => { callback: util.update, indices: { startIndex: 1, - stopIndex: 1, - }, + stopIndex: 1 + } }); expect(util.numCalls()).toEqual(2); expect(util.startIndex()).toEqual(1); @@ -125,16 +133,16 @@ describe('createCallbackMemoizer', () => { callback: util.update, indices: { startIndex: 1, - stopIndex: 2, - }, + stopIndex: 2 + } }); expect(util.numCalls()).toEqual(3); expect(util.startIndex()).toEqual(1); expect(util.stopIndex()).toEqual(2); }); - it('should call onRowsRendered if :overscanCellsCount changes', () => { - const util = new OnRowsRendered(); + it("should call onRowsRendered if :overscanCellsCount changes", () => { + const util = OnRowsRendered(); const helper = createCallbackMemoizer(); helper({ callback: util.update, @@ -142,8 +150,8 @@ describe('createCallbackMemoizer', () => { overscanStartIndex: 0, overscanStopIndex: 2, startIndex: 0, - stopIndex: 1, - }, + stopIndex: 1 + } }); expect(util.numCalls()).toEqual(1); expect(util.startIndex()).toEqual(0); @@ -156,8 +164,8 @@ describe('createCallbackMemoizer', () => { overscanStartIndex: 0, overscanStopIndex: 3, startIndex: 0, - stopIndex: 1, - }, + stopIndex: 1 + } }); expect(util.numCalls()).toEqual(2); expect(util.startIndex()).toEqual(0); @@ -166,32 +174,32 @@ describe('createCallbackMemoizer', () => { expect(util.overscanStopIndex()).toEqual(3); }); - it('should support an array of indices', () => { + it("should support an array of indices", () => { let numCalls = 0; - let indices; - const callback = params => { + let indices: number[] | undefined; + const callback = (params: number[]) => { indices = params; numCalls++; }; const helper = createCallbackMemoizer(); helper({ callback, - indices: [0, 1, 2], + indices: [0, 1, 2] }); expect(numCalls).toEqual(1); expect(indices).toEqual([0, 1, 2]); helper({ callback, - indices: [0, 1], + indices: [0, 1] }); expect(numCalls).toEqual(2); expect(indices).toEqual([0, 1]); }); - it('should support an attribute containing an array of indices', () => { + it("should support an attribute containing an array of indices", () => { let numCalls = 0; - let indices; - const callback = params => { + let indices: number[] | undefined; + const callback = (params: { indices: number[] }) => { indices = params.indices; numCalls++; }; @@ -199,16 +207,16 @@ describe('createCallbackMemoizer', () => { helper({ callback, indices: { - indices: [0, 1, 2], - }, + indices: [0, 1, 2] + } }); expect(numCalls).toEqual(1); expect(indices).toEqual([0, 1, 2]); helper({ callback, indices: { - indices: [0, 1], - }, + indices: [0, 1] + } }); expect(numCalls).toEqual(2); expect(indices).toEqual([0, 1]); diff --git a/lib/utils/createCallbackMemoizer.ts b/lib/utils/createCallbackMemoizer.ts new file mode 100644 index 000000000..528386e04 --- /dev/null +++ b/lib/utils/createCallbackMemoizer.ts @@ -0,0 +1,39 @@ +/** + * Helper utility that updates the specified callback whenever any of the specified indices have changed. + */ +export function createCallbackMemoizer(requireAllKeys = true) { + let cachedIndices: Record = {}; + + return ({ + callback, + indices + }: { + callback: (indices: Indices) => void; + indices: Indices; + }) => { + const indicesMap = indices as Record; + const keys = Object.keys(indices); + const allInitialized = + !requireAllKeys || + keys.every((key) => { + const value = indicesMap[key]; + return Array.isArray(value) ? value.length > 0 : (value as number) >= 0; + }); + const indexChanged = + keys.length !== Object.keys(cachedIndices).length || + keys.some((key) => { + const cachedValue = cachedIndices[key]; + const value = indicesMap[key]; + + return Array.isArray(value) + ? (cachedValue as unknown[]).join(",") !== value.join(",") + : cachedValue !== value; + }); + + cachedIndices = indicesMap; + + if (allInitialized && indexChanged) { + callback(indices); + } + }; +} diff --git a/lib/utils/createInnerGridRef.test.ts b/lib/utils/createInnerGridRef.test.ts new file mode 100644 index 000000000..fb49e20e6 --- /dev/null +++ b/lib/utils/createInnerGridRef.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import type { GridImperativeAPI } from "../components/Grid/types"; +import { createInnerGridRef } from "./createInnerGridRef"; + +function createGrid() { + return { + invalidateCellSizeAfterRender: vi.fn(), + recomputeGridSize: vi.fn() + } as unknown as GridImperativeAPI & { + invalidateCellSizeAfterRender: ReturnType; + recomputeGridSize: ReturnType; + }; +} + +describe("createInnerGridRef", () => { + it("should forward calls once the Grid's ref has been attached", () => { + const innerGrid = createInnerGridRef(); + const grid = createGrid(); + innerGrid.setGridRef(grid); + expect(innerGrid.getGrid()).toBe(grid); + + innerGrid.invalidateCellSizeAfterRender({ columnIndex: 1, rowIndex: 2 }); + innerGrid.recomputeGridSize({ columnIndex: 3, rowIndex: 4 }); + expect(grid.invalidateCellSizeAfterRender).toHaveBeenCalledWith({ + columnIndex: 1, + rowIndex: 2 + }); + expect(grid.recomputeGridSize).toHaveBeenCalledWith({ + columnIndex: 3, + rowIndex: 4 + }); + }); + + it("should keep calls made before the Grid's ref is attached (merged to the smallest indices) until it is attached", () => { + const innerGrid = createInnerGridRef(); + expect(innerGrid.getGrid()).toBe(null); + + innerGrid.invalidateCellSizeAfterRender({ columnIndex: 2, rowIndex: 5 }); + innerGrid.invalidateCellSizeAfterRender({ columnIndex: 0, rowIndex: 7 }); + innerGrid.recomputeGridSize({ columnIndex: 4, rowIndex: 3 }); + innerGrid.recomputeGridSize({ columnIndex: 6, rowIndex: 1 }); + + const grid = createGrid(); + innerGrid.setGridRef(grid); + expect(grid.invalidateCellSizeAfterRender).toHaveBeenCalledTimes(1); + expect(grid.invalidateCellSizeAfterRender).toHaveBeenCalledWith({ + columnIndex: 0, + rowIndex: 5 + }); + expect(grid.recomputeGridSize).toHaveBeenCalledTimes(1); + expect(grid.recomputeGridSize).toHaveBeenCalledWith({ + columnIndex: 4, + rowIndex: 1 + }); + + // Kept calls are only passed on once + innerGrid.setGridRef(null); + innerGrid.setGridRef(grid); + expect(grid.invalidateCellSizeAfterRender).toHaveBeenCalledTimes(1); + expect(grid.recomputeGridSize).toHaveBeenCalledTimes(1); + }); + + it("should keep calls made after the Grid's ref has been detached", () => { + const innerGrid = createInnerGridRef(); + const grid = createGrid(); + innerGrid.setGridRef(grid); + innerGrid.setGridRef(null); + + innerGrid.recomputeGridSize({ columnIndex: 0, rowIndex: 3 }); + expect(grid.recomputeGridSize).not.toHaveBeenCalled(); + + const nextGrid = createGrid(); + innerGrid.setGridRef(nextGrid); + expect(nextGrid.recomputeGridSize).toHaveBeenCalledWith({ + columnIndex: 0, + rowIndex: 3 + }); + }); +}); diff --git a/lib/utils/createInnerGridRef.ts b/lib/utils/createInnerGridRef.ts new file mode 100644 index 000000000..a4f54f650 --- /dev/null +++ b/lib/utils/createInnerGridRef.ts @@ -0,0 +1,80 @@ +import type { CellPosition, GridImperativeAPI } from "../components/Grid/types"; + +export type InnerGridRef = { + /** The inner Grid's handle (once its ref has been attached). */ + getGrid: () => GridImperativeAPI | null; + + /** Ref callback for the inner Grid. */ + setGridRef: (grid: GridImperativeAPI | null) => void; + + /** Calls the Grid's `invalidateCellSizeAfterRender`, or keeps the call until the Grid's ref has been attached. */ + invalidateCellSizeAfterRender: (cell: CellPosition) => void; + + /** Calls the Grid's `recomputeGridSize`, or keeps the call until the Grid's ref has been attached. */ + recomputeGridSize: (cell: CellPosition) => void; +}; + +// Merges two cell positions into the one that covers both (i.e. the smallest indices) +const mergeCellPositions = ( + prev: CellPosition | null, + next: CellPosition +): CellPosition => + prev === null + ? next + : { + columnIndex: Math.min(prev.columnIndex, next.columnIndex), + rowIndex: Math.min(prev.rowIndex, next.rowIndex) + }; + +/** + * Tracks the inner Grid of a component that wraps one (e.g. List or Table, whose handles delegate to the Grid). + * + * When the wrapper mounts, its cells (e.g. CellMeasurer) run their layout effects before the inner Grid's handle + * is attached to the wrapper's ref (the refs of parents are attached after the effects of their children run). + * Cell size invalidations and recomputations requested in the meantime are kept (merged to the smallest indices) + * and passed on to the Grid once its ref has been attached. + * (The Grid attaches its handle before running its own layout effect, which then processes the invalidations.) + */ +export function createInnerGridRef(): InnerGridRef { + let grid: GridImperativeAPI | null = null; + let pendingInvalidation: CellPosition | null = null; + let pendingRecomputation: CellPosition | null = null; + + return { + getGrid: () => grid, + + setGridRef(value) { + grid = value; + if (grid === null) { + return; + } + + if (pendingRecomputation !== null) { + const cell = pendingRecomputation; + pendingRecomputation = null; + grid.recomputeGridSize(cell); + } + if (pendingInvalidation !== null) { + const cell = pendingInvalidation; + pendingInvalidation = null; + grid.invalidateCellSizeAfterRender(cell); + } + }, + + invalidateCellSizeAfterRender(cell) { + if (grid !== null) { + grid.invalidateCellSizeAfterRender(cell); + } else { + pendingInvalidation = mergeCellPositions(pendingInvalidation, cell); + } + }, + + recomputeGridSize(cell) { + if (grid !== null) { + grid.recomputeGridSize(cell); + } else { + pendingRecomputation = mergeCellPositions(pendingRecomputation, cell); + } + } + }; +} diff --git a/lib/utils/getUpdatedOffsetForIndex.test.ts b/lib/utils/getUpdatedOffsetForIndex.test.ts new file mode 100644 index 000000000..a2e8b0a7d --- /dev/null +++ b/lib/utils/getUpdatedOffsetForIndex.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { getUpdatedOffsetForIndex } from "./getUpdatedOffsetForIndex"; +import { getCellMetadata } from "./TestHelper"; + +describe("getUpdatedOffsetForIndex", () => { + function testHelper( + targetIndex: number, + currentOffset: number, + cellMetadata = getCellMetadata() + ) { + return getUpdatedOffsetForIndex({ + cellOffset: cellMetadata[targetIndex].offset, + cellSize: cellMetadata[targetIndex].size, + containerSize: 50, + currentOffset + }); + } + + it("should scroll to the beginning", () => { + expect(testHelper(0, 100)).toEqual(0); + }); + + it("should scroll forward to the middle", () => { + expect(testHelper(4, 0)).toEqual(20); + }); + + it("should scroll backward to the middle", () => { + expect(testHelper(2, 100)).toEqual(30); + }); + + it("should not scroll if an item is already visible", () => { + expect(testHelper(2, 20)).toEqual(20); + }); + + it("should scroll to the end", () => { + expect(testHelper(8, 0)).toEqual(110); + }); + + it("should align a cell larger than the container to its start with auto alignment (#1264)", () => { + // Scrolling forward (and backward) to a cell that doesn't fit shows its start rather than its end + for (const currentOffset of [0, 200]) { + expect( + getUpdatedOffsetForIndex({ + cellOffset: 100, + cellSize: 80, + containerSize: 50, + currentOffset + }) + ).toEqual(100); + } + }); + + it("should honor specified :align values", () => { + expect( + getUpdatedOffsetForIndex({ + align: "auto", + cellOffset: 50, + cellSize: 10, + containerSize: 50, + currentOffset: 0 + }) + ).toEqual(10); + expect( + getUpdatedOffsetForIndex({ + align: "start", + cellOffset: 50, + cellSize: 10, + containerSize: 50, + currentOffset: 0 + }) + ).toEqual(50); + expect( + getUpdatedOffsetForIndex({ + align: "auto", + cellOffset: 50, + cellSize: 10, + containerSize: 50, + currentOffset: 100 + }) + ).toEqual(50); + expect( + getUpdatedOffsetForIndex({ + align: "end", + cellOffset: 50, + cellSize: 10, + containerSize: 50, + currentOffset: 100 + }) + ).toEqual(10); + expect( + getUpdatedOffsetForIndex({ + align: "center", + cellOffset: 50, + cellSize: 10, + containerSize: 50, + currentOffset: 100 + }) + ).toEqual(30); + }); +}); diff --git a/source/utils/getUpdatedOffsetForIndex.js b/lib/utils/getUpdatedOffsetForIndex.ts similarity index 62% rename from source/utils/getUpdatedOffsetForIndex.js rename to lib/utils/getUpdatedOffsetForIndex.ts index 4c51fc2ec..87b19116d 100644 --- a/source/utils/getUpdatedOffsetForIndex.js +++ b/lib/utils/getUpdatedOffsetForIndex.ts @@ -3,31 +3,42 @@ * If the cell is already visible then the current offset will be returned. * If the current offset is too great or small, it will be adjusted just enough to ensure the specified index is visible. * - * @param align Desired alignment within container; one of "auto" (default), "start", or "end" + * @param align Desired alignment within container; one of "auto" (default), "start", "end", or "center". + * With "auto", a cell that is larger than the container is aligned to its start. * @param cellOffset Offset (x or y) position for cell * @param cellSize Size (width or height) of cell * @param containerSize Total size (width or height) of the container * @param currentOffset Container's current (x or y) offset * @return Offset to use to ensure the specified cell is visible */ -export default function getUpdatedOffsetForIndex({ - align = 'auto', +export function getUpdatedOffsetForIndex({ + align = "auto", cellOffset, cellSize, containerSize, - currentOffset, -}) { + currentOffset +}: { + align?: "auto" | "end" | "start" | "center"; + cellOffset: number; + cellSize: number; + containerSize: number; + currentOffset: number; +}): number { const maxOffset = cellOffset; const minOffset = maxOffset - containerSize + cellSize; switch (align) { - case 'start': + case "start": return maxOffset; - case 'end': + case "end": return minOffset; - case 'center': + case "center": return maxOffset - (containerSize - cellSize) / 2; default: + // A cell that doesn't fit within the container is aligned to its start, so that its start is visible (#1264) + if (cellSize >= containerSize) { + return maxOffset; + } return Math.max(minOffset, Math.min(maxOffset, currentOffset)); } } diff --git a/source/utils/initCellMetadata.js b/lib/utils/initCellMetadata.ts similarity index 58% rename from source/utils/initCellMetadata.js rename to lib/utils/initCellMetadata.ts index 92b00c9cb..481353933 100644 --- a/source/utils/initCellMetadata.js +++ b/lib/utils/initCellMetadata.ts @@ -1,3 +1,8 @@ +export type CellMetadata = { + offset: number; + size: number; +}; + /** * Initializes metadata for an axis and its cells. * This data is used to determine which cells are visible given a container size and scroll position. @@ -6,14 +11,20 @@ * @param size Either a fixed size or a function that returns the size for a given given an index. * @return Object mapping cell index to cell metadata (size, offset) */ -export default function initCellMetadata({cellCount, size}) { - const sizeGetter = typeof size === 'function' ? size : () => size; +export function initCellMetadata({ + cellCount, + size +}: { + cellCount: number; + size: number | ((params: { index: number }) => number); +}): CellMetadata[] { + const sizeGetter = typeof size === "function" ? size : () => size; - const cellMetadata = []; + const cellMetadata: CellMetadata[] = []; let offset = 0; - for (var i = 0; i < cellCount; i++) { - let size = sizeGetter({index: i}); + for (let i = 0; i < cellCount; i++) { + const size = sizeGetter({ index: i }); if (size == null || isNaN(size)) { throw Error(`Invalid size returned for cell ${i} of value ${size}`); @@ -21,7 +32,7 @@ export default function initCellMetadata({cellCount, size}) { cellMetadata[i] = { size, - offset, + offset }; offset += size; diff --git a/source/utils/requestAnimationTimeout.js b/lib/utils/requestAnimationTimeout.ts similarity index 82% rename from source/utils/requestAnimationTimeout.js rename to lib/utils/requestAnimationTimeout.ts index 8d82dc8a6..ccee3f09a 100644 --- a/source/utils/requestAnimationTimeout.js +++ b/lib/utils/requestAnimationTimeout.ts @@ -1,9 +1,7 @@ -/** @flow */ - -import {caf, raf} from './animationFrame'; +import { caf, raf } from "./animationFrame"; export type AnimationTimeoutId = { - id: number, + id: number; }; export const cancelAnimationTimeout = (frame: AnimationTimeoutId) => @@ -16,10 +14,10 @@ export const cancelAnimationTimeout = (frame: AnimationTimeoutId) => * Credit: Joe Lambert (https://gist.github.com/joelambert/1002116#file-requesttimeout-js) */ export const requestAnimationTimeout = ( - callback: Function, - delay: number, + callback: () => void, + delay: number ): AnimationTimeoutId => { - let start; + let start: number; // wait for end of processing current event handler, because event handler may be long Promise.resolve().then(() => { start = Date.now(); @@ -27,14 +25,14 @@ export const requestAnimationTimeout = ( const timeout = () => { if (Date.now() - start >= delay) { - callback.call(); + callback.call(undefined); } else { frame.id = raf(timeout); } }; const frame: AnimationTimeoutId = { - id: raf(timeout), + id: raf(timeout) }; return frame; diff --git a/lib/utils/scrollElement/dimensions.test.ts b/lib/utils/scrollElement/dimensions.test.ts new file mode 100644 index 000000000..567c67c28 --- /dev/null +++ b/lib/utils/scrollElement/dimensions.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mockWindowScrolling } from "../test/scrollElement"; +import { + getDimensions, + getPositionOffset, + getScrollOffset +} from "./dimensions"; + +function mockRect(element: Element, rect: DOMRect) { + element.getBoundingClientRect = () => rect; +} + +describe("dimensions", () => { + afterEach(() => { + // Remove the per-instance mocks + delete (document.documentElement as { getBoundingClientRect?: unknown }) + .getBoundingClientRect; + }); + + describe("getDimensions", () => { + it("should return the inner size of the window", () => { + mockWindowScrolling({ innerHeight: 300, innerWidth: 400 }); + expect(getDimensions(window)).toEqual({ height: 300, width: 400 }); + }); + + it("should return the size of an element (reading the DOMRect fields explicitly) (#27)", () => { + const element = document.createElement("div"); + // Real DOMRect fields are prototype getters (which can't be copied by spreading) + mockRect(element, new DOMRect(10, 20, 200, 100)); + expect(getDimensions(element)).toEqual({ height: 100, width: 200 }); + }); + }); + + describe("getPositionOffset", () => { + it("should return the position of an element within a scroll container, including its scroll offset", () => { + const container = document.createElement("div"); + container.scrollTop = 100; + container.scrollLeft = 150; + mockRect(container, new DOMRect(250, 200, 400, 300)); + const element = document.createElement("div"); + mockRect(element, new DOMRect(350, 300, 100, 100)); + + expect(getPositionOffset(element, container)).toEqual({ + top: 300 + 100 - 200, + left: 350 + 150 - 250 + }); + }); + + it("should return negative values for an element that has been scrolled past", () => { + const container = document.createElement("div"); + container.scrollTop = 500; + mockRect(container, new DOMRect(0, 0, 400, 300)); + const element = document.createElement("div"); + // 200px into the content, scrolled 500px + mockRect(element, new DOMRect(0, -300, 100, 100)); + + expect(getPositionOffset(element, container).top).toEqual(200); + }); + + it("should return the position of an element within the document", () => { + mockRect(document.documentElement, new DOMRect(0, 0, 500, 5000)); + const element = document.createElement("div"); + mockRect(element, new DOMRect(50, 100, 100, 100)); + + expect(getPositionOffset(element, window)).toEqual({ + top: 100, + left: 50 + }); + }); + + // Edge-case reported in bvaughn/react-virtualized/pull/346 (e.g. navigating back to an already scrolled page) + it("should account for an already scrolled document element", () => { + mockRect(document.documentElement, new DOMRect(-100, -100, 500, 5000)); + const element = document.createElement("div"); + mockRect(element, new DOMRect(50, 100, 100, 100)); + + expect(getPositionOffset(element, window)).toEqual({ + top: 100 + 100, + left: 50 + 100 + }); + }); + }); + + describe("getScrollOffset", () => { + it("should return the scroll offsets of the window", () => { + const windowScrolling = mockWindowScrolling(); + windowScrolling.scroll(300, 50); + expect(getScrollOffset(window)).toEqual({ top: 300, left: 50 }); + }); + + it("should return the scroll offsets of an element", () => { + const element = document.createElement("div"); + element.scrollTop = 120; + element.scrollLeft = 30; + expect(getScrollOffset(element)).toEqual({ top: 120, left: 30 }); + }); + }); +}); diff --git a/source/WindowScroller/utils/dimensions.js b/lib/utils/scrollElement/dimensions.ts similarity index 55% rename from source/WindowScroller/utils/dimensions.js rename to lib/utils/scrollElement/dimensions.ts index e547c5644..a07461f55 100644 --- a/source/WindowScroller/utils/dimensions.js +++ b/lib/utils/scrollElement/dimensions.ts @@ -1,4 +1,4 @@ -// @flow +import type { ResolvedScrollElement } from "./types"; /** * Gets the dimensions of the element, accounting for API differences between @@ -6,37 +6,34 @@ */ type Dimensions = { - height: number, - width: number, + height: number; + width: number; }; -// TODO Move this into WindowScroller and import from there -type WindowScrollerProps = { - serverHeight: number, - serverWidth: number, +type Offset = { + top: number; + left: number; }; -const isWindow = element => element === window; +const isWindow = (element: ResolvedScrollElement): element is Window => + element === window; -const getBoundingBox = element => element.getBoundingClientRect(); +const getBoundingBox = (element: Element) => element.getBoundingClientRect(); export function getDimensions( - scrollElement: ?Element, - props: WindowScrollerProps, + scrollElement: ResolvedScrollElement ): Dimensions { - if (!scrollElement) { + if (isWindow(scrollElement)) { + const { innerHeight, innerWidth } = window; return { - height: props.serverHeight, - width: props.serverWidth, - }; - } else if (isWindow(scrollElement)) { - const {innerHeight, innerWidth} = window; - return { - height: typeof innerHeight === 'number' ? innerHeight : 0, - width: typeof innerWidth === 'number' ? innerWidth : 0, + height: typeof innerHeight === "number" ? innerHeight : 0, + width: typeof innerWidth === "number" ? innerWidth : 0 }; } else { - return getBoundingBox(scrollElement); + // Read the fields explicitly; DOMRect fields are prototype getters, + // so spreading a DOMRect (e.g. into state) would copy nothing + const { height, width } = getBoundingBox(scrollElement); + return { height, width }; } } @@ -46,22 +43,25 @@ export function getDimensions( * Handles edge-case where a user is navigating back (history) from an already-scrolled page. * In this case the body’s top or left position will be a negative number and this element’s top or left will be increased (by that amount). */ -export function getPositionOffset(element: Element, container: Element) { +export function getPositionOffset( + element: Element, + container: ResolvedScrollElement +): Offset { if (isWindow(container) && document.documentElement) { const containerElement = document.documentElement; const elementRect = getBoundingBox(element); const containerRect = getBoundingBox(containerElement); return { top: elementRect.top - containerRect.top, - left: elementRect.left - containerRect.left, + left: elementRect.left - containerRect.left }; } else { const scrollOffset = getScrollOffset(container); const elementRect = getBoundingBox(element); - const containerRect = getBoundingBox(container); + const containerRect = getBoundingBox(container as Element); return { top: elementRect.top + scrollOffset.top - containerRect.top, - left: elementRect.left + scrollOffset.left - containerRect.left, + left: elementRect.left + scrollOffset.left - containerRect.left }; } } @@ -70,22 +70,22 @@ export function getPositionOffset(element: Element, container: Element) { * Gets the vertical and horizontal scroll amount of the element, accounting for IE compatibility * and API differences between `window` and other DOM elements. */ -export function getScrollOffset(element: Element) { +export function getScrollOffset(element: ResolvedScrollElement): Offset { if (isWindow(element) && document.documentElement) { return { top: - 'scrollY' in window + "scrollY" in window ? window.scrollY : document.documentElement.scrollTop, left: - 'scrollX' in window + "scrollX" in window ? window.scrollX - : document.documentElement.scrollLeft, + : document.documentElement.scrollLeft }; } else { return { - top: element.scrollTop, - left: element.scrollLeft, + top: (element as Element).scrollTop, + left: (element as Element).scrollLeft }; } } diff --git a/lib/utils/scrollElement/scrollElement.hydration.test.tsx b/lib/utils/scrollElement/scrollElement.hydration.test.tsx new file mode 100644 index 000000000..ac34070a4 --- /dev/null +++ b/lib/utils/scrollElement/scrollElement.hydration.test.tsx @@ -0,0 +1,91 @@ +import { act } from "react"; +import { hydrateRoot, type Root } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { List } from "../../components/List/List"; +import type { RenderedRows } from "../../components/List/types"; +import { mockWindowScrolling } from "../test/scrollElement"; + +describe("scrollElement (hydration)", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(() => { + if (root !== null) { + const currentRoot = root; + act(() => currentRoot.unmount()); + root = null; + } + container?.remove(); + container = null; + vi.restoreAllMocks(); + }); + + function getMarkup(onRowsRendered?: (params: RenderedRows) => void) { + return ( + ({ + overscanStartIndex: startIndex, + overscanStopIndex: stopIndex + })} + overscanRowCount={0} + rowCount={1000} + rowHeight={20} + rowRenderer={({ index, key, style }) => ( +
+ {`Row ${index}`} +
+ )} + scrollElement="window" + width={200} + /> + ); + } + + function hydrate(onRowsRendered: (params: RenderedRows) => void) { + container = document.createElement("div"); + container.innerHTML = renderToString(getMarkup()); + document.body.appendChild(container); + expect(container.textContent).toContain("Row 4"); + expect(container.textContent).not.toContain("Row 5"); + + const hydrationContainer = container; + act(() => { + root = hydrateRoot(hydrationContainer, getMarkup(onRowsRendered)); + }); + return hydrationContainer; + } + + it("should hydrate the server-rendered markup without errors, and then link to the window", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const windowScrolling = mockWindowScrolling({ innerHeight: 200 }); + let rendered: RenderedRows | null = null; + + const element = hydrate((params) => (rendered = params)); + expect(console.error).not.toHaveBeenCalled(); + + // The viewport height is measured after hydration + expect(rendered).toMatchObject({ startIndex: 0, stopIndex: 9 }); + expect( + element.querySelector(".ReactVirtualized__List")!.style + .height + ).toBe("auto"); + + windowScrolling.scroll(1000); + expect(rendered).toMatchObject({ startIndex: 50, stopIndex: 59 }); + expect(element.textContent).toContain("Row 50"); + }); + + it("should render the visible rows of an already scrolled window after hydration", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + mockWindowScrolling({ innerHeight: 100, scrollY: 2000 }); + let rendered: RenderedRows | null = null; + + const element = hydrate((params) => (rendered = params)); + expect(console.error).not.toHaveBeenCalled(); + expect(rendered).toMatchObject({ startIndex: 100, stopIndex: 104 }); + expect(element.textContent).toContain("Row 100"); + }); +}); diff --git a/lib/utils/scrollElement/scrollElement.ssr.test.tsx b/lib/utils/scrollElement/scrollElement.ssr.test.tsx new file mode 100644 index 000000000..19746788a --- /dev/null +++ b/lib/utils/scrollElement/scrollElement.ssr.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment node + +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { Grid } from "../../components/Grid/Grid"; +import { List } from "../../components/List/List"; +import { Column } from "../../components/Table/Column"; +import { Table } from "../../components/Table/Table"; + +// Server rendering: the scroll element can't be measured, so the height prop is used as the viewport height +describe("scrollElement (server rendering)", () => { + it("should render a List at its full height, with the first rows (within the fallback height)", () => { + const html = renderToString( + ( +
+ {`Row ${index}`} +
+ )} + scrollElement="window" + width={200} + /> + ); + + expect(html).toContain("height:auto"); + expect(html).not.toContain("height:100px"); + // The full height of the rows + expect(html).toContain("height:20000px"); + expect(html).toContain("Row 0"); + expect(html).toContain("Row 4"); + // (List and Table overscan by at least one row, for accessibility) + expect(html).not.toContain("Row 6<"); + }); + + it("should render a Grid at its full height, with the first rows (within the fallback height)", () => { + const html = renderToString( + ( +
+ {`${rowIndex}:${columnIndex}`} +
+ )} + columnCount={10} + columnWidth={50} + height={60} + overscanColumnCount={0} + overscanRowCount={0} + rowCount={100} + rowHeight={20} + scrollElement="window" + width={100} + /> + ); + + expect(html).toContain("height:auto"); + expect(html).toContain("0:0"); + expect(html).toContain("2:1"); + expect(html).not.toContain("3:0"); + }); + + it("should render a Grid without a fallback height (with no rows)", () => { + const html = renderToString( + ( +
+ {`Row ${rowIndex}`} +
+ )} + columnCount={1} + columnWidth={50} + rowCount={100} + rowHeight={20} + scrollElement="window" + width={100} + /> + ); + + expect(html).toContain("height:auto"); + expect(html).not.toContain("Row 0"); + }); + + it("should render a Table at its full height, with the first rows (within the fallback height)", () => { + const html = renderToString( + ({ name: `Name ${index}` })} + rowHeight={20} + scrollElement="window" + width={200} + > + +
+ ); + + expect(html).toContain("height:auto"); + // The rows may fill the whole viewport (since the header is scrolled along with them) + expect(html).toContain("Name 4"); + expect(html).not.toContain("Name 6<"); + }); +}); diff --git a/lib/utils/scrollElement/scrollListeners.test.ts b/lib/utils/scrollElement/scrollListeners.test.ts new file mode 100644 index 000000000..9a122c0c8 --- /dev/null +++ b/lib/utils/scrollElement/scrollListeners.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + registerScrollListener, + unregisterScrollListener, + type ScrollElementListener +} from "./scrollListeners"; +import type { ResolvedScrollElement } from "./types"; + +function createListener(scrollingResetTimeInterval = 10) { + return { + getScrollingResetTimeInterval: vi.fn(() => scrollingResetTimeInterval), + handleScrollEvent: vi.fn(), + resetIsScrolling: vi.fn() + } satisfies ScrollElementListener; +} + +// Vitest's jsdom environment aliases the global `window` to `globalThis`, so the +// `currentTarget` of an event bubbling up to the (internal) jsdom Window is not `=== window`; +// report the global `window` as the current target (as a browser would). +function dispatchWindowScroll() { + const event = new window.Event("scroll", { bubbles: true }); + Object.defineProperty(event, "currentTarget", { get: () => window }); + document.dispatchEvent(event); +} + +function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("scrollListeners", () => { + const registered: Array<[ScrollElementListener, ResolvedScrollElement]> = []; + + function register( + listener: ScrollElementListener, + element: ResolvedScrollElement + ) { + registered.push([listener, element]); + registerScrollListener(listener, element); + } + + afterEach(() => { + registered + .splice(0) + .forEach(([listener, element]) => + unregisterScrollListener(listener, element) + ); + document.body.style.pointerEvents = ""; + vi.restoreAllMocks(); + }); + + it("should add one DOM listener per element, shared by all of its listeners", () => { + const element = document.createElement("div"); + const addEventListener = vi.spyOn(element, "addEventListener"); + const listenerA = createListener(); + const listenerB = createListener(); + + register(listenerA, element); + register(listenerB, element); + // Registering the same listener twice is ignored + register(listenerA, element); + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(addEventListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function) + ); + + element.dispatchEvent(new window.Event("scroll")); + expect(listenerA.handleScrollEvent).toHaveBeenCalledTimes(1); + expect(listenerB.handleScrollEvent).toHaveBeenCalledTimes(1); + }); + + it("should only notify the listeners of the element that scrolled", () => { + const elementA = document.createElement("div"); + const elementB = document.createElement("div"); + const listenerA = createListener(); + const listenerB = createListener(); + register(listenerA, elementA); + register(listenerB, elementB); + + elementA.dispatchEvent(new window.Event("scroll")); + expect(listenerA.handleScrollEvent).toHaveBeenCalledTimes(1); + expect(listenerB.handleScrollEvent).not.toHaveBeenCalled(); + }); + + it("should remove an element's DOM listener once its last listener unregisters, even if other elements still have listeners (#28)", () => { + const elementA = document.createElement("div"); + const elementB = document.createElement("div"); + const removeEventListenerA = vi.spyOn(elementA, "removeEventListener"); + const removeEventListenerB = vi.spyOn(elementB, "removeEventListener"); + const listenerA1 = createListener(); + const listenerA2 = createListener(); + const listenerB = createListener(); + register(listenerA1, elementA); + register(listenerA2, elementA); + register(listenerB, elementB); + + unregisterScrollListener(listenerB, elementB); + expect(removeEventListenerB).toHaveBeenCalledWith( + "scroll", + expect.any(Function) + ); + expect(removeEventListenerA).not.toHaveBeenCalled(); + + // Element A still has a listener + unregisterScrollListener(listenerA1, elementA); + expect(removeEventListenerA).not.toHaveBeenCalled(); + elementA.dispatchEvent(new window.Event("scroll")); + expect(listenerA1.handleScrollEvent).not.toHaveBeenCalled(); + expect(listenerA2.handleScrollEvent).toHaveBeenCalledTimes(1); + + unregisterScrollListener(listenerA2, elementA); + expect(removeEventListenerA).toHaveBeenCalledWith( + "scroll", + expect.any(Function) + ); + }); + + it("should disable pointer events on the body while the window scrolls, and restore them after the longest interval", async () => { + const listenerA = createListener(10); + const listenerB = createListener(100); + register(listenerA, window); + register(listenerB, document.createElement("div")); + document.body.style.pointerEvents = "all"; + + dispatchWindowScroll(); + expect(document.body.style.pointerEvents).toEqual("none"); + expect(listenerA.handleScrollEvent).toHaveBeenCalledTimes(1); + expect(listenerB.handleScrollEvent).not.toHaveBeenCalled(); + + await wait(50); + expect(document.body.style.pointerEvents).toEqual("none"); + expect(listenerA.resetIsScrolling).not.toHaveBeenCalled(); + + await wait(150); + expect(document.body.style.pointerEvents).toEqual("all"); + // Every listener is reset + expect(listenerA.resetIsScrolling).toHaveBeenCalledTimes(1); + expect(listenerB.resetIsScrolling).toHaveBeenCalledTimes(1); + }); + + it("should restart the interval on every scroll event", async () => { + const listener = createListener(60); + register(listener, window); + + dispatchWindowScroll(); + await wait(40); + dispatchWindowScroll(); + await wait(40); + expect(listener.resetIsScrolling).not.toHaveBeenCalled(); + expect(document.body.style.pointerEvents).toEqual("none"); + + await wait(100); + expect(listener.resetIsScrolling).toHaveBeenCalledTimes(1); + expect(document.body.style.pointerEvents).toEqual(""); + }); + + it("should not disable pointer events while an element scrolls", async () => { + const element = document.createElement("div"); + const listener = createListener(10); + register(listener, element); + + element.dispatchEvent(new window.Event("scroll")); + expect(document.body.style.pointerEvents).toEqual(""); + + // Its listeners are reset all the same + await wait(50); + expect(listener.resetIsScrolling).toHaveBeenCalledTimes(1); + }); + + it("should restore pointer events on the body once the last listener unregisters", () => { + const listener = createListener(1000); + registerScrollListener(listener, window); + document.body.style.pointerEvents = "all"; + + dispatchWindowScroll(); + expect(document.body.style.pointerEvents).toEqual("none"); + + unregisterScrollListener(listener, window); + expect(document.body.style.pointerEvents).toEqual("all"); + }); +}); diff --git a/lib/utils/scrollElement/scrollListeners.ts b/lib/utils/scrollElement/scrollListeners.ts new file mode 100644 index 000000000..167270d0b --- /dev/null +++ b/lib/utils/scrollElement/scrollListeners.ts @@ -0,0 +1,126 @@ +import { + requestAnimationTimeout, + cancelAnimationTimeout, + type AnimationTimeoutId +} from "../requestAnimationTimeout"; +import type { ResolvedScrollElement } from "./types"; + +/** + * Stable, per-instance object that a component registers to receive the (shared) scroll events of its scroll element. + * Its methods read the instance's latest props/state, so the object itself never changes. + */ +export type ScrollElementListener = { + /** Wait time (in milliseconds) after the last scroll event before `resetIsScrolling()` is called. */ + getScrollingResetTimeInterval(): number; + + /** Called when the element the listener is registered for scrolls. */ + handleScrollEvent(): void; + + /** Called once scrolling has stopped (for the longest interval of all registered listeners). */ + resetIsScrolling(): void; +}; + +type Registration = { + listener: ScrollElementListener; + element: ResolvedScrollElement; +}; + +let registrations: Registration[] = []; +let originalBodyPointerEvents: string | null = null; +let disablePointerEventsTimeoutId: AnimationTimeoutId | null = null; + +function enablePointerEventsIfDisabled() { + if (disablePointerEventsTimeoutId) { + disablePointerEventsTimeoutId = null; + + if (document.body && originalBodyPointerEvents != null) { + document.body.style.pointerEvents = originalBodyPointerEvents; + } + + originalBodyPointerEvents = null; + } +} + +function enablePointerEventsAfterDelayCallback() { + enablePointerEventsIfDisabled(); + registrations.forEach(({ listener }) => listener.resetIsScrolling()); +} + +function enablePointerEventsAfterDelay() { + if (disablePointerEventsTimeoutId) { + cancelAnimationTimeout(disablePointerEventsTimeoutId); + } + + let maximumTimeout = 0; + registrations.forEach(({ listener }) => { + maximumTimeout = Math.max( + maximumTimeout, + listener.getScrollingResetTimeInterval() + ); + }); + + disablePointerEventsTimeoutId = requestAnimationTimeout( + enablePointerEventsAfterDelayCallback, + maximumTimeout + ); +} + +function onScroll(event: Event) { + if ( + event.currentTarget === window && + originalBodyPointerEvents == null && + document.body + ) { + originalBodyPointerEvents = document.body.style.pointerEvents; + + document.body.style.pointerEvents = "none"; + } + enablePointerEventsAfterDelay(); + registrations.forEach(({ listener, element }) => { + if (element === event.currentTarget) { + listener.handleScrollEvent(); + } + }); +} + +export function registerScrollListener( + listener: ScrollElementListener, + element: ResolvedScrollElement +) { + if ( + registrations.some( + (registration) => + registration.listener === listener && registration.element === element + ) + ) { + return; + } + + if (!registrations.some((registration) => registration.element === element)) { + element.addEventListener("scroll", onScroll); + } + registrations.push({ listener, element }); +} + +export function unregisterScrollListener( + listener: ScrollElementListener, + element: ResolvedScrollElement +) { + registrations = registrations.filter( + (registration) => + registration.listener !== listener || registration.element !== element + ); + + // Remove the listener once no instance is listening to this element anymore, + // even if other instances are still listening to other elements + if (!registrations.some((registration) => registration.element === element)) { + element.removeEventListener("scroll", onScroll); + } + + if (!registrations.length) { + if (disablePointerEventsTimeoutId) { + cancelAnimationTimeout(disablePointerEventsTimeoutId); + enablePointerEventsIfDisabled(); + } + } +} diff --git a/lib/utils/scrollElement/types.ts b/lib/utils/scrollElement/types.ts new file mode 100644 index 000000000..6f372bc9f --- /dev/null +++ b/lib/utils/scrollElement/types.ts @@ -0,0 +1,49 @@ +/** + * Element that scrolls a virtualized component (instead of the component scrolling itself): + * `"window"`, or a scrollable DOM element. + */ +export type ScrollElement = "window" | Element; + +/** A scroll element resolved to the object that is listened to (and scrolled). */ +export type ResolvedScrollElement = Window | Element; + +/** The part of the scroll element (e.g. the window) visible to the component. */ +export type ScrollElementViewport = { + /** Height of the scroll element's viewport (e.g. the window's inner height). */ + height: number; + + /** The scroll element is being scrolled. */ + isScrolling: boolean; + + /** + * Scroll offset of the scroll element, relative to the top of the component's element + * (i.e. the vertical scroll offset the component should render). + * This is negative while the top of the component is below the top of the viewport. + */ + scrollTop: number; +}; + +/** Provided to a component that is scrolled by a scroll element. */ +export type ScrollElementLinkSource = { + /** + * Scrolls the scroll element so that the component is scrolled to the specified offset + * (relative to the top of its element, like `ScrollElementViewport.scrollTop`). + * Returns false if the scroll element was already scrolled to that offset (no scroll event will follow). + */ + scrollTo(scrollTop: number): boolean; +}; + +/** Implemented by the components that can be scrolled by a scroll element (Grid and Masonry). */ +export type ScrollElementLinkTarget = { + /** The element whose top is the origin of the component's (vertical) scroll offset. */ + getElement(): Element | null; + + /** Links the component to its scroll element (or unlinks it, if `source` is null). */ + link( + source: ScrollElementLinkSource | null, + viewport: ScrollElementViewport + ): void; + + /** Updates the viewport of a linked component. */ + setViewport(viewport: ScrollElementViewport): void; +}; diff --git a/lib/utils/scrollElement/useMissingHeightWarning.ts b/lib/utils/scrollElement/useMissingHeightWarning.ts new file mode 100644 index 000000000..c0bb1b80e --- /dev/null +++ b/lib/utils/scrollElement/useMissingHeightWarning.ts @@ -0,0 +1,18 @@ +import { useEffect } from "react"; +import type { ScrollElement } from "./types"; + +/** Warns (in DEV) if a component has neither a `height` nor a `scrollElement` (it then renders nothing). */ +export function useMissingHeightWarning( + componentName: string, + height: number | undefined, + scrollElement: ScrollElement | null | undefined +) { + const heightMissing = height === undefined && scrollElement === undefined; + useEffect(() => { + if (process.env.NODE_ENV !== "production" && heightMissing) { + console.warn( + `${componentName} requires a height prop (unless a scrollElement is specified)` + ); + } + }, [componentName, heightMissing]); +} diff --git a/lib/utils/scrollElement/useScrollElementLink.test.tsx b/lib/utils/scrollElement/useScrollElementLink.test.tsx new file mode 100644 index 000000000..96fcafa0e --- /dev/null +++ b/lib/utils/scrollElement/useScrollElementLink.test.tsx @@ -0,0 +1,393 @@ +import { act, StrictMode, useRef, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getObserverCount, + mockResizeObserver +} from "../test/mockResizeObserver"; +import { render } from "../test/render"; +import { + createScrollContainer, + mockWindowScrolling +} from "../test/scrollElement"; +import type { + ScrollElement, + ScrollElementLinkSource, + ScrollElementLinkTarget, + ScrollElementViewport +} from "./types"; +import { + resolveScrollElement, + useScrollElementLink +} from "./useScrollElementLink"; + +type Recorder = { + link: ReturnType>; + setViewport: ReturnType>; + source: ScrollElementLinkSource | null; + viewport: ScrollElementViewport | null; +}; + +function createRecorder(): Recorder { + const recorder: Recorder = { + link: vi.fn((source, viewport) => { + recorder.source = source; + recorder.viewport = source !== null ? viewport : null; + }), + setViewport: vi.fn((viewport) => { + recorder.viewport = viewport; + }), + source: null, + viewport: null + }; + return recorder; +} + +// A minimal link target: a 1000px high element +function Target({ + recorder, + scrollElement, + scrollingResetTimeInterval = 10 +}: { + recorder: Recorder; + scrollElement: ScrollElement | null | undefined; + scrollingResetTimeInterval?: number; +}) { + const elementRef = useRef(null); + const [target] = useState(() => ({ + getElement: () => elementRef.current, + link: (source, viewport) => recorder.link(source, viewport), + setViewport: (viewport) => recorder.setViewport(viewport) + })); + useScrollElementLink(scrollElement, target, scrollingResetTimeInterval); + return
; +} + +function wait(ms: number) { + return act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +describe("useScrollElementLink", () => { + let unmockResizeObserver: () => void; + beforeEach(() => { + unmockResizeObserver = mockResizeObserver(); + }); + afterEach(() => { + render.unmount(); + unmockResizeObserver(); + }); + + it("should resolve the scrollElement prop", () => { + const element = document.createElement("div"); + expect(resolveScrollElement("window")).toBe(window); + expect(resolveScrollElement(element)).toBe(element); + expect(resolveScrollElement(null)).toBe(null); + expect(resolveScrollElement(undefined)).toBe(null); + }); + + it("should link the target to the measured viewport when mounted", () => { + const recorder = createRecorder(); + const container = createScrollContainer({ + contentOffset: 100, + height: 300 + }); + container.element.scrollTop = 250; + render(); + + expect(recorder.link).toHaveBeenCalledTimes(1); + expect(recorder.source).not.toBe(null); + expect(recorder.viewport).toEqual({ + height: 300, + isScrolling: false, + scrollTop: 150 + }); + }); + + it("should not link the target without a scroll element", () => { + const recorder = createRecorder(); + render(); + render(); + expect(recorder.link).not.toHaveBeenCalled(); + expect(recorder.setViewport).not.toHaveBeenCalled(); + }); + + it("should update the viewport (isScrolling) when the scroll element scrolls, and reset isScrolling afterwards", async () => { + const recorder = createRecorder(); + const container = createScrollContainer({ contentOffset: 100 }); + render(); + expect(recorder.viewport).toEqual({ + height: 100, + isScrolling: false, + scrollTop: -100 + }); + + container.scroll(300); + expect(recorder.viewport).toEqual({ + height: 100, + isScrolling: true, + scrollTop: 200 + }); + + await wait(50); + expect(recorder.viewport).toEqual({ + height: 100, + isScrolling: false, + scrollTop: 200 + }); + }); + + it("should use the latest scrollingResetTimeInterval", async () => { + const recorder = createRecorder(); + const container = createScrollContainer(); + render( + + ); + render( + + ); + + container.scroll(100); + await wait(100); + expect(recorder.viewport!.isScrolling).toBe(true); + + await wait(400); + expect(recorder.viewport!.isScrolling).toBe(false); + }); + + it("should not update the viewport unless it changed", () => { + const recorder = createRecorder(); + const container = createScrollContainer(); + render(); + container.scroll(100); + recorder.setViewport.mockClear(); + + // Re-rendering re-measures the position, which hasn't changed + render(); + expect(recorder.setViewport).not.toHaveBeenCalled(); + }); + + it("should re-measure when the scroll element is resized", () => { + const recorder = createRecorder(); + const container = createScrollContainer({ contentOffset: 100 }); + container.element.scrollTop = 200; + render(); + expect(getObserverCount(container.element)).toBe(1); + + container.setContentOffset(50); + container.resize({ height: 400 }); + expect(recorder.viewport).toEqual({ + height: 400, + isScrolling: false, + scrollTop: 150 + }); + }); + + it("should re-measure the position after every commit", () => { + const recorder = createRecorder(); + const container = createScrollContainer({ contentOffset: 100 }); + container.element.scrollTop = 200; + render(); + + container.setContentOffset(150); + render( + + ); + expect(recorder.viewport!.scrollTop).toEqual(50); + }); + + it("should re-measure when the window is resized", () => { + const recorder = createRecorder(); + const windowScrolling = mockWindowScrolling({ innerHeight: 500 }); + render(); + expect(recorder.viewport).toEqual({ + height: 500, + isScrolling: false, + scrollTop: 0 + }); + + windowScrolling.resize({ height: 800 }); + expect(recorder.viewport!.height).toEqual(800); + + // The position is measured again as well (e.g. content above the target reflowed) + const contentAbove = document.createElement("div"); + contentAbove.style.height = "100px"; + document.body.prepend(contentAbove); + windowScrolling.resize({ height: 600 }); + expect(recorder.viewport).toEqual({ + height: 600, + isScrolling: false, + scrollTop: -100 + }); + contentAbove.remove(); + }); + + it("should link to an already scrolled window (measuring the position relative to the scrolled document)", () => { + const recorder = createRecorder(); + mockWindowScrolling({ scrollY: 400 }); + const contentAbove = document.createElement("div"); + contentAbove.style.height = "100px"; + document.body.prepend(contentAbove); + + render(); + expect(recorder.viewport!.scrollTop).toEqual(300); + contentAbove.remove(); + }); + + it("should unlink the target and stop listening when unmounted", () => { + const recorder = createRecorder(); + const container = createScrollContainer(); + const removeEventListener = vi.spyOn( + container.element, + "removeEventListener" + ); + render(); + + render.unmount(); + expect(recorder.link).toHaveBeenLastCalledWith(null, expect.anything()); + expect(removeEventListener).toHaveBeenCalledWith( + "scroll", + expect.any(Function) + ); + expect(getObserverCount(container.element)).toBe(0); + + recorder.setViewport.mockClear(); + container.scroll(100); + expect(recorder.setViewport).not.toHaveBeenCalled(); + }); + + it("should re-link the target when the scroll element changes", () => { + const recorder = createRecorder(); + const containerA = createScrollContainer(); + const containerB = createScrollContainer({ adopt: false, height: 200 }); + containerB.element.scrollTop = 300; + render(); + + containerB.adopt(); + render(); + expect(recorder.link.mock.calls.map(([source]) => source !== null)).toEqual( + [true, false, true] + ); + expect(recorder.viewport).toEqual({ + height: 200, + isScrolling: false, + scrollTop: 300 + }); + expect(getObserverCount(containerA.element)).toBe(0); + expect(getObserverCount(containerB.element)).toBe(1); + + recorder.setViewport.mockClear(); + containerA.scroll(100); + expect(recorder.setViewport).not.toHaveBeenCalled(); + }); + + it("should work in StrictMode", () => { + const recorder = createRecorder(); + const container = createScrollContainer(); + const addEventListener = vi.spyOn(container.element, "addEventListener"); + const removeEventListener = vi.spyOn( + container.element, + "removeEventListener" + ); + render( + + + + ); + expect(recorder.source).not.toBe(null); + expect( + addEventListener.mock.calls.filter(([type]) => type === "scroll").length - + removeEventListener.mock.calls.filter(([type]) => type === "scroll") + .length + ).toBe(1); + expect(getObserverCount(container.element)).toBe(1); + + container.scroll(100); + expect(recorder.viewport!.scrollTop).toEqual(100); + }); + + describe("link source (scrollTo)", () => { + it("should scroll the scroll element to the offset relative to the target, preserving the horizontal scroll position (#18)", () => { + const recorder = createRecorder(); + const container = createScrollContainer({ contentOffset: 100 }); + const scrollTo = vi.spyOn(container.element, "scrollTo"); + render(); + container.scroll(0, 40); + + let scrolled = false; + act(() => { + scrolled = recorder.source!.scrollTo(200); + }); + expect(scrolled).toBe(true); + expect(scrollTo).toHaveBeenLastCalledWith(40, 300); + expect(recorder.viewport!.scrollTop).toEqual(200); + }); + + it("should scroll the window, preserving its horizontal scroll position (#18)", () => { + const recorder = createRecorder(); + const windowScrolling = mockWindowScrolling(); + const scrollTo = vi.spyOn(window, "scrollTo"); + render(); + windowScrolling.scroll(0, 300); + + act(() => { + recorder.source!.scrollTo(200); + }); + expect(scrollTo).toHaveBeenLastCalledWith(300, 200); + expect(window.scrollY).toEqual(200); + }); + + it("should set scrollTop on a scroll element without scrollTo()", () => { + const recorder = createRecorder(); + const container = createScrollContainer({ contentOffset: 100 }); + (container.element as unknown as { scrollTo: unknown }).scrollTo = + undefined; + render(); + + act(() => { + recorder.source!.scrollTo(200); + }); + expect(container.element.scrollTop).toEqual(300); + }); + + it("should not scroll if the scroll element is already scrolled to the offset", () => { + const recorder = createRecorder(); + const container = createScrollContainer({ contentOffset: 100 }); + container.element.scrollTop = 300; + const scrollTo = vi.spyOn(container.element, "scrollTo"); + render(); + + let scrolled = true; + act(() => { + scrolled = recorder.source!.scrollTo(200); + }); + expect(scrolled).toBe(false); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it("should measure the position of the target again before scrolling", () => { + const recorder = createRecorder(); + const container = createScrollContainer({ contentOffset: 100 }); + const scrollTo = vi.spyOn(container.element, "scrollTo"); + render(); + + container.setContentOffset(250); + act(() => { + recorder.source!.scrollTo(200); + }); + expect(scrollTo).toHaveBeenLastCalledWith(0, 450); + }); + }); +}); diff --git a/lib/utils/scrollElement/useScrollElementLink.ts b/lib/utils/scrollElement/useScrollElementLink.ts new file mode 100644 index 000000000..87fe0a264 --- /dev/null +++ b/lib/utils/scrollElement/useScrollElementLink.ts @@ -0,0 +1,208 @@ +import { useInsertionEffect, useState } from "react"; +import { useIsomorphicLayoutEffect } from "../useIsomorphicLayoutEffect"; +import { + getDimensions, + getPositionOffset, + getScrollOffset +} from "./dimensions"; +import { + registerScrollListener, + unregisterScrollListener, + type ScrollElementListener +} from "./scrollListeners"; +import type { + ResolvedScrollElement, + ScrollElement, + ScrollElementLinkSource, + ScrollElementLinkTarget, + ScrollElementViewport +} from "./types"; + +type Instance = ScrollElementListener & { + element: ResolvedScrollElement | null; + scrollingResetTimeInterval: number; + source: ScrollElementLinkSource; + target: ScrollElementLinkTarget; + // Most recently provided to the target + viewport: ScrollElementViewport; + + // Measures the scroll element and the target's position within it, and provides the target with the resulting viewport (if it changed) + update(isScrolling: boolean): void; +}; + +/** Resolves the `scrollElement` prop to the object that is listened to (null during server rendering, or if not specified). */ +export function resolveScrollElement( + scrollElement: ScrollElement | null | undefined +): ResolvedScrollElement | null { + if (scrollElement === "window") { + return typeof window !== "undefined" ? window : null; + } + return scrollElement ?? null; +} + +function measureViewport( + element: ResolvedScrollElement, + target: ScrollElementLinkTarget, + isScrolling: boolean +): ScrollElementViewport { + const targetElement = target.getElement(); + const positionFromTop = targetElement + ? getPositionOffset(targetElement, element).top + : 0; + + return { + height: getDimensions(element).height, + isScrolling, + scrollTop: getScrollOffset(element).top - positionFromTop + }; +} + +function createInstance( + target: ScrollElementLinkTarget, + scrollingResetTimeInterval: number +): Instance { + const instance: Instance = { + element: null, + scrollingResetTimeInterval, + target, + viewport: { height: 0, isScrolling: false, scrollTop: 0 }, + + source: { + scrollTo(scrollTop) { + const { element } = instance; + if (element === null) { + return false; + } + + // Measure the position again, in case content above the component has changed size since it was last measured + const targetElement = instance.target.getElement(); + const positionFromTop = targetElement + ? getPositionOffset(targetElement, element).top + : 0; + + const scrollOffset = getScrollOffset(element); + const top = scrollTop + positionFromTop; + if (Math.abs(scrollOffset.top - top) < 1) { + return false; + } + + if (typeof element.scrollTo === "function") { + // Preserve the current horizontal scroll position + element.scrollTo(scrollOffset.left, top); + } else { + (element as Element).scrollTop = top; + } + return true; + } + }, + + update(isScrolling) { + const { element } = instance; + if (element === null) { + return; + } + + const viewport = measureViewport(element, instance.target, isScrolling); + const prevViewport = instance.viewport; + if ( + viewport.height !== prevViewport.height || + viewport.isScrolling !== prevViewport.isScrolling || + viewport.scrollTop !== prevViewport.scrollTop + ) { + instance.viewport = viewport; + instance.target.setViewport(viewport); + } + }, + + // ScrollElementListener (called by scrollListeners.ts) + getScrollingResetTimeInterval() { + return instance.scrollingResetTimeInterval; + }, + + handleScrollEvent() { + instance.update(true); + }, + + resetIsScrolling() { + instance.update(false); + } + }; + + return instance; +} + +// Calls `onResize` when the scroll element is resized: the window (resize event) or an element (ResizeObserver). +// Returns a function that stops observing it. +function observeResize(element: ResolvedScrollElement, onResize: () => void) { + if (element === window) { + window.addEventListener("resize", onResize, false); + return () => window.removeEventListener("resize", onResize, false); + } + + const ResizeObserverImpl = + (element as Element).ownerDocument?.defaultView?.ResizeObserver ?? + (typeof ResizeObserver !== "undefined" ? ResizeObserver : undefined); + if (!ResizeObserverImpl) { + return () => {}; + } + + const resizeObserver = new ResizeObserverImpl(onResize); + resizeObserver.observe(element as Element, { box: "border-box" }); + return () => resizeObserver.disconnect(); +} + +/** + * Links a component (the target) to the element that scrolls it (its `scrollElement` prop): the window or a scrollable element. + * + * While the scroll element is available, the target is linked to it (see `ScrollElementLinkTarget`): + * it is provided with the part of the scroll element that is visible to it (its viewport), + * which is measured again whenever the scroll element scrolls or is resized, and after every commit of the component + * (so that its position within the scroll element is current, e.g. if content above it has changed size). + * The target scrolls the scroll element through the link source it is provided with. + * + * The target is unlinked when the scroll element is removed (or changes) and when the component unmounts. + * (During server rendering, effects don't run, so the target is never linked.) + */ +export function useScrollElementLink( + scrollElement: ScrollElement | null | undefined, + target: ScrollElementLinkTarget, + scrollingResetTimeInterval: number +) { + const [instance] = useState(() => + createInstance(target, scrollingResetTimeInterval) + ); + + useInsertionEffect(() => { + instance.scrollingResetTimeInterval = scrollingResetTimeInterval; + }); + + const element = resolveScrollElement(scrollElement); + + useIsomorphicLayoutEffect(() => { + if (element === null) { + return; + } + + instance.element = element; + instance.viewport = measureViewport(element, instance.target, false); + instance.target.link(instance.source, instance.viewport); + + registerScrollListener(instance, element); + const stopObservingResize = observeResize(element, () => + instance.update(instance.viewport.isScrolling) + ); + + return () => { + unregisterScrollListener(instance, element); + stopObservingResize(); + + instance.element = null; + instance.target.link(null, instance.viewport); + }; + }, [element, instance]); + + // The component's position within the scroll element may have changed (e.g. if content above it has changed size) + useIsomorphicLayoutEffect(() => { + instance.update(instance.viewport.isScrolling); + }); +} diff --git a/lib/utils/test/mockResizeObserver.ts b/lib/utils/test/mockResizeObserver.ts new file mode 100644 index 000000000..8e57871e5 --- /dev/null +++ b/lib/utils/test/mockResizeObserver.ts @@ -0,0 +1,140 @@ +/** + * Test-only `ResizeObserver` mock (jsdom does not implement `ResizeObserver`). + * + * Call `mockResizeObserver()` (e.g. in `beforeEach`) to install the mock; it returns a function that restores the original. + * Then use `setElementSize()` to simulate an element being resized; this also updates the element's + * `offsetWidth`/`offsetHeight` (which jsdom always reports as 0) and notifies every observer of that element. + * + * Like the real API, observers are notified once when they start observing an element. + */ + +const elementSizes = new Map(); +const observers = new Set(); + +const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetHeight" +); +const originalOffsetWidth = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + "offsetWidth" +); + +export function mockResizeObserver() { + const originalResizeObserver = window.ResizeObserver; + + window.ResizeObserver = MockResizeObserver; + + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get(this: HTMLElement) { + return elementSizes.get(this)?.height ?? 0; + } + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + get(this: HTMLElement) { + return elementSizes.get(this)?.width ?? 0; + } + }); + + return function unmockResizeObserver() { + window.ResizeObserver = originalResizeObserver; + + if (originalOffsetHeight) { + Object.defineProperty( + HTMLElement.prototype, + "offsetHeight", + originalOffsetHeight + ); + } + if (originalOffsetWidth) { + Object.defineProperty( + HTMLElement.prototype, + "offsetWidth", + originalOffsetWidth + ); + } + + elementSizes.clear(); + observers.clear(); + }; +} + +/** Returns the number of mock observers currently observing the specified element. */ +export function getObserverCount(element: Element) { + let count = 0; + observers.forEach((observer) => { + if (observer.isObserving(element)) { + count++; + } + }); + return count; +} + +/** Simulates the element being resized (notifying the observers of that element). */ +export function setElementSize({ + element, + height, + width +}: { + element: Element; + height: number; + width: number; +}) { + elementSizes.set(element, { height, width }); + + observers.forEach((observer) => observer.notify(element)); +} + +class MockResizeObserver implements ResizeObserver { + readonly #callback: ResizeObserverCallback; + readonly #elements = new Set(); + + constructor(callback: ResizeObserverCallback) { + this.#callback = callback; + } + + observe(element: Element) { + this.#elements.add(element); + + observers.add(this); + + this.notify(element); + } + + unobserve(element: Element) { + this.#elements.delete(element); + } + + disconnect() { + this.#elements.clear(); + + observers.delete(this); + } + + isObserving(element: Element) { + return this.#elements.has(element); + } + + notify(element: Element) { + if (!this.#elements.has(element)) { + return; + } + + const { height, width } = elementSizes.get(element) ?? { + height: 0, + width: 0 + }; + const size = { blockSize: height, inlineSize: width }; + const entry = { + borderBoxSize: [size], + contentBoxSize: [size], + contentRect: new DOMRect(0, 0, width, height), + devicePixelContentBoxSize: [], + target: element + } as unknown as ResizeObserverEntry; + + this.#callback([entry], this); + } +} diff --git a/lib/utils/test/render.ts b/lib/utils/test/render.ts new file mode 100644 index 000000000..201a5cba2 --- /dev/null +++ b/lib/utils/test/render.ts @@ -0,0 +1,146 @@ +import { + act, + cloneElement, + type ReactElement, + type Ref, + type RefCallback +} from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach } from "vitest"; + +let mountNode: HTMLDivElement | null = null; +let root: Root | null = null; +let lastRendered: unknown = null; + +// The ref attached to the rendered element is kept stable across render() calls (as long as the element's own ref doesn't change), +// so that re-rendering the same markup doesn't defeat memo() (which re-renders a component whenever its ref changes). +let stableRef: RefCallback | null = null; +let stableRefOriginal: Ref | undefined = undefined; +let currentInstance: unknown = null; + +/** + * Returns the node that `render()` renders into (creating it, if it doesn't exist yet). + * By default, it is appended to the body; tests may move it elsewhere (e.g. into a scroll container, see scrollElement.ts). + */ +export function getMountNode(): HTMLDivElement { + if (!mountNode) { + mountNode = document.createElement("div"); + + // Unless we attach the mount-node to body, getBoundingClientRect() won't work + document.body.appendChild(mountNode); + + root = createRoot(mountNode); + } + return mountNode; +} + +/** + * Helper method for testing components that may use Portal and thus require cleanup. + * This helper method renders components to a transient node that is destroyed after the test completes. + * Note that rendering twice within the same test method will update the same element (rather than recreate it). + * + * Rendering is wrapped in `act()` so that it is applied synchronously. + * Like the legacy `ReactDOM.render()` API, the rendered class component instance is returned + * (or `null` for function components). + * + * Note that updates triggered outside of React (e.g. calling an instance method or dispatching a DOM event) + * must also be wrapped in `act()` for them to be applied synchronously. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function render(markup: ReactElement): Instance { + getMountNode(); + + const originalRef = (markup.props as { ref?: Ref }).ref; + if (stableRef === null || originalRef !== stableRefOriginal) { + stableRefOriginal = originalRef; + stableRef = (value: unknown) => { + currentInstance = value; + + if (typeof originalRef === "function") { + return originalRef(value); + } else if (originalRef) { + (originalRef as { current: unknown }).current = value; + } + }; + } + const ref = stableRef; + + act(() => { + root!.render(cloneElement(markup, { ref } as object)); + }); + + lastRendered = currentInstance; + + return currentInstance as Instance; +} + +/** + * The render() method auto-unmounts components after each test has completed. + * Use this method manually to test the componentWillUnmount() lifecycle method. + */ +render.unmount = function unmount() { + if (mountNode) { + act(() => { + root!.unmount(); + }); + + // (The mount node may have been moved, e.g. into a scroll container) + mountNode.remove(); + + mountNode = null; + root = null; + lastRendered = null; + stableRef = null; + stableRefOriginal = undefined; + currentInstance = null; + } +}; + +afterEach(render.unmount); + +type Fiber = { + child: Fiber | null; + sibling: Fiber | null; + stateNode: unknown; +}; + +/** + * Test-only replacement for `ReactDOM.findDOMNode()`, which was removed in React 19. + * Returns the first DOM node rendered by a class component instance (or the node itself, if passed a node). + * Also accepts the value returned by the most recent `render()` call (e.g. the imperative handle of a function component), + * in which case the first DOM node rendered into the test container is returned. + */ +export function findDOMNode(instance: unknown): HTMLElement | Text | null { + if (instance == null) { + return null; + } else if (instance instanceof Node) { + return instance as HTMLElement | Text; + } + + const fiber = (instance as { _reactInternals?: Fiber })._reactInternals; + if (!fiber && instance === lastRendered && mountNode) { + return mountNode.firstChild as HTMLElement | Text | null; + } + if (!fiber) { + throw Error( + "findDOMNode() expects a mounted class component instance or the value returned by the most recent render() call" + ); + } + + // Depth-first search for the first host (DOM) node + const stack: Fiber[] = fiber.child ? [fiber.child] : []; + while (stack.length > 0) { + const current = stack.pop()!; + if (current.stateNode instanceof Node) { + return current.stateNode as HTMLElement | Text; + } + if (current.sibling) { + stack.push(current.sibling); + } + if (current.child) { + stack.push(current.child); + } + } + + return null; +} diff --git a/lib/utils/test/scrollElement.ts b/lib/utils/test/scrollElement.ts new file mode 100644 index 000000000..362a4a562 --- /dev/null +++ b/lib/utils/test/scrollElement.ts @@ -0,0 +1,386 @@ +/** + * Test-only helpers for components scrolled by a scroll element (their `scrollElement` prop): a scrollable container, or the window. + * + * jsdom has no layout (every `getBoundingClientRect()` is all zeros), so these helpers install a minimal layout model: + * - Elements are laid out as blocks: an element's top is the sum of the heights of its previous (non-absolutely positioned) siblings + * plus the top of its parent; absolutely positioned elements are placed at their `top` style. + * An element's height is its inline `height` style in pixels or, without one (e.g. `height: auto`), the sum of the heights of its + * (non-absolutely positioned) children. + * - A scroll container created by `createScrollContainer()` has a fixed rect; the elements inside it are positioned relative to it, + * moved up by its `scrollTop` (i.e. `top = containerTop + contentOffset - scrollTop`). + * - Any other element is positioned relative to the document, moved up by `window.scrollY` (as is the document element itself). + * + * The layout model and all other mocks are installed on first use and restored after each test. + */ +import { act } from "react"; +import { afterEach } from "vitest"; +import { setElementSize } from "./mockResizeObserver"; +import { getMountNode } from "./render"; + +type Rect = { height: number; left: number; top: number; width: number }; + +// jsdom allows these (readonly in the DOM typings) to be overwritten +const mutableWindow = window as unknown as { + innerHeight: number; + innerWidth: number; + scrollTo: Window["scrollTo"]; + scrollX: number; + scrollY: number; +}; + +const containerRects = new Map(); +const containers: ScrollContainer[] = []; + +let originalGetBoundingClientRect: + | typeof Element.prototype.getBoundingClientRect + | null = null; + +function parsePixels(value: string): number | null { + const match = /^(-?\d+(?:\.\d+)?)px$/.exec(value); + return match ? parseFloat(match[1]) : null; +} + +function getStyle(element: Element): CSSStyleDeclaration | null { + return element instanceof HTMLElement ? element.style : null; +} + +function isAbsolutelyPositioned(element: Element) { + const position = getStyle(element)?.position; + return position === "absolute" || position === "fixed"; +} + +function getLayoutHeight(element: Element): number { + const containerRect = containerRects.get(element); + if (containerRect) { + return containerRect.height; + } + + const height = parsePixels(getStyle(element)?.height ?? ""); + if (height !== null) { + return height; + } + + let sum = 0; + for (const child of Array.from(element.children)) { + if (!isAbsolutelyPositioned(child)) { + sum += getLayoutHeight(child); + } + } + return sum; +} + +// The top of the element relative to the top of the root (e.g. the content of a scroll container) +function getLayoutTop(element: Element, root: Element): number { + let top = 0; + let node: Element = element; + while (node !== root && node.parentElement) { + if (isAbsolutelyPositioned(node)) { + top += parsePixels(getStyle(node)?.top ?? "") ?? 0; + } else { + for ( + let sibling = node.previousElementSibling; + sibling; + sibling = sibling.previousElementSibling + ) { + if (!isAbsolutelyPositioned(sibling)) { + top += getLayoutHeight(sibling); + } + } + } + node = node.parentElement; + } + return top; +} + +function getClosestContainer(element: Element): Element | null { + for ( + let node = element.parentElement; + node !== null; + node = node.parentElement + ) { + if (containerRects.has(node)) { + return node; + } + } + return null; +} + +function getBoundingClientRect(this: Element): DOMRect { + const containerRect = containerRects.get(this); + if (containerRect) { + return new DOMRect( + containerRect.left, + containerRect.top, + containerRect.width, + containerRect.height + ); + } + + const documentElement = document.documentElement; + if (this === documentElement) { + return new DOMRect( + -mutableWindow.scrollX, + -mutableWindow.scrollY, + mutableWindow.innerWidth, + getLayoutHeight(document.body) + ); + } + + const height = getLayoutHeight(this); + const width = parsePixels(getStyle(this)?.width ?? ""); + + const container = getClosestContainer(this); + if (container !== null) { + const rect = containerRects.get(container)!; + return new DOMRect( + rect.left - container.scrollLeft, + rect.top + getLayoutTop(this, container) - container.scrollTop, + width ?? rect.width, + height + ); + } + + return new DOMRect( + -mutableWindow.scrollX, + getLayoutTop(this, documentElement) - mutableWindow.scrollY, + width ?? mutableWindow.innerWidth, + height + ); +} + +/** Installs the layout model (see above); it is restored after each test. */ +export function mockLayout() { + if (originalGetBoundingClientRect === null) { + originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = getBoundingClientRect; + } +} + +export type ScrollContainer = { + /** The scrollable container (to pass as `scrollElement`). */ + element: HTMLDivElement; + + /** Content above the component (within the container); its height is the component's offset. */ + contentAbove: HTMLDivElement; + + /** Moves the node rendered into by `render()` into this container (below `contentAbove`). Done on creation by default. */ + adopt(): void; + + /** Changes the height of the content above the component (without any events, like a layout change). */ + setContentOffset(offset: number): void; + + /** Resizes the container (and notifies the mocked ResizeObserver, if installed with `mockResizeObserver()`). */ + resize(size: { height?: number; width?: number }): void; + + /** Scrolls the container (dispatching a scroll event), inside `act()`. */ + scroll(scrollTop: number, scrollLeft?: number): void; + + /** Scrolls the container like the browser would (dispatching a scroll event); this is the container's `scrollTo()`. */ + scrollTo: (left: number, top: number) => void; +}; + +/** + * Creates a scrollable container (appended to the body) with a fixed rect, for use as a `scrollElement`, + * and (unless `adopt` is false) moves the node that `render()` renders into into it, + * below `contentOffset` pixels of other content. + * Its `scrollTo()` sets `scrollTop`/`scrollLeft` and dispatches a `scroll` event (jsdom doesn't scroll). + */ +export function createScrollContainer({ + adopt = true, + contentOffset = 0, + height = 100, + left = 0, + top = 0, + width = 200 +}: { + adopt?: boolean; + contentOffset?: number; + height?: number; + left?: number; + top?: number; + width?: number; +} = {}): ScrollContainer { + mockLayout(); + + const element = document.createElement("div"); + element.style.height = `${height}px`; + element.style.overflow = "auto"; + element.style.width = `${width}px`; + document.body.appendChild(element); + containerRects.set(element, { height, left, top, width }); + + const contentAbove = document.createElement("div"); + contentAbove.style.height = `${contentOffset}px`; + element.appendChild(contentAbove); + + const scrollTo = (left: number, top: number) => { + element.scrollLeft = left; + element.scrollTop = top; + element.dispatchEvent(new window.Event("scroll")); + }; + element.scrollTo = (( + leftOrOptions?: number | ScrollToOptions, + top?: number + ) => { + if (typeof leftOrOptions === "object") { + scrollTo( + leftOrOptions.left ?? element.scrollLeft, + leftOrOptions.top ?? element.scrollTop + ); + } else { + scrollTo(leftOrOptions ?? 0, top ?? 0); + } + }) as typeof element.scrollTo; + + const container: ScrollContainer = { + element, + contentAbove, + adopt() { + element.appendChild(getMountNode()); + }, + setContentOffset(offset) { + contentAbove.style.height = `${offset}px`; + }, + resize(size) { + const rect = containerRects.get(element)!; + const newRect = { ...rect, ...size }; + containerRects.set(element, newRect); + element.style.height = `${newRect.height}px`; + element.style.width = `${newRect.width}px`; + act(() => { + setElementSize({ + element, + height: newRect.height, + width: newRect.width + }); + }); + }, + scroll(scrollTop, scrollLeft = element.scrollLeft) { + act(() => { + scrollTo(scrollLeft, scrollTop); + }); + }, + scrollTo + }; + + if (adopt) { + container.adopt(); + } + + containers.push(container); + + return container; +} + +export type WindowScrolling = { + /** Scrolls the window (dispatching a scroll event), inside `act()`. */ + scroll(scrollY: number, scrollX?: number): void; + + /** Resizes the window (dispatching a resize event), inside `act()`. */ + resize(size: { height?: number; width?: number }): void; + + /** The mocked `window.scrollTo()`, which scrolls the window (dispatching a scroll event). */ + scrollTo: Window["scrollTo"]; +}; + +let originalWindow: { + innerHeight: number; + innerWidth: number; + scrollTo: Window["scrollTo"]; +} | null = null; + +/** Dispatches a scroll event to the window (without `act()`). */ +function dispatchWindowScroll() { + const event = new window.Event("scroll", { bubbles: true }); + // Vitest's jsdom environment aliases the global `window` to `globalThis`, so the + // `currentTarget` of an event bubbling up to the (internal) jsdom Window is not `=== window`. + // Report the global `window` as the current target so that the scroll listeners recognize it. + Object.defineProperty(event, "currentTarget", { get: () => window }); + document.dispatchEvent(event); +} + +/** + * Mocks scrolling the window: `scrollX`/`scrollY` (initially 0), `innerHeight`/`innerWidth` and `scrollTo()` + * (which sets the scroll offsets and dispatches a scroll event); and installs the layout model (see above). + * Everything is restored after each test. + */ +export function mockWindowScrolling({ + innerHeight = 500, + innerWidth = 500, + scrollX = 0, + scrollY = 0 +}: { + innerHeight?: number; + innerWidth?: number; + scrollX?: number; + scrollY?: number; +} = {}): WindowScrolling { + mockLayout(); + + if (originalWindow === null) { + originalWindow = { + innerHeight: mutableWindow.innerHeight, + innerWidth: mutableWindow.innerWidth, + scrollTo: mutableWindow.scrollTo + }; + } + + mutableWindow.innerHeight = innerHeight; + mutableWindow.innerWidth = innerWidth; + mutableWindow.scrollX = scrollX; + mutableWindow.scrollY = scrollY; + + const scrollTo = ((xOrOptions?: number | ScrollToOptions, y?: number) => { + if (typeof xOrOptions === "object") { + mutableWindow.scrollX = xOrOptions.left ?? mutableWindow.scrollX; + mutableWindow.scrollY = xOrOptions.top ?? mutableWindow.scrollY; + } else { + mutableWindow.scrollX = xOrOptions ?? 0; + mutableWindow.scrollY = y ?? 0; + } + dispatchWindowScroll(); + }) as Window["scrollTo"]; + mutableWindow.scrollTo = scrollTo; + + return { + scroll(scrollY, scrollX = mutableWindow.scrollX) { + act(() => { + mutableWindow.scrollX = scrollX; + mutableWindow.scrollY = scrollY; + dispatchWindowScroll(); + }); + }, + resize({ + height = mutableWindow.innerHeight, + width = mutableWindow.innerWidth + }) { + act(() => { + mutableWindow.innerHeight = height; + mutableWindow.innerWidth = width; + document.dispatchEvent(new window.Event("resize", { bubbles: true })); + }); + }, + scrollTo + }; +} + +afterEach(() => { + containers.splice(0).forEach((container) => container.element.remove()); + containerRects.clear(); + + if (originalGetBoundingClientRect !== null) { + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + originalGetBoundingClientRect = null; + } + + if (originalWindow !== null) { + mutableWindow.innerHeight = originalWindow.innerHeight; + mutableWindow.innerWidth = originalWindow.innerWidth; + mutableWindow.scrollTo = originalWindow.scrollTo; + mutableWindow.scrollX = 0; + mutableWindow.scrollY = 0; + originalWindow = null; + } + + document.body.style.pointerEvents = ""; +}); diff --git a/lib/utils/useIsomorphicLayoutEffect.ts b/lib/utils/useIsomorphicLayoutEffect.ts new file mode 100644 index 000000000..de50ead7a --- /dev/null +++ b/lib/utils/useIsomorphicLayoutEffect.ts @@ -0,0 +1,7 @@ +import { useEffect, useLayoutEffect } from "react"; + +/** + * `useLayoutEffect` in the browser; `useEffect` during server rendering (where layout effects warn and never run). + */ +export const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; diff --git a/lib/vendor/binarySearchBounds.ts b/lib/vendor/binarySearchBounds.ts new file mode 100644 index 000000000..b2c32425e --- /dev/null +++ b/lib/vendor/binarySearchBounds.ts @@ -0,0 +1,339 @@ +/** + * Binary Search Bounds + * https://github.com/mikolalysenko/binary-search-bounds + * Mikola Lysenko + * + * Inlined because of Content Security Policy issue caused by the use of `new Function(...)` syntax. + * Issue reported here: https://github.com/mikolalysenko/binary-search-bounds/issues/5 + **/ + +type Compare = (a: T, b: Y) => number; + +/** + * Searches a sorted array, optionally within the inclusive [lo, hi] index range. + * Either uses the natural ordering of the values, or the specified comparator. + */ +export type BoundsSearch = { + (array: ArrayLike, value: T, lo?: number, hi?: number): number; + ( + array: ArrayLike, + value: Y, + compare: Compare, + lo?: number, + hi?: number + ): number; +}; + +function _GEA(a: ArrayLike, l: number, h: number, y: T): number { + let i = h + 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (x >= y) { + i = m; + h = m - 1; + } else { + l = m + 1; + } + } + return i; +} +function _GEP( + a: ArrayLike, + l: number, + h: number, + y: Y, + c: Compare +): number { + let i = h + 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (c(x, y) >= 0) { + i = m; + h = m - 1; + } else { + l = m + 1; + } + } + return i; +} +function dispatchBsearchGE( + a: ArrayLike, + y: T | Y, + c?: Compare | number, + l?: number, + h?: number +): number { + if (typeof c === "function") { + return _GEP( + a, + l === void 0 ? 0 : l | 0, + h === void 0 ? a.length - 1 : h | 0, + y as Y, + c + ); + } else { + return _GEA( + a, + c === void 0 ? 0 : c | 0, + l === void 0 ? a.length - 1 : l | 0, + y as T + ); + } +} + +function _GTA(a: ArrayLike, l: number, h: number, y: T): number { + let i = h + 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (x > y) { + i = m; + h = m - 1; + } else { + l = m + 1; + } + } + return i; +} +function _GTP( + a: ArrayLike, + l: number, + h: number, + y: Y, + c: Compare +): number { + let i = h + 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (c(x, y) > 0) { + i = m; + h = m - 1; + } else { + l = m + 1; + } + } + return i; +} +function dispatchBsearchGT( + a: ArrayLike, + y: T | Y, + c?: Compare | number, + l?: number, + h?: number +): number { + if (typeof c === "function") { + return _GTP( + a, + l === void 0 ? 0 : l | 0, + h === void 0 ? a.length - 1 : h | 0, + y as Y, + c + ); + } else { + return _GTA( + a, + c === void 0 ? 0 : c | 0, + l === void 0 ? a.length - 1 : l | 0, + y as T + ); + } +} + +function _LTA(a: ArrayLike, l: number, h: number, y: T): number { + let i = l - 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (x < y) { + i = m; + l = m + 1; + } else { + h = m - 1; + } + } + return i; +} +function _LTP( + a: ArrayLike, + l: number, + h: number, + y: Y, + c: Compare +): number { + let i = l - 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (c(x, y) < 0) { + i = m; + l = m + 1; + } else { + h = m - 1; + } + } + return i; +} +function dispatchBsearchLT( + a: ArrayLike, + y: T | Y, + c?: Compare | number, + l?: number, + h?: number +): number { + if (typeof c === "function") { + return _LTP( + a, + l === void 0 ? 0 : l | 0, + h === void 0 ? a.length - 1 : h | 0, + y as Y, + c + ); + } else { + return _LTA( + a, + c === void 0 ? 0 : c | 0, + l === void 0 ? a.length - 1 : l | 0, + y as T + ); + } +} + +function _LEA(a: ArrayLike, l: number, h: number, y: T): number { + let i = l - 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (x <= y) { + i = m; + l = m + 1; + } else { + h = m - 1; + } + } + return i; +} +function _LEP( + a: ArrayLike, + l: number, + h: number, + y: Y, + c: Compare +): number { + let i = l - 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (c(x, y) <= 0) { + i = m; + l = m + 1; + } else { + h = m - 1; + } + } + return i; +} +function dispatchBsearchLE( + a: ArrayLike, + y: T | Y, + c?: Compare | number, + l?: number, + h?: number +): number { + if (typeof c === "function") { + return _LEP( + a, + l === void 0 ? 0 : l | 0, + h === void 0 ? a.length - 1 : h | 0, + y as Y, + c + ); + } else { + return _LEA( + a, + c === void 0 ? 0 : c | 0, + l === void 0 ? a.length - 1 : l | 0, + y as T + ); + } +} + +function _EQA(a: ArrayLike, l: number, h: number, y: T): number { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + l - 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + if (x === y) { + return m; + } else if (x <= y) { + l = m + 1; + } else { + h = m - 1; + } + } + return -1; +} +function _EQP( + a: ArrayLike, + l: number, + h: number, + y: Y, + c: Compare +): number { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + l - 1; + while (l <= h) { + const m = (l + h) >>> 1, + x = a[m]; + const p = c(x, y); + if (p === 0) { + return m; + } else if (p <= 0) { + l = m + 1; + } else { + h = m - 1; + } + } + return -1; +} +function dispatchBsearchEQ( + a: ArrayLike, + y: T | Y, + c?: Compare | number, + l?: number, + h?: number +): number { + if (typeof c === "function") { + return _EQP( + a, + l === void 0 ? 0 : l | 0, + h === void 0 ? a.length - 1 : h | 0, + y as Y, + c + ); + } else { + return _EQA( + a, + c === void 0 ? 0 : c | 0, + l === void 0 ? a.length - 1 : l | 0, + y as T + ); + } +} + +export const binarySearchBounds: { + ge: BoundsSearch; + gt: BoundsSearch; + lt: BoundsSearch; + le: BoundsSearch; + eq: BoundsSearch; +} = { + ge: dispatchBsearchGE, + gt: dispatchBsearchGT, + lt: dispatchBsearchLT, + le: dispatchBsearchLE, + eq: dispatchBsearchEQ +}; diff --git a/lib/vendor/intervalTree.test.ts b/lib/vendor/intervalTree.test.ts new file mode 100644 index 000000000..bb7de3ace --- /dev/null +++ b/lib/vendor/intervalTree.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { createIntervalTree, type Interval } from "./intervalTree"; + +// Seeded so that failures are reproducible +function createRandom(seed: number) { + return () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; +} + +function sortIntervals(intervals: Interval[]): Interval[] { + return [...intervals].sort((a, b) => a[2] - b[2]); +} + +function queryPoint(tree: ReturnType, x: number) { + const result: Interval[] = []; + tree.queryPoint(x, (interval) => { + result.push(interval); + }); + return sortIntervals(result); +} + +function queryInterval( + tree: ReturnType, + lo: number, + hi: number +) { + const result: Interval[] = []; + tree.queryInterval(lo, hi, (interval) => { + result.push(interval); + }); + return sortIntervals(result); +} + +describe("intervalTree", () => { + it("should remove an interval and return whether it was found", () => { + const a: Interval = [0, 10, 0]; + const b: Interval = [20, 30, 1]; + const tree = createIntervalTree([a, b]); + + expect(tree.remove(a)).toBe(true); + expect(tree.count).toBe(1); + expect(tree.intervals).toEqual([b]); + + // Intervals are removed by reference + expect(tree.remove([20, 30, 1])).toBe(false); + expect(tree.remove(a)).toBe(false); + expect(tree.count).toBe(1); + + expect(tree.remove(b)).toBe(true); + expect(tree.count).toBe(0); + expect(tree.intervals).toEqual([]); + expect(tree.remove(b)).toBe(false); + }); + + it.each([1, 2, 3, 4, 5])( + "should match a list of intervals when inserting and removing (seed %i)", + (seed) => { + const random = createRandom(seed * 7919); + const size = (max: number) => Math.floor(random() * max); + + const tree = createIntervalTree(); + const expected: Interval[] = []; + let nextId = 0; + + for (let step = 0; step < 2000; step++) { + if (expected.length === 0 || random() < 0.55) { + const lo = size(1000); + const interval: Interval = [lo, lo + size(100), nextId++]; + tree.insert(interval); + expected.push(interval); + } else { + const [interval] = expected.splice(size(expected.length), 1); + expect(tree.remove(interval)).toBe(true); + expect(tree.remove(interval)).toBe(false); + } + + expect(tree.count).toBe(expected.length); + + if (step % 10 === 0) { + expect(sortIntervals(tree.intervals)).toEqual( + sortIntervals(expected) + ); + + const x = size(1100); + expect(queryPoint(tree, x)).toEqual( + sortIntervals(expected.filter(([lo, hi]) => lo <= x && x <= hi)) + ); + + const lo = size(1100); + const hi = lo + size(200); + expect(queryInterval(tree, lo, hi)).toEqual( + sortIntervals( + expected.filter(([start, end]) => start <= hi && end >= lo) + ) + ); + } + } + } + ); +}); diff --git a/lib/vendor/intervalTree.ts b/lib/vendor/intervalTree.ts new file mode 100644 index 000000000..6ca42f5b7 --- /dev/null +++ b/lib/vendor/intervalTree.ts @@ -0,0 +1,470 @@ +/** + * Binary Search Bounds + * https://github.com/mikolalysenko/interval-tree-1d + * Mikola Lysenko + * + * Inlined because of Content Security Policy issue caused by the use of `new Function(...)` syntax in an upstream dependency. + * Issue reported here: https://github.com/mikolalysenko/binary-search-bounds/issues/5 + **/ + +import { binarySearchBounds as bounds } from "./binarySearchBounds"; + +/** An interval [low, high], optionally followed by additional data (e.g. an index). */ +export type Interval = [number, number, ...number[]]; + +type ResultCode = 0 | 1 | 2; + +const NOT_FOUND: ResultCode = 0; +const SUCCESS: ResultCode = 1; +const EMPTY: ResultCode = 2; + +class IntervalTreeNode { + mid: number; + left: IntervalTreeNode | null; + right: IntervalTreeNode | null; + leftPoints: Interval[]; + rightPoints: Interval[]; + count: number; + + constructor( + mid: number, + left: IntervalTreeNode | null, + right: IntervalTreeNode | null, + leftPoints: Interval[], + rightPoints: Interval[] + ) { + this.mid = mid; + this.left = left; + this.right = right; + this.leftPoints = leftPoints; + this.rightPoints = rightPoints; + this.count = + (left ? left.count : 0) + (right ? right.count : 0) + leftPoints.length; + } + + intervals(result: Interval[]): Interval[] { + // eslint-disable-next-line prefer-spread + result.push.apply(result, this.leftPoints); + if (this.left) { + this.left.intervals(result); + } + if (this.right) { + this.right.intervals(result); + } + return result; + } + + insert(interval: Interval): void { + const weight = this.count - this.leftPoints.length; + this.count += 1; + if (interval[1] < this.mid) { + if (this.left) { + if (4 * (this.left.count + 1) > 3 * (weight + 1)) { + rebuildWithInterval(this, interval); + } else { + this.left.insert(interval); + } + } else { + this.left = createIntervalTreeNode([interval]); + } + } else if (interval[0] > this.mid) { + if (this.right) { + if (4 * (this.right.count + 1) > 3 * (weight + 1)) { + rebuildWithInterval(this, interval); + } else { + this.right.insert(interval); + } + } else { + this.right = createIntervalTreeNode([interval]); + } + } else { + const l = bounds.ge(this.leftPoints, interval, compareBegin); + const r = bounds.ge(this.rightPoints, interval, compareEnd); + this.leftPoints.splice(l, 0, interval); + this.rightPoints.splice(r, 0, interval); + } + } + + remove(interval: Interval): ResultCode { + // (Upstream subtracted the leftPoints array itself, which yields NaN and disabled rebalancing) + const weight = this.count - this.leftPoints.length; + if (interval[1] < this.mid) { + if (!this.left) { + return NOT_FOUND; + } + const rw = this.right ? this.right.count : 0; + if (4 * rw > 3 * (weight - 1)) { + return rebuildWithoutInterval(this, interval); + } + const r = this.left.remove(interval); + if (r === EMPTY) { + this.left = null; + this.count -= 1; + return SUCCESS; + } else if (r === SUCCESS) { + this.count -= 1; + } + return r; + } else if (interval[0] > this.mid) { + if (!this.right) { + return NOT_FOUND; + } + const lw = this.left ? this.left.count : 0; + if (4 * lw > 3 * (weight - 1)) { + return rebuildWithoutInterval(this, interval); + } + const r = this.right.remove(interval); + if (r === EMPTY) { + this.right = null; + this.count -= 1; + return SUCCESS; + } else if (r === SUCCESS) { + this.count -= 1; + } + return r; + } else { + if (this.count === 1) { + if (this.leftPoints[0] === interval) { + return EMPTY; + } else { + return NOT_FOUND; + } + } + if (this.leftPoints.length === 1 && this.leftPoints[0] === interval) { + if (this.left && this.right) { + // Upstream promoted the rightmost node of the left subtree into this node, which only adjusted the count + // of that node's parent, and could leave intervals of the left subtree that span the promoted node's mid point + // on the wrong side of it (where searches for them no longer reach); rebuild this subtree instead + return rebuildWithoutInterval(this, interval); + } else if (this.left) { + copy(this, this.left); + } else { + copy(this, this.right!); + } + return SUCCESS; + } + for ( + let l = bounds.ge(this.leftPoints, interval, compareBegin); + l < this.leftPoints.length; + ++l + ) { + if (this.leftPoints[l][0] !== interval[0]) { + break; + } + if (this.leftPoints[l] === interval) { + this.count -= 1; + this.leftPoints.splice(l, 1); + for ( + let r = bounds.ge(this.rightPoints, interval, compareEnd); + r < this.rightPoints.length; + ++r + ) { + if (this.rightPoints[r][1] !== interval[1]) { + break; + } else if (this.rightPoints[r] === interval) { + this.rightPoints.splice(r, 1); + return SUCCESS; + } + } + } + } + return NOT_FOUND; + } + } + + queryPoint(x: number, cb: (interval: Interval) => T): T | undefined { + if (x < this.mid) { + if (this.left) { + const r = this.left.queryPoint(x, cb); + if (r) { + return r; + } + } + return reportLeftRange(this.leftPoints, x, cb); + } else if (x > this.mid) { + if (this.right) { + const r = this.right.queryPoint(x, cb); + if (r) { + return r; + } + } + return reportRightRange(this.rightPoints, x, cb); + } else { + return reportRange(this.leftPoints, cb); + } + } + + queryInterval( + lo: number, + hi: number, + cb: (interval: Interval) => T + ): T | undefined { + if (lo < this.mid && this.left) { + const r = this.left.queryInterval(lo, hi, cb); + if (r) { + return r; + } + } + if (hi > this.mid && this.right) { + const r = this.right.queryInterval(lo, hi, cb); + if (r) { + return r; + } + } + if (hi < this.mid) { + return reportLeftRange(this.leftPoints, hi, cb); + } else if (lo > this.mid) { + return reportRightRange(this.rightPoints, lo, cb); + } else { + return reportRange(this.leftPoints, cb); + } + } +} + +function copy(a: IntervalTreeNode, b: IntervalTreeNode) { + a.mid = b.mid; + a.left = b.left; + a.right = b.right; + a.leftPoints = b.leftPoints; + a.rightPoints = b.rightPoints; + a.count = b.count; +} + +function rebuild(node: IntervalTreeNode, intervals: Interval[]) { + // Note: an empty list of intervals results in a null tree (and a TypeError); preserved as-is. + const ntree = createIntervalTreeNode(intervals)!; + node.mid = ntree.mid; + node.left = ntree.left; + node.right = ntree.right; + node.leftPoints = ntree.leftPoints; + node.rightPoints = ntree.rightPoints; + node.count = ntree.count; +} + +function rebuildWithInterval(node: IntervalTreeNode, interval: Interval) { + const intervals = node.intervals([]); + intervals.push(interval); + rebuild(node, intervals); +} + +function rebuildWithoutInterval( + node: IntervalTreeNode, + interval: Interval +): ResultCode { + const intervals = node.intervals([]); + const idx = intervals.indexOf(interval); + if (idx < 0) { + return NOT_FOUND; + } + intervals.splice(idx, 1); + if (intervals.length === 0) { + return EMPTY; + } + rebuild(node, intervals); + return SUCCESS; +} + +function reportLeftRange( + arr: Interval[], + hi: number, + cb: (interval: Interval) => T +): T | undefined { + for (let i = 0; i < arr.length && arr[i][0] <= hi; ++i) { + const r = cb(arr[i]); + if (r) { + return r; + } + } +} + +function reportRightRange( + arr: Interval[], + lo: number, + cb: (interval: Interval) => T +): T | undefined { + for (let i = arr.length - 1; i >= 0 && arr[i][1] >= lo; --i) { + const r = cb(arr[i]); + if (r) { + return r; + } + } +} + +function reportRange( + arr: Interval[], + cb: (interval: Interval) => T +): T | undefined { + for (let i = 0; i < arr.length; ++i) { + const r = cb(arr[i]); + if (r) { + return r; + } + } +} + +function compareNumbers(a: number, b: number) { + return a - b; +} + +function compareBegin(a: Interval, b: Interval) { + const d = a[0] - b[0]; + if (d) { + return d; + } + return a[1] - b[1]; +} + +function compareEnd(a: Interval, b: Interval) { + const d = a[1] - b[1]; + if (d) { + return d; + } + return a[0] - b[0]; +} + +function createIntervalTreeNode( + intervals: Interval[] +): IntervalTreeNode | null { + if (intervals.length === 0) { + return null; + } + const pts: number[] = []; + for (let i = 0; i < intervals.length; ++i) { + pts.push(intervals[i][0], intervals[i][1]); + } + pts.sort(compareNumbers); + + const mid = pts[pts.length >> 1]; + + const leftIntervals: Interval[] = []; + const rightIntervals: Interval[] = []; + const centerIntervals: Interval[] = []; + for (let i = 0; i < intervals.length; ++i) { + const s = intervals[i]; + if (s[1] < mid) { + leftIntervals.push(s); + } else if (mid < s[0]) { + rightIntervals.push(s); + } else { + centerIntervals.push(s); + } + } + + //Split center intervals + const leftPoints = centerIntervals; + const rightPoints = centerIntervals.slice(); + leftPoints.sort(compareBegin); + rightPoints.sort(compareEnd); + + return new IntervalTreeNode( + mid, + createIntervalTreeNode(leftIntervals), + createIntervalTreeNode(rightIntervals), + leftPoints, + rightPoints + ); +} + +export type IntervalTree = { + /** Inserts an interval into the tree. */ + insert(interval: Interval): void; + + /** + * Removes an interval (by reference) from the tree. + * Returns true if the interval was found and removed. + */ + remove(interval: Interval): boolean; + + /** + * Visits every interval containing the point; stops early (and returns the value) + * if the callback returns a truthy value. + */ + queryPoint(p: number, cb: (interval: Interval) => T): T | undefined; + + /** + * Visits every interval overlapping [lo, hi]; stops early (and returns the value) + * if the callback returns a truthy value. + */ + queryInterval( + lo: number, + hi: number, + cb: (interval: Interval) => T + ): T | undefined; + + /** Number of intervals in the tree. */ + readonly count: number; + + /** A (new) list of every interval in the tree. */ + readonly intervals: Interval[]; +}; + +//User friendly wrapper that makes it possible to support empty trees +class IntervalTreeWrapper implements IntervalTree { + root: IntervalTreeNode | null; + + constructor(root: IntervalTreeNode | null) { + this.root = root; + } + + insert(interval: Interval): void { + if (this.root) { + this.root.insert(interval); + } else { + this.root = new IntervalTreeNode( + interval[0], + null, + null, + [interval], + [interval] + ); + } + } + + remove(interval: Interval): boolean { + if (this.root) { + const r = this.root.remove(interval); + if (r === EMPTY) { + this.root = null; + } + return r !== NOT_FOUND; + } + return false; + } + + queryPoint(p: number, cb: (interval: Interval) => T): T | undefined { + if (this.root) { + return this.root.queryPoint(p, cb); + } + } + + queryInterval( + lo: number, + hi: number, + cb: (interval: Interval) => T + ): T | undefined { + if (lo <= hi && this.root) { + return this.root.queryInterval(lo, hi, cb); + } + } + + get count(): number { + if (this.root) { + return this.root.count; + } + return 0; + } + + get intervals(): Interval[] { + if (this.root) { + return this.root.intervals([]); + } + return []; + } +} + +export function createIntervalTree(intervals?: Interval[]): IntervalTree { + if (!intervals || intervals.length === 0) { + return new IntervalTreeWrapper(null); + } + return new IntervalTreeWrapper(createIntervalTreeNode(intervals)); +} diff --git a/package.json b/package.json index a0db08872..0fa21597c 100644 --- a/package.json +++ b/package.json @@ -1,54 +1,21 @@ { "name": "react-virtualized", "description": "React components for efficiently rendering large, scrollable lists and tabular data", - "author": "Brian Vaughn ", - "user": "bvaughn", "version": "9.22.6", - "homepage": "https://github.com/bvaughn/react-virtualized", - "main": "dist/commonjs/index.js", - "module": "dist/es/index.js", - "jsnext:main": "dist/es/index.js", - "license": "MIT", - "scripts": { - "build:types": "flow-copy-source --ignore \"**/*.{jest,e2e,ssr,example}.js\" source/WindowScroller dist/es/WindowScroller && flow-copy-source --ignore \"**/*.{jest,e2e,ssr,example}.js\" source/AutoSizer dist/es/AutoSizer", - "build": "yarn run build:commonjs && yarn run build:css && yarn run build:es && yarn run build:demo && yarn run build:umd", - "build:commonjs": "yarn run clean:commonjs && cross-env NODE_ENV=commonjs babel source --out-dir dist/commonjs", - "build:css": "postcss source/styles.css -o styles.css --use autoprefixer", - "build:demo": "yarn run clean:demo && cross-env NODE_ENV=production webpack --config webpack.config.demo.js -p --bail", - "build:es": "yarn run clean:es && yarn run build:types && cross-env NODE_ENV=es babel source --out-dir dist/es", - "build:umd": "yarn run clean:umd && cross-env NODE_ENV=rollup rollup -c", - "clean": "yarn run clean:commonjs && yarn run clean:demo && yarn run clean:es && yarn run clean:umd", - "clean:commonjs": "rimraf dist/commonjs", - "clean:demo": "rimraf build", - "clean:es": "rimraf dist/es", - "clean:umd": "rimraf dist/umd", - "deploy": "gh-pages -d build", - "lint": "eslint 'source/**/*.js'", - "typecheck": "flow check", - "prettier": "prettier --write '{playground,source,docs}/**/*.{js,md}'", - "prettier:diff": "prettier --list-different '{playground,source,docs}/**/*.{js,md}'", - "postpublish": "yarn run deploy", - "prepublishOnly": "yarn run build", - "start": "cross-env NODE_ENV=development webpack-dev-server --hot --config webpack.config.dev.js", - "test": "yarn run test:jest", - "test:jest": "jest --no-watchman --runInBand", - "test:coverage": "jest --no-watchman --maxWorkers 2 --coverage && codecov", - "watch": "watch 'clear && yarn run test -s' source", - "watch:jest": "jest --no-watchman --watch" - }, - "husky": { - "hooks": { - "pre-commit": "pretty-quick --staged" - } - }, - "files": [ - "dist", - "styles.css" + "type": "module", + "author": "Brian Vaughn (https://github.com/bvaughn/)", + "contributors": [ + "Brian Vaughn (https://github.com/bvaughn/)" ], + "license": "MIT", + "homepage": "https://react-virtualized.vercel.app/", "repository": { "type": "git", "url": "https://github.com/bvaughn/react-virtualized.git" }, + "bugs": { + "url": "https://github.com/bvaughn/react-virtualized/issues" + }, "keywords": [ "react", "reactjs", @@ -66,93 +33,98 @@ "grid", "spreadsheet" ], - "bugs": { - "url": "https://github.com/bvaughn/react-virtualized/issues" + "main": "dist/react-virtualized.cjs", + "module": "dist/react-virtualized.js", + "types": "dist/react-virtualized.d.ts", + "exports": { + ".": { + "types": "./dist/react-virtualized.d.ts", + "import": "./dist/react-virtualized.js", + "require": "./dist/react-virtualized.cjs" + }, + "./styles.css": "./dist/styles.css", + "./package.json": "./package.json" }, - "devDependencies": { - "@babel/cli": "^7.7.0", - "@babel/core": "^7.7.2", - "@babel/plugin-external-helpers": "^7.2.0", - "@babel/plugin-proposal-class-properties": "^7.7.0", - "@babel/plugin-transform-flow-comments": "^7.12.13", - "@babel/plugin-transform-modules-commonjs": "^7.7.0", - "@babel/plugin-transform-runtime": "^7.6.2", - "@babel/polyfill": "^7.7.0", - "@babel/preset-env": "^7.7.1", - "@babel/preset-flow": "^7.0.0", - "@babel/preset-react": "^7.7.0", - "@babel/preset-stage-2": "^7.0.0", - "autoprefixer": "^9.7.1", - "babel-eslint": "^10.0.3", - "babel-jest": "^24.9.0", - "babel-loader": "8.0.6", - "babel-plugin-flow-react-proptypes": "^26.0.0", - "babel-plugin-transform-react-remove-prop-types": "^0.4.24", - "codecov": "^3.6.1", - "codemirror": "^5.49.2", - "cross-env": "^6.0.3", - "css-loader": "^3.2.0", - "eslint": "^6.6.0", - "eslint-config-fbjs": "^3.1.1", - "eslint-config-prettier": "^6.5.0", - "eslint-config-react": "^1.1.7", - "eslint-plugin-babel": "^5.3.0", - "eslint-plugin-flowtype": "^4.3.0", - "eslint-plugin-jsx-a11y": "^6.2.3", - "eslint-plugin-prettier": "^3.1.1", - "eslint-plugin-react": "^7.16.0", - "eslint-plugin-relay": "^1.3.12", - "extract-text-webpack-plugin": "^3.0.2", - "file-loader": "^4.2.0", - "flow-bin": "^0.111.3", - "flow-copy-source": "^2.0.8", - "gh-pages": "^2.1.1", - "html-webpack-plugin": "^3.2.0", - "husky": "^3.0.9", - "immutable": "^4.0.0-rc.12", - "jest": "^24.9.0", - "jest-environment-puppeteer": "^4.3.0", - "lint-staged": "^9.4.2", - "postcss": "^7.0.21", - "postcss-cli": "^6.1.3", - "postcss-loader": "^3.0.0", - "prettier": "1.19.1", - "pretty-quick": "^2.0.1", - "puppeteer": "^2.0.0", - "react": "^17.0.1", - "react-codemirror": "^1.0.0", - "react-dom": "^17.0.1", - "react-router": "^5.2.0", - "react-router-dom": "^5.2.0", - "react-test-renderer": "^17.0.1", - "rimraf": "^6.0.1", - "rollup": "^1.26.5", - "rollup-plugin-babel": "^4.3.3", - "rollup-plugin-commonjs": "^10.1.0", - "rollup-plugin-node-resolve": "^5.2.0", - "rollup-plugin-replace": "^2.2.0", - "rollup-plugin-uglify": "^6.0.3", - "style-loader": "^1.0.0", - "watch": "^1.0.2", - "webpack": "^4.41.2", - "webpack-cli": "^3.3.10", - "webpack-dev-server": "^3.9.0" + "files": [ + "dist" + ], + "sideEffects": [ + "*.css" + ], + "scripts": { + "dev": "vite", + "dev:integrations": "pnpm run /^dev:integrations:.*/", + "dev:integrations:vite": "pnpm -C integrations/vite/ run dev", + "build": "pnpm run /^build:.*/", + "build:docs": "cross-env TARGET=docs vite build", + "build:lib": "cross-env TARGET=lib vite build", + "compile": "pnpm run --sequential /^compile:.*/", + "compile:docs": "tsx ./scripts/compile-docs", + "compile:examples": "tsx ./scripts/compile-examples", + "compile:search-index": "tsx ./scripts/compile-search-index", + "e2e:install": "pnpm -C integrations/tests exec playwright install --with-deps chromium", + "e2e:test": "pnpm -C integrations/tests run test", + "lint": "eslint .", + "prerelease": "rm -rf dist && pnpm run build:lib", + "prettier": "prettier --write \"**/*.{css,html,js,json,jsx,ts,tsx}\"", + "prettier:ci": "prettier --check \"**/*.{css,html,js,json,jsx,ts,tsx}\"", + "preview": "vite preview", + "test": "vitest", + "test:ci": "vitest run", + "test:debug": "vitest --inspect-brk=127.0.0.1:3000 --no-file-parallelism", + "tsc": "tsc -b" }, "dependencies": { - "@babel/runtime": "^7.7.2", "clsx": "^1.0.4", - "dom-helpers": "^5.1.3", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-lifecycles-compat": "^3.0.4" + "dom-helpers": "^5.1.3" }, "peerDependencies": { - "react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@csstools/postcss-oklab-function": "^4.0.11", + "@eslint/js": "^9.30.1", + "@tailwindcss/vite": "^4.1.11", + "@testing-library/jest-dom": "^6.6.4", + "@testing-library/react": "^16.3.0", + "@types/markdown-it": "^14.1.2", + "@types/node": "^24.2.0", + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^3.10.2", + "cross-env": "^10.1.0", + "csstype": "^3.1.3", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "immutable": "^4.3.9", + "jsdom": "^26.1.0", + "markdown-it": "^14.1.0", + "marked": "^16.4.2", + "postcss": "^8.5.6", + "prettier": "3.6.2", + "prettier-plugin-tailwindcss": "^0.7.1", + "react": "^19.2.3", + "react-docgen-typescript": "^2.4.0", + "react-dom": "^19.2.3", + "react-lib-tools": "0.0.67", + "react-virtualized-auto-sizer": "^2.0.4", + "rollup-plugin-visualizer": "^6.0.3", + "rollup-preserve-directives": "^1.1.3", + "tailwindcss": "^4.1.11", + "terser": "^5.43.1", + "tsx": "^4.21.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.35.1", + "vite": "^7.0.4", + "vite-plugin-dts": "^4.5.4", + "vite-plugin-static-copy": "^3.1.1", + "vite-plugin-svgr": "^4.3.0", + "vitest": "^3.2.4" }, - "browserify": { - "transform": [ - "loose-envify" - ] + "react-lib-tools": { + "devServerPort": 3300 } } diff --git a/playground/chat-no-resize.html b/playground/chat-no-resize.html deleted file mode 100644 index 89496fef2..000000000 --- a/playground/chat-no-resize.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - foo - - - -
- - - - - - diff --git a/playground/chat-no-resize.js b/playground/chat-no-resize.js deleted file mode 100644 index de179bbef..000000000 --- a/playground/chat-no-resize.js +++ /dev/null @@ -1,274 +0,0 @@ -var cache = new ReactVirtualized.CellMeasurerCache({ - fixedWidth: true, -}); - -var list; -var mostRecentWidth; - -function rowRenderer(params) { - var datum = chatHistory[params.index]; - - return React.createElement( - ReactVirtualized.CellMeasurer, - { - cache: cache, - columnIndex: 0, - key: params.key, - parent: params.parent, - rowIndex: params.index, - width: mostRecentWidth, - }, - React.createElement( - 'div', - { - className: 'item', - key: params.key, - style: params.style, - }, - React.createElement('strong', null, datum.name), - ':', - datum.text, - ), - ); -} - -function cellRenderer(params) { - params.index = params.rowIndex; - - return rowRenderer(params); -} - -var App = React.createClass({ - render: function() { - return React.createElement( - 'div', - { - className: 'container', - }, - React.createElement(ReactVirtualized.AutoSizer, {}, function( - autoSizerParams, - ) { - if (mostRecentWidth && mostRecentWidth !== autoSizerParams.width) { - cache.clearAll(); - list.recomputeRowHeights(); - } - - mostRecentWidth = autoSizerParams.width; - - return React.createElement(ReactVirtualized.List, { - className: 'chat', - deferredMeasurementCache: cache, - height: autoSizerParams.height, - ref: function(ref) { - list = ref; - }, - rowCount: chatHistory.length, - rowHeight: cache.rowHeight, - rowRenderer: rowRenderer, - width: autoSizerParams.width, - }); - }), - ); - }, -}); - -var NAMES = [ - 'Peter Brimer', - 'Tera Gaona', - 'Kandy Liston', - 'Lonna Wrede', - 'Kristie Yard', - 'Raul Host', - 'Yukiko Binger', - 'Velvet Natera', - 'Donette Ponton', - 'Loraine Grim', - 'Shyla Mable', - 'Marhta Sing', - 'Alene Munden', - 'Holley Pagel', - 'Randell Tolman', - 'Wilfred Juneau', - 'Naida Madson', - 'Marine Amison', - 'Glinda Palazzo', - 'Lupe Island', - 'Cordelia Trotta', - 'Samara Berrier', - 'Era Stepp', - 'Malka Spradlin', - 'Edward Haner', - 'Clemencia Feather', - 'Loretta Rasnake', - 'Dana Hasbrouck', - 'Sanda Nery', - 'Soo Reiling', - 'Apolonia Volk', - 'Liliana Cacho', - 'Angel Couchman', - 'Yvonne Adam', - 'Jonas Curci', - 'Tran Cesar', - 'Buddy Panos', - 'Rosita Ells', - 'Rosalind Tavares', - 'Renae Keehn', - 'Deandrea Bester', - 'Kelvin Lemmon', - 'Guadalupe Mccullar', - 'Zelma Mayers', - 'Laurel Stcyr', - 'Edyth Everette', - 'Marylin Shevlin', - 'Hsiu Blackwelder', - 'Mark Ferguson', - 'Winford Noggle', - 'Shizuko Gilchrist', - 'Roslyn Cress', - 'Nilsa Lesniak', - 'Agustin Grant', - 'Earlie Jester', - 'Libby Daigle', - 'Shanna Maloy', - 'Brendan Wilken', - 'Windy Knittel', - 'Alice Curren', - 'Eden Lumsden', - 'Klara Morfin', - 'Sherryl Noack', - 'Gala Munsey', - 'Stephani Frew', - 'Twana Anthony', - 'Mauro Matlock', - 'Claudie Meisner', - 'Adrienne Petrarca', - 'Pearlene Shurtleff', - 'Rachelle Piro', - 'Louis Cocco', - 'Susann Mcsweeney', - 'Mandi Kempker', - 'Ola Moller', - 'Leif Mcgahan', - 'Tisha Wurster', - 'Hector Pinkett', - 'Benita Jemison', - 'Kaley Findley', - 'Jim Torkelson', - 'Freda Okafor', - 'Rafaela Markert', - 'Stasia Carwile', - 'Evia Kahler', - 'Rocky Almon', - 'Sonja Beals', - 'Dee Fomby', - 'Damon Eatman', - 'Alma Grieve', - 'Linsey Bollig', - 'Stefan Cloninger', - 'Giovanna Blind', - 'Myrtis Remy', - 'Marguerita Dostal', - 'Junior Baranowski', - 'Allene Seto', - 'Margery Caves', - 'Nelly Moudy', - 'Felix Sailer', -]; -var SENTENCES = [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Phasellus vulputate odio commodo tortor sodales, et vehicula ipsum viverra.', - 'In et mollis velit, accumsan volutpat libero.', - 'Nulla rutrum tellus ipsum, eget fermentum sem dictum quis.', - 'Suspendisse eget vehicula elit.', - 'Proin ut lacus lacus.', - 'Aliquam erat volutpat.', - 'Vivamus ac suscipit est, et elementum lectus.', - 'Cras tincidunt nisi in urna molestie varius.', - 'Integer in magna eu nibh imperdiet tristique.', - 'Curabitur eu pellentesque nisl.', - 'Etiam non consequat est.', - 'Duis mi massa, feugiat nec molestie sit amet, suscipit et metus.', - 'Curabitur ac enim dictum arcu varius fermentum vel sodales dui.', - 'Ut tristique augue at congue molestie.', - 'Integer semper sem lorem, scelerisque suscipit lacus consequat nec.', - 'Etiam euismod efficitur magna nec dignissim.', - 'Morbi vel neque lectus.', - 'Etiam ac accumsan elit, et pharetra ex.', - 'Suspendisse vitae gravida mauris.', - 'Pellentesque sed laoreet erat.', - 'Nam aliquet purus quis massa eleifend, et efficitur felis aliquam.', - 'Fusce faucibus diam erat, sed consectetur urna auctor at.', - 'Praesent et nulla velit.', - 'Cras eget enim nec odio feugiat tristique eu quis ante.', - 'Morbi blandit diam vitae odio sollicitudin finibus.', - 'Integer ac ante fermentum, placerat orci vel, fermentum lacus.', - 'Maecenas est elit, semper ut posuere et, congue ut orci.', - 'Phasellus eget enim vitae nunc luctus sodales a eu erat.', - 'Curabitur dapibus nisi sed nisi dictum, in imperdiet urna posuere.', - 'Vivamus commodo odio metus, tincidunt facilisis augue dictum quis.', - 'Curabitur sagittis a lectus ac sodales.', - 'Nam eget eros purus.', - 'Nam scelerisque et ante in porta.', - 'Proin vitae augue tristique, malesuada nisl ut, fermentum nisl.', - 'Nulla bibendum quam id velit blandit dictum.', - 'Cras tempus ac dolor ut convallis.', - 'Sed vel ipsum est.', - 'Nulla ut leo vestibulum, ultricies sapien ac, pellentesque dolor.', - 'Etiam ultricies maximus tempus.', - 'Donec dignissim mi ac libero feugiat, vitae lacinia odio viverra.', - 'Curabitur condimentum tellus sit amet neque posuere, condimentum tempus purus eleifend.', - 'Donec tempus, augue id hendrerit pretium, mauris leo congue nulla, ac iaculis erat nunc in dolor.', - 'Praesent vel lectus venenatis, elementum mauris vitae, ullamcorper nulla.', - 'Maecenas non diam cursus, imperdiet massa eget, pellentesque ex.', - 'Vestibulum luctus risus vel augue auctor blandit.', - 'Nullam augue diam, pulvinar sed sapien et, hendrerit venenatis risus.', - 'Quisque sollicitudin nulla nec tellus feugiat hendrerit.', - 'Vestibulum a eros accumsan, lacinia eros non, pretium diam.', - 'Aenean iaculis augue sit amet scelerisque aliquam.', - 'Donec ornare felis et dui hendrerit, eget bibendum nibh interdum.', - 'Maecenas tellus magna, tristique vitae orci vel, auctor tincidunt nisi.', - 'Fusce non libero quis velit porttitor maximus at eget enim.', - 'Sed in aliquet tellus.', - 'Etiam a tortor erat.', - 'Donec nec diam vel tellus egestas lobortis.', - 'Vivamus dictum erat nulla, sit amet accumsan dolor scelerisque eu.', - 'In nec eleifend ex, pellentesque dapibus sapien.', - 'Duis a mollis nisi.', - 'Sed ornare nisl sit amet dolor pellentesque, eu fermentum leo interdum.', - 'Sed eget mauris condimentum, molestie justo eu, feugiat felis.', - 'Nunc suscipit leo non dui blandit, ac malesuada ex consequat.', - 'Morbi varius placerat congue.', - 'Praesent id velit in nunc elementum aliquet.', - 'Sed luctus justo vitae nibh bibendum blandit.', - 'Sed et sapien turpis.', - 'Nulla ac eros vestibulum, mollis ante eu, rutrum nulla.', - 'Sed cursus magna ut vehicula rutrum.', - 'Ut consectetur feugiat consectetur.', - 'Nulla nec ligula posuere neque sollicitudin rutrum a a dui.', - 'Nulla ut quam odio.', - 'Integer dignissim sapien et orci sodales volutpat.', - 'Nullam a sapien leo.', - 'Praesent cursus semper purus, vitae gravida risus dapibus mattis.', - 'Sed pellentesque nulla lorem, in commodo arcu feugiat sed.', - 'Phasellus blandit arcu non diam varius ornare.', -]; -var chatHistory = []; - -for (var i = 0; i < 1000; i++) { - var name = NAMES[Math.floor(Math.random() * NAMES.length)]; - var sentences = Math.ceil(Math.random() * 5); - var texts = []; - - for (var x = 0; x < sentences; x++) { - texts.push(SENTENCES[Math.floor(Math.random() * SENTENCES.length)]); - } - - chatHistory.push({ - name, - text: texts.join(' '), - }); -} - -const container = document.getElementById('mount'); - -ReactDOM.render(React.createElement(App), container); diff --git a/playground/chat.html b/playground/chat.html deleted file mode 100644 index 80bafdfaf..000000000 --- a/playground/chat.html +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - foo - - - -
- - - - - -
- Click body to resize ... -
- - diff --git a/playground/chat.js b/playground/chat.js deleted file mode 100644 index c69926a40..000000000 --- a/playground/chat.js +++ /dev/null @@ -1,283 +0,0 @@ -var cache = new ReactVirtualized.CellMeasurerCache({ - fixedWidth: true, -}); - -var list; -var mostRecentWidth; - -function rowRenderer(params) { - var datum = chatHistory[params.index]; - - return React.createElement( - ReactVirtualized.CellMeasurer, - { - cache: cache, - columnIndex: 0, - key: params.key, - parent: params.parent, - rowIndex: params.index, - width: mostRecentWidth, - }, - React.createElement( - 'div', - { - className: 'item', - key: params.key, - style: params.style, - }, - React.createElement('strong', null, datum.name), - ':', - datum.text, - ), - ); -} - -function cellRenderer(params) { - params.index = params.rowIndex; - - return rowRenderer(params); -} - -var App = React.createClass({ - render: function() { - return React.createElement( - 'div', - { - className: 'container', - }, - React.createElement(ReactVirtualized.AutoSizer, {}, function( - autoSizerParams, - ) { - if (mostRecentWidth && mostRecentWidth !== autoSizerParams.width) { - cache.clearAll(); - list.recomputeRowHeights(); - } - - mostRecentWidth = autoSizerParams.width; - - return React.createElement(ReactVirtualized.List, { - className: 'chat', - deferredMeasurementCache: cache, - height: autoSizerParams.height, - ref: function(ref) { - list = ref; - }, - rowCount: chatHistory.length, - rowHeight: cache.rowHeight, - rowRenderer: rowRenderer, - width: autoSizerParams.width, - }); - }), - ); - }, -}); - -var NAMES = [ - 'Peter Brimer', - 'Tera Gaona', - 'Kandy Liston', - 'Lonna Wrede', - 'Kristie Yard', - 'Raul Host', - 'Yukiko Binger', - 'Velvet Natera', - 'Donette Ponton', - 'Loraine Grim', - 'Shyla Mable', - 'Marhta Sing', - 'Alene Munden', - 'Holley Pagel', - 'Randell Tolman', - 'Wilfred Juneau', - 'Naida Madson', - 'Marine Amison', - 'Glinda Palazzo', - 'Lupe Island', - 'Cordelia Trotta', - 'Samara Berrier', - 'Era Stepp', - 'Malka Spradlin', - 'Edward Haner', - 'Clemencia Feather', - 'Loretta Rasnake', - 'Dana Hasbrouck', - 'Sanda Nery', - 'Soo Reiling', - 'Apolonia Volk', - 'Liliana Cacho', - 'Angel Couchman', - 'Yvonne Adam', - 'Jonas Curci', - 'Tran Cesar', - 'Buddy Panos', - 'Rosita Ells', - 'Rosalind Tavares', - 'Renae Keehn', - 'Deandrea Bester', - 'Kelvin Lemmon', - 'Guadalupe Mccullar', - 'Zelma Mayers', - 'Laurel Stcyr', - 'Edyth Everette', - 'Marylin Shevlin', - 'Hsiu Blackwelder', - 'Mark Ferguson', - 'Winford Noggle', - 'Shizuko Gilchrist', - 'Roslyn Cress', - 'Nilsa Lesniak', - 'Agustin Grant', - 'Earlie Jester', - 'Libby Daigle', - 'Shanna Maloy', - 'Brendan Wilken', - 'Windy Knittel', - 'Alice Curren', - 'Eden Lumsden', - 'Klara Morfin', - 'Sherryl Noack', - 'Gala Munsey', - 'Stephani Frew', - 'Twana Anthony', - 'Mauro Matlock', - 'Claudie Meisner', - 'Adrienne Petrarca', - 'Pearlene Shurtleff', - 'Rachelle Piro', - 'Louis Cocco', - 'Susann Mcsweeney', - 'Mandi Kempker', - 'Ola Moller', - 'Leif Mcgahan', - 'Tisha Wurster', - 'Hector Pinkett', - 'Benita Jemison', - 'Kaley Findley', - 'Jim Torkelson', - 'Freda Okafor', - 'Rafaela Markert', - 'Stasia Carwile', - 'Evia Kahler', - 'Rocky Almon', - 'Sonja Beals', - 'Dee Fomby', - 'Damon Eatman', - 'Alma Grieve', - 'Linsey Bollig', - 'Stefan Cloninger', - 'Giovanna Blind', - 'Myrtis Remy', - 'Marguerita Dostal', - 'Junior Baranowski', - 'Allene Seto', - 'Margery Caves', - 'Nelly Moudy', - 'Felix Sailer', -]; -var SENTENCES = [ - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', - 'Phasellus vulputate odio commodo tortor sodales, et vehicula ipsum viverra.', - 'In et mollis velit, accumsan volutpat libero.', - 'Nulla rutrum tellus ipsum, eget fermentum sem dictum quis.', - 'Suspendisse eget vehicula elit.', - 'Proin ut lacus lacus.', - 'Aliquam erat volutpat.', - 'Vivamus ac suscipit est, et elementum lectus.', - 'Cras tincidunt nisi in urna molestie varius.', - 'Integer in magna eu nibh imperdiet tristique.', - 'Curabitur eu pellentesque nisl.', - 'Etiam non consequat est.', - 'Duis mi massa, feugiat nec molestie sit amet, suscipit et metus.', - 'Curabitur ac enim dictum arcu varius fermentum vel sodales dui.', - 'Ut tristique augue at congue molestie.', - 'Integer semper sem lorem, scelerisque suscipit lacus consequat nec.', - 'Etiam euismod efficitur magna nec dignissim.', - 'Morbi vel neque lectus.', - 'Etiam ac accumsan elit, et pharetra ex.', - 'Suspendisse vitae gravida mauris.', - 'Pellentesque sed laoreet erat.', - 'Nam aliquet purus quis massa eleifend, et efficitur felis aliquam.', - 'Fusce faucibus diam erat, sed consectetur urna auctor at.', - 'Praesent et nulla velit.', - 'Cras eget enim nec odio feugiat tristique eu quis ante.', - 'Morbi blandit diam vitae odio sollicitudin finibus.', - 'Integer ac ante fermentum, placerat orci vel, fermentum lacus.', - 'Maecenas est elit, semper ut posuere et, congue ut orci.', - 'Phasellus eget enim vitae nunc luctus sodales a eu erat.', - 'Curabitur dapibus nisi sed nisi dictum, in imperdiet urna posuere.', - 'Vivamus commodo odio metus, tincidunt facilisis augue dictum quis.', - 'Curabitur sagittis a lectus ac sodales.', - 'Nam eget eros purus.', - 'Nam scelerisque et ante in porta.', - 'Proin vitae augue tristique, malesuada nisl ut, fermentum nisl.', - 'Nulla bibendum quam id velit blandit dictum.', - 'Cras tempus ac dolor ut convallis.', - 'Sed vel ipsum est.', - 'Nulla ut leo vestibulum, ultricies sapien ac, pellentesque dolor.', - 'Etiam ultricies maximus tempus.', - 'Donec dignissim mi ac libero feugiat, vitae lacinia odio viverra.', - 'Curabitur condimentum tellus sit amet neque posuere, condimentum tempus purus eleifend.', - 'Donec tempus, augue id hendrerit pretium, mauris leo congue nulla, ac iaculis erat nunc in dolor.', - 'Praesent vel lectus venenatis, elementum mauris vitae, ullamcorper nulla.', - 'Maecenas non diam cursus, imperdiet massa eget, pellentesque ex.', - 'Vestibulum luctus risus vel augue auctor blandit.', - 'Nullam augue diam, pulvinar sed sapien et, hendrerit venenatis risus.', - 'Quisque sollicitudin nulla nec tellus feugiat hendrerit.', - 'Vestibulum a eros accumsan, lacinia eros non, pretium diam.', - 'Aenean iaculis augue sit amet scelerisque aliquam.', - 'Donec ornare felis et dui hendrerit, eget bibendum nibh interdum.', - 'Maecenas tellus magna, tristique vitae orci vel, auctor tincidunt nisi.', - 'Fusce non libero quis velit porttitor maximus at eget enim.', - 'Sed in aliquet tellus.', - 'Etiam a tortor erat.', - 'Donec nec diam vel tellus egestas lobortis.', - 'Vivamus dictum erat nulla, sit amet accumsan dolor scelerisque eu.', - 'In nec eleifend ex, pellentesque dapibus sapien.', - 'Duis a mollis nisi.', - 'Sed ornare nisl sit amet dolor pellentesque, eu fermentum leo interdum.', - 'Sed eget mauris condimentum, molestie justo eu, feugiat felis.', - 'Nunc suscipit leo non dui blandit, ac malesuada ex consequat.', - 'Morbi varius placerat congue.', - 'Praesent id velit in nunc elementum aliquet.', - 'Sed luctus justo vitae nibh bibendum blandit.', - 'Sed et sapien turpis.', - 'Nulla ac eros vestibulum, mollis ante eu, rutrum nulla.', - 'Sed cursus magna ut vehicula rutrum.', - 'Ut consectetur feugiat consectetur.', - 'Nulla nec ligula posuere neque sollicitudin rutrum a a dui.', - 'Nulla ut quam odio.', - 'Integer dignissim sapien et orci sodales volutpat.', - 'Nullam a sapien leo.', - 'Praesent cursus semper purus, vitae gravida risus dapibus mattis.', - 'Sed pellentesque nulla lorem, in commodo arcu feugiat sed.', - 'Phasellus blandit arcu non diam varius ornare.', -]; -var chatHistory = []; - -for (var i = 0; i < 1000; i++) { - var name = NAMES[Math.floor(Math.random() * NAMES.length)]; - var sentences = Math.ceil(Math.random() * 5); - var texts = []; - - for (var x = 0; x < sentences; x++) { - texts.push(SENTENCES[Math.floor(Math.random() * SENTENCES.length)]); - } - - chatHistory.push({ - name, - text: texts.join(' '), - }); -} - -const container = document.getElementById('mount'); - -ReactDOM.render(React.createElement(App), container); - -document.body.addEventListener('click', function() { - const bodyWidth = document.body.getBoundingClientRect().width; - const minWidth = 300; - - container.style.display = 'inline-block'; - container.style.maxWidth = `${minWidth + - Math.round(Math.random() * (bodyWidth - minWidth))}px`; -}); diff --git a/playground/grid-test.html b/playground/grid-test.html deleted file mode 100644 index 694a2d770..000000000 --- a/playground/grid-test.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - foo - - - -
- - - - - - - - diff --git a/playground/grid-test.js b/playground/grid-test.js deleted file mode 100644 index 5d674716e..000000000 --- a/playground/grid-test.js +++ /dev/null @@ -1,46 +0,0 @@ -function cellRenderer(params) { - return React.createElement( - 'div', - { - className: 'item', - key: params.key, - style: params.style, - }, - params.columnIndex, - ); -} - -var App = React.createClass({ - render: function() { - return React.createElement(ReactVirtualized.AutoSizer, null, function( - params, - ) { - return React.createElement(ReactVirtualized.Grid, { - columnCount: 1000, - columnWidth: 35, - height: params.height, - overscanRowCount: 0, - cellRenderer: cellRenderer, - rowHeight: 30, - rowCount: 5000, - width: params.width, - }); - }); - }, -}); - -ReactDOM.render(React.createElement(App), document.querySelector('#mount')); - -const testCase = createScrollingTestCase( - document.querySelector('.ReactVirtualized__Grid'), -); -const TestRunner = FpsMeasurer.TestRunner; -const testRunner = new TestRunner(testCase, 5); - -document.body.addEventListener('click', function(event) { - if (testRunner.isRunning()) { - testRunner.stop(); - } else { - testRunner.start(); - } -}); diff --git a/playground/grid.html b/playground/grid.html deleted file mode 100644 index efb4413b0..000000000 --- a/playground/grid.html +++ /dev/null @@ -1,50 +0,0 @@ - - - - - foo - - - -
- - - - - - diff --git a/playground/grid.js b/playground/grid.js deleted file mode 100644 index c4c94d934..000000000 --- a/playground/grid.js +++ /dev/null @@ -1,64 +0,0 @@ -var REACT_VIRTUALIZED_BANNER = - 'https://cloud.githubusercontent.com/assets/29597/11737732/0ca1e55e-9f91-11e5-97f3-098f2f8ed866.png'; - -function getColumnWidth(params) { - switch (params.index % 3) { - case 0: - return 65; - case 1: - return 65; - case 2: - return 100; - } -} - -function cellRenderer(params) { - var key = `c:${params.columnIndex}, r:${params.rowIndex}`; - switch (params.columnIndex % 3) { - case 0: - return React.DOM.input({ - className: 'input', - defaultValue: key, - key: params.key, - onChange: function() {}, - style: params.style, - }); - case 1: - return React.DOM.button( - { - className: 'button', - key: params.key, - style: params.style, - }, - key, - ); - case 2: - return React.DOM.img({ - className: 'image', - key: params.key, - src: REACT_VIRTUALIZED_BANNER, - style: params.style, - }); - } -} - -var App = React.createClass({ - render: function() { - return React.createElement(ReactVirtualized.AutoSizer, null, function( - params, - ) { - return React.createElement(ReactVirtualized.Grid, { - columnCount: 1000, - columnWidth: getColumnWidth, - height: params.height, - overscanRowCount: 0, - cellRenderer: cellRenderer, - rowHeight: 30, - rowCount: 1000, - width: params.width, - }); - }); - }, -}); - -ReactDOM.render(React.createElement(App), document.querySelector('#mount')); diff --git a/playground/helper.js b/playground/helper.js deleted file mode 100644 index bdc1ee7f0..000000000 --- a/playground/helper.js +++ /dev/null @@ -1,50 +0,0 @@ -function loadStyle(source, callback) { - var link = document.createElement('link'); - link.setAttribute('rel', 'stylesheet'); - link.setAttribute('href', source); - link.onload = callback; - document.head.appendChild(link); -} - -function loadScript(source) { - var script = document.createElement('script'); - script.setAttribute('src', source); - script.async = false; - document.head.appendChild(script); -} - -function loadScriptsAndStyles(source) { - var baseDir = 'https://unpkg.com/react-virtualized/'; - var sourceParam = getUrlParam('source'); - - if (sourceParam) { - baseDir = - sourceParam === 'local' - ? '../' - : `https://unpkg.com/react-virtualized@${sourceParam}/`; - } - - var styleSource = baseDir + 'styles.css'; - var scriptSource = baseDir + 'dist/umd/react-virtualized.js'; - var appSource = source; - - loadStyle(styleSource, function() { - loadScript(scriptSource); - loadScript(appSource); - }); -} - -function loadReact() { - var baseDir = 'https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2'; - var reactParam = getUrlParam('react'); - - if (reactParam) { - baseDir = - reactParam === 'latest' - ? 'http://react.zpao.com/builds/master/latest' - : `https://cdnjs.cloudflare.com/ajax/libs/react/${reactParam}`; - } - - loadScript(`${baseDir}/react.min.js`); - loadScript(`${baseDir}/react-dom.min.js`); -} diff --git a/playground/hover.html b/playground/hover.html deleted file mode 100644 index 8f38c3922..000000000 --- a/playground/hover.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - foo - - - -
- - - - - - diff --git a/playground/hover.js b/playground/hover.js deleted file mode 100644 index c1709faaa..000000000 --- a/playground/hover.js +++ /dev/null @@ -1,60 +0,0 @@ -var App = React.createClass({ - getInitialState: function() { - return {}; - }, - - render: function() { - var cellRenderer = this._cellRenderer; - - return React.createElement( - ReactVirtualized.AutoSizer, - { - ref: 'AutoSizer', - }, - function(params) { - return React.createElement(ReactVirtualized.Grid, { - columnCount: 1000, - columnWidth: 100, - height: params.height, - ref: 'Grid', - cellRenderer: cellRenderer, - rowHeight: 30, - rowCount: 1000, - width: params.width, - }); - }, - ); - }, - - _cellRenderer(params) { - var columnIndex = params.columnIndex; - var rowIndex = params.rowIndex; - var key = `c:${columnIndex}, r:${rowIndex}`; - var setState = this.setState.bind(this); - var grid = this.refs.AutoSizer.refs.Grid; - - var className = - columnIndex === this.state.hoveredColumnIndex || - rowIndex === this.state.hoveredRowIndex - ? 'item hoveredItem' - : 'item'; - - return React.DOM.div( - { - className: className, - key: params.key, - onMouseOver: function() { - setState({ - hoveredColumnIndex: columnIndex, - hoveredRowIndex: rowIndex, - }); - grid.forceUpdate(); - }, - style: params.style, - }, - key, - ); - }, -}); - -ReactDOM.render(React.createElement(App), document.querySelector('#mount')); diff --git a/playground/render-counters.html b/playground/render-counters.html deleted file mode 100644 index a4b5501eb..000000000 --- a/playground/render-counters.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - foo - - - -
- - - - - - diff --git a/playground/render-counters.js b/playground/render-counters.js deleted file mode 100644 index ff9fbe065..000000000 --- a/playground/render-counters.js +++ /dev/null @@ -1,65 +0,0 @@ -const {PureComponent} = React; -const {AutoSizer, List} = ReactVirtualized; - -class ListExample extends PureComponent { - render() { - return React.createElement(AutoSizer, null, ({height, width}) => - React.createElement(List, { - height: height, - overscanRowCount: 0, - rowCount: 1000, - rowHeight: 30, - rowRenderer: this._rowRenderer, - width: width, - }), - ); - } - - _rowRenderer({index, isScrolling, key, style}) { - return React.createElement(Row, { - index: index, - key: key, - style: style, - }); - } -} - -class Row extends PureComponent { - constructor(props, context) { - super(props, context); - - this.state = { - counter: 0, - }; - - this._renderCount = 0; - } - - render() { - this._renderCount++; - - const {counter} = this.state; - const {index, style} = this.props; - - return React.createElement( - 'div', - { - onClick: () => { - this.setState(state => { - counter: state.counter++; - }); - }, - style: style, - }, - 'Row ', - index, - ', ', - this._renderCount, - ); - } -} - -ReactDOM.render( - React.createElement(ListExample), - document.querySelector('#mount'), -); diff --git a/playground/scroll-sync.html b/playground/scroll-sync.html deleted file mode 100644 index d7cdcfe27..000000000 --- a/playground/scroll-sync.html +++ /dev/null @@ -1,25 +0,0 @@ - - - - - foo - - - -
- - - - - - diff --git a/playground/scroll-sync.js b/playground/scroll-sync.js deleted file mode 100644 index ceaf9165d..000000000 --- a/playground/scroll-sync.js +++ /dev/null @@ -1,59 +0,0 @@ -function cellRenderer(params) { - return React.createElement( - 'div', - { - className: 'item', - key: params.key, - style: params.style, - }, - params.columnIndex, - ); -} - -var App = React.createClass({ - render: function() { - return React.createElement(ReactVirtualized.AutoSizer, null, function( - autoSizerParams, - ) { - return React.createElement(ReactVirtualized.ScrollSync, null, function( - scrollSyncParams, - ) { - return React.createElement( - 'div', - { - style: { - height: autoSizerParams.height, - width: autoSizerParams.width, - }, - }, - React.createElement(ReactVirtualized.Grid, { - cellRenderer: cellRenderer, - columnCount: 1000, - columnWidth: 35, - height: autoSizerParams.height / 2, - key: 0, - overscanRowCount: 0, - rowHeight: 30, - rowCount: 5000, - scrollLeft: scrollSyncParams.scrollLeft, - width: autoSizerParams.width, - }), - React.createElement(ReactVirtualized.Grid, { - cellRenderer: cellRenderer, - columnCount: 1000, - columnWidth: 35, - height: autoSizerParams.height / 2, - key: 1, - overscanRowCount: 0, - onScroll: scrollSyncParams.onScroll, - rowHeight: 30, - rowCount: 5000, - width: autoSizerParams.width, - }), - ); - }); - }); - }, -}); - -ReactDOM.render(React.createElement(App), document.querySelector('#mount')); diff --git a/playground/table.html b/playground/table.html deleted file mode 100644 index 21af0cf45..000000000 --- a/playground/table.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - foo - - - -
- - - - - - - - diff --git a/playground/table.js b/playground/table.js deleted file mode 100644 index 68b3d98fa..000000000 --- a/playground/table.js +++ /dev/null @@ -1,58 +0,0 @@ -const NUM_COLUMNS = 40; - -function rowGetter(params) { - return new Array(NUM_COLUMNS).fill('').map(function(_, index) { - return index; - }); -} - -var App = React.createClass({ - render: function() { - const flexColumns = []; - - for (var i = 0; i < NUM_COLUMNS; i++) { - flexColumns.push( - React.createElement(ReactVirtualized.Column, { - dataKey: i, - flexGrow: 1, - key: i, - width: 50, - }), - ); - } - - return React.createElement(ReactVirtualized.AutoSizer, null, function( - params, - ) { - return React.createElement( - ReactVirtualized.Table, - { - height: params.height, - overscanRowCount: 0, - rowGetter, - rowHeight: 30, - rowCount: 1000, - width: params.width, - }, - null, - flexColumns, - ); - }); - }, -}); - -ReactDOM.render(React.createElement(App), document.querySelector('#mount')); - -const testCase = createScrollingTestCase( - document.querySelector('.ReactVirtualized__Grid'), -); -const TestRunner = FpsMeasurer.TestRunner; -const testRunner = new TestRunner(testCase, 5); - -document.body.addEventListener('click', function(event) { - if (testRunner.isRunning()) { - testRunner.stop(); - } else { - testRunner.start(); - } -}); diff --git a/playground/tests.js b/playground/tests.js deleted file mode 100644 index bc25d23f0..000000000 --- a/playground/tests.js +++ /dev/null @@ -1,52 +0,0 @@ -function createScrollingTestCase(component) { - var scrollDown = getUrlParam('direction') !== 'up'; - - return function testCase(completedCallback) { - component.scrollTop = scrollDown ? 0 : component.scrollHeight; - - var maxScrollTop = component.scrollHeight; - - var interval = 1; - var scrollTop = component.scrollTop; - - function incrementScrollDown() { - if (!testRunner.isRunning()) { - return; - } - - interval *= 1.05; - scrollTop = Math.min(scrollTop + interval, maxScrollTop); - - component.scrollTop = scrollTop; - - if (scrollTop < maxScrollTop) { - requestAnimationFrame(incrementScrollDown); - } else { - completedCallback(); - } - } - - function incrementScrollUp() { - if (!testRunner.isRunning()) { - return; - } - - interval *= 1.05; - scrollTop = Math.max(scrollTop - interval, 0); - - component.scrollTop = scrollTop; - - if (scrollTop > 0) { - requestAnimationFrame(incrementScrollUp); - } else { - completedCallback(); - } - } - - if (scrollDown) { - incrementScrollDown(); - } else { - incrementScrollUp(); - } - }; -} diff --git a/playground/tree.html b/playground/tree.html deleted file mode 100644 index d066421b4..000000000 --- a/playground/tree.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - foo - - - -
- - - - - - diff --git a/playground/tree.js b/playground/tree.js deleted file mode 100644 index 68f11d8a7..000000000 --- a/playground/tree.js +++ /dev/null @@ -1,212 +0,0 @@ -var ROW_HEIGHT = 20; -var RANDOM_WORDS = [ - 'abstrusity', - 'advertisable', - 'bellwood', - 'benzole', - 'boreum', - 'brenda', - 'cassiopeian', - 'chansonnier', - 'cleric', - 'conclusional', - 'conventicle', - 'copalm', - 'cornopion', - 'crossbar', - 'disputative', - 'djilas', - 'ebracteate', - 'ephemerally', - 'epidemical', - 'evasive', - 'eyeglasses', - 'farragut', - 'fenny', - 'ferryman', - 'fluently', - 'foreigner', - 'genseng', - 'glaiket', - 'haunch', - 'histogeny', - 'illocution', - 'imprescriptible', - 'inapproachable', - 'incisory', - 'intrusiveness', - 'isoceraunic', - 'japygid', - 'juiciest', - 'jump', - 'kananga', - 'leavening', - 'legerdemain', - 'licence', - 'licia', - 'luanda', - 'malaga', - 'mathewson', - 'nonhumus', - 'nonsailor', - 'nummary', - 'nyregyhza', - 'onanist', - 'opis', - 'orphrey', - 'paganising', - 'pebbling', - 'penchi', - 'photopia', - 'pinocle', - 'principally', - 'prosector.', - 'radiosensitive', - 'redbrick', - 'reexposure', - 'revived', - 'subexternal', - 'sukarnapura', - 'supersphenoid', - 'tabularizing', - 'territorialism', - 'tester', - 'thalassography', - 'tuberculise', - 'uncranked', - 'undersawyer', - 'unimpartible', - 'unsubdivided', - 'untwining', - 'unwaived', - 'webfoot', - 'wedeling', - 'wellingborough', - 'whiffet', - 'whipstall', - 'wot', - 'yonkersite', - 'zonary', -]; -var data = createRandomizedData(); - -function renderItem(item, keyPrefix) { - var onClick = function(event) { - event.stopPropagation(); - item.expanded = !item.expanded; - List.recomputeRowHeights(); - List.forceUpdate(); - }; - - var props = {key: keyPrefix}; - var children = []; - var itemText; - - if (item.expanded) { - props.onClick = onClick; - itemText = '[-] ' + item.name; - children = item.children.map(function(child, index) { - return renderItem(child, keyPrefix + '-' + index); - }); - } else if (item.children.length) { - props.onClick = onClick; - itemText = '[+] ' + item.name; - } else { - itemText = ' ' + item.name; - } - - children.unshift( - React.DOM.div( - { - className: 'item', - key: 'label', - style: { - cursor: item.children.length ? 'pointer' : 'auto', - }, - }, - itemText, - ), - ); - - return React.DOM.ul(null, React.DOM.li(props, children)); -} - -function getExpandedItemCount(item) { - var count = 1; - - if (item.expanded) { - count += item.children - .map(getExpandedItemCount) - .reduce(function(total, count) { - return total + count; - }, 0); - } - - return count; -} - -var List; -function setRef(ref) { - List = ref; -} - -function cellRenderer(params) { - var renderedCell = renderItem(data[params.index], params.index); - - return React.DOM.ul( - { - key: params.key, - style: params.style, - }, - renderedCell, - ); -} - -function rowHeight(params) { - return getExpandedItemCount(data[params.index]) * ROW_HEIGHT; -} - -var App = React.createClass({ - render: function() { - return React.createElement(ReactVirtualized.AutoSizer, null, function( - params, - ) { - return React.createElement(ReactVirtualized.List, { - height: params.height, - overscanRowCount: 10, - ref: setRef, - rowHeight: rowHeight, - rowRenderer: cellRenderer, - rowCount: data.length, - width: params.width, - }); - }); - }, -}); - -ReactDOM.render(React.createElement(App), document.querySelector('#mount')); - -function createRandomizedData() { - var data = []; - - for (var i = 0; i < 10000; i++) { - data.push(createRandomizedItem(0)); - } - - return data; -} - -function createRandomizedItem(depth) { - var item = {}; - item.children = []; - item.name = RANDOM_WORDS[Math.floor(Math.random() * RANDOM_WORDS.length)]; - - var numChildren = depth < 3 ? Math.floor(Math.random() * 5) : 0; - for (var i = 0; i < numChildren; i++) { - item.children.push(createRandomizedItem(depth + 1)); - } - - item.expanded = numChildren > 0 && Math.random() < 0.25; - - return item; -} diff --git a/playground/utils.js b/playground/utils.js deleted file mode 100644 index a8f7526ed..000000000 --- a/playground/utils.js +++ /dev/null @@ -1,18 +0,0 @@ -function getUrlParams() { - var search = window.location.search; - - return search.length - ? search - .substr(1) - .split('&') - .reduce(function(reduced, value) { - var matches = value.split('='); - reduced[matches[0]] = matches[1]; - return reduced; - }, {}) - : {}; -} - -function getUrlParam(key) { - return getUrlParams()[key]; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 000000000..6ec637b6e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5647 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + clsx: + specifier: ^1.0.4 + version: 1.2.1 + dom-helpers: + specifier: ^5.1.3 + version: 5.2.1 + devDependencies: + '@csstools/postcss-oklab-function': + specifier: ^4.0.11 + version: 4.0.12(postcss@8.5.6) + '@eslint/js': + specifier: ^9.30.1 + version: 9.39.2 + '@tailwindcss/vite': + specifier: ^4.1.11 + version: 4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@testing-library/jest-dom': + specifier: ^6.6.4 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 + '@types/node': + specifier: ^24.2.0 + version: 24.10.4 + '@types/react': + specifier: ^19.2.3 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react-swc': + specifier: ^3.10.2 + version: 3.11.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + csstype: + specifier: ^3.1.3 + version: 3.2.3 + eslint: + specifier: ^9.30.1 + version: 9.39.2(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.4.20 + version: 0.4.26(eslint@9.39.2(jiti@2.6.1)) + globals: + specifier: ^16.3.0 + version: 16.5.0 + immutable: + specifier: ^4.3.9 + version: 4.3.9 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + markdown-it: + specifier: ^14.1.0 + version: 14.1.0 + marked: + specifier: ^16.4.2 + version: 16.4.2 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + prettier: + specifier: 3.6.2 + version: 3.6.2 + prettier-plugin-tailwindcss: + specifier: ^0.7.1 + version: 0.7.2(prettier@3.6.2) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-docgen-typescript: + specifier: ^2.4.0 + version: 2.4.0(typescript@5.8.3) + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + react-lib-tools: + specifier: 0.0.67 + version: 0.0.67(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.8.3) + react-virtualized-auto-sizer: + specifier: ^2.0.4 + version: 2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + rollup-plugin-visualizer: + specifier: ^6.0.3 + version: 6.0.5(rollup@4.54.0) + rollup-preserve-directives: + specifier: ^1.1.3 + version: 1.1.3(rollup@4.54.0) + tailwindcss: + specifier: ^4.1.11 + version: 4.1.18 + terser: + specifier: ^5.43.1 + version: 5.44.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.35.1 + version: 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + vite: + specifier: ^7.0.4 + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-plugin-dts: + specifier: ^4.5.4 + version: 4.5.4(@types/node@24.10.4)(rollup@4.54.0)(typescript@5.8.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vite-plugin-static-copy: + specifier: ^3.1.1 + version: 3.4.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vite-plugin-svgr: + specifier: ^4.3.0 + version: 4.5.0(rollup@4.54.0)(typescript@5.8.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + integrations/tests: + dependencies: + react-virtualized: + specifier: workspace:* + version: link:../.. + devDependencies: + '@playwright/test': + specifier: ^1 + version: 1.57.0 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + + integrations/vite: + dependencies: + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + react-router: + specifier: ^7 + version: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-virtualized: + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react-swc': + specifier: ^3.10.2 + version: 3.11.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^7.0.4 + version: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + +packages: + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.11': + resolution: {integrity: sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==} + + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/lang-markdown@6.5.0': + resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + + '@codemirror/language@6.12.1': + resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==} + + '@codemirror/lint@6.9.2': + resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==} + + '@codemirror/state@6.5.3': + resolution: {integrity: sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==} + + '@codemirror/view@6.39.7': + resolution: {integrity: sha512-3Vif9hnNHJnl2YgOtkR/wzGzhYcQ8gy3LGdUhkLUU8xSBbgsTxrE8he/CMTpeINm5TgxLe2FmzvF6IYQL/BSAg==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/postcss-oklab-function@4.0.12': + resolution: {integrity: sha512-HhlSmnE1NKBhXsTnNGjxvhryKtO7tJd1w42DKOGFD6jSHtYOrsJTQDKPMwvOfrzUAk8t7GcpIfRyM7ssqHpFjg==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/postcss-progressive-custom-properties@4.2.1': + resolution: {integrity: sha512-uPiiXf7IEKtUQXsxu6uWtOlRMXd2QWWy5fhxHDnPdXKCQckPP3E34ZgDoZ62r2iT+UOgWsSbM4NvHE5m3mAEdw==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@csstools/utilities@2.0.0': + resolution: {integrity: sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4 + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.0': + resolution: {integrity: sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==} + + '@lezer/css@1.3.0': + resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.5': + resolution: {integrity: sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==} + + '@lezer/markdown@1.6.2': + resolution: {integrity: sha512-iNSdKrIK0FfOjVPVpV0fu7OykdncYpEzf4vkG9szFf60ql/ObZShoVbM9u1tgkogDOmubms1CyoNS2/unOXWNw==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + + '@microsoft/api-extractor-model@7.32.2': + resolution: {integrity: sha512-Ussc25rAalc+4JJs9HNQE7TuO9y6jpYQX9nWD1DhqUzYPBr3Lr7O9intf+ZY8kD5HnIqeIRJX7ccCT0QyBy2Ww==} + + '@microsoft/api-extractor@7.55.2': + resolution: {integrity: sha512-1jlWO4qmgqYoVUcyh+oXYRztZde/pAi7cSVzBz/rc+S7CoVzDasy8QE13dx6sLG4VRo8SfkkLbFORR6tBw4uGQ==} + hasBin: true + + '@microsoft/tsdoc-config@0.18.0': + resolution: {integrity: sha512-8N/vClYyfOH+l4fLkkr9+myAoR6M7akc8ntBJ4DJdWH2b09uVfr71+LTMpNyG19fNqWDg8KEDZhx5wxuqHyGjw==} + + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@playwright/test@1.57.0': + resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + engines: {node: '>=18'} + hasBin: true + + '@puppeteer/browsers@2.13.0': + resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==} + engines: {node: '>=18'} + hasBin: true + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.54.0': + resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.54.0': + resolution: {integrity: sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.54.0': + resolution: {integrity: sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.54.0': + resolution: {integrity: sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.54.0': + resolution: {integrity: sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.54.0': + resolution: {integrity: sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + resolution: {integrity: sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + resolution: {integrity: sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + resolution: {integrity: sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.54.0': + resolution: {integrity: sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.54.0': + resolution: {integrity: sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + resolution: {integrity: sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + resolution: {integrity: sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + resolution: {integrity: sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + resolution: {integrity: sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.54.0': + resolution: {integrity: sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.54.0': + resolution: {integrity: sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.54.0': + resolution: {integrity: sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + resolution: {integrity: sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.54.0': + resolution: {integrity: sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.54.0': + resolution: {integrity: sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.54.0': + resolution: {integrity: sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==} + cpu: [x64] + os: [win32] + + '@rushstack/node-core-library@5.19.1': + resolution: {integrity: sha512-ESpb2Tajlatgbmzzukg6zyAhH+sICqJR2CNXNhXcEbz6UGCQfrKCtkxOpJTftWc8RGouroHG0Nud1SJAszvpmA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/problem-matcher@0.1.1': + resolution: {integrity: sha512-Fm5XtS7+G8HLcJHCWpES5VmeMyjAKaWeyZU5qPzZC+22mPlJzAsOxymHiWIfuirtPckX3aptWws+K2d0BzniJA==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.6.0': + resolution: {integrity: sha512-ZQmfzsLE2+Y91GF15c65L/slMRVhF6Hycq04D4TwtdGaUAbIXXg9c5pKA5KFU7M4QMaihoobp9JJYpYcaY3zOw==} + + '@rushstack/terminal@0.19.5': + resolution: {integrity: sha512-6k5tpdB88G0K7QrH/3yfKO84HK9ggftfUZ51p7fePyCE7+RLLHkWZbID9OFWbXuna+eeCFE7AkKnRMHMxNbz7Q==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@5.1.5': + resolution: {integrity: sha512-YmrFTFUdHXblYSa+Xc9OO9FsL/XFcckZy0ycQ6q7VSBsVs5P0uD9vcges5Q9vctGlVdu27w+Ct6IuJ458V0cTQ==} + + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0': + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0': + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0': + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0': + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0': + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0': + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-plugin-transform-svg-component@8.0.0': + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/babel-preset@8.1.0': + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@svgr/core@8.1.0': + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + + '@svgr/hast-util-to-babel-ast@8.0.0': + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + + '@svgr/plugin-jsx@8.1.0': + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + + '@swc/core-darwin-arm64@1.15.7': + resolution: {integrity: sha512-+hNVUfezUid7LeSHqnhoC6Gh3BROABxjlDNInuZ/fie1RUxaEX4qzDwdTgozJELgHhvYxyPIg1ro8ibnKtgO4g==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.7': + resolution: {integrity: sha512-ZAFuvtSYZTuXPcrhanaD5eyp27H8LlDzx2NAeVyH0FchYcuXf0h5/k3GL9ZU6Jw9eQ63R1E8KBgpXEJlgRwZUQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.7': + resolution: {integrity: sha512-K3HTYocpqnOw8KcD8SBFxiDHjIma7G/X+bLdfWqf+qzETNBrzOub/IEkq9UaeupaJiZJkPptr/2EhEXXWryS/A==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.7': + resolution: {integrity: sha512-HCnVIlsLnCtQ3uXcXgWrvQ6SAraskLA9QJo9ykTnqTH6TvUYqEta+TdTdGjzngD6TOE7XjlAiUs/RBtU8Z0t+Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.7': + resolution: {integrity: sha512-/OOp9UZBg4v2q9+x/U21Jtld0Wb8ghzBScwhscI7YvoSh4E8RALaJ1msV8V8AKkBkZH7FUAFB7Vbv0oVzZsezA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.7': + resolution: {integrity: sha512-VBbs4gtD4XQxrHuQ2/2+TDZpPQQgrOHYRnS6SyJW+dw0Nj/OomRqH+n5Z4e/TgKRRbieufipeIGvADYC/90PYQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.7': + resolution: {integrity: sha512-kVuy2unodso6p0rMauS2zby8/bhzoGRYxBDyD6i2tls/fEYAE74oP0VPFzxIyHaIjK1SN6u5TgvV9MpyJ5xVug==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.7': + resolution: {integrity: sha512-uddYoo5Xmo1XKLhAnh4NBIyy5d0xk33x1sX3nIJboFySLNz878ksCFCZ3IBqrt1Za0gaoIWoOSSSk0eNhAc/sw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.7': + resolution: {integrity: sha512-rqq8JjNMLx3QNlh0aPTtN/4+BGLEHC94rj9mkH1stoNRf3ra6IksNHMHy+V1HUqElEgcZyx+0yeXx3eLOTcoFw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.7': + resolution: {integrity: sha512-4BK06EGdPnuplgcNhmSbOIiLdRgHYX3v1nl4HXo5uo4GZMfllXaCyBUes+0ePRfwbn9OFgVhCWPcYYjMT6hycQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.7': + resolution: {integrity: sha512-kTGB8XI7P+pTKW83tnUEDVP4zduF951u3UAOn5eTi0vyW6MvL56A3+ggMdfuVFtDI0/DsbSzf5z34HVBbuScWw==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.25': + resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + + '@tailwindcss/node@4.1.18': + resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} + + '@tailwindcss/oxide-android-arm64@4.1.18': + resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.18': + resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.18': + resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.18': + resolution: {integrity: sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.1': + resolution: {integrity: sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + + '@ts-ast-parser/comment@0.2.0': + resolution: {integrity: sha512-W/nxZGgoK0HS90Zuy5tg4I2TF6izsW3U8jIHk+M9dEvpBjvVMLOZCmZbaewsOsu8xA11vAiLAD4IMBjLMuUwcQ==} + engines: {node: 18.x || 20.x || 21.x} + + '@ts-ast-parser/core@0.8.0': + resolution: {integrity: sha512-UN78bQwfayfefhJ5f3Z7GnSTh7/oKC1G/Fp5BIR6NxP8EebEC8DRmtSDIBwO6nnLVJjI69W0R57Fa3/EzgtcvA==} + engines: {node: 18.x || 20.x || 21.x} + peerDependencies: + typescript: ^4.6.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x + + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@24.10.4': + resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.50.1': + resolution: {integrity: sha512-PKhLGDq3JAg0Jk/aK890knnqduuI/Qj+udH7wCf0217IGi4gt+acgCyPVe79qoT+qKUvHMDQkwJeKW9fwl8Cyw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.50.1 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.50.1': + resolution: {integrity: sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.50.1': + resolution: {integrity: sha512-E1ur1MCVf+YiP89+o4Les/oBAVzmSbeRB0MQLfSlYtbWU17HPxZ6Bhs5iYmKZRALvEuBoXIZMOIRRc/P++Ortg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.50.1': + resolution: {integrity: sha512-mfRx06Myt3T4vuoHaKi8ZWNTPdzKPNBhiblze5N50//TSHOAQQevl/aolqA/BcqqbJ88GUnLqjjcBc8EWdBcVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.50.1': + resolution: {integrity: sha512-ooHmotT/lCWLXi55G4mvaUF60aJa012QzvLK0Y+Mp4WdSt17QhMhWOaBWeGTFVkb2gDgBe19Cxy1elPXylslDw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.50.1': + resolution: {integrity: sha512-7J3bf022QZE42tYMO6SL+6lTPKFk/WphhRPe9Tw/el+cEwzLz1Jjz2PX3GtGQVxooLDKeMVmMt7fWpYRdG5Etg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.50.1': + resolution: {integrity: sha512-v5lFIS2feTkNyMhd7AucE/9j/4V9v5iIbpVRncjk/K0sQ6Sb+Np9fgYS/63n6nwqahHQvbmujeBL7mp07Q9mlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.50.1': + resolution: {integrity: sha512-woHPdW+0gj53aM+cxchymJCrh0cyS7BTIdcDxWUNsclr9VDkOSbqC13juHzxOmQ22dDkMZEpZB+3X1WpUvzgVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.50.1': + resolution: {integrity: sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.50.1': + resolution: {integrity: sha512-IrDKrw7pCRUR94zeuCSUWQ+w8JEf5ZX5jl/e6AHGSLi1/zIr0lgutfn/7JpfCey+urpgQEdrZVYzCaVVKiTwhQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react-swc@3.11.0': + resolution: {integrity: sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==} + peerDependencies: + vite: ^4 || ^5 || ^6 || ^7 + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@volar/language-core@2.4.27': + resolution: {integrity: sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==} + + '@volar/source-map@2.4.27': + resolution: {integrity: sha512-ynlcBReMgOZj2i6po+qVswtDUeeBRCTgDurjMGShbm8WYZgJ0PA4RmtebBJ0BCYol1qPv3GQF6jK7C9qoVc7lg==} + + '@volar/typescript@2.4.27': + resolution: {integrity: sha512-eWaYCcl/uAPInSK2Lze6IqVWaBu/itVqR5InXcHXFyles4zO++Mglt3oxdgj75BDcv1Knr9Y93nowS8U3wqhxg==} + + '@vue/compiler-core@3.5.26': + resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} + + '@vue/compiler-dom@3.5.26': + resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.2.0': + resolution: {integrity: sha512-O1ZZFaaBGkKbsRfnVH1ifOK1/1BUkyK+3SQsfnh6PmMmD4qJcTU8godCeA96jjDRTL6zgnK7YzCHfaUlH2r0Mw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/shared@3.5.26': + resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.13.0: + resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + + alien-signals@0.4.14: + resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.5.5: + resolution: {integrity: sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.7.1: + resolution: {integrity: sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.8.0: + resolution: {integrity: sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.3.2: + resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + + baseline-browser-mapping@2.9.11: + resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + hasBin: true + + basic-ftp@5.2.0: + resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} + engines: {node: '>=10.0.0'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001761: + resolution: {integrity: sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chromium-bidi@14.0.0: + resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} + peerDependencies: + devtools-protocol: '*' + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devtools-protocol@0.0.1581282: + resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==} + + diff@8.0.2: + resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} + engines: {node: '>=0.3.1'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.18.4: + resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.0: + resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + globby@14.1.0: + resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + netmask@2.0.2: + resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + engines: {node: '>= 0.4.0'} + + no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@7.0.4: + resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} + engines: {node: '>=18'} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + path-type@6.0.0: + resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} + engines: {node: '>=18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + puppeteer-core@24.38.0: + resolution: {integrity: sha512-zB3S/tksIhgi2gZRndUe07AudBz5SXOB7hqG0kEa9/YXWrGwlVlYm3tZtwKgfRftBzbmLQl5iwHkQQl04n/mWw==} + engines: {node: '>=18'} + + puppeteer@24.38.0: + resolution: {integrity: sha512-abnJOBVoL9PQTLKSbYGm9mjNFyIPaTVj77J/6cS370dIQtcZMpx8wyZoAuBzR71Aoon6yvI71NEVFUsl3JU82g==} + engines: {node: '>=18'} + hasBin: true + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-docgen-typescript@2.4.0: + resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} + peerDependencies: + typescript: '>= 4.3.x' + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-lib-tools@0.0.67: + resolution: {integrity: sha512-1Q/zvldl8kV5iceaDMX+D/nW+rdu+7RfVp8xyPn9VECKiyLovIVDcrETENyvFeVwhWb4f62QYe+GRo1zqaCcCw==} + peerDependencies: + react: ^19.0.0 + react-dom: ^19.0.0 + + react-router@7.11.0: + resolution: {integrity: sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-virtualized-auto-sizer@2.0.4: + resolution: {integrity: sha512-OgkJ2qCvm7ahHpkiHwHyRz9n1nSLr+U1Tz1xewtXF++KKwLt5X7tcuQA8dmsL2RPpEhB3Uz8jtSNSZ86Mx/lEQ==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup-plugin-visualizer@6.0.5: + resolution: {integrity: sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + + rollup-preserve-directives@1.1.3: + resolution: {integrity: sha512-oXqxd6ZzkoQej8Qt0k+S/yvO2+S4CEVEVv2g85oL15o0cjAKTKEuo2MzyA8FcsBBXbtytBzBMFAbhvQg4YyPUQ==} + peerDependencies: + rollup: ^2.0.0 || ^3.0.0 || ^4.0.0 + + rollup@4.54.0: + resolution: {integrity: sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwindcss@4.1.18: + resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar-fs@3.1.1: + resolution: {integrity: sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + terser@5.44.1: + resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} + engines: {node: '>=10'} + hasBin: true + + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.3.0: + resolution: {integrity: sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-query-selector@2.12.1: + resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==} + + typescript-eslint@8.50.1: + resolution: {integrity: sha512-ytTHO+SoYSbhAH9CrYnMhiLx8To6PSSvqnvXyPUgPETCvB6eBKmTI9w6XMPS3HsBRGkwTVBX+urA8dYQx6bHfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.8.2: + resolution: {integrity: sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.1: + resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-plugin-dts@4.5.4: + resolution: {integrity: sha512-d4sOM8M/8z7vRXHHq/ebbblfaxENjogAAekcfcDCCwAyvGqnPrc7f4NZbvItS+g4WTgerW0xDwSz5qz11JT3vg==} + peerDependencies: + typescript: '*' + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite-plugin-static-copy@3.4.0: + resolution: {integrity: sha512-ekryzCw0ouAOE8tw4RvVL/dfqguXzumsV3FBKoKso4MQ1MUUrUXtl5RI4KpJQUNGqFEsg9kxl4EvDl02YtA9VQ==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite-plugin-svgr@4.5.0: + resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==} + peerDependencies: + vite: '>=2.6.0' + + vite@7.3.0: + resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webdriver-bidi-protocol@0.4.1: + resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@adobe/css-tools@4.4.4': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.6': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.6': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/traverse@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@codemirror/autocomplete@6.20.0': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.7 + '@lezer/common': 1.5.0 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@lezer/common': 1.5.0 + '@lezer/css': 1.3.0 + + '@codemirror/lang-html@6.4.11': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.7 + '@lezer/common': 1.5.0 + '@lezer/css': 1.3.0 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.4': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/lint': 6.9.2 + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.7 + '@lezer/common': 1.5.0 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.1 + '@lezer/json': 1.0.3 + + '@codemirror/lang-markdown@6.5.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.7 + '@lezer/common': 1.5.0 + '@lezer/markdown': 1.6.2 + + '@codemirror/language@6.12.1': + dependencies: + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.7 + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.2': + dependencies: + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.7 + crelt: 1.0.6 + + '@codemirror/state@6.5.3': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.39.7': + dependencies: + '@codemirror/state': 6.5.3 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/postcss-oklab-function@4.0.12(postcss@8.5.6)': + dependencies: + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/postcss-progressive-custom-properties': 4.2.1(postcss@8.5.6) + '@csstools/utilities': 2.0.0(postcss@8.5.6) + postcss: 8.5.6 + + '@csstools/postcss-progressive-custom-properties@4.2.1(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + '@csstools/utilities@2.0.0(postcss@8.5.6)': + dependencies: + postcss: 8.5.6 + + '@epic-web/invariant@1.0.0': {} + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@2.6.1))': + dependencies: + eslint: 9.39.2(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lezer/common@1.5.0': {} + + '@lezer/css@1.3.0': + dependencies: + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.0 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.5 + + '@lezer/lr@1.4.5': + dependencies: + '@lezer/common': 1.5.0 + + '@lezer/markdown@1.6.2': + dependencies: + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + + '@marijn/find-cluster-break@1.0.2': {} + + '@microsoft/api-extractor-model@7.32.2(@types/node@24.10.4)': + dependencies: + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.0 + '@rushstack/node-core-library': 5.19.1(@types/node@24.10.4) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.55.2(@types/node@24.10.4)': + dependencies: + '@microsoft/api-extractor-model': 7.32.2(@types/node@24.10.4) + '@microsoft/tsdoc': 0.16.0 + '@microsoft/tsdoc-config': 0.18.0 + '@rushstack/node-core-library': 5.19.1(@types/node@24.10.4) + '@rushstack/rig-package': 0.6.0 + '@rushstack/terminal': 0.19.5(@types/node@24.10.4) + '@rushstack/ts-command-line': 5.1.5(@types/node@24.10.4) + diff: 8.0.2 + lodash: 4.17.21 + minimatch: 10.0.3 + resolve: 1.22.11 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.8.2 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.18.0': + dependencies: + '@microsoft/tsdoc': 0.16.0 + ajv: 8.12.0 + jju: 1.4.0 + resolve: 1.22.11 + + '@microsoft/tsdoc@0.16.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@playwright/test@1.57.0': + dependencies: + playwright: 1.57.0 + + '@puppeteer/browsers@2.13.0': + dependencies: + debug: 4.4.3 + extract-zip: 2.0.1 + progress: 2.0.3 + proxy-agent: 6.5.0 + semver: 7.7.4 + tar-fs: 3.1.1 + yargs: 17.7.2 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/pluginutils@5.3.0(rollup@4.54.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.54.0 + + '@rollup/rollup-android-arm-eabi@4.54.0': + optional: true + + '@rollup/rollup-android-arm64@4.54.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.54.0': + optional: true + + '@rollup/rollup-darwin-x64@4.54.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.54.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.54.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.54.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.54.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.54.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.54.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.54.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.54.0': + optional: true + + '@rushstack/node-core-library@5.19.1(@types/node@24.10.4)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1(ajv@8.13.0) + fs-extra: 11.3.3 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.11 + semver: 7.5.4 + optionalDependencies: + '@types/node': 24.10.4 + + '@rushstack/problem-matcher@0.1.1(@types/node@24.10.4)': + optionalDependencies: + '@types/node': 24.10.4 + + '@rushstack/rig-package@0.6.0': + dependencies: + resolve: 1.22.11 + strip-json-comments: 3.1.1 + + '@rushstack/terminal@0.19.5(@types/node@24.10.4)': + dependencies: + '@rushstack/node-core-library': 5.19.1(@types/node@24.10.4) + '@rushstack/problem-matcher': 0.1.1(@types/node@24.10.4) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 24.10.4 + + '@rushstack/ts-command-line@5.1.5(@types/node@24.10.4)': + dependencies: + '@rushstack/terminal': 0.19.5(@types/node@24.10.4) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + + '@sindresorhus/merge-streams@2.3.0': {} + + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + + '@svgr/babel-preset@8.1.0(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.28.5) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.28.5) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.28.5) + + '@svgr/core@8.1.0(typescript@5.8.3)': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-preset': 8.1.0(@babel/core@7.28.5) + camelcase: 6.3.0 + cosmiconfig: 8.3.6(typescript@5.8.3) + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@svgr/hast-util-to-babel-ast@8.0.0': + dependencies: + '@babel/types': 7.28.6 + entities: 4.5.0 + + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.8.3))': + dependencies: + '@babel/core': 7.28.5 + '@svgr/babel-preset': 8.1.0(@babel/core@7.28.5) + '@svgr/core': 8.1.0(typescript@5.8.3) + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + + '@swc/core-darwin-arm64@1.15.7': + optional: true + + '@swc/core-darwin-x64@1.15.7': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.7': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.7': + optional: true + + '@swc/core-linux-arm64-musl@1.15.7': + optional: true + + '@swc/core-linux-x64-gnu@1.15.7': + optional: true + + '@swc/core-linux-x64-musl@1.15.7': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.7': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.7': + optional: true + + '@swc/core-win32-x64-msvc@1.15.7': + optional: true + + '@swc/core@1.15.7': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.25 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.7 + '@swc/core-darwin-x64': 1.15.7 + '@swc/core-linux-arm-gnueabihf': 1.15.7 + '@swc/core-linux-arm64-gnu': 1.15.7 + '@swc/core-linux-arm64-musl': 1.15.7 + '@swc/core-linux-x64-gnu': 1.15.7 + '@swc/core-linux-x64-musl': 1.15.7 + '@swc/core-win32-arm64-msvc': 1.15.7 + '@swc/core-win32-ia32-msvc': 1.15.7 + '@swc/core-win32-x64-msvc': 1.15.7 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.25': + dependencies: + '@swc/counter': 0.1.3 + + '@tailwindcss/node@4.1.18': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.4 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.18 + + '@tailwindcss/oxide-android-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.18': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.18': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + optional: true + + '@tailwindcss/oxide@4.1.18': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-arm64': 4.1.18 + '@tailwindcss/oxide-darwin-x64': 4.1.18 + '@tailwindcss/oxide-freebsd-x64': 4.1.18 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 + '@tailwindcss/oxide-linux-x64-musl': 4.1.18 + '@tailwindcss/oxide-wasm32-wasi': 4.1.18 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 + + '@tailwindcss/vite@4.1.18(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.1.18 + '@tailwindcss/oxide': 4.1.18 + tailwindcss: 4.1.18 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.1(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.7 + '@types/react-dom': 19.2.3(@types/react@19.2.7) + + '@tootallnate/quickjs-emscripten@0.23.0': {} + + '@ts-ast-parser/comment@0.2.0': + dependencies: + tslib: 2.8.1 + + '@ts-ast-parser/core@0.8.0(typescript@5.8.3)': + dependencies: + '@ts-ast-parser/comment': 0.2.0 + globby: 14.1.0 + tslib: 2.8.1 + typescript: 5.8.3 + + '@types/argparse@1.0.38': {} + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + + '@types/node@24.10.4': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.10.4 + optional: true + + '@typescript-eslint/eslint-plugin@8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/type-utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.50.1 + eslint: 9.39.2(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.3.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.50.1 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.50.1(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.8.3) + '@typescript-eslint/types': 8.50.1 + debug: 4.4.3 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.50.1': + dependencies: + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/visitor-keys': 8.50.1 + + '@typescript-eslint/tsconfig-utils@8.50.1(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.8.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.3.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.50.1': {} + + '@typescript-eslint/typescript-estree@8.50.1(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.50.1(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.50.1(typescript@5.8.3) + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/visitor-keys': 8.50.1 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.3.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.50.1 + '@typescript-eslint/types': 8.50.1 + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.8.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.50.1': + dependencies: + '@typescript-eslint/types': 8.50.1 + eslint-visitor-keys: 4.2.1 + + '@vitejs/plugin-react-swc@3.11.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.27 + '@swc/core': 1.15.7 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@swc/helpers' + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@volar/language-core@2.4.27': + dependencies: + '@volar/source-map': 2.4.27 + + '@volar/source-map@2.4.27': {} + + '@volar/typescript@2.4.27': + dependencies: + '@volar/language-core': 2.4.27 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.26': + dependencies: + '@babel/parser': 7.28.6 + '@vue/shared': 3.5.26 + entities: 7.0.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.26': + dependencies: + '@vue/compiler-core': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.2.0(typescript@5.8.3)': + dependencies: + '@volar/language-core': 2.4.27 + '@vue/compiler-dom': 3.5.26 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.26 + alien-signals: 0.4.14 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.8.3 + + '@vue/shared@3.5.26': {} + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + agent-base@7.1.4: {} + + ajv-draft-04@1.0.0(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + + ajv-formats@3.0.1(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.13.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + alien-signals@0.4.14: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + + b4a@1.7.3: {} + + balanced-match@1.0.2: {} + + bare-events@2.8.2: {} + + bare-fs@4.5.5: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.8.0(bare-events@2.8.2) + bare-url: 2.3.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-os@3.7.1: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.7.1 + optional: true + + bare-stream@2.8.0(bare-events@2.8.2): + dependencies: + streamx: 2.23.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + bare-url@2.3.2: + dependencies: + bare-path: 3.0.0 + optional: true + + baseline-browser-mapping@2.9.11: {} + + basic-ftp@5.2.0: {} + + binary-extensions@2.3.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001761 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + cac@6.7.14: {} + + callsites@3.1.0: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001761: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.1: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chromium-bidi@14.0.0(devtools-protocol@0.0.1581282): + dependencies: + devtools-protocol: 0.0.1581282 + mitt: 3.0.1 + zod: 3.25.76 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@1.2.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@2.20.3: {} + + compare-versions@6.1.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.2: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cosmiconfig@8.3.6(typescript@5.8.3): + dependencies: + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.8.3 + + cosmiconfig@9.0.0(typescript@5.8.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.8.3 + + crelt@1.0.6: {} + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-uri-to-buffer@6.0.2: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + define-lazy-prop@2.0.0: {} + + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devtools-protocol@0.0.1581282: {} + + diff@8.0.2: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.2.3 + + dot-case@3.0.4: + dependencies: + no-case: 3.0.4 + tslib: 2.8.1 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.18.4: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-module-lexer@1.7.0: {} + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.2(jiti@2.6.1)): + dependencies: + eslint: 9.39.2(jiti@2.6.1) + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@2.6.1)): + dependencies: + eslint: 9.39.2(jiti@2.6.1) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.2(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + expect-type@1.3.0: {} + + exsolve@1.0.8: {} + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + get-uri@6.0.5: + dependencies: + basic-ftp: 5.2.0 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + globby@14.1.0: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + path-type: 6.0.0 + slash: 5.1.0 + unicorn-magic: 0.3.0 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@4.3.9: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-lazy@4.0.0: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + ip-address@10.1.0: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-docker@2.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + jju@1.4.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kolorist@1.8.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lodash@4.17.21: {} + + loupe@3.2.1: {} + + lower-case@2.0.2: + dependencies: + tslib: 2.8.1 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked@16.4.2: {} + + mdurl@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + min-indent@1.0.1: {} + + minimatch@10.0.3: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + mitt@3.0.1: {} + + mlly@1.8.0: + dependencies: + acorn: 8.15.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.1 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + netmask@2.0.2: {} + + no-case@3.0.4: + dependencies: + lower-case: 2.0.2 + tslib: 2.8.1 + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + nwsapi@2.2.23: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@7.0.4: {} + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.0.2 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.28.6 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + path-type@6.0.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + + playwright-core@1.57.0: {} + + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.7.2(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + + prettier@3.6.2: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + progress@2.0.3: {} + + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + + proxy-from-env@1.1.0: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + puppeteer-core@24.38.0: + dependencies: + '@puppeteer/browsers': 2.13.0 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1581282) + debug: 4.4.3 + devtools-protocol: 0.0.1581282 + typed-query-selector: 2.12.1 + webdriver-bidi-protocol: 0.4.1 + ws: 8.19.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - utf-8-validate + + puppeteer@24.38.0(typescript@5.8.3): + dependencies: + '@puppeteer/browsers': 2.13.0 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1581282) + cosmiconfig: 9.0.0(typescript@5.8.3) + devtools-protocol: 0.0.1581282 + puppeteer-core: 24.38.0 + typed-query-selector: 2.12.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - typescript + - utf-8-validate + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + react-docgen-typescript@2.4.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-lib-tools@0.0.67(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.8.3): + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@lezer/highlight': 1.2.3 + '@ts-ast-parser/core': 0.8.0(typescript@5.8.3) + puppeteer: 24.38.0(typescript@5.8.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - react-native-b4a + - supports-color + - typescript + - utf-8-validate + + react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + cookie: 1.1.1 + react: 19.2.3 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + + react-virtualized-auto-sizer@2.0.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + react@19.2.3: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup-plugin-visualizer@6.0.5(rollup@4.54.0): + dependencies: + open: 8.4.2 + picomatch: 4.0.3 + source-map: 0.7.6 + yargs: 17.7.2 + optionalDependencies: + rollup: 4.54.0 + + rollup-preserve-directives@1.1.3(rollup@4.54.0): + dependencies: + magic-string: 0.30.21 + rollup: 4.54.0 + + rollup@4.54.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.54.0 + '@rollup/rollup-android-arm64': 4.54.0 + '@rollup/rollup-darwin-arm64': 4.54.0 + '@rollup/rollup-darwin-x64': 4.54.0 + '@rollup/rollup-freebsd-arm64': 4.54.0 + '@rollup/rollup-freebsd-x64': 4.54.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.54.0 + '@rollup/rollup-linux-arm-musleabihf': 4.54.0 + '@rollup/rollup-linux-arm64-gnu': 4.54.0 + '@rollup/rollup-linux-arm64-musl': 4.54.0 + '@rollup/rollup-linux-loong64-gnu': 4.54.0 + '@rollup/rollup-linux-ppc64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-gnu': 4.54.0 + '@rollup/rollup-linux-riscv64-musl': 4.54.0 + '@rollup/rollup-linux-s390x-gnu': 4.54.0 + '@rollup/rollup-linux-x64-gnu': 4.54.0 + '@rollup/rollup-linux-x64-musl': 4.54.0 + '@rollup/rollup-openharmony-arm64': 4.54.0 + '@rollup/rollup-win32-arm64-msvc': 4.54.0 + '@rollup/rollup-win32-ia32-msvc': 4.54.0 + '@rollup/rollup-win32-x64-gnu': 4.54.0 + '@rollup/rollup-win32-x64-msvc': 4.54.0 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.5.4: + dependencies: + lru-cache: 6.0.0 + + semver@7.7.4: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + slash@5.1.0: {} + + smart-buffer@4.2.0: {} + + snake-case@3.0.4: + dependencies: + dot-case: 3.0.4 + tslib: 2.8.1 + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + sprintf-js@1.0.3: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-mod@4.1.3: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svg-parser@2.0.4: {} + + symbol-tree@3.2.4: {} + + tailwindcss@4.1.18: {} + + tapable@2.3.0: {} + + tar-fs@3.1.1: + dependencies: + pump: 3.0.3 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.5.5 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + optional: true + + terser@5.44.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.15.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.3.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-query-selector@2.12.1: {} + + typescript-eslint@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.50.1(@typescript-eslint/parser@8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/parser': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.50.1(typescript@5.8.3) + '@typescript-eslint/utils': 8.50.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.8.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.8.2: {} + + typescript@5.8.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.1: {} + + undici-types@7.16.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.54.0)(typescript@5.8.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@microsoft/api-extractor': 7.55.2(@types/node@24.10.4) + '@rollup/pluginutils': 5.3.0(rollup@4.54.0) + '@volar/typescript': 2.4.27 + '@vue/language-core': 2.2.0(typescript@5.8.3) + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + typescript: 5.8.3 + optionalDependencies: + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + + vite-plugin-static-copy@3.4.0(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + chokidar: 3.6.0 + p-map: 7.0.4 + picocolors: 1.1.1 + tinyglobby: 0.2.15 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + + vite-plugin-svgr@4.5.0(rollup@4.54.0)(typescript@5.8.3)(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.54.0) + '@svgr/core': 8.1.0(typescript@5.8.3) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.8.3)) + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - rollup + - supports-color + - typescript + + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.54.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.4 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.44.1 + tsx: 4.21.0 + yaml: 2.8.2 + + vitest@3.2.4(@types/node@24.10.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.4 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vscode-uri@3.1.0: {} + + w3c-keyname@2.2.8: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webdriver-bidi-protocol@0.4.1: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml@2.8.2: + optional: true + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..90fadc6cd --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - integrations/* diff --git a/postcss.config.js b/postcss.config.js index 5802d8908..8f82e5744 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,7 +1,4 @@ -module.exports = { - autoprefixer: { - browsers: ['last 2 version', 'Firefox 15', 'iOS 8'], - }, - // The plugins section is used by postcss-loader with webpack - plugins: [require('autoprefixer')], -}; +import postcss from "postcss"; +import postcssOKLabFunction from "@csstools/postcss-oklab-function"; + +export default postcss([postcssOKLabFunction()]); diff --git a/prettier.config.js b/prettier.config.js new file mode 100644 index 000000000..0e35e1d37 --- /dev/null +++ b/prettier.config.js @@ -0,0 +1,9 @@ +export default { + overrides: [ + { + files: "*.ts, *.tsx" + } + ], + singleQuote: false, + trailingComma: "none" +}; diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 000000000..82077a133 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,15 @@ + diff --git a/public/generated/docs/ArrowKeyStepper.json b/public/generated/docs/ArrowKeyStepper.json new file mode 100644 index 000000000..fc55dc8ff --- /dev/null +++ b/public/generated/docs/ArrowKeyStepper.json @@ -0,0 +1,114 @@ +{ + "description": [ + { + "content": "

This HOC decorates a virtualized component and responds to arrow-key events by scrolling one row or column at a time.\nThis provides a snap-to behavior rather than the default browser scrolling behavior.

\n" + }, + { + "content": "

Note that unlike the other HOCs in react-virtualized, ArrowKeyStepper adds a <div> element around its children in order to attach a key-down event handler.\nThe appearance of this wrapper element can be customized using the className property.

\n" + } + ], + "filePath": "lib/components/ArrowKeyStepper/ArrowKeyStepper.tsx", + "name": "ArrowKeyStepper", + "props": { + "children": { + "description": [ + { + "content": "

Function responsible for rendering children.\nIt is passed onSectionRendered and registerChild parameters, which should be attached to the decorated\nvirtualized component (e.g. <Grid onSectionRendered={onSectionRendered} ref={registerChild} />),\nand the scrollToColumn and scrollToRow indices of the active cell (e.g. to highlight it).\nThe active cell is scrolled into view through the registered component's scrollToCell method.

\n" + } + ], + "html": "
children: (params: ArrowKeyStepperChildProps) => ReactNode
", + "name": "children", + "required": true + }, + "className": { + "description": [ + { + "content": "

CSS class name to attach to the wrapper <div>.

\n" + } + ], + "html": "
className?: string
", + "name": "className", + "required": false + }, + "columnCount": { + "description": [ + { + "content": "

Number of columns in grid; for Table and List this property should always be 1.

\n" + } + ], + "html": "
columnCount: number
", + "name": "columnCount", + "required": true + }, + "disabled": { + "description": [ + { + "content": "

Disables all scrolling using arrow-keys.

\n" + } + ], + "html": "
disabled?: boolean = false
", + "name": "disabled", + "required": false + }, + "isControlled": { + "description": [ + { + "content": "

This component is "controlled"; it will not update scrollToColumn or scrollToRow itself.\nThis property should be used with onScrollToChange.

\n" + } + ], + "html": "
isControlled?: boolean = false
", + "name": "isControlled", + "required": false + }, + "mode": { + "description": [ + { + "content": "

Controls behavior of stepper when arrow key direction changes.\n"cells" means that the index will only increment or decrement by 1;\n"edges" means that the opposite side of the grid will be incremented.

\n" + } + ], + "html": "
mode?: \"cells\" | \"edges\" = \"edges\"
", + "name": "mode", + "required": false + }, + "onScrollToChange": { + "description": [ + { + "content": "

Called when arrow key navigation should update the current scroll-to values.

\n" + } + ], + "html": "
onScrollToChange?: (params: ScrollIndices) => void
", + "name": "onScrollToChange", + "required": false + }, + "rowCount": { + "description": [ + { + "content": "

Number of rows in grid.

\n" + } + ], + "html": "
rowCount: number
", + "name": "rowCount", + "required": true + }, + "scrollToColumn": { + "description": [ + { + "content": "

Default/initial scrollToColumn value.\nWhen isControlled is true, this is the current scrollToColumn value.

\n" + } + ], + "html": "
scrollToColumn?: number = 0
", + "name": "scrollToColumn", + "required": false + }, + "scrollToRow": { + "description": [ + { + "content": "

Default/initial scrollToRow value.\nWhen isControlled is true, this is the current scrollToRow value.

\n" + } + ], + "html": "
scrollToRow?: number = 0
", + "name": "scrollToRow", + "required": false + } + } +} \ No newline at end of file diff --git a/public/generated/docs/ArrowKeyStepperImperativeAPI.json b/public/generated/docs/ArrowKeyStepperImperativeAPI.json new file mode 100644 index 000000000..a3a63f5e0 --- /dev/null +++ b/public/generated/docs/ArrowKeyStepperImperativeAPI.json @@ -0,0 +1,20 @@ +{ + "description": [ + { + "content": "

Public methods available on an ArrowKeyStepper ref.

\n" + } + ], + "filePath": "lib/components/ArrowKeyStepper/types.ts", + "methods": [ + { + "description": [ + { + "content": "

Override the local state of the component with new values for scrollToRow and scrollToColumn.

\n" + } + ], + "html": "
setScrollIndexes(params: ScrollIndices): void;
", + "name": "setScrollIndexes" + } + ], + "name": "ArrowKeyStepperImperativeAPI" +} \ No newline at end of file diff --git a/public/generated/docs/CellMeasurer.json b/public/generated/docs/CellMeasurer.json new file mode 100644 index 000000000..07f9ad42d --- /dev/null +++ b/public/generated/docs/CellMeasurer.json @@ -0,0 +1,83 @@ +{ + "description": [ + { + "content": "

Wraps a cell and measures its rendered content.\nMeasurements are stored in a per-cell cache.\nCached-content is not be re-measured.

\n" + }, + { + "content": "

The child must be a DOM element (e.g. <div>) or a component that passes its ref along to one;\notherwise use a function child and pass registerChild as the ref of the element to be measured.

\n" + } + ], + "filePath": "lib/components/CellMeasurer/CellMeasurer.tsx", + "name": "CellMeasurer", + "props": { + "cache": { + "description": [ + { + "content": "

Cache to be shared between CellMeasurer and its parent Grid.\nTypically an instance of CellMeasurerCache.

\n" + } + ], + "html": "
cache: CellMeasureCache
", + "name": "cache", + "required": true + }, + "children": { + "description": [ + { + "content": "

Either a React element as a child (eg <div />) or a function\n(eg. ({ measure, registerChild }) => <div ref={registerChild} />).

\n" + }, + { + "content": "

CellMeasurer attaches a ref to the child element to find the DOM element to measure\n(the child's own ref, if any, still receives the element).\nSo the child must be a DOM element (e.g. <div>) or a component that passes its ref along to one:\nin React 19, a function component can pass its ref prop to a DOM element;\nin React 18, function components must use forwardRef for this.

\n" + }, + { + "content": "

The function receives a measure callback (to re-measure the cell, e.g. after an image has loaded)\nand a registerChild callback (to specify the DOM element to be measured; can be used as a ref).\nUse registerChild if the child doesn't pass its ref along to a DOM element,\nor to measure an element nested within the child.

\n" + }, + { + "content": "

Children that can't be measured (e.g. text, an array or a fragment, without registerChild) are rendered as-is,\nand a warning is logged in development mode.

\n" + } + ], + "html": "
children: ReactNode | CellMeasurerChildFunction
", + "name": "children", + "required": true + }, + "columnIndex": { + "description": [ + { + "content": "

Index of column being measured (within the parent Grid) or 0 (if used within a List or Table).

\n" + } + ], + "html": "
columnIndex?: number = 0
", + "name": "columnIndex", + "required": false + }, + "index": { + "description": [ + { + "content": "

Index of row being measured; alias of rowIndex (as passed to a List row renderer).\nOnly used if rowIndex is not specified.

\n" + } + ], + "html": "
index?: number
", + "name": "index", + "required": false + }, + "parent": { + "description": [ + { + "content": "

Reference to the parent Grid (or List, Table, MultiGrid, Masonry);\nthis value is passed by Grid to the cellRenderer and should be passed along as-is.

\n" + } + ], + "html": "
parent: CellMeasurerParent
", + "name": "parent", + "required": true + }, + "rowIndex": { + "description": [ + { + "content": "

Index of row being measured (within the parent Grid).\nDefaults to index (or 0 if neither is specified).

\n" + } + ], + "html": "
rowIndex?: number
", + "name": "rowIndex", + "required": false + } + } +} \ No newline at end of file diff --git a/public/generated/docs/CellMeasurerCacheAPI.json b/public/generated/docs/CellMeasurerCacheAPI.json new file mode 100644 index 000000000..ffeccb969 --- /dev/null +++ b/public/generated/docs/CellMeasurerCacheAPI.json @@ -0,0 +1,119 @@ +{ + "description": [ + { + "content": "

Public API of a CellMeasurerCache instance.

\n" + } + ], + "filePath": "lib/components/CellMeasurer/types.ts", + "methods": [ + { + "description": [ + { + "content": "

Reset cached measurements for a specific cell.\nThis should be called when a cell needs to be re-measured to handle dynamic content\n(e.g. replacing a loading indicator with loaded content, or reacting to state changes for stateful cells).\ncolumnIndex defaults to 0.

\n" + } + ], + "html": "
clear(rowIndex: number, columnIndex?: number): void;
", + "name": "clear" + }, + { + "description": [ + { + "content": "

Reset cached measurements for all cells.\nThis should be called when a Grid, List or Table needs to reflow its content due to a resize in a responsive layout\n(e.g. a change in window width may impact the height of cells).

\n" + } + ], + "html": "
clearAll(): void;
", + "name": "clearAll" + }, + { + "description": [ + { + "content": "

Returns the derived width of a column (its widest measured cell) or the default width.\nPass this as the columnWidth prop of a Grid (or MultiGrid);\nit's bound to the cache, so it can be passed as-is.

\n" + } + ], + "html": "
columnWidth(params: { index: number }): number;
", + "name": "columnWidth" + }, + { + "description": [ + { + "content": "

Height reported for unmeasured cells (read-only).

\n" + } + ], + "html": "
readonly defaultHeight: number;
", + "name": "defaultHeight" + }, + { + "description": [ + { + "content": "

Width reported for unmeasured cells (read-only).

\n" + } + ], + "html": "
readonly defaultWidth: number;
", + "name": "defaultWidth" + }, + { + "description": [ + { + "content": "

Returns the (measured or default) height of the specified cell.\ncolumnIndex defaults to 0.

\n" + } + ], + "html": "
getHeight(rowIndex: number, columnIndex?: number): number;
", + "name": "getHeight" + }, + { + "description": [ + { + "content": "

Returns the (measured or default) width of the specified cell.\ncolumnIndex defaults to 0.

\n" + } + ], + "html": "
getWidth(rowIndex: number, columnIndex?: number): number;
", + "name": "getWidth" + }, + { + "description": [ + { + "content": "

Returns true if the specified cell has already been measured.\ncolumnIndex defaults to 0.

\n" + } + ], + "html": "
has(rowIndex: number, columnIndex?: number): boolean;
", + "name": "has" + }, + { + "description": [ + { + "content": "

Returns true if cells have a fixed height (and a dynamic, measured width);\nsee the fixedHeight option.

\n" + } + ], + "html": "
hasFixedHeight(): boolean;
", + "name": "hasFixedHeight" + }, + { + "description": [ + { + "content": "

Returns true if cells have a fixed width (and a dynamic, measured height);\nsee the fixedWidth option.

\n" + } + ], + "html": "
hasFixedWidth(): boolean;
", + "name": "hasFixedWidth" + }, + { + "description": [ + { + "content": "

Returns the derived height of a row (its tallest measured cell) or the default height.\nPass this as the rowHeight prop of a Grid, List or Table;\nit's bound to the cache, so it can be passed as-is.

\n" + } + ], + "html": "
rowHeight(params: { index: number }): number;
", + "name": "rowHeight" + }, + { + "description": [ + { + "content": "

Stores the measured size of the specified cell.\nThis is called by CellMeasurer and doesn't usually need to be called directly.

\n" + } + ], + "html": "
set(
\n
rowIndex: number,
\n
columnIndex: number,
\n
width: number,
\n
height: number
\n
): void;
", + "name": "set" + } + ], + "name": "CellMeasurerCacheAPI" +} \ No newline at end of file diff --git a/public/generated/docs/CellMeasurerCacheOptions.json b/public/generated/docs/CellMeasurerCacheOptions.json new file mode 100644 index 000000000..c979bcf6f --- /dev/null +++ b/public/generated/docs/CellMeasurerCacheOptions.json @@ -0,0 +1,81 @@ +{ + "description": [ + { + "content": "

Options accepted by the CellMeasurerCache constructor (all optional).

\n" + } + ], + "filePath": "lib/components/CellMeasurer/CellMeasurerCache.ts", + "name": "CellMeasurerCacheOptions", + "props": { + "defaultHeight": { + "description": [ + { + "content": "

Unmeasured cells will initially report this height\n(or minHeight, if that is greater).

\n" + } + ], + "html": "
defaultHeight?: number = 30
", + "name": "defaultHeight", + "required": false + }, + "defaultWidth": { + "description": [ + { + "content": "

Unmeasured cells will initially report this width\n(or minWidth, if that is greater).

\n" + } + ], + "html": "
defaultWidth?: number = 100
", + "name": "defaultWidth", + "required": false + }, + "fixedHeight": { + "description": [ + { + "content": "

Rendered cells will have a fixed height and a dynamic (measured) width.

\n" + } + ], + "html": "
fixedHeight?: boolean = false
", + "name": "fixedHeight", + "required": false + }, + "fixedWidth": { + "description": [ + { + "content": "

Rendered cells will have a fixed width and a dynamic (measured) height.

\n" + } + ], + "html": "
fixedWidth?: boolean = false
", + "name": "fixedWidth", + "required": false + }, + "minHeight": { + "description": [ + { + "content": "

Derived row height (of multiple cells) should not be less than this value.

\n" + } + ], + "html": "
minHeight?: number = 0
", + "name": "minHeight", + "required": false + }, + "minWidth": { + "description": [ + { + "content": "

Derived column width (of multiple cells) should not be less than this value.

\n" + } + ], + "html": "
minWidth?: number = 0
", + "name": "minWidth", + "required": false + }, + "keyMapper": { + "description": [ + { + "content": "

Maps a cell's row and column index to a cache key, e.g. an item ID.\nThis prevents the cached measurements from being invalidated when the parent collection is modified\n(e.g. items are inserted or reordered).\nDefaults to a mapper that returns `${rowIndex}-${columnIndex}`.

\n" + } + ], + "html": "
keyMapper?: ((rowIndex: number, columnIndex: number) => unknown)
", + "name": "keyMapper", + "required": false + } + } +} \ No newline at end of file diff --git a/public/generated/docs/Column.json b/public/generated/docs/Column.json new file mode 100644 index 000000000..c12c22243 --- /dev/null +++ b/public/generated/docs/Column.json @@ -0,0 +1,213 @@ +{ + "description": [ + { + "content": "

Describes the header and cell contents of a table column.

\n" + }, + { + "content": "

This component does not render anything itself;\nTable reads the props of its children to render headers and cells\n(applying defaultColumnProps to each child's props).\nComponents that render a Column (e.g. const MyColumn = (props) => <Column {...props} />) can be used as Table children too;\ntheir props are read the same way (the wrapper component itself is never rendered).

\n" + } + ], + "filePath": "lib/components/Table/Column.tsx", + "name": "Column", + "props": { + "aria-label": { + "description": [ + { + "content": "

Optional aria-label value to set on the column header.

\n" + } + ], + "html": "
aria-label?: string
", + "name": "aria-label", + "required": false + }, + "cellDataGetter": { + "description": [ + { + "content": "

Callback responsible for returning a cell's data, given its dataKey:\n({ columnData: any, dataKey: string, rowData: any }): any.

\n" + }, + { + "content": "

The default cellDataGetter expects to operate on either a vanilla Object or a Map-like object with a get method.\nYou should override it if your data is calculated or requires any custom processing.\nDefaults to defaultTableCellDataGetter.

\n" + } + ], + "html": "
cellDataGetter?: TableCellDataGetter
", + "name": "cellDataGetter", + "required": false + }, + "cellRenderer": { + "description": [ + { + "content": "

Callback responsible for rendering a cell's contents:\n({ cellData: any, columnData: any, columnIndex: number, dataKey: string, isScrolling: boolean, parent: TableImperativeAPI, rowData: any, rowIndex: number }): ReactNode.

\n" + }, + { + "content": "

The default cellRenderer (defaultTableCellRenderer) displays an attribute as a simple string.\nYou should override it if your data is some other type of object or requires custom formatting.

\n" + } + ], + "html": "
cellRenderer?: TableCellRenderer
", + "name": "cellRenderer", + "required": false + }, + "className": { + "description": [ + { + "content": "

Optional CSS class to apply to the rendered cell container.

\n" + } + ], + "html": "
className?: string
", + "name": "className", + "required": false + }, + "columnData": { + "description": [ + { + "content": "

Additional data passed to this column's cellDataGetter, cellRenderer, and headerRenderer.\nUse this object to relay action-creators or relational data.

\n" + } + ], + "html": "
columnData?: any
", + "name": "columnData", + "required": false + }, + "dataKey": { + "description": [ + { + "content": "

Uniquely identifies the row-data attribute corresponding to this cell\n(eg this might be "name" in an array of user objects).

\n" + } + ], + "html": "
dataKey: string
", + "name": "dataKey", + "required": true + }, + "defaultSortDirection": { + "description": [ + { + "content": "

Default sort order when clicked for the first time.

\n" + } + ], + "html": "
defaultSortDirection?: SortDirectionType = \"ASC\"
", + "name": "defaultSortDirection", + "required": false + }, + "disableSort": { + "description": [ + { + "content": "

If sort is enabled for the table at large, disable it for this column.

\n" + } + ], + "html": "
disableSort?: boolean
", + "name": "disableSort", + "required": false + }, + "flexGrow": { + "description": [ + { + "content": "

Flex grow style.

\n" + } + ], + "html": "
flexGrow?: number = 0
", + "name": "flexGrow", + "required": false + }, + "flexShrink": { + "description": [ + { + "content": "

Flex shrink style.

\n" + } + ], + "html": "
flexShrink?: number = 1
", + "name": "flexShrink", + "required": false + }, + "headerClassName": { + "description": [ + { + "content": "

Optional CSS class to apply to this column's header.

\n" + } + ], + "html": "
headerClassName?: string
", + "name": "headerClassName", + "required": false + }, + "headerRenderer": { + "description": [ + { + "content": "

Optional callback responsible for rendering a column's header contents:\n({ columnData: any, dataKey: string, disableSort: boolean, label: ReactNode, sortBy: string, sortDirection: SortDirection }): ReactNode.

\n" + }, + { + "content": "

The default headerRenderer (defaultTableHeaderRenderer) displays the column label along with a sort indicator if the column is sort-enabled and active.

\n" + } + ], + "html": "
headerRenderer?: TableHeaderRenderer
", + "name": "headerRenderer", + "required": false + }, + "headerStyle": { + "description": [ + { + "content": "

Optional inline style to apply to this column's header.

\n" + } + ], + "html": "
headerStyle?: CSSProperties
", + "name": "headerStyle", + "required": false + }, + "id": { + "description": [ + { + "content": "

Optional id to set on the column header; used for the cells' aria-describedby.

\n" + } + ], + "html": "
id?: string
", + "name": "id", + "required": false + }, + "label": { + "description": [ + { + "content": "

Header label for this column.

\n" + } + ], + "html": "
label?: ReactNode
", + "name": "label", + "required": false + }, + "maxWidth": { + "description": [ + { + "content": "

Maximum width of column; this property will only be used if flexGrow is greater than 0.

\n" + } + ], + "html": "
maxWidth?: number
", + "name": "maxWidth", + "required": false + }, + "minWidth": { + "description": [ + { + "content": "

Minimum width of column.

\n" + } + ], + "html": "
minWidth?: number
", + "name": "minWidth", + "required": false + }, + "style": { + "description": [ + { + "content": "

Optional inline style to apply to the rendered cell container.

\n" + } + ], + "html": "
style?: CSSProperties = {}
", + "name": "style", + "required": false + }, + "width": { + "description": [ + { + "content": "

Flex basis (width) for this column;\nThis value can grow or shrink based on flexGrow and flexShrink properties.

\n" + } + ], + "html": "
width: number
", + "name": "width", + "required": true + } + } +} \ No newline at end of file diff --git a/public/generated/docs/ColumnSizer.json b/public/generated/docs/ColumnSizer.json new file mode 100644 index 000000000..8f41f9213 --- /dev/null +++ b/public/generated/docs/ColumnSizer.json @@ -0,0 +1,64 @@ +{ + "description": [ + { + "content": "

High-order component that auto-calculates column-widths for Grid cells.

\n" + } + ], + "filePath": "lib/components/ColumnSizer/ColumnSizer.tsx", + "name": "ColumnSizer", + "props": { + "children": { + "description": [ + { + "content": "

Function responsible for rendering a virtualized Grid.\nIt is passed { adjustedWidth, columnWidth, getColumnWidth, registerChild }.

\n" + }, + { + "content": "

The columnWidth value should be passed to the Grid's columnWidth property,\nand registerChild should be passed to the Grid's ref property.\nThe adjustedWidth value is optional; it reflects the lesser of the overall width or the width of all columns.

\n" + } + ], + "html": "
children: (params: ColumnSizerChildProps) => ReactNode
", + "name": "children", + "required": true + }, + "columnMaxWidth": { + "description": [ + { + "content": "

Optional maximum allowed column width.

\n" + } + ], + "html": "
columnMaxWidth?: number
", + "name": "columnMaxWidth", + "required": false + }, + "columnMinWidth": { + "description": [ + { + "content": "

Optional minimum allowed column width.

\n" + } + ], + "html": "
columnMinWidth?: number
", + "name": "columnMinWidth", + "required": false + }, + "columnCount": { + "description": [ + { + "content": "

Number of columns in the Grid or Table child.

\n" + } + ], + "html": "
columnCount: number
", + "name": "columnCount", + "required": true + }, + "width": { + "description": [ + { + "content": "

Width of the Grid or Table child.

\n" + } + ], + "html": "
width: number
", + "name": "width", + "required": true + } + } +} \ No newline at end of file diff --git a/public/generated/docs/Grid.json b/public/generated/docs/Grid.json new file mode 100644 index 000000000..9fea11089 --- /dev/null +++ b/public/generated/docs/Grid.json @@ -0,0 +1,401 @@ +{ + "description": [ + { + "content": "

Renders tabular data with virtualization along the vertical and horizontal axes.\nRow heights and column widths must be known ahead of time and specified as properties.

\n" + }, + { + "content": "

Scrolling is done through the imperative handle (ref): see scrollToCell and scrollToPosition.

\n" + } + ], + "filePath": "lib/components/Grid/Grid.tsx", + "name": "Grid", + "props": { + "aria-label": { + "description": [ + { + "content": "

ARIA label for the grid element.

\n" + } + ], + "html": "
aria-label?: string = \"grid\"
", + "name": "aria-label", + "required": false + }, + "aria-readonly": { + "description": [ + { + "content": "

ARIA readonly attribute for the grid element.

\n" + } + ], + "html": "
aria-readonly?: boolean = true
", + "name": "aria-readonly", + "required": false + }, + "autoContainerWidth": { + "description": [ + { + "content": "

Set the width of the inner scrollable container to 'auto'.\nThis is useful for single-column Grids to ensure that the column doesn't extend below a vertical scrollbar.

\n" + } + ], + "html": "
autoContainerWidth?: boolean = false
", + "name": "autoContainerWidth", + "required": false + }, + "cellRangeRenderer": { + "description": [ + { + "content": "

Responsible for rendering a group of cells given their index ranges.

\n" + }, + { + "content": "

This is an advanced property.\nIt is useful for situations where the Grid requires additional, overlayed UI (such as a Gantt chart or a calendar application).\nThe easiest way to override it is to decorate defaultCellRangeRenderer.

\n" + }, + { + "content": "

Defaults to defaultCellRangeRenderer.

\n" + } + ], + "html": "
cellRangeRenderer?: CellRangeRenderer
", + "name": "cellRangeRenderer", + "required": false + }, + "cellRenderer": { + "description": [ + { + "content": "

Responsible for rendering a cell given an row and column index.\nIt receives columnIndex, isScrolling, isVisible, key, parent, rowIndex, and style parameters.

\n" + }, + { + "content": "

The style parameter specifies how the cell is to be sized and positioned and must be passed through to the rendered cell element.\nThe key parameter must also be applied to the rendered element.

\n" + } + ], + "html": "
cellRenderer: CellRenderer
", + "name": "cellRenderer", + "required": true + }, + "className": { + "description": [ + { + "content": "

Optional custom CSS class name to attach to root Grid element.

\n" + } + ], + "html": "
className?: string
", + "name": "className", + "required": false + }, + "columnCount": { + "description": [ + { + "content": "

Number of columns in grid.

\n" + } + ], + "html": "
columnCount: number
", + "name": "columnCount", + "required": true + }, + "columnWidth": { + "description": [ + { + "content": "

Either a fixed column width (number) or a function that returns the width of a column given its index: ({ index: number }): number.\nIf function is used, specify estimatedColumnSize for more consistent scrolling behavior.

\n" + } + ], + "html": "
columnWidth: CellSize
", + "name": "columnWidth", + "required": true + }, + "containerProps": { + "description": [ + { + "content": "

Additional props (eg onWheel) to spread onto the outer Grid element.

\n" + } + ], + "html": "
containerProps?: HTMLAttributes<HTMLDivElement>
", + "name": "containerProps", + "required": false + }, + "containerRole": { + "description": [ + { + "content": "

ARIA role for the inner cell-container.

\n" + } + ], + "html": "
containerRole?: string = \"row\"
", + "name": "containerRole", + "required": false + }, + "containerStyle": { + "description": [ + { + "content": "

Optional custom inline style to attach to inner cell-container element.

\n" + } + ], + "html": "
containerStyle?: CSSProperties = {}
", + "name": "containerStyle", + "required": false + }, + "deferredMeasurementCache": { + "description": [ + { + "content": "

If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache.\nA shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data.

\n" + } + ], + "html": "
deferredMeasurementCache?: CellMeasureCache
", + "name": "deferredMeasurementCache", + "required": false + }, + "elementRef": { + "description": [ + { + "content": "

Reference to the outer (scrolling) DOM element.

\n" + } + ], + "html": "
elementRef?: Ref<HTMLDivElement>
", + "name": "elementRef", + "required": false + }, + "estimatedColumnSize": { + "description": [ + { + "content": "

Used to estimate the total width of a Grid before all of its columns have actually been measured.\nThe estimated total width is adjusted as columns are rendered.

\n" + } + ], + "html": "
estimatedColumnSize?: number = 100
", + "name": "estimatedColumnSize", + "required": false + }, + "estimatedRowSize": { + "description": [ + { + "content": "

Used to estimate the total height of a Grid before all of its rows have actually been measured.\nThe estimated total height is adjusted as rows are rendered.

\n" + } + ], + "html": "
estimatedRowSize?: number = 30
", + "name": "estimatedRowSize", + "required": false + }, + "getScrollbarSize": { + "description": [ + { + "content": "

Returns the size of the browser's scrollbar.\nExposed for testing purposes only.

\n" + }, + { + "content": "

Defaults to dom-helpers' scrollbarSize (which returns undefined during server rendering).

\n" + } + ], + "html": "
getScrollbarSize?: () => number
", + "name": "getScrollbarSize", + "required": false + }, + "height": { + "description": [ + { + "content": "

Height of Grid; this property determines the number of visible (vs virtualized) rows.\nRequired, unless a scrollElement is specified.

\n" + }, + { + "content": "

With a scrollElement, the height of the scroll element's viewport is used instead;\nthis prop is then only used until the scroll element has been measured (e.g. during server rendering).

\n" + } + ], + "html": "
height?: number
", + "name": "height", + "required": false + }, + "id": { + "description": [ + { + "content": "

Optional custom id to attach to root Grid element.

\n" + } + ], + "html": "
id?: string
", + "name": "id", + "required": false + }, + "initialScrollLeft": { + "description": [ + { + "content": "

Horizontal scroll offset to initially render (and scroll to) the Grid at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the cells at this offset are rendered on the server,\nand they stay in place when the page is hydrated.

\n" + } + ], + "html": "
initialScrollLeft?: number
", + "name": "initialScrollLeft", + "required": false + }, + "initialScrollTop": { + "description": [ + { + "content": "

Vertical scroll offset to initially render (and scroll to) the Grid at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the cells at this offset are rendered on the server,\nand they stay in place when the page is hydrated.\nIgnored if a scrollElement is specified (which then determines the vertical offset).

\n" + } + ], + "html": "
initialScrollTop?: number
", + "name": "initialScrollTop", + "required": false + }, + "isScrollingOptOut": { + "description": [ + { + "content": "

Prevents re-rendering of visible cells on scroll end.\n(Opts out of the isScrolling param passed to cellRangeRenderer, to avoid the extra render when scroll stops.)\nVisible cells are still re-rendered when the Grid's props change or forceUpdate() is called.

\n" + } + ], + "html": "
isScrollingOptOut?: boolean = false
", + "name": "isScrollingOptOut", + "required": false + }, + "noContentRenderer": { + "description": [ + { + "content": "

Optional renderer to be rendered inside the grid when either rowCount or columnCount is empty.\nRenders nothing by default.

\n" + } + ], + "html": "
noContentRenderer?: NoContentRenderer
", + "name": "noContentRenderer", + "required": false + }, + "onScroll": { + "description": [ + { + "content": "

Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, clientWidth: number, scrollHeight: number, scrollLeft: number, scrollTop: number, scrollWidth: number }): void.\nThis callback can be used to sync scrolling between lists, tables, or grids.

\n" + } + ], + "html": "
onScroll?: (params: Scroll) => void
", + "name": "onScroll", + "required": false + }, + "onScrollbarPresenceChange": { + "description": [ + { + "content": "

Called whenever a horizontal or vertical scrollbar is added or removed:\n({ horizontal: boolean, size: number, vertical: boolean }): void.\nThis prop is not intended for end-user use;\nit is used by MultiGrid to support fixed-row/fixed-column scroll syncing.

\n" + } + ], + "html": "
onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void
", + "name": "onScrollbarPresenceChange", + "required": false + }, + "onSectionRendered": { + "description": [ + { + "content": "

Callback invoked with information about the section of the Grid that was just rendered.\nThis callback is only invoked when visible rows have changed:\n({ columnOverscanStartIndex: number, columnOverscanStopIndex: number, columnStartIndex: number, columnStopIndex: number, rowOverscanStartIndex: number, rowOverscanStopIndex: number, rowStartIndex: number, rowStopIndex: number }): void.

\n" + } + ], + "html": "
onSectionRendered?: (params: RenderedSection) => void
", + "name": "onSectionRendered", + "required": false + }, + "overscanColumnCount": { + "description": [ + { + "content": "

Number of columns to render before/after the visible slice of the grid.\nThis can help reduce flickering during scrolling on certain browsers/devices.

\n" + }, + { + "content": "

Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event.\nAim to use the lowest value possible that still generally avoids any empty space from appearing during normal scrolling.

\n" + } + ], + "html": "
overscanColumnCount?: number = 0
", + "name": "overscanColumnCount", + "required": false + }, + "overscanIndicesGetter": { + "description": [ + { + "content": "

Responsible for calculating the number of cells to overscan before and after a specified range.\nBy default, cells are overscanned in the direction the user is scrolling.\nThis function receives direction, cellCount, scrollDirection, overscanCellsCount, startIndex, and stopIndex parameters\nand returns an { overscanStartIndex, overscanStopIndex } object.

\n" + }, + { + "content": "

Defaults to defaultOverscanIndicesGetter.

\n" + } + ], + "html": "
overscanIndicesGetter?: OverscanIndicesGetter
", + "name": "overscanIndicesGetter", + "required": false + }, + "overscanRowCount": { + "description": [ + { + "content": "

Number of rows to render above/below the visible slice of the grid.\nThis can help reduce flickering during scrolling on certain browsers/devices.

\n" + }, + { + "content": "

Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event.\nThe default value (10) is a good starting point; lower it if possible.

\n" + } + ], + "html": "
overscanRowCount?: number = 10
", + "name": "overscanRowCount", + "required": false + }, + "role": { + "description": [ + { + "content": "

Optional override of ARIA role default.

\n" + } + ], + "html": "
role?: string = \"grid\"
", + "name": "role", + "required": false + }, + "rowCount": { + "description": [ + { + "content": "

Number of rows in grid.

\n" + } + ], + "html": "
rowCount: number
", + "name": "rowCount", + "required": true + }, + "rowHeight": { + "description": [ + { + "content": "

Either a fixed row height (number) or a function that returns the height of a row given its index: ({ index: number }): number.\nIf function is used, specify estimatedRowSize for more consistent scrolling behavior.

\n" + } + ], + "html": "
rowHeight: CellSize
", + "name": "rowHeight", + "required": true + }, + "scrollElement": { + "description": [ + { + "content": "

Element that scrolls the Grid (instead of the Grid scrolling itself): "window", or a scrollable DOM element\n(e.g. a container that the Grid is rendered within, along with other content).

\n" + }, + { + "content": "

The Grid then renders at its full height (it may still scroll horizontally itself), renders only the rows visible within the scroll element's viewport,\nand its scroll methods scroll the scroll element.\nIts position within the scroll element is measured when the scroll element scrolls or is resized, and when the Grid re-renders\n(e.g. call forceUpdate() if content above the Grid changes size without the Grid re-rendering).\nPass null while the scroll element isn't available yet (e.g. before a ref to it has been attached);\nthe Grid then renders the rows at its top.

\n" + } + ], + "html": "
scrollElement?: ScrollElement | null
", + "name": "scrollElement", + "required": false + }, + "scrollingResetTimeInterval": { + "description": [ + { + "content": "

Wait this amount of time (in milliseconds) after the last scroll event before resetting Grid pointer-events.

\n" + } + ], + "html": "
scrollingResetTimeInterval?: number = 150
", + "name": "scrollingResetTimeInterval", + "required": false + }, + "style": { + "description": [ + { + "content": "

Optional custom inline style to attach to root Grid element.

\n" + } + ], + "html": "
style?: CSSProperties = {}
", + "name": "style", + "required": false + }, + "tabIndex": { + "description": [ + { + "content": "

Optional override of tab index default.

\n" + } + ], + "html": "
tabIndex?: number | null = 0
", + "name": "tabIndex", + "required": false + }, + "width": { + "description": [ + { + "content": "

Width of Grid; this property determines the number of visible (vs virtualized) columns.

\n" + } + ], + "html": "
width: number
", + "name": "width", + "required": true + } + } +} \ No newline at end of file diff --git a/public/generated/docs/GridImperativeAPI.json b/public/generated/docs/GridImperativeAPI.json new file mode 100644 index 000000000..8cb53d363 --- /dev/null +++ b/public/generated/docs/GridImperativeAPI.json @@ -0,0 +1,101 @@ +{ + "description": [ + { + "content": "

Public (imperative) methods of a Grid (its ref handle).

\n" + } + ], + "filePath": "lib/components/Grid/types.ts", + "methods": [ + { + "description": [ + { + "content": "

Forcefully re-render the Grid (and its visible cells),\ne.g. because the data rendered by cellRenderer has changed but the Grid's props have not.

\n" + } + ], + "html": "
forceUpdate(): void;
", + "name": "forceUpdate" + }, + { + "description": [ + { + "content": "

Gets offsets for a given cell and alignment (see scrollToCell for the alignment values).\nThe alignment defaults to "auto"; an index of -1 (the default) refers to the last column or row.

\n" + } + ], + "html": "
getOffsetForCell(params?: {
\n
alignment?: Alignment;
\n
columnIndex?: number;
\n
rowIndex?: number;
\n
}): { scrollLeft: number; scrollTop: number };
", + "name": "getOffsetForCell" + }, + { + "description": [ + { + "content": "

Gets estimated total rows' height.

\n" + } + ], + "html": "
getTotalRowsHeight(): number;
", + "name": "getTotalRowsHeight" + }, + { + "description": [ + { + "content": "

Gets estimated total columns' width.

\n" + } + ], + "html": "
getTotalColumnsWidth(): number;
", + "name": "getTotalColumnsWidth" + }, + { + "description": [ + { + "content": "

This method handles a scroll event originating from an external scroll control.\nIt's an advanced method and should probably not be used unless you're implementing a custom scroll-bar solution.

\n" + } + ], + "html": "
handleScrollEvent(params: ScrollPosition): void;
", + "name": "handleScrollEvent" + }, + { + "description": [ + { + "content": "

Invalidate the size of a cell and recompute visible cells after the current render has been committed.\nThis is a deferred version of recomputeGridSize() (batching the invalidations of a render);\nit is intended for advanced use-cases like CellMeasurer.

\n" + } + ], + "html": "
invalidateCellSizeAfterRender(params: CellPosition): void;
", + "name": "invalidateCellSizeAfterRender" + }, + { + "description": [ + { + "content": "

Pre-measure all columns and rows in a Grid.\nTypically cells are only measured as needed and estimated sizes are used for cells that have not yet been measured.\nThis method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one).

\n" + } + ], + "html": "
measureAllCells(): void;
", + "name": "measureAllCells" + }, + { + "description": [ + { + "content": "

Recomputes row heights and column widths after the specified index (both default to 0).\nThis function should be called if dynamic column or row sizes have changed but nothing else has.\nSince Grid only receives columnCount and rowCount it has no way of detecting when the underlying data changes.\nThis method will also force a render cycle to ensure that the updated measurements are reflected in the rendered grid.

\n" + } + ], + "html": "
recomputeGridSize(params?: { columnIndex?: number; rowIndex?: number }): void;
", + "name": "recomputeGridSize" + }, + { + "description": [ + { + "content": "

Ensure the specified column and/or row is visible (scrolling if necessary).\nThis method can be used to safely scroll back to a cell that a user has scrolled away from even if it was previously scrolled to.\nThe align parameter (default "auto") controls the alignment of the cell:\n"auto" scrolls the least amount possible to ensure that the specified cell is fully visible\n(a cell larger than the Grid is aligned to its start, so that its start is visible);\n"start" aligns the cell to the top/left of the Grid and "end" to the bottom/right;\n"center" aligns the cell in the middle of the Grid.\nThis is a one-time scroll: the cell is not kept in view afterwards (e.g. if the Grid's size or its cell sizes change).\nCells that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated sizes.\nNothing is scrolled while the Grid's width or height is 0.\nThe column is ignored for single-column grids (e.g. List, Table).\nIf the Grid has a scrollElement, the scroll element (e.g. the window) is scrolled vertically instead.

\n" + } + ], + "html": "
scrollToCell(params: {
\n
align?: Alignment;
\n
columnIndex?: number;
\n
rowIndex?: number;
\n
}): void;
", + "name": "scrollToCell" + }, + { + "description": [ + { + "content": "

Scroll to the specified offset(s); an omitted offset is left unchanged.\nOffsets are clamped to the scrollable range.\nCalling this method with the current offsets does nothing.\nIf the Grid has a scrollElement, scrollTop scrolls the scroll element (e.g. the window) instead\n(to the position at which the Grid is scrolled by scrollTop).

\n" + } + ], + "html": "
scrollToPosition(params: ScrollPosition): void;
", + "name": "scrollToPosition" + } + ], + "name": "GridImperativeAPI" +} \ No newline at end of file diff --git a/public/generated/docs/InfiniteLoader.json b/public/generated/docs/InfiniteLoader.json new file mode 100644 index 000000000..a9296f12a --- /dev/null +++ b/public/generated/docs/InfiniteLoader.json @@ -0,0 +1,71 @@ +{ + "description": [ + { + "content": "

Higher-order component that manages lazy-loading for "infinite" data.\nThis component decorates a virtual component and just-in-time prefetches rows as a user scrolls.\nIt is intended as a convenience component; fork it if you'd like finer-grained control over data-loading.

\n" + } + ], + "filePath": "lib/components/InfiniteLoader/InfiniteLoader.tsx", + "name": "InfiniteLoader", + "props": { + "children": { + "description": [ + { + "content": "

Function responsible for rendering a virtualized component.\nIt is passed onRowsRendered and registerChild parameters.\nThe onRowsRendered function should be passed through to the child's onRowsRendered property.\nThe registerChild callback should be set as the virtualized component's ref.

\n" + } + ], + "html": "
children: (params: InfiniteLoaderChildProps) => ReactNode
", + "name": "children", + "required": true + }, + "isRowLoaded": { + "description": [ + { + "content": "

Function responsible for tracking the loaded state of each row.\nRows that are currently loading can be treated as loaded to avoid requesting them multiple times.

\n" + } + ], + "html": "
isRowLoaded: (params: { index: number; }) => boolean
", + "name": "isRowLoaded", + "required": true + }, + "loadMoreRows": { + "description": [ + { + "content": "

Callback to be invoked when more rows must be loaded.\nThe returned Promise should be resolved once row data has finished loading.\nIt will be used to determine when to refresh the list with the newly-loaded data.\nThis callback may be called multiple times in reaction to a single scroll event.

\n" + } + ], + "html": "
loadMoreRows: (params: IndexRange) => void | Promise<unknown>
", + "name": "loadMoreRows", + "required": true + }, + "minimumBatchSize": { + "description": [ + { + "content": "

Minimum number of rows to be loaded at a time.\nThis property can be used to batch requests to reduce HTTP requests.

\n" + } + ], + "html": "
minimumBatchSize?: number = 10
", + "name": "minimumBatchSize", + "required": false + }, + "rowCount": { + "description": [ + { + "content": "

Number of rows in list; can be arbitrary high number if actual number is unknown.

\n" + } + ], + "html": "
rowCount?: number = 0
", + "name": "rowCount", + "required": false + }, + "threshold": { + "description": [ + { + "content": "

Threshold at which to pre-fetch data.\nA threshold X means that data will start loading when a user scrolls within X rows.

\n" + } + ], + "html": "
threshold?: number = 15
", + "name": "threshold", + "required": false + } + } +} \ No newline at end of file diff --git a/public/generated/docs/InfiniteLoaderImperativeAPI.json b/public/generated/docs/InfiniteLoaderImperativeAPI.json new file mode 100644 index 000000000..de7fbbee9 --- /dev/null +++ b/public/generated/docs/InfiniteLoaderImperativeAPI.json @@ -0,0 +1,20 @@ +{ + "description": [ + { + "content": "

Imperative API exposed by InfiniteLoader (e.g. via useRef<InfiniteLoaderImperativeAPI>(null)).

\n" + } + ], + "filePath": "lib/components/InfiniteLoader/types.ts", + "methods": [ + { + "description": [ + { + "content": "

Reset any cached data about already-loaded rows.\nThis method should be called if any/all loaded data needs to be refetched (e.g. a filtered list where the search criteria changes).\nIf autoReload is true, the last loaded batch will be automatically reloaded.

\n" + } + ], + "html": "
resetLoadMoreRowsCache(autoReload?: boolean): void;
", + "name": "resetLoadMoreRowsCache" + } + ], + "name": "InfiniteLoaderImperativeAPI" +} \ No newline at end of file diff --git a/public/generated/docs/List.json b/public/generated/docs/List.json new file mode 100644 index 000000000..3f638757f --- /dev/null +++ b/public/generated/docs/List.json @@ -0,0 +1,335 @@ +{ + "description": [ + { + "content": "

It is inefficient to create and manage a large list of DOM elements within a scrolling container\nif only a few of those elements are visible. The primary purpose of this component is to improve\nperformance by only rendering the DOM nodes that a user is able to see based on their current\nscroll position.

\n" + }, + { + "content": "

This component renders a virtualized list of elements with either fixed or dynamic heights.

\n" + } + ], + "filePath": "lib/components/List/List.tsx", + "name": "List", + "props": { + "cellRangeRenderer": { + "description": [ + { + "content": "

Responsible for rendering a group of cells given their index ranges.

\n" + }, + { + "content": "

This is an advanced property.\nIt is useful for situations where the Grid requires additional, overlayed UI (such as a Gantt chart or a calendar application).\nThe easiest way to override it is to decorate defaultCellRangeRenderer.

\n" + }, + { + "content": "

Defaults to defaultCellRangeRenderer.

\n" + } + ], + "html": "
cellRangeRenderer?: CellRangeRenderer
", + "name": "cellRangeRenderer", + "required": false + }, + "containerProps": { + "description": [ + { + "content": "

Additional props (eg onWheel) to spread onto the outer Grid element.

\n" + } + ], + "html": "
containerProps?: HTMLAttributes<HTMLDivElement>
", + "name": "containerProps", + "required": false + }, + "containerStyle": { + "description": [ + { + "content": "

Optional custom inline style to attach to inner cell-container element.

\n" + } + ], + "html": "
containerStyle?: CSSProperties = {}
", + "name": "containerStyle", + "required": false + }, + "deferredMeasurementCache": { + "description": [ + { + "content": "

If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache.\nA shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data.

\n" + } + ], + "html": "
deferredMeasurementCache?: CellMeasureCache
", + "name": "deferredMeasurementCache", + "required": false + }, + "elementRef": { + "description": [ + { + "content": "

Reference to the outer (scrolling) DOM element.

\n" + } + ], + "html": "
elementRef?: Ref<HTMLDivElement>
", + "name": "elementRef", + "required": false + }, + "getScrollbarSize": { + "description": [ + { + "content": "

Returns the size of the browser's scrollbar.\nExposed for testing purposes only.

\n" + }, + { + "content": "

Defaults to dom-helpers' scrollbarSize (which returns undefined during server rendering).

\n" + } + ], + "html": "
getScrollbarSize?: () => number
", + "name": "getScrollbarSize", + "required": false + }, + "initialScrollTop": { + "description": [ + { + "content": "

Vertical scroll offset to initially render (and scroll to) the Grid at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the cells at this offset are rendered on the server,\nand they stay in place when the page is hydrated.\nIgnored if a scrollElement is specified (which then determines the vertical offset).

\n" + } + ], + "html": "
initialScrollTop?: number
", + "name": "initialScrollTop", + "required": false + }, + "isScrollingOptOut": { + "description": [ + { + "content": "

Prevents re-rendering of visible cells on scroll end.\n(Opts out of the isScrolling param passed to cellRangeRenderer, to avoid the extra render when scroll stops.)\nVisible cells are still re-rendered when the Grid's props change or forceUpdate() is called.

\n" + } + ], + "html": "
isScrollingOptOut?: boolean = false
", + "name": "isScrollingOptOut", + "required": false + }, + "onScrollbarPresenceChange": { + "description": [ + { + "content": "

Called whenever a horizontal or vertical scrollbar is added or removed:\n({ horizontal: boolean, size: number, vertical: boolean }): void.\nThis prop is not intended for end-user use;\nit is used by MultiGrid to support fixed-row/fixed-column scroll syncing.

\n" + } + ], + "html": "
onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void
", + "name": "onScrollbarPresenceChange", + "required": false + }, + "scrollingResetTimeInterval": { + "description": [ + { + "content": "

Wait this amount of time (in milliseconds) after the last scroll event before resetting Grid pointer-events.

\n" + } + ], + "html": "
scrollingResetTimeInterval?: number = 150
", + "name": "scrollingResetTimeInterval", + "required": false + }, + "aria-label": { + "description": [ + { + "content": "

ARIA label for the list element. There is no default label.\n(With role="grid", it defaults to "grid", as in version 9.)

\n" + } + ], + "html": "
aria-label?: string
", + "name": "aria-label", + "required": false + }, + "aria-readonly": { + "description": [ + { + "content": "

ARIA readonly attribute for the list element.\nNot set by default (it doesn't apply to role="list"); with role="grid", it defaults to true.

\n" + } + ], + "html": "
aria-readonly?: boolean
", + "name": "aria-readonly", + "required": false + }, + "containerRole": { + "description": [ + { + "content": "

ARIA role for the inner container of the rows.\nDefaults to "presentation" (so that the rows belong to the list), or to "row" with role="grid".

\n" + } + ], + "html": "
containerRole?: string
", + "name": "containerRole", + "required": false + }, + "role": { + "description": [ + { + "content": "

ARIA role for the list element.\nWith the default "list", rows that don't specify a role get role="listitem" (#1657).\nPass "grid" for the version 9 roles (a grid element whose rows are gridcells within a single row).

\n" + } + ], + "html": "
role?: string = \"list\"
", + "name": "role", + "required": false + }, + "className": { + "description": [ + { + "content": "

Optional custom CSS class name to attach to root List element.

\n" + } + ], + "html": "
className?: string
", + "name": "className", + "required": false + }, + "estimatedRowSize": { + "description": [ + { + "content": "

Used to estimate the total height of a List before all of its rows have actually been measured.\nThe estimated total height is adjusted as rows are rendered.

\n" + } + ], + "html": "
estimatedRowSize?: number = 30
", + "name": "estimatedRowSize", + "required": false + }, + "height": { + "description": [ + { + "content": "

Height constraint for list (determines how many actual rows are rendered).\nRequired, unless a scrollElement is specified.

\n" + }, + { + "content": "

With a scrollElement, the height of the scroll element's viewport is used instead;\nthis prop is then only used until the scroll element has been measured (e.g. during server rendering).

\n" + } + ], + "html": "
height?: number
", + "name": "height", + "required": false + }, + "id": { + "description": [ + { + "content": "

Optional custom id to attach to root List element.

\n" + } + ], + "html": "
id?: string
", + "name": "id", + "required": false + }, + "noRowsRenderer": { + "description": [ + { + "content": "

Callback used to render placeholder content when rowCount is 0.\nBy default, nothing is rendered.

\n" + } + ], + "html": "
noRowsRenderer?: NoContentRenderer
", + "name": "noRowsRenderer", + "required": false + }, + "onRowsRendered": { + "description": [ + { + "content": "

Callback invoked with information about the slice of rows that were just rendered:\n({ overscanStartIndex: number, overscanStopIndex: number, startIndex: number, stopIndex: number }): void.

\n" + } + ], + "html": "
onRowsRendered?: (params: RenderedRows) => void
", + "name": "onRowsRendered", + "required": false + }, + "onScroll": { + "description": [ + { + "content": "

Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, scrollHeight: number, scrollTop: number }): void.\nThis callback can be used to sync scrolling between lists, tables, or grids.

\n" + } + ], + "html": "
onScroll?: (params: Scroll) => void
", + "name": "onScroll", + "required": false + }, + "overscanIndicesGetter": { + "description": [ + { + "content": "

Responsible for calculating the number of rows to overscan before and after a specified range.\nBy default (accessibilityOverscanIndicesGetter), at least one row is overscanned in each direction (for accessibility).\nSee Grid overscanIndicesGetter for details.

\n" + } + ], + "html": "
overscanIndicesGetter?: OverscanIndicesGetter
", + "name": "overscanIndicesGetter", + "required": false + }, + "overscanRowCount": { + "description": [ + { + "content": "

Number of rows to render above/below the visible bounds of the list.\nThis can help reduce flickering during scrolling on certain browsers/devices.

\n" + }, + { + "content": "

Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event.

\n" + } + ], + "html": "
overscanRowCount?: number = 10
", + "name": "overscanRowCount", + "required": false + }, + "rowCount": { + "description": [ + { + "content": "

Number of rows in list.

\n" + } + ], + "html": "
rowCount: number
", + "name": "rowCount", + "required": true + }, + "rowHeight": { + "description": [ + { + "content": "

Either a fixed row height (number) or a function that returns the height of a row given its index: ({ index: number }): number.\nIf function is used, specify estimatedRowSize for more consistent scrolling behavior.

\n" + } + ], + "html": "
rowHeight: CellSize
", + "name": "rowHeight", + "required": true + }, + "rowRenderer": { + "description": [ + { + "content": "

Responsible for rendering a row given its index.\nIt receives index, isScrolling, isVisible, key, parent, and style parameters.

\n" + }, + { + "content": "

The style parameter specifies how the row is to be sized and positioned and must be passed through to the rendered row element.\nThe key parameter must also be applied to the rendered element.

\n" + } + ], + "html": "
rowRenderer: RowRenderer
", + "name": "rowRenderer", + "required": true + }, + "scrollElement": { + "description": [ + { + "content": "

Element that scrolls the List (instead of the List scrolling itself): "window", or a scrollable DOM element\n(e.g. a container that the List is rendered within, along with other content).

\n" + }, + { + "content": "

The List then renders at its full height, renders only the rows visible within the scroll element's viewport,\nand its scroll methods scroll the scroll element.\nIts position within the scroll element is measured when the scroll element scrolls or is resized, and when the List re-renders\n(e.g. call forceUpdateGrid() if content above the List changes size without the List re-rendering).\nPass null while the scroll element isn't available yet (e.g. before a ref to it has been attached);\nthe List then renders the rows at its top.

\n" + } + ], + "html": "
scrollElement?: ScrollElement | null
", + "name": "scrollElement", + "required": false + }, + "style": { + "description": [ + { + "content": "

Optional custom inline style to attach to root List element.

\n" + } + ], + "html": "
style?: CSSProperties = {}
", + "name": "style", + "required": false + }, + "tabIndex": { + "description": [ + { + "content": "

Optional override of tab index default.

\n" + } + ], + "html": "
tabIndex?: number | null = 0
", + "name": "tabIndex", + "required": false + }, + "width": { + "description": [ + { + "content": "

Width of the list.

\n" + } + ], + "html": "
width: number
", + "name": "width", + "required": true + } + } +} \ No newline at end of file diff --git a/public/generated/docs/ListImperativeAPI.json b/public/generated/docs/ListImperativeAPI.json new file mode 100644 index 000000000..9352492a9 --- /dev/null +++ b/public/generated/docs/ListImperativeAPI.json @@ -0,0 +1,92 @@ +{ + "description": [ + { + "content": "

Public (imperative) methods of a List (its ref handle).\nThe handle is also passed to rowRenderer as parent (e.g. for CellMeasurer).

\n" + } + ], + "filePath": "lib/components/List/types.ts", + "methods": [ + { + "description": [ + { + "content": "

Forcefully re-render the inner Grid component.\nRe-rendering a List with the same props does not re-render its rows (both List and the inner Grid are memoized).\nUse this method if you want to manually trigger a re-render.\nThis may be appropriate if the underlying row data has changed but the row sizes themselves have not.

\n" + } + ], + "html": "
forceUpdateGrid(): void;
", + "name": "forceUpdateGrid" + }, + { + "description": [ + { + "content": "

Gets the vertical offset (scrollTop) for a given row and alignment (see scrollToRow for the align values).\nThe alignment defaults to "auto"; an index of -1 (the default) refers to the last row.\nalignment is a deprecated alias of align (align takes precedence if both are specified).

\n" + } + ], + "html": "
getOffsetForRow(params?: {
\n
align?: Alignment;
\n
/** @deprecated Use `align` instead. */
\n
alignment?: Alignment;
\n
index?: number;
\n
}): number;
", + "name": "getOffsetForRow" + }, + { + "description": [ + { + "content": "

Invalidate the size of a row (after the current render has been committed); see Grid#invalidateCellSizeAfterRender.\nThis method is intended for CellMeasurer.

\n" + } + ], + "html": "
invalidateCellSizeAfterRender(params: CellPosition): void;
", + "name": "invalidateCellSizeAfterRender" + }, + { + "description": [ + { + "content": "

Pre-measure all rows in a List.\nTypically rows are only measured as needed and estimated heights are used for cells that have not yet been measured.\nThis method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one).

\n" + } + ], + "html": "
measureAllRows(): void;
", + "name": "measureAllRows" + }, + { + "description": [ + { + "content": "

Recompute row heights and offsets after the specified index (defaults to 0).\nList has no way of knowing when its underlying list data has changed since it only receives a rowHeight property.\nIf the rowHeight is a number it can compare before and after values but if it is a function that comparison is error prone.\nIn the event that a dynamic rowHeight function is in use and the row heights have changed this function should be manually called by the "smart" container parent.\nThis method will also force a render cycle (via forceUpdate) to ensure that the updated measurements are reflected in the rendered list.

\n" + } + ], + "html": "
recomputeRowHeights(index?: number): void;
", + "name": "recomputeRowHeights" + }, + { + "description": [ + { + "content": "

Recompute row heights and offsets after the specified row index (defaults to 0); see Grid#recomputeGridSize.\n(The column index is ignored.) This method is intended for CellMeasurer; also see recomputeRowHeights.

\n" + } + ], + "html": "
recomputeGridSize(params?: Partial<CellPosition>): void;
", + "name": "recomputeGridSize" + }, + { + "description": [ + { + "content": "

Ensure the specified row is visible (scrolling if necessary); the column index is ignored.\nThis allows a List to be used with ArrowKeyStepper; also see scrollToRow.

\n" + } + ], + "html": "
scrollToCell(params: {
\n
align?: Alignment;
\n
columnIndex?: number;
\n
rowIndex?: number;
\n
}): void;
", + "name": "scrollToCell" + }, + { + "description": [ + { + "content": "

Scroll to the specified vertical offset; scrollLeft is ignored (lists don't scroll horizontally).\nOffsets are clamped to the scrollable range. This allows a List to be used with ScrollSync.\nUseful for animating position changes.\nIf the List has a scrollElement, the scroll element (e.g. the window) is scrolled instead.

\n" + } + ], + "html": "
scrollToPosition(params: ScrollPosition): void;
", + "name": "scrollToPosition" + }, + { + "description": [ + { + "content": "

Ensure the row at the specified index is visible (scrolling if necessary).\nThis method can be used to safely scroll back to a row that a user has scrolled away from even if it was previously scrolled to.\nThe align parameter (default "auto") controls the alignment of the row:\n"auto" scrolls the least amount possible to ensure that the specified row is fully visible\n(a row taller than the list is aligned to its start, so that its start is visible);\n"start" aligns the row to the top of the list and "end" to the bottom; "center" aligns it in the middle.\nThis is a one-time scroll: the row is not kept in view afterwards (e.g. if the list's size or its row heights change).\nRows that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated heights.\nIf the List has a scrollElement, the scroll element (e.g. the window) is scrolled instead.

\n" + } + ], + "html": "
scrollToRow(params: { align?: Alignment; index: number }): void;
", + "name": "scrollToRow" + } + ], + "name": "ListImperativeAPI" +} \ No newline at end of file diff --git a/public/generated/docs/Masonry.json b/public/generated/docs/Masonry.json new file mode 100644 index 000000000..27cde3e7a --- /dev/null +++ b/public/generated/docs/Masonry.json @@ -0,0 +1,216 @@ +{ + "description": [ + { + "content": "

This component efficiently displays arbitrarily positioned cells using windowing techniques.\nCell position is determined by an injected cellPositioner property.\nWindowing is vertical; this component does not support horizontal scrolling.

\n" + }, + { + "content": "

Rendering occurs in two phases:

\n
    \n
  1. First pass uses estimated cell sizes (provided by the cache) to determine how many cells to measure in a batch.\nBatch size is chosen using a fast, naive layout algorithm that stacks images in order until the viewport has been filled.\nAfter measurement is complete (once the render has been committed) this component evaluates positioned cells\nin order to determine if another measurement pass is required (eg if actual cell sizes were less than estimated sizes).\nAll measurements are permanently cached (keyed by keyMapper) for performance purposes.
  2. \n
  3. Second pass uses the external cellPositioner to layout cells.\nAt this time the positioner has access to cached size measurements for all cells.\nThe positions it returns are cached by Masonry for fast access later.\nPhase one is repeated if the user scrolls beyond the current layout's bounds.\nIf the layout is invalidated due to eg a resize, cached positions can be cleared using recomputeCellPositions().
  4. \n
\n" + }, + { + "content": "

Animation constraints:\nSimple animations are supported (eg translate/slide into place on initial reveal).\nMore complex animations are not (eg flying from one position to another on resize).

\n" + }, + { + "content": "

Layout constraints:\nThis component supports multi-column layout.\nThe height of each item may vary.\nThe width of each item must not exceed the width of the column it is "in".\nThe left position of all items within a column must align.\n(Items may not span multiple columns.)

\n" + } + ], + "filePath": "lib/components/Masonry/Masonry.tsx", + "name": "Masonry", + "props": { + "aria-label": { + "description": [ + { + "content": "

Optional ARIA label to attach to root Masonry element.

\n" + } + ], + "html": "
aria-label?: string
", + "name": "aria-label", + "required": false + }, + "cellCount": { + "description": [ + { + "content": "

Total number of items.

\n" + } + ], + "html": "
cellCount: number
", + "name": "cellCount", + "required": true + }, + "cellMeasurerCache": { + "description": [ + { + "content": "

Caches item measurements (typically an instance of CellMeasurerCache).\nDefault sizes help Masonry decide how many images to batch-measure.

\n" + } + ], + "html": "
cellMeasurerCache: MasonryCellMeasurerCache
", + "name": "cellMeasurerCache", + "required": true + }, + "cellPositioner": { + "description": [ + { + "content": "

Positions a cell given an index: (index: number) => ({ left: number, top: number }).\nSee createMasonryCellPositioner for a built-in positioner.

\n" + } + ], + "html": "
cellPositioner: Positioner
", + "name": "cellPositioner", + "required": true + }, + "cellRenderer": { + "description": [ + { + "content": "

Responsible for rendering a cell given an index:\n({ index: number, isScrolling: boolean, key: Key, parent: MasonryImperativeAPI, style: CSSProperties }) => ReactNode.\nThe style param must be passed through to the rendered cell element.\nThe cell should typically be wrapped in a CellMeasurer.

\n" + } + ], + "html": "
cellRenderer: MasonryCellRenderer
", + "name": "cellRenderer", + "required": true + }, + "className": { + "description": [ + { + "content": "

Optional custom CSS class name to attach to root Masonry element.

\n" + } + ], + "html": "
className?: string | null
", + "name": "className", + "required": false + }, + "height": { + "description": [ + { + "content": "

Height of the component; this value determines the number of visible items.\nRequired, unless a scrollElement is specified.

\n" + }, + { + "content": "

With a scrollElement, the height of the scroll element's viewport is used instead;\nthis prop is then only used until the scroll element has been measured (e.g. during server rendering).

\n" + } + ], + "html": "
height?: number
", + "name": "height", + "required": false + }, + "id": { + "description": [ + { + "content": "

Optional custom id to attach to root Masonry element.

\n" + } + ], + "html": "
id?: string | null
", + "name": "id", + "required": false + }, + "keyMapper": { + "description": [ + { + "content": "

Maps an index to a unique id to store cached measurement and position info for a cell.\nThis prevents eg cached measurements from being invalidated when a collection is re-ordered.\nDefaults to the identity function (index => index).

\n" + } + ], + "html": "
keyMapper?: MasonryKeyMapper
", + "name": "keyMapper", + "required": false + }, + "onCellsRendered": { + "description": [ + { + "content": "

Callback invoked with information about the cells that were most recently rendered.\nThis callback is only invoked when visible cells have changed:\n({ startIndex: number, stopIndex: number }): void.\nDoes nothing by default.

\n" + } + ], + "html": "
onCellsRendered?: MasonryOnCellsRenderedCallback | null
", + "name": "onCellsRendered", + "required": false + }, + "onScroll": { + "description": [ + { + "content": "

Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, scrollHeight: number, scrollTop: number }): void.\nDoes nothing by default.

\n" + } + ], + "html": "
onScroll?: MasonryOnScrollCallback | null
", + "name": "onScroll", + "required": false + }, + "overscanByPixels": { + "description": [ + { + "content": "

Render this many additional pixels above and below the viewport.\nThis helps reduce flicker when a user scrolls quickly.

\n" + } + ], + "html": "
overscanByPixels?: number = 20
", + "name": "overscanByPixels", + "required": false + }, + "role": { + "description": [ + { + "content": "

Optional override of ARIA role default.

\n" + } + ], + "html": "
role?: string = \"grid\"
", + "name": "role", + "required": false + }, + "rowDirection": { + "description": [ + { + "content": "

Row direction of items; can be "ltr" or "rtl".

\n" + } + ], + "html": "
rowDirection?: string = \"ltr\"
", + "name": "rowDirection", + "required": false + }, + "scrollElement": { + "description": [ + { + "content": "

Element that scrolls the Masonry (instead of the Masonry scrolling itself): "window", or a scrollable DOM element\n(e.g. a container that the Masonry is rendered within, along with other content).

\n" + }, + { + "content": "

The Masonry then renders at its full height, and renders only the cells visible within the scroll element's viewport.\nIts position within the scroll element is measured when the scroll element scrolls or is resized, and when the Masonry re-renders\n(e.g. call forceUpdate() if content above the Masonry changes size without the Masonry re-rendering).\nPass null while the scroll element isn't available yet (e.g. before a ref to it has been attached);\nthe Masonry then renders the cells at its top.

\n" + } + ], + "html": "
scrollElement?: ScrollElement | null
", + "name": "scrollElement", + "required": false + }, + "scrollingResetTimeInterval": { + "description": [ + { + "content": "

Wait this amount of time (in milliseconds) after the last scroll event before resetting pointer-events\n(and the isScrolling parameter passed to cellRenderer).

\n" + } + ], + "html": "
scrollingResetTimeInterval?: number = 150
", + "name": "scrollingResetTimeInterval", + "required": false + }, + "style": { + "description": [ + { + "content": "

Optional custom inline style to attach to root Masonry element.

\n" + } + ], + "html": "
style?: CSSProperties = {}
", + "name": "style", + "required": false + }, + "tabIndex": { + "description": [ + { + "content": "

Optional override of tab index default.

\n" + } + ], + "html": "
tabIndex?: number = 0
", + "name": "tabIndex", + "required": false + }, + "width": { + "description": [ + { + "content": "

Width of the component; this value determines the number of visible items.

\n" + } + ], + "html": "
width: number
", + "name": "width", + "required": true + } + } +} \ No newline at end of file diff --git a/public/generated/docs/MasonryImperativeAPI.json b/public/generated/docs/MasonryImperativeAPI.json new file mode 100644 index 000000000..5745e7b51 --- /dev/null +++ b/public/generated/docs/MasonryImperativeAPI.json @@ -0,0 +1,56 @@ +{ + "description": [ + { + "content": "

Imperative API of a Masonry (accessed using a ref, eg useRef<MasonryImperativeAPI>(null)).\nThis is also the parent value passed to cellRenderer (for CellMeasurer).

\n" + } + ], + "filePath": "lib/components/Masonry/types.ts", + "methods": [ + { + "description": [ + { + "content": "

Clears the internal position cache and re-renders.\nCells are positioned again (by the cellPositioner) as they are rendered.

\n" + } + ], + "html": "
clearCellPositions(): void;
", + "name": "clearCellPositions" + }, + { + "description": [ + { + "content": "

Forcefully re-renders the Masonry (and its visible cells).\nRe-rendering a Masonry with the same props does not re-render its cells (it is memoized).\nUse this method if the underlying cell data has changed but the props have not.

\n" + } + ], + "html": "
forceUpdate(): void;
", + "name": "forceUpdate" + }, + { + "description": [ + { + "content": "

Notifies the Masonry that the specified cell has been measured for the first time (while rendering);\nthe cell is positioned (by the cellPositioner) once the current render has been committed, then the Masonry re-renders.\nOnly rowIndex (the cell's index) is used.\nThis method is intended for CellMeasurer.

\n" + } + ], + "html": "
invalidateCellSizeAfterRender(params: MasonryCellPosition): void;
", + "name": "invalidateCellSizeAfterRender" + }, + { + "description": [ + { + "content": "

Resets the internal position cache, synchronously re-computes the positions of all measured cells\n(by calling the cellPositioner for each of them, in index order), then re-renders.\nReset the cellPositioner (e.g. with its reset method) before calling this method.

\n" + } + ], + "html": "
recomputeCellPositions(): void;
", + "name": "recomputeCellPositions" + }, + { + "description": [ + { + "content": "

Re-renders the Masonry, e.g. after more cells have been loaded (this method is called by InfiniteLoader)\nor a cell has been re-measured (it is called by CellMeasurer's measure function).\nParameters (the index of the first changed cell, as rowIndex) are accepted for compatibility with Grid and ignored.\nCached cell positions are not changed: to re-position cells after their sizes have changed,\nreset the cellPositioner and call recomputeCellPositions().

\n" + } + ], + "html": "
recomputeGridSize(params?: Partial<MasonryCellPosition>): void;
", + "name": "recomputeGridSize" + } + ], + "name": "MasonryImperativeAPI" +} \ No newline at end of file diff --git a/public/generated/docs/MultiGrid.json b/public/generated/docs/MultiGrid.json new file mode 100644 index 000000000..d8d71d91d --- /dev/null +++ b/public/generated/docs/MultiGrid.json @@ -0,0 +1,525 @@ +{ + "description": [ + { + "content": "

Renders 1, 2, or 4 Grids depending on configuration.\nA main (body) Grid will always be rendered.\nOptionally, 1-2 Grids for sticky header rows will also be rendered.\nIf no sticky columns, only 1 sticky header Grid will be rendered.\nIf sticky columns, 2 sticky header Grids will be rendered.

\n" + }, + { + "content": "

Scrolling is done through the imperative handle (ref): see scrollToCell and scrollToPosition.

\n" + } + ], + "filePath": "lib/components/MultiGrid/MultiGrid.tsx", + "name": "MultiGrid", + "props": { + "aria-label": { + "description": [ + { + "content": "

ARIA label for the grid element.

\n" + } + ], + "html": "
aria-label?: string = \"grid\"
", + "name": "aria-label", + "required": false + }, + "aria-readonly": { + "description": [ + { + "content": "

ARIA readonly attribute for the grid element.

\n" + } + ], + "html": "
aria-readonly?: boolean = true
", + "name": "aria-readonly", + "required": false + }, + "autoContainerWidth": { + "description": [ + { + "content": "

Set the width of the inner scrollable container to 'auto'.\nThis is useful for single-column Grids to ensure that the column doesn't extend below a vertical scrollbar.

\n" + } + ], + "html": "
autoContainerWidth?: boolean = false
", + "name": "autoContainerWidth", + "required": false + }, + "cellRangeRenderer": { + "description": [ + { + "content": "

Responsible for rendering a group of cells given their index ranges.

\n" + }, + { + "content": "

This is an advanced property.\nIt is useful for situations where the Grid requires additional, overlayed UI (such as a Gantt chart or a calendar application).\nThe easiest way to override it is to decorate defaultCellRangeRenderer.

\n" + }, + { + "content": "

Defaults to defaultCellRangeRenderer.

\n" + } + ], + "html": "
cellRangeRenderer?: CellRangeRenderer
", + "name": "cellRangeRenderer", + "required": false + }, + "cellRenderer": { + "description": [ + { + "content": "

Responsible for rendering a cell given an row and column index.\nIt receives columnIndex, isScrolling, isVisible, key, parent, rowIndex, and style parameters.

\n" + }, + { + "content": "

The style parameter specifies how the cell is to be sized and positioned and must be passed through to the rendered cell element.\nThe key parameter must also be applied to the rendered element.

\n" + } + ], + "html": "
cellRenderer: CellRenderer
", + "name": "cellRenderer", + "required": true + }, + "className": { + "description": [ + { + "content": "

Optional custom CSS class name to attach to root Grid element.

\n" + } + ], + "html": "
className?: string
", + "name": "className", + "required": false + }, + "columnCount": { + "description": [ + { + "content": "

Number of columns in grid.

\n" + } + ], + "html": "
columnCount: number
", + "name": "columnCount", + "required": true + }, + "columnWidth": { + "description": [ + { + "content": "

Either a fixed column width (number) or a function that returns the width of a column given its index: ({ index: number }): number.\nIf function is used, specify estimatedColumnSize for more consistent scrolling behavior.

\n" + } + ], + "html": "
columnWidth: CellSize
", + "name": "columnWidth", + "required": true + }, + "containerProps": { + "description": [ + { + "content": "

Additional props (eg onWheel) to spread onto the outer Grid element.

\n" + } + ], + "html": "
containerProps?: HTMLAttributes<HTMLDivElement>
", + "name": "containerProps", + "required": false + }, + "containerRole": { + "description": [ + { + "content": "

ARIA role for the inner cell-container.

\n" + } + ], + "html": "
containerRole?: string = \"row\"
", + "name": "containerRole", + "required": false + }, + "containerStyle": { + "description": [ + { + "content": "

Optional custom inline style to attach to inner cell-container element.

\n" + } + ], + "html": "
containerStyle?: CSSProperties = {}
", + "name": "containerStyle", + "required": false + }, + "deferredMeasurementCache": { + "description": [ + { + "content": "

If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache.\nA shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data.

\n" + } + ], + "html": "
deferredMeasurementCache?: CellMeasureCache
", + "name": "deferredMeasurementCache", + "required": false + }, + "elementRef": { + "description": [ + { + "content": "

Reference to the outer (scrolling) DOM element.

\n" + } + ], + "html": "
elementRef?: Ref<HTMLDivElement>
", + "name": "elementRef", + "required": false + }, + "estimatedColumnSize": { + "description": [ + { + "content": "

Used to estimate the total width of a Grid before all of its columns have actually been measured.\nThe estimated total width is adjusted as columns are rendered.

\n" + } + ], + "html": "
estimatedColumnSize?: number = 100
", + "name": "estimatedColumnSize", + "required": false + }, + "estimatedRowSize": { + "description": [ + { + "content": "

Used to estimate the total height of a Grid before all of its rows have actually been measured.\nThe estimated total height is adjusted as rows are rendered.

\n" + } + ], + "html": "
estimatedRowSize?: number = 30
", + "name": "estimatedRowSize", + "required": false + }, + "getScrollbarSize": { + "description": [ + { + "content": "

Returns the size of the browser's scrollbar.\nExposed for testing purposes only.

\n" + }, + { + "content": "

Defaults to dom-helpers' scrollbarSize (which returns undefined during server rendering).

\n" + } + ], + "html": "
getScrollbarSize?: () => number
", + "name": "getScrollbarSize", + "required": false + }, + "id": { + "description": [ + { + "content": "

Optional custom id to attach to root Grid element.

\n" + } + ], + "html": "
id?: string
", + "name": "id", + "required": false + }, + "initialScrollLeft": { + "description": [ + { + "content": "

Horizontal scroll offset to initially render (and scroll to) the Grid at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the cells at this offset are rendered on the server,\nand they stay in place when the page is hydrated.

\n" + } + ], + "html": "
initialScrollLeft?: number
", + "name": "initialScrollLeft", + "required": false + }, + "initialScrollTop": { + "description": [ + { + "content": "

Vertical scroll offset to initially render (and scroll to) the Grid at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the cells at this offset are rendered on the server,\nand they stay in place when the page is hydrated.\nIgnored if a scrollElement is specified (which then determines the vertical offset).

\n" + } + ], + "html": "
initialScrollTop?: number
", + "name": "initialScrollTop", + "required": false + }, + "isScrollingOptOut": { + "description": [ + { + "content": "

Prevents re-rendering of visible cells on scroll end.\n(Opts out of the isScrolling param passed to cellRangeRenderer, to avoid the extra render when scroll stops.)\nVisible cells are still re-rendered when the Grid's props change or forceUpdate() is called.

\n" + } + ], + "html": "
isScrollingOptOut?: boolean = false
", + "name": "isScrollingOptOut", + "required": false + }, + "noContentRenderer": { + "description": [ + { + "content": "

Optional renderer to be rendered inside the grid when either rowCount or columnCount is empty.\nRenders nothing by default.

\n" + } + ], + "html": "
noContentRenderer?: NoContentRenderer
", + "name": "noContentRenderer", + "required": false + }, + "onScroll": { + "description": [ + { + "content": "

Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, clientWidth: number, scrollHeight: number, scrollLeft: number, scrollTop: number, scrollWidth: number }): void.\nThis callback can be used to sync scrolling between lists, tables, or grids.

\n" + } + ], + "html": "
onScroll?: (params: Scroll) => void
", + "name": "onScroll", + "required": false + }, + "onSectionRendered": { + "description": [ + { + "content": "

Callback invoked with information about the section of the Grid that was just rendered.\nThis callback is only invoked when visible rows have changed:\n({ columnOverscanStartIndex: number, columnOverscanStopIndex: number, columnStartIndex: number, columnStopIndex: number, rowOverscanStartIndex: number, rowOverscanStopIndex: number, rowStartIndex: number, rowStopIndex: number }): void.

\n" + } + ], + "html": "
onSectionRendered?: (params: RenderedSection) => void
", + "name": "onSectionRendered", + "required": false + }, + "overscanColumnCount": { + "description": [ + { + "content": "

Number of columns to render before/after the visible slice of the grid.\nThis can help reduce flickering during scrolling on certain browsers/devices.

\n" + }, + { + "content": "

Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event.\nAim to use the lowest value possible that still generally avoids any empty space from appearing during normal scrolling.

\n" + } + ], + "html": "
overscanColumnCount?: number = 0
", + "name": "overscanColumnCount", + "required": false + }, + "overscanIndicesGetter": { + "description": [ + { + "content": "

Responsible for calculating the number of cells to overscan before and after a specified range.\nBy default, cells are overscanned in the direction the user is scrolling.\nThis function receives direction, cellCount, scrollDirection, overscanCellsCount, startIndex, and stopIndex parameters\nand returns an { overscanStartIndex, overscanStopIndex } object.

\n" + }, + { + "content": "

Defaults to defaultOverscanIndicesGetter.

\n" + } + ], + "html": "
overscanIndicesGetter?: OverscanIndicesGetter
", + "name": "overscanIndicesGetter", + "required": false + }, + "overscanRowCount": { + "description": [ + { + "content": "

Number of rows to render above/below the visible slice of the grid.\nThis can help reduce flickering during scrolling on certain browsers/devices.

\n" + }, + { + "content": "

Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event.\nThe default value (10) is a good starting point; lower it if possible.

\n" + } + ], + "html": "
overscanRowCount?: number = 10
", + "name": "overscanRowCount", + "required": false + }, + "role": { + "description": [ + { + "content": "

Optional override of ARIA role default.

\n" + } + ], + "html": "
role?: string = \"grid\"
", + "name": "role", + "required": false + }, + "rowCount": { + "description": [ + { + "content": "

Number of rows in grid.

\n" + } + ], + "html": "
rowCount: number
", + "name": "rowCount", + "required": true + }, + "rowHeight": { + "description": [ + { + "content": "

Either a fixed row height (number) or a function that returns the height of a row given its index: ({ index: number }): number.\nIf function is used, specify estimatedRowSize for more consistent scrolling behavior.

\n" + } + ], + "html": "
rowHeight: CellSize
", + "name": "rowHeight", + "required": true + }, + "scrollingResetTimeInterval": { + "description": [ + { + "content": "

Wait this amount of time (in milliseconds) after the last scroll event before resetting Grid pointer-events.

\n" + } + ], + "html": "
scrollingResetTimeInterval?: number = 150
", + "name": "scrollingResetTimeInterval", + "required": false + }, + "tabIndex": { + "description": [ + { + "content": "

Optional override of tab index default.

\n" + } + ], + "html": "
tabIndex?: number | null = 0
", + "name": "tabIndex", + "required": false + }, + "width": { + "description": [ + { + "content": "

Width of Grid; this property determines the number of visible (vs virtualized) columns.

\n" + } + ], + "html": "
width: number
", + "name": "width", + "required": true + }, + "height": { + "description": [ + { + "content": "

Height of the MultiGrid (including its fixed rows); determines the number of visible (vs virtualized) rows.

\n" + } + ], + "html": "
height: number
", + "name": "height", + "required": true + }, + "classNameBottomLeftGrid": { + "description": [ + { + "content": "

Optional custom className to attach to bottom-left Grid element.

\n" + } + ], + "html": "
classNameBottomLeftGrid?: string = \"\"
", + "name": "classNameBottomLeftGrid", + "required": false + }, + "classNameBottomRightGrid": { + "description": [ + { + "content": "

Optional custom className to attach to bottom-right Grid element.

\n" + } + ], + "html": "
classNameBottomRightGrid?: string = \"\"
", + "name": "classNameBottomRightGrid", + "required": false + }, + "classNameTopLeftGrid": { + "description": [ + { + "content": "

Optional custom className to attach to top-left Grid element.

\n" + } + ], + "html": "
classNameTopLeftGrid?: string = \"\"
", + "name": "classNameTopLeftGrid", + "required": false + }, + "classNameTopRightGrid": { + "description": [ + { + "content": "

Optional custom className to attach to top-right Grid element.

\n" + } + ], + "html": "
classNameTopRightGrid?: string = \"\"
", + "name": "classNameTopRightGrid", + "required": false + }, + "enableFixedColumnScroll": { + "description": [ + { + "content": "

Fixed columns can be actively scrolled (e.g. with a mouse wheel while the cursor is over them).

\n" + } + ], + "html": "
enableFixedColumnScroll?: boolean = false
", + "name": "enableFixedColumnScroll", + "required": false + }, + "enableFixedRowScroll": { + "description": [ + { + "content": "

Fixed rows can be actively scrolled (e.g. with a mouse wheel while the cursor is over them).

\n" + } + ], + "html": "
enableFixedRowScroll?: boolean = false
", + "name": "enableFixedRowScroll", + "required": false + }, + "fixedColumnCount": { + "description": [ + { + "content": "

Number of fixed columns.

\n" + } + ], + "html": "
fixedColumnCount?: number = 0
", + "name": "fixedColumnCount", + "required": false + }, + "fixedRowCount": { + "description": [ + { + "content": "

Number of fixed rows.

\n" + } + ], + "html": "
fixedRowCount?: number = 0
", + "name": "fixedRowCount", + "required": false + }, + "hideBottomLeftGridScrollbar": { + "description": [ + { + "content": "

Optionally hides the bottom-left Grid scrollbar by adding an additional wrapper.\nOnly useful if enableFixedColumnScroll is set to true.

\n" + } + ], + "html": "
hideBottomLeftGridScrollbar?: boolean = false
", + "name": "hideBottomLeftGridScrollbar", + "required": false + }, + "hideTopRightGridScrollbar": { + "description": [ + { + "content": "

Optionally hides the top-right Grid scrollbar by adding an additional wrapper.\nOnly useful if enableFixedRowScroll is set to true.

\n" + } + ], + "html": "
hideTopRightGridScrollbar?: boolean = false
", + "name": "hideTopRightGridScrollbar", + "required": false + }, + "onScrollbarPresenceChange": { + "description": [ + { + "content": "

Called whenever a horizontal or vertical scrollbar is added or removed from the bottom-right Grid:\n({ horizontal: boolean, size: number, vertical: boolean }): void.

\n" + } + ], + "html": "
onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void
", + "name": "onScrollbarPresenceChange", + "required": false + }, + "style": { + "description": [ + { + "content": "

Optional custom inline style to attach to root MultiGrid element.

\n" + } + ], + "html": "
style?: CSSProperties = {}
", + "name": "style", + "required": false + }, + "styleBottomLeftGrid": { + "description": [ + { + "content": "

Optional custom inline style to attach to bottom-left Grid element.

\n" + } + ], + "html": "
styleBottomLeftGrid?: CSSProperties = {}
", + "name": "styleBottomLeftGrid", + "required": false + }, + "styleBottomRightGrid": { + "description": [ + { + "content": "

Optional custom inline style to attach to bottom-right Grid element.

\n" + } + ], + "html": "
styleBottomRightGrid?: CSSProperties = {}
", + "name": "styleBottomRightGrid", + "required": false + }, + "styleTopLeftGrid": { + "description": [ + { + "content": "

Optional custom inline style to attach to top-left Grid element.

\n" + } + ], + "html": "
styleTopLeftGrid?: CSSProperties = {}
", + "name": "styleTopLeftGrid", + "required": false + }, + "styleTopRightGrid": { + "description": [ + { + "content": "

Optional custom inline style to attach to top-right Grid element.

\n" + } + ], + "html": "
styleTopRightGrid?: CSSProperties = {}
", + "name": "styleTopRightGrid", + "required": false + } + } +} \ No newline at end of file diff --git a/public/generated/docs/MultiGridImperativeAPI.json b/public/generated/docs/MultiGridImperativeAPI.json new file mode 100644 index 000000000..abb03c560 --- /dev/null +++ b/public/generated/docs/MultiGridImperativeAPI.json @@ -0,0 +1,65 @@ +{ + "description": [ + { + "content": "

Public (imperative) methods of a MultiGrid instance.

\n" + } + ], + "filePath": "lib/components/MultiGrid/types.ts", + "methods": [ + { + "description": [ + { + "content": "

Pass-thru that calls forceUpdate on all child Grids.

\n" + } + ], + "html": "
forceUpdateGrids(): void;
", + "name": "forceUpdateGrids" + }, + { + "description": [ + { + "content": "

Invalidate the size of a cell (after the current render has been committed); see Grid#invalidateCellSizeAfterRender.\nThe invalidated cells (of all child Grids) are recomputed, and the MultiGrid re-rendered, once the current render has been committed.\nThis method is intended for CellMeasurer.

\n" + } + ], + "html": "
invalidateCellSizeAfterRender(params?: Partial<CellPosition>): void;
", + "name": "invalidateCellSizeAfterRender" + }, + { + "description": [ + { + "content": "

Pass-thru that calls measureAllCells on all child Grids.

\n" + } + ], + "html": "
measureAllCells(): void;
", + "name": "measureAllCells" + }, + { + "description": [ + { + "content": "

Pass-thru that calls recomputeGridSize on all child Grids\n(adjusting the specified indices for fixed rows and columns).\nBoth indices default to 0.\nThis also recomputes the MultiGrid's own layout (the height of the fixed rows and the width of the fixed columns)\nand re-renders it.

\n" + } + ], + "html": "
recomputeGridSize(params?: { columnIndex?: number; rowIndex?: number }): void;
", + "name": "recomputeGridSize" + }, + { + "description": [ + { + "content": "

Ensure the specified column and/or row is visible (scrolling if necessary); see Grid#scrollToCell.\nIndices include the fixed columns and rows (which are always visible, so they are not scrolled to).\nThe fixed rows and columns are kept in sync with the scrolled section.

\n" + } + ], + "html": "
scrollToCell(params: {
\n
align?: Alignment;
\n
columnIndex?: number;
\n
rowIndex?: number;
\n
}): void;
", + "name": "scrollToCell" + }, + { + "description": [ + { + "content": "

Scroll to the specified offset(s) of the scrollable (bottom-right) section; see Grid#scrollToPosition.\nThe fixed rows and columns are kept in sync with it.\nThis allows a MultiGrid to be used with ScrollSync.

\n" + } + ], + "html": "
scrollToPosition(params: ScrollPosition): void;
", + "name": "scrollToPosition" + } + ], + "name": "MultiGridImperativeAPI" +} \ No newline at end of file diff --git a/public/generated/docs/ScrollSync.json b/public/generated/docs/ScrollSync.json new file mode 100644 index 000000000..64839e864 --- /dev/null +++ b/public/generated/docs/ScrollSync.json @@ -0,0 +1,21 @@ +{ + "description": [ + { + "content": "

HOC that simplifies the process of synchronizing scrolling between two or more virtualized components.

\n" + } + ], + "filePath": "lib/components/ScrollSync/ScrollSync.tsx", + "name": "ScrollSync", + "props": { + "children": { + "description": [ + { + "content": "

Function responsible for rendering 2 or more virtualized components.\nIt is passed the current scroll and size values (clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth),\nan onScroll callback that should be passed through to the virtualized children that the user can scroll,\nand a registerChild function that returns a ref callback for each virtualized child that should be kept in sync\n(e.g. <Grid ref={registerChild("body")} onScroll={onScroll} />).\nThe scroll and size values can also be used for custom rendering (e.g. shading based on the scroll offset).

\n" + } + ], + "html": "
children: (params: ScrollSyncChildProps) => ReactNode
", + "name": "children", + "required": true + } + } +} \ No newline at end of file diff --git a/public/generated/docs/Table.json b/public/generated/docs/Table.json new file mode 100644 index 000000000..f41d43ca7 --- /dev/null +++ b/public/generated/docs/Table.json @@ -0,0 +1,528 @@ +{ + "description": [ + { + "content": "

Table component with fixed headers and virtualized rows for improved performance with large data sets.\nThis component expects explicit width, height, and padding parameters.

\n" + }, + { + "content": "

Columns are described by Column children (or components that render a Column).\nScrolling is done through the imperative handle (ref): see scrollToRow, scrollToCell, and scrollToPosition.

\n" + } + ], + "filePath": "lib/components/Table/Table.tsx", + "name": "Table", + "props": { + "cellRangeRenderer": { + "description": [ + { + "content": "

Responsible for rendering a group of cells given their index ranges.

\n" + }, + { + "content": "

This is an advanced property.\nIt is useful for situations where the Grid requires additional, overlayed UI (such as a Gantt chart or a calendar application).\nThe easiest way to override it is to decorate defaultCellRangeRenderer.

\n" + }, + { + "content": "

Defaults to defaultCellRangeRenderer.

\n" + } + ], + "html": "
cellRangeRenderer?: CellRangeRenderer
", + "name": "cellRangeRenderer", + "required": false + }, + "containerProps": { + "description": [ + { + "content": "

Additional props (eg onWheel) to spread onto the outer Grid element.

\n" + } + ], + "html": "
containerProps?: HTMLAttributes<HTMLDivElement>
", + "name": "containerProps", + "required": false + }, + "containerRole": { + "description": [ + { + "content": "

ARIA role for the inner cell-container.

\n" + } + ], + "html": "
containerRole?: string = \"row\"
", + "name": "containerRole", + "required": false + }, + "containerStyle": { + "description": [ + { + "content": "

Optional custom inline style to attach to inner cell-container element.

\n" + } + ], + "html": "
containerStyle?: CSSProperties = {}
", + "name": "containerStyle", + "required": false + }, + "getScrollbarSize": { + "description": [ + { + "content": "

Returns the size of the browser's scrollbar.\nExposed for testing purposes only.

\n" + }, + { + "content": "

Defaults to dom-helpers' scrollbarSize (which returns undefined during server rendering).

\n" + } + ], + "html": "
getScrollbarSize?: () => number
", + "name": "getScrollbarSize", + "required": false + }, + "isScrollingOptOut": { + "description": [ + { + "content": "

Prevents re-rendering of visible cells on scroll end.\n(Opts out of the isScrolling param passed to cellRangeRenderer, to avoid the extra render when scroll stops.)\nVisible cells are still re-rendered when the Grid's props change or forceUpdate() is called.

\n" + } + ], + "html": "
isScrollingOptOut?: boolean = false
", + "name": "isScrollingOptOut", + "required": false + }, + "onScrollbarPresenceChange": { + "description": [ + { + "content": "

Called whenever a horizontal or vertical scrollbar is added or removed:\n({ horizontal: boolean, size: number, vertical: boolean }): void.\nThis prop is not intended for end-user use;\nit is used by MultiGrid to support fixed-row/fixed-column scroll syncing.

\n" + } + ], + "html": "
onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void
", + "name": "onScrollbarPresenceChange", + "required": false + }, + "scrollingResetTimeInterval": { + "description": [ + { + "content": "

Wait this amount of time (in milliseconds) after the last scroll event before resetting Grid pointer-events.

\n" + } + ], + "html": "
scrollingResetTimeInterval?: number = 150
", + "name": "scrollingResetTimeInterval", + "required": false + }, + "aria-label": { + "description": [ + { + "content": "

ARIA label set on the outer Table element.

\n" + } + ], + "html": "
aria-label?: string
", + "name": "aria-label", + "required": false + }, + "aria-labelledby": { + "description": [ + { + "content": "

ARIA labelledby attribute set on the outer Table element.

\n" + } + ], + "html": "
aria-labelledby?: string
", + "name": "aria-labelledby", + "required": false + }, + "children": { + "description": [ + { + "content": "

One or more Columns describing the data displayed in this table.\nFalsy children are ignored, to allow easier dynamic showing/hiding of columns.

\n" + }, + { + "content": "

Table reads the props of its children (with defaultColumnProps applied) without rendering them,\nso components that wrap Column (e.g. const MyColumn = (props: ColumnProps) => <Column {...props} />) can be used as children too.\n(Subclassing Column is no longer supported.)

\n" + } + ], + "html": "
children?: ReactNode
", + "name": "children", + "required": false + }, + "className": { + "description": [ + { + "content": "

Optional custom CSS class name to attach to root Table element.

\n" + } + ], + "html": "
className?: string
", + "name": "className", + "required": false + }, + "deferredMeasurementCache": { + "description": [ + { + "content": "

If CellMeasurer is used to measure this Table's cells, this should be a pointer to its CellMeasurerCache.\nIt is passed through to the inner Grid.

\n" + } + ], + "html": "
deferredMeasurementCache?: CellMeasureCache
", + "name": "deferredMeasurementCache", + "required": false + }, + "disableHeader": { + "description": [ + { + "content": "

Do not render the table header (only the rows).

\n" + } + ], + "html": "
disableHeader?: boolean = false
", + "name": "disableHeader", + "required": false + }, + "estimatedRowSize": { + "description": [ + { + "content": "

Used to estimate the total height of a Table before all of its rows have actually been measured.\nThe estimated total height is adjusted as rows are rendered.

\n" + } + ], + "html": "
estimatedRowSize?: number = 30
", + "name": "estimatedRowSize", + "required": false + }, + "gridClassName": { + "description": [ + { + "content": "

Optional custom CSS class name to attach to inner Grid element.

\n" + } + ], + "html": "
gridClassName?: string
", + "name": "gridClassName", + "required": false + }, + "gridStyle": { + "description": [ + { + "content": "

Optional inline style to attach to inner Grid element.

\n" + } + ], + "html": "
gridStyle?: CSSProperties
", + "name": "gridStyle", + "required": false + }, + "headerClassName": { + "description": [ + { + "content": "

Optional CSS class to apply to all column headers.

\n" + } + ], + "html": "
headerClassName?: string
", + "name": "headerClassName", + "required": false + }, + "headerHeight": { + "description": [ + { + "content": "

Fixed height of header row.

\n" + } + ], + "html": "
headerHeight?: number = 0
", + "name": "headerHeight", + "required": false + }, + "headerRowRenderer": { + "description": [ + { + "content": "

Responsible for rendering the table header row given an array of columns:\n({ className: string, columns: ReactNode[], style: CSSProperties }): ReactNode.

\n" + }, + { + "content": "

This is an advanced property.\nIt is useful for situations where you require additional hooks into Table to render additional custom UI elements.\nYou may want to start by forking the defaultTableHeaderRowRenderer function (the default).

\n" + } + ], + "html": "
headerRowRenderer?: TableHeaderRowRenderer
", + "name": "headerRowRenderer", + "required": false + }, + "headerStyle": { + "description": [ + { + "content": "

Optional custom inline style to attach to table header columns.

\n" + } + ], + "html": "
headerStyle?: CSSProperties = {}
", + "name": "headerStyle", + "required": false + }, + "height": { + "description": [ + { + "content": "

Fixed/available height for the outer DOM element.\nRequired, unless a scrollElement is specified.

\n" + }, + { + "content": "

With a scrollElement, the height of the scroll element's viewport is used instead;\nthis prop is then only used until the scroll element has been measured (e.g. during server rendering).

\n" + } + ], + "html": "
height?: number
", + "name": "height", + "required": false + }, + "id": { + "description": [ + { + "content": "

Optional custom id to attach to root Table element.

\n" + } + ], + "html": "
id?: string
", + "name": "id", + "required": false + }, + "initialScrollTop": { + "description": [ + { + "content": "

Vertical scroll offset to initially render (and scroll to) the Table at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the rows at this offset are rendered on the server,\nand they stay in place when the page is hydrated.\nIgnored if a scrollElement is specified (which then determines the vertical offset).

\n" + } + ], + "html": "
initialScrollTop?: number
", + "name": "initialScrollTop", + "required": false + }, + "noRowsRenderer": { + "description": [ + { + "content": "

Optional renderer to be used in place of table body rows when rowCount is 0.

\n" + } + ], + "html": "
noRowsRenderer?: () => ReactNode
", + "name": "noRowsRenderer", + "required": false + }, + "onColumnClick": { + "description": [ + { + "content": "

Callback invoked when a user clicks on a table column (cell):\n({ columnData: any, dataKey: string, event: Event }): void.

\n" + } + ], + "html": "
onColumnClick?: (params: TableColumnEventHandlerParams) => void
", + "name": "onColumnClick", + "required": false + }, + "onHeaderClick": { + "description": [ + { + "content": "

Callback invoked when a user clicks on (or activates via the ENTER or SPACE key) a table header:\n({ columnData: any, dataKey: string, event: Event }): void.

\n" + } + ], + "html": "
onHeaderClick?: (params: TableColumnEventHandlerParams) => void
", + "name": "onHeaderClick", + "required": false + }, + "onRowClick": { + "description": [ + { + "content": "

Callback invoked when a user clicks on a table row:\n({ event: Event, index: number, rowData: any }): void.

\n" + } + ], + "html": "
onRowClick?: TableRowMouseEventHandler
", + "name": "onRowClick", + "required": false + }, + "onRowDoubleClick": { + "description": [ + { + "content": "

Callback invoked when a user double-clicks on a table row:\n({ event: Event, index: number, rowData: any }): void.

\n" + } + ], + "html": "
onRowDoubleClick?: TableRowMouseEventHandler
", + "name": "onRowDoubleClick", + "required": false + }, + "onRowMouseOut": { + "description": [ + { + "content": "

Callback invoked when the mouse leaves a table row:\n({ event: Event, index: number, rowData: any }): void.

\n" + } + ], + "html": "
onRowMouseOut?: TableRowMouseEventHandler
", + "name": "onRowMouseOut", + "required": false + }, + "onRowMouseOver": { + "description": [ + { + "content": "

Callback invoked when a user moves the mouse over a table row:\n({ event: Event, index: number, rowData: any }): void.

\n" + } + ], + "html": "
onRowMouseOver?: TableRowMouseEventHandler
", + "name": "onRowMouseOver", + "required": false + }, + "onRowRightClick": { + "description": [ + { + "content": "

Callback invoked when a user right-clicks on a table row:\n({ event: Event, index: number, rowData: any }): void.

\n" + } + ], + "html": "
onRowRightClick?: TableRowMouseEventHandler
", + "name": "onRowRightClick", + "required": false + }, + "onRowsRendered": { + "description": [ + { + "content": "

Callback invoked with information about the slice of rows that were just rendered:\n({ overscanStartIndex: number, overscanStopIndex: number, startIndex: number, stopIndex: number }): void.

\n" + } + ], + "html": "
onRowsRendered?: (params: TableRowsRenderedParams) => void
", + "name": "onRowsRendered", + "required": false + }, + "onScroll": { + "description": [ + { + "content": "

Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, scrollHeight: number, scrollTop: number }): void.\nThis callback can be used to sync scrolling between lists, tables, or grids.

\n" + } + ], + "html": "
onScroll?: (params: TableScrollParams) => void
", + "name": "onScroll", + "required": false + }, + "overscanIndicesGetter": { + "description": [ + { + "content": "

Responsible for calculating the number of rows to overscan before and after a specified range (see Grid overscanIndicesGetter).\nBy default (accessibilityOverscanIndicesGetter), Table overscans by at least one row in each direction (for accessibility).

\n" + } + ], + "html": "
overscanIndicesGetter?: OverscanIndicesGetter
", + "name": "overscanIndicesGetter", + "required": false + }, + "overscanRowCount": { + "description": [ + { + "content": "

Number of rows to render above/below the visible bounds of the list.\nThis can help reduce flickering during scrolling on certain browsers/devices.

\n" + } + ], + "html": "
overscanRowCount?: number = 10
", + "name": "overscanRowCount", + "required": false + }, + "rowClassName": { + "description": [ + { + "content": "

Optional CSS class to apply to all table rows (including the header row).\nThis value may be either a static string or a function with the signature ({ index: number }): string.\nNote that for the header row an index of -1 is provided.

\n" + } + ], + "html": "
rowClassName?: string | ((params: { index: number; }) => string)
", + "name": "rowClassName", + "required": false + }, + "rowCount": { + "description": [ + { + "content": "

Number of rows in table.

\n" + } + ], + "html": "
rowCount: number
", + "name": "rowCount", + "required": true + }, + "rowGetter": { + "description": [ + { + "content": "

Callback responsible for returning a data row given an index:\n({ index: number }): any.

\n" + } + ], + "html": "
rowGetter: (params: { index: number; }) => any
", + "name": "rowGetter", + "required": true + }, + "rowHeight": { + "description": [ + { + "content": "

Either a fixed row height (number) or a function that returns the height of a row given its index:\n({ index: number }): number.

\n" + } + ], + "html": "
rowHeight: CellSize
", + "name": "rowHeight", + "required": true + }, + "rowRenderer": { + "description": [ + { + "content": "

Responsible for rendering a table row given an array of columns.

\n" + }, + { + "content": "

This is an advanced property.\nIt is useful for situations where you require additional hooks into Table (eg integration with a library like react-sortable-hoc).\nThe easiest way to override it is to decorate (or fork) the defaultTableRowRenderer function (the default).

\n" + } + ], + "html": "
rowRenderer?: TableRowRenderer
", + "name": "rowRenderer", + "required": false + }, + "rowStyle": { + "description": [ + { + "content": "

Optional custom inline style to attach to table rows (including the header row).\nThis value may be either a style object or a function with the signature ({ index: number }): CSSProperties.\nNote that for the header row an index of -1 is provided.

\n" + } + ], + "html": "
rowStyle?: CSSProperties | ((params: { index: number; }) => CSSProperties) = {}
", + "name": "rowStyle", + "required": false + }, + "sort": { + "description": [ + { + "content": "

Sort function to be called if a sortable header is clicked:\n({ defaultSortDirection: SortDirection, event: Event, sortBy: string, sortDirection: SortDirection }): void.

\n" + } + ], + "html": "
sort?: (params: TableSortParams) => void
", + "name": "sort", + "required": false + }, + "sortBy": { + "description": [ + { + "content": "

Data is currently sorted by this dataKey (if it is sorted at all).

\n" + } + ], + "html": "
sortBy?: string
", + "name": "sortBy", + "required": false + }, + "sortDirection": { + "description": [ + { + "content": "

Data is currently sorted in this direction (if it is sorted at all).

\n" + } + ], + "html": "
sortDirection?: SortDirectionType
", + "name": "sortDirection", + "required": false + }, + "scrollElement": { + "description": [ + { + "content": "

Element that scrolls the Table (instead of the Table scrolling itself): "window", or a scrollable DOM element\n(e.g. a container that the Table is rendered within, along with other content).

\n" + }, + { + "content": "

The Table then renders all of its rows at their full height (below its header, which scrolls along with them), renders only the rows visible within the scroll element's viewport,\nand its scroll methods scroll the scroll element.\nIts position within the scroll element is measured when the scroll element scrolls or is resized, and when the Table re-renders\n(e.g. call forceUpdateGrid() if content above the Table changes size without the Table re-rendering).\nPass null while the scroll element isn't available yet (e.g. before a ref to it has been attached);\nthe Table then renders the rows at its top.

\n" + } + ], + "html": "
scrollElement?: ScrollElement | null
", + "name": "scrollElement", + "required": false + }, + "style": { + "description": [ + { + "content": "

Optional custom inline style to attach to root Table element.

\n" + } + ], + "html": "
style?: CSSProperties = {}
", + "name": "style", + "required": false + }, + "tabIndex": { + "description": [ + { + "content": "

Optional override of inner Grid tab index default.

\n" + } + ], + "html": "
tabIndex?: number | null = 0
", + "name": "tabIndex", + "required": false + }, + "width": { + "description": [ + { + "content": "

Width of the table.

\n" + } + ], + "html": "
width: number
", + "name": "width", + "required": true + } + } +} \ No newline at end of file diff --git a/public/generated/docs/TableImperativeAPI.json b/public/generated/docs/TableImperativeAPI.json new file mode 100644 index 000000000..33f96d4d5 --- /dev/null +++ b/public/generated/docs/TableImperativeAPI.json @@ -0,0 +1,101 @@ +{ + "description": [ + { + "content": "

Public (imperative) methods of a Table instance.

\n" + } + ], + "filePath": "lib/components/Table/types.ts", + "methods": [ + { + "description": [ + { + "content": "

Forcefully re-render the inner Grid component.\nCalling forceUpdate on Table may not re-render the inner Grid since it uses shallowCompare as a performance optimization.\nUse this method if you want to manually trigger a re-render.\nThis may be appropriate if the underlying row data has changed but the row sizes themselves have not.

\n" + } + ], + "html": "
forceUpdateGrid(): void;
", + "name": "forceUpdateGrid" + }, + { + "description": [ + { + "content": "

Gets offset for a given row and alignment (align; alignment is a deprecated alias).\nThe alignment defaults to "auto"; an index of -1 (the default) refers to the last row.

\n" + } + ], + "html": "
getOffsetForRow(params?: {
\n
align?: Alignment;
\n
/** @deprecated Use `align` instead. */
\n
alignment?: Alignment;
\n
index?: number;
\n
}): number;
", + "name": "getOffsetForRow" + }, + { + "description": [ + { + "content": "

Gets the scrollbar width used to pad the table-header.

\n" + } + ], + "html": "
getScrollbarWidth(): number;
", + "name": "getScrollbarWidth" + }, + { + "description": [ + { + "content": "

Invalidate the size of a row (after the current render has been committed); see Grid#invalidateCellSizeAfterRender.\nThis method is intended for CellMeasurer.

\n" + } + ], + "html": "
invalidateCellSizeAfterRender(params: CellPosition): void;
", + "name": "invalidateCellSizeAfterRender" + }, + { + "description": [ + { + "content": "

Pre-measure all rows in a Table.\nTypically rows are only measured as needed and estimated heights are used for cells that have not yet been measured.\nThis method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one).

\n" + } + ], + "html": "
measureAllRows(): void;
", + "name": "measureAllRows" + }, + { + "description": [ + { + "content": "

Recompute row heights and offsets after the specified index (defaults to 0).\nTable has no way of knowing when its underlying list data has changed since it only receives a rowHeight property.\nIf the rowHeight is a number it can compare before and after values but if it is a function that comparison is error prone.\nIn the event that a dynamic rowHeight function is in use and the row heights have changed this function should be manually called by the "smart" container parent.\nThis method will also force a render cycle (via forceUpdate) to ensure that the updated measurements are reflected in the rendered table.

\n" + } + ], + "html": "
recomputeRowHeights(index?: number): void;
", + "name": "recomputeRowHeights" + }, + { + "description": [ + { + "content": "

Recompute row heights and offsets after the specified row index (defaults to 0); see Grid#recomputeGridSize.\n(The column index is ignored.) This method is intended for CellMeasurer; also see recomputeRowHeights.

\n" + } + ], + "html": "
recomputeGridSize(params?: Partial<CellPosition>): void;
", + "name": "recomputeGridSize" + }, + { + "description": [ + { + "content": "

Ensure the specified row is visible (scrolling if necessary); the column index is ignored.\nThis allows a Table to be used with ArrowKeyStepper; also see scrollToRow.

\n" + } + ], + "html": "
scrollToCell(params: {
\n
align?: Alignment;
\n
columnIndex?: number;
\n
rowIndex?: number;
\n
}): void;
", + "name": "scrollToCell" + }, + { + "description": [ + { + "content": "

Scroll to the specified vertical offset; scrollLeft is ignored (tables don't scroll horizontally).\nOffsets are clamped to the scrollable range. This allows a Table to be used with ScrollSync.\nUseful for animating position changes.\nIf the Table has a scrollElement, the scroll element (e.g. the window) is scrolled instead.

\n" + } + ], + "html": "
scrollToPosition(params: ScrollPosition): void;
", + "name": "scrollToPosition" + }, + { + "description": [ + { + "content": "

Ensure the row at the specified index is visible (scrolling if necessary).\nThis method can be used to safely scroll back to a row that a user has scrolled away from even if it was previously scrolled to.\nThe align parameter (default "auto") controls the alignment of the row:\n"auto" scrolls the least amount possible to ensure that the specified row is fully visible\n(a row taller than the table is aligned to its start, so that its start is visible);\n"start" aligns the row to the top of the table and "end" to the bottom; "center" aligns it in the middle.\nThis is a one-time scroll: the row is not kept in view afterwards (e.g. if the table's size or its row heights change).\nRows that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated heights.\nIf the Table has a scrollElement, the scroll element (e.g. the window) is scrolled instead.

\n" + } + ], + "html": "
scrollToRow(params: { align?: Alignment; index: number }): void;
", + "name": "scrollToRow" + } + ], + "name": "TableImperativeAPI" +} \ No newline at end of file diff --git a/public/generated/examples/ArrowKeyStepperControlled.json b/public/generated/examples/ArrowKeyStepperControlled.json new file mode 100644 index 000000000..aa035df7f --- /dev/null +++ b/public/generated/examples/ArrowKeyStepperControlled.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import {
\n
ArrowKeyStepper,
\n
Grid,
\n
type CellRendererParams,
\n
type ScrollIndices
\n
} from \"react-virtualized\";
\n
 
\n
function ArrowKeyStepperControlled({ width }: { width: number }) {
\n
const [selected, setSelected] = useState<ScrollIndices>({
\n
scrollToColumn: 0,
\n
scrollToRow: 0
\n
});
\n
 
\n
const cellRenderer = ({
\n
columnIndex,
\n
key,
\n
rowIndex,
\n
style
\n
}: CellRendererParams) => {
\n
const isSelected =
\n
columnIndex === selected.scrollToColumn &&
\n
rowIndex === selected.scrollToRow;
\n
 
\n
return (
\n
<div
\n
className={[
\n
\"flex cursor-pointer items-center justify-center border-r border-b border-white/10 text-sm\",
\n
isSelected ? \"bg-sky-500/40 text-white\" : \"hover:bg-white/5\"
\n
].join(\" \")}
\n
key={key}
\n
onClick={() =>
\n
setSelected({ scrollToColumn: columnIndex, scrollToRow: rowIndex })
\n
}
\n
style={style}
\n
>
\n
{`r:${rowIndex}, c:${columnIndex}`}
\n
</div>
\n
);
\n
};
\n
 
\n
return (
\n
<ArrowKeyStepper
\n
columnCount={100}
\n
isControlled
\n
onScrollToChange={setSelected}
\n
rowCount={100}
\n
scrollToColumn={selected.scrollToColumn}
\n
scrollToRow={selected.scrollToRow}
\n
>
\n
{({ onSectionRendered, registerChild }) => (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={100}
\n
columnWidth={getColumnWidth}
\n
height={200}
\n
onSectionRendered={onSectionRendered}
\n
ref={registerChild}
\n
rowCount={100}
\n
rowHeight={getRowHeight}
\n
width={width}
\n
/>
\n
)}
\n
</ArrowKeyStepper>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ArrowKeyStepperGrid.json b/public/generated/examples/ArrowKeyStepperGrid.json new file mode 100644 index 000000000..a9c15b24d --- /dev/null +++ b/public/generated/examples/ArrowKeyStepperGrid.json @@ -0,0 +1,3 @@ +{ + "html": "
import { ArrowKeyStepper, Grid } from \"react-virtualized\";
\n
 
\n
function ArrowKeyStepperGrid({
\n
mode,
\n
width
\n
}: {
\n
mode: \"cells\" | \"edges\";
\n
width: number;
\n
}) {
\n
return (
\n
<ArrowKeyStepper columnCount={100} mode={mode} rowCount={100}>
\n
{({ onSectionRendered, registerChild, scrollToColumn, scrollToRow }) => (
\n
<>
\n
<div className=\"mb-2 text-sm whitespace-nowrap\">
\n
Most-recently-stepped column: {scrollToColumn}, row: {scrollToRow}
\n
</div>
\n
<Grid
\n
cellRenderer={(params) =>
\n
renderCell({
\n
...params,
\n
isSelected:
\n
params.columnIndex === scrollToColumn &&
\n
params.rowIndex === scrollToRow
\n
})
\n
}
\n
columnCount={100}
\n
columnWidth={getColumnWidth}
\n
height={200}
\n
onSectionRendered={onSectionRendered}
\n
ref={registerChild}
\n
rowCount={100}
\n
rowHeight={getRowHeight}
\n
width={width}
\n
/>
\n
</>
\n
)}
\n
</ArrowKeyStepper>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/AutoSizerGrid.json b/public/generated/examples/AutoSizerGrid.json new file mode 100644 index 000000000..da2848dc0 --- /dev/null +++ b/public/generated/examples/AutoSizerGrid.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Grid, type CellRendererParams } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
const columns = [\"name\", \"color\", \"random\"] as const;
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
const cellRenderer = ({
\n
columnIndex,
\n
key,
\n
rowIndex,
\n
style
\n
}: CellRendererParams) => (
\n
<div className=\"truncate px-2 leading-[30px]\" key={key} style={style}>
\n
{items[rowIndex][columns[columnIndex]]}
\n
</div>
\n
);
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
// Width and height are undefined until AutoSizer has measured its parent
\n
height !== undefined && width !== undefined ? (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={columns.length}
\n
columnWidth={200}
\n
height={height}
\n
rowCount={items.length}
\n
rowHeight={30}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/AutoSizerList.json b/public/generated/examples/AutoSizerList.json new file mode 100644 index 000000000..f2a3585a8 --- /dev/null +++ b/public/generated/examples/AutoSizerList.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List, type RowRendererParams } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
const rowRenderer = ({ index, key, style }: RowRendererParams) => (
\n
<div
\n
className={
\n
index % 2
\n
? \"flex items-center px-2\"
\n
: \"flex items-center px-2 bg-white/5\"
\n
}
\n
key={key}
\n
style={style}
\n
>
\n
{items[index].name}
\n
</div>
\n
);
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
rowCount={items.length}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/AutoSizerTable.json b/public/generated/examples/AutoSizerTable.json new file mode 100644 index 000000000..0c08685db --- /dev/null +++ b/public/generated/examples/AutoSizerTable.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Column, Table } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<Table
\n
headerClassName=\"text-white\"
\n
headerHeight={30}
\n
height={height}
\n
rowCount={items.length}
\n
rowGetter={({ index }) => items[index]}
\n
rowHeight={30}
\n
width={width}
\n
>
\n
<Column dataKey=\"name\" label=\"Name\" width={150} />
\n
<Column
\n
dataKey=\"random\"
\n
flexGrow={1}
\n
label=\"Description\"
\n
width={200}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerCacheClear.json b/public/generated/examples/CellMeasurerCacheClear.json new file mode 100644 index 000000000..50a70e30d --- /dev/null +++ b/public/generated/examples/CellMeasurerCacheClear.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef, useState } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
List,
\n
type ListImperativeAPI,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
 
\n
function Example({ messages }: { messages: string[] }) {
\n
const [cache] = useState(
\n
() => new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true })
\n
);
\n
const listRef = useRef<ListImperativeAPI>(null);
\n
const [expanded, setExpanded] = useState<Set<number>>(new Set());
\n
 
\n
const toggle = (index: number) => {
\n
setExpanded((prev) => {
\n
const next = new Set(prev);
\n
if (next.has(index)) {
\n
next.delete(index);
\n
} else {
\n
next.add(index);
\n
}
\n
return next;
\n
});
\n
 
\n
// The row's content (and so its height) is about to change:
\n
// discard its cached measurement and tell the List to re-layout
\n
cache.clear(index, 0);
\n
listRef.current?.recomputeRowHeights(index);
\n
};
\n
 
\n
const rowRenderer = ({ index, key, parent, style }: RowRendererParams) => (
\n
<CellMeasurer
\n
cache={cache}
\n
columnIndex={0}
\n
key={key}
\n
parent={parent}
\n
rowIndex={index}
\n
>
\n
<div onClick={() => toggle(index)} style={style}>
\n
{expanded.has(index) ? messages[index] : messages[index].slice(0, 25)}
\n
</div>
\n
</CellMeasurer>
\n
);
\n
 
\n
return (
\n
<List
\n
deferredMeasurementCache={cache}
\n
height={300}
\n
ref={listRef}
\n
rowCount={messages.length}
\n
rowHeight={cache.rowHeight}
\n
rowRenderer={rowRenderer}
\n
width={300}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerDynamicHeightGrid.json b/public/generated/examples/CellMeasurerDynamicHeightGrid.json new file mode 100644 index 000000000..a939b9652 --- /dev/null +++ b/public/generated/examples/CellMeasurerDynamicHeightGrid.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
Grid,
\n
type CellRendererParams
\n
} from \"react-virtualized\";
\n
 
\n
function Example({ items, width }: { items: ListItem[]; width: number }) {
\n
// Columns have a fixed width (150px) and rows have a dynamic height;
\n
// each row is as tall as its tallest cell.
\n
const [cache] = useState(
\n
() => new CellMeasurerCache({ defaultWidth: 150, fixedWidth: true })
\n
);
\n
 
\n
const cellRenderer = ({
\n
columnIndex,
\n
key,
\n
parent,
\n
rowIndex,
\n
style
\n
}: CellRendererParams) => (
\n
<CellMeasurer
\n
cache={cache}
\n
columnIndex={columnIndex}
\n
key={key}
\n
parent={parent}
\n
rowIndex={rowIndex}
\n
>
\n
<div style={{ ...style, width: 150 }}>
\n
{items[(rowIndex + columnIndex) % items.length].randomLong}
\n
</div>
\n
</CellMeasurer>
\n
);
\n
 
\n
return (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={50}
\n
columnWidth={150}
\n
deferredMeasurementCache={cache}
\n
height={400}
\n
rowCount={1000}
\n
rowHeight={cache.rowHeight}
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerDynamicHeightList.json b/public/generated/examples/CellMeasurerDynamicHeightList.json new file mode 100644 index 000000000..be44b4d9a --- /dev/null +++ b/public/generated/examples/CellMeasurerDynamicHeightList.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
List,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
// Rows have a fixed width (the width of the list) and a dynamic height.
\n
// Unmeasured rows are assumed to be 50px tall.
\n
const [cache] = useState(
\n
() => new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true })
\n
);
\n
 
\n
const rowRenderer = ({ index, key, parent, style }: RowRendererParams) => {
\n
const item = items[index];
\n
 
\n
return (
\n
<CellMeasurer
\n
cache={cache}
\n
columnIndex={0}
\n
key={key}
\n
parent={parent}
\n
rowIndex={index}
\n
>
\n
<div
\n
className=\"flex flex-col gap-1 px-3 py-2 border-b border-white/10\"
\n
style={style}
\n
>
\n
<div className=\"font-bold text-white\">{item.name}</div>
\n
<div>{item.randomLong}</div>
\n
</div>
\n
</CellMeasurer>
\n
);
\n
};
\n
 
\n
return (
\n
// Text wraps differently at different widths,
\n
// so measurements must be cleared when the list resizes
\n
<AutoSizer
\n
onResize={() => cache.clearAll()}
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
deferredMeasurementCache={cache}
\n
height={height}
\n
rowCount={items.length}
\n
rowHeight={cache.rowHeight}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerDynamicHeightTableColumn.json b/public/generated/examples/CellMeasurerDynamicHeightTableColumn.json new file mode 100644 index 000000000..b078bb3d1 --- /dev/null +++ b/public/generated/examples/CellMeasurerDynamicHeightTableColumn.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
Column,
\n
Table
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
// Rows have a dynamic height (but are never shorter than 25px)
\n
const [cache] = useState(
\n
() => new CellMeasurerCache({ fixedWidth: true, minHeight: 25 })
\n
);
\n
 
\n
return (
\n
<AutoSizer
\n
onResize={() => cache.clearAll()}
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<Table
\n
deferredMeasurementCache={cache}
\n
headerClassName=\"text-white\"
\n
headerHeight={30}
\n
height={height}
\n
rowClassName=\"border-b border-white/10\"
\n
rowCount={items.length}
\n
rowGetter={({ index }) => items[index]}
\n
rowHeight={cache.rowHeight}
\n
width={width}
\n
>
\n
<Column dataKey=\"name\" label=\"Name\" width={125} />
\n
<Column dataKey=\"color\" label=\"Color\" width={75} />
\n
{/* Only this column is measured; the others are a single line */}
\n
<Column
\n
cellRenderer={({ parent, rowData, rowIndex }) => (
\n
<CellMeasurer
\n
cache={cache}
\n
columnIndex={0}
\n
parent={parent}
\n
rowIndex={rowIndex}
\n
>
\n
<div className=\"py-1 whitespace-normal\">
\n
{rowData.randomLong}
\n
</div>
\n
</CellMeasurer>
\n
)}
\n
dataKey=\"randomLong\"
\n
flexGrow={1}
\n
label=\"Description\"
\n
width={200}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerDynamicWidthGrid.json b/public/generated/examples/CellMeasurerDynamicWidthGrid.json new file mode 100644 index 000000000..d3cb0a5dc --- /dev/null +++ b/public/generated/examples/CellMeasurerDynamicWidthGrid.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
Grid,
\n
type CellRendererParams
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
// Cells have a fixed height and a dynamic width.
\n
// Unmeasured columns are assumed to be 100px wide.
\n
const [cache] = useState(
\n
() => new CellMeasurerCache({ defaultWidth: 100, fixedHeight: true })
\n
);
\n
 
\n
const cellRenderer = ({
\n
columnIndex,
\n
key,
\n
parent,
\n
rowIndex,
\n
style
\n
}: CellRendererParams) => {
\n
const item = items[(rowIndex + columnIndex) % items.length];
\n
 
\n
let content;
\n
switch (columnIndex % 3) {
\n
case 0:
\n
content = item.color;
\n
break;
\n
case 1:
\n
content = item.name;
\n
break;
\n
default:
\n
content = item.random;
\n
break;
\n
}
\n
 
\n
return (
\n
<CellMeasurer
\n
cache={cache}
\n
columnIndex={columnIndex}
\n
key={key}
\n
parent={parent}
\n
rowIndex={rowIndex}
\n
>
\n
<div
\n
className=\"flex items-center px-3 whitespace-nowrap border-r border-b border-white/10\"
\n
style={{ ...style, height: 35 }}
\n
>
\n
{content}
\n
</div>
\n
</CellMeasurer>
\n
);
\n
};
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={1000}
\n
columnWidth={cache.columnWidth}
\n
deferredMeasurementCache={cache}
\n
height={height}
\n
rowCount={50}
\n
rowHeight={35}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerDynamicWidthMultiGrid.json b/public/generated/examples/CellMeasurerDynamicWidthMultiGrid.json new file mode 100644 index 000000000..39765bc8f --- /dev/null +++ b/public/generated/examples/CellMeasurerDynamicWidthMultiGrid.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
MultiGrid,
\n
type CellRendererParams
\n
} from \"react-virtualized\";
\n
 
\n
function Example({ items, width }: { items: ListItem[]; width: number }) {
\n
// Rows have a fixed height (30px) and columns have a dynamic width;
\n
// each column is as wide as its widest cell.
\n
const [cache] = useState(
\n
() =>
\n
new CellMeasurerCache({
\n
defaultHeight: 30,
\n
defaultWidth: 150,
\n
fixedHeight: true
\n
})
\n
);
\n
 
\n
const cellRenderer = ({
\n
columnIndex,
\n
key,
\n
parent,
\n
rowIndex,
\n
style
\n
}: CellRendererParams) => (
\n
<CellMeasurer
\n
cache={cache}
\n
columnIndex={columnIndex}
\n
key={key}
\n
parent={parent}
\n
rowIndex={rowIndex}
\n
>
\n
<div style={{ ...style, whiteSpace: \"nowrap\" }}>
\n
{items[(rowIndex + columnIndex) % items.length].random}
\n
</div>
\n
</CellMeasurer>
\n
);
\n
 
\n
return (
\n
<MultiGrid
\n
cellRenderer={cellRenderer}
\n
columnCount={50}
\n
columnWidth={cache.columnWidth}
\n
deferredMeasurementCache={cache}
\n
fixedColumnCount={1}
\n
height={400}
\n
rowCount={50}
\n
rowHeight={30}
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerFunctionComponent.json b/public/generated/examples/CellMeasurerFunctionComponent.json new file mode 100644 index 000000000..730dc2b87 --- /dev/null +++ b/public/generated/examples/CellMeasurerFunctionComponent.json @@ -0,0 +1,3 @@ +{ + "html": "
import { forwardRef, useState, type CSSProperties, type Ref } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
List,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
 
\n
// React 19: \"ref\" is a regular prop; pass it along to the DOM element
\n
function Row({
\n
ref,
\n
style,
\n
text
\n
}: {
\n
ref?: Ref<HTMLDivElement>;
\n
style: CSSProperties;
\n
text: string;
\n
}) {
\n
return (
\n
<div ref={ref} style={style}>
\n
{text}
\n
</div>
\n
);
\n
}
\n
 
\n
// React 18: function components must use forwardRef to receive a ref
\n
const ForwardRefRow = forwardRef<
\n
HTMLDivElement,
\n
{ style: CSSProperties; text: string }
\n
>(function ForwardRefRow({ style, text }, ref) {
\n
return (
\n
<div ref={ref} style={style}>
\n
{text}
\n
</div>
\n
);
\n
});
\n
 
\n
// Either version: attach registerChild to the element to be measured
\n
function RegisteredRow({
\n
registerChild,
\n
style,
\n
text
\n
}: {
\n
registerChild: Ref<HTMLDivElement>;
\n
style: CSSProperties;
\n
text: string;
\n
}) {
\n
return (
\n
<div ref={registerChild} style={style}>
\n
{text}
\n
</div>
\n
);
\n
}
\n
 
\n
function Example({ items }: { items: string[] }) {
\n
const [cache] = useState(
\n
() => new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true })
\n
);
\n
 
\n
const rowRenderer = ({ index, key, parent, style }: RowRendererParams) => {
\n
const text = items[index];
\n
 
\n
switch (index % 3) {
\n
case 0:
\n
return (
\n
<CellMeasurer
\n
key={key}
\n
cache={cache}
\n
parent={parent}
\n
rowIndex={index}
\n
>
\n
<Row style={style} text={text} />
\n
</CellMeasurer>
\n
);
\n
case 1:
\n
return (
\n
<CellMeasurer
\n
key={key}
\n
cache={cache}
\n
parent={parent}
\n
rowIndex={index}
\n
>
\n
<ForwardRefRow style={style} text={text} />
\n
</CellMeasurer>
\n
);
\n
default:
\n
return (
\n
<CellMeasurer
\n
key={key}
\n
cache={cache}
\n
parent={parent}
\n
rowIndex={index}
\n
>
\n
{({ registerChild }) => (
\n
<RegisteredRow
\n
registerChild={registerChild}
\n
style={style}
\n
text={text}
\n
/>
\n
)}
\n
</CellMeasurer>
\n
);
\n
}
\n
};
\n
 
\n
return (
\n
<List
\n
deferredMeasurementCache={cache}
\n
height={300}
\n
rowCount={items.length}
\n
rowHeight={cache.rowHeight}
\n
rowRenderer={rowRenderer}
\n
width={300}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerImages.json b/public/generated/examples/CellMeasurerImages.json new file mode 100644 index 000000000..3171f6db9 --- /dev/null +++ b/public/generated/examples/CellMeasurerImages.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
List,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
 
\n
function Example({
\n
height,
\n
imageUrls,
\n
width
\n
}: {
\n
height: number;
\n
imageUrls: string[];
\n
width: number;
\n
}) {
\n
// Average row height is assumed to be about 50px;
\n
// the width of each row is not dynamic.
\n
const [cache] = useState(
\n
() => new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true })
\n
);
\n
 
\n
const rowRenderer = ({ index, key, parent, style }: RowRendererParams) => (
\n
<CellMeasurer
\n
cache={cache}
\n
columnIndex={0}
\n
key={key}
\n
parent={parent}
\n
rowIndex={index}
\n
>
\n
{({ measure, registerChild }) => (
\n
// \"style\" is required to position the row (within the parent List)
\n
<div ref={registerChild} style={style}>
\n
{/* Re-measure the row once the image has loaded */}
\n
<img onLoad={measure} src={imageUrls[index]} />
\n
</div>
\n
)}
\n
</CellMeasurer>
\n
);
\n
 
\n
return (
\n
<List
\n
deferredMeasurementCache={cache}
\n
height={height}
\n
rowCount={imageUrls.length}
\n
rowHeight={cache.rowHeight}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CellMeasurerKeyMapper.json b/public/generated/examples/CellMeasurerKeyMapper.json new file mode 100644 index 000000000..6fcbd978d --- /dev/null +++ b/public/generated/examples/CellMeasurerKeyMapper.json @@ -0,0 +1,3 @@ +{ + "html": "
import { CellMeasurerCache } from \"react-virtualized\";
\n
 
\n
// Normally every cell is measured individually, which can be slow.
\n
// If all rows are known to have the same (but unknown) height,
\n
// a keyMapper that returns a constant tells CellMeasurer that
\n
// every measurement after the first one will hit the cache.
\n
const cache = new CellMeasurerCache({
\n
defaultHeight: 30,
\n
fixedWidth: true,
\n
keyMapper: () => 1
\n
});
" +} \ No newline at end of file diff --git a/public/generated/examples/ColumnSizerGrid.json b/public/generated/examples/ColumnSizerGrid.json new file mode 100644 index 000000000..7ce5dcf2e --- /dev/null +++ b/public/generated/examples/ColumnSizerGrid.json @@ -0,0 +1,3 @@ +{ + "html": "
import { ColumnSizer, Grid } from \"react-virtualized\";
\n
 
\n
function ColumnSizerGrid({
\n
columnCount,
\n
columnMaxWidth,
\n
columnMinWidth,
\n
width
\n
}: {
\n
columnCount: number;
\n
columnMaxWidth?: number;
\n
columnMinWidth?: number;
\n
width: number;
\n
}) {
\n
return (
\n
<ColumnSizer
\n
columnCount={columnCount}
\n
columnMaxWidth={columnMaxWidth}
\n
columnMinWidth={columnMinWidth}
\n
width={width}
\n
>
\n
{({ adjustedWidth, columnWidth, registerChild }) => (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={columnCount}
\n
columnWidth={columnWidth}
\n
height={160}
\n
ref={registerChild}
\n
rowCount={4}
\n
rowHeight={40}
\n
// Shrink to fit if the columns don't fill the available width
\n
width={adjustedWidth}
\n
/>
\n
)}
\n
</ColumnSizer>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CustomizingStylesClassNames.json b/public/generated/examples/CustomizingStylesClassNames.json new file mode 100644 index 000000000..6120dccb8 --- /dev/null +++ b/public/generated/examples/CustomizingStylesClassNames.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Column, Table } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<Table
\n
className=\"text-sm\"
\n
gridClassName=\"outline-none\"
\n
headerClassName=\"text-sky-300\"
\n
headerHeight={30}
\n
height={height}
\n
// The header row is passed an index of -1
\n
rowClassName={({ index }) =>
\n
index < 0
\n
? \"border-b-2 border-white/20\"
\n
: index % 2
\n
? \"hover:bg-white/10\"
\n
: \"bg-white/5 hover:bg-white/10\"
\n
}
\n
rowCount={items.length}
\n
rowGetter={({ index }) => items[index]}
\n
rowHeight={30}
\n
width={width}
\n
>
\n
<Column dataKey=\"index\" label=\"Index\" width={60} />
\n
<Column
\n
className=\"font-bold\"
\n
dataKey=\"name\"
\n
flexGrow={1}
\n
label=\"Name\"
\n
width={150}
\n
/>
\n
<Column
\n
cellRenderer={({ cellData }) => (
\n
<span
\n
className=\"inline-block w-3 h-3 rounded-full\"
\n
style={{ backgroundColor: cellData }}
\n
/>
\n
)}
\n
dataKey=\"color\"
\n
label=\"Color\"
\n
style={{ display: \"flex\", alignItems: \"center\" }}
\n
width={60}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CustomizingStylesCssModules.json b/public/generated/examples/CustomizingStylesCssModules.json new file mode 100644 index 000000000..912b628ac --- /dev/null +++ b/public/generated/examples/CustomizingStylesCssModules.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Column, Table } from \"react-virtualized\";
\n
import styles from \"./CustomizingStylesCssModules.module.css\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
return (
\n
<Table
\n
className={styles.table}
\n
headerClassName={styles.headerColumn}
\n
headerHeight={30}
\n
height={300}
\n
rowClassName={styles.row}
\n
rowCount={items.length}
\n
rowGetter={({ index }) => items[index]}
\n
rowHeight={30}
\n
width={400}
\n
>
\n
<Column dataKey=\"name\" label=\"Name\" width={250} />
\n
<Column dataKey=\"color\" label=\"Color\" width={100} />
\n
</Table>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CustomizingStylesCssModules.module.json b/public/generated/examples/CustomizingStylesCssModules.module.json new file mode 100644 index 000000000..0f072b70c --- /dev/null +++ b/public/generated/examples/CustomizingStylesCssModules.module.json @@ -0,0 +1,3 @@ +{ + "html": "
.table {
\n
font-size: 0.875rem;
\n
}
\n
 
\n
.headerColumn {
\n
color: #7dd3fc;
\n
}
\n
 
\n
.row {
\n
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CustomizingStylesGlobal.json b/public/generated/examples/CustomizingStylesGlobal.json new file mode 100644 index 000000000..1e727aa06 --- /dev/null +++ b/public/generated/examples/CustomizingStylesGlobal.json @@ -0,0 +1,3 @@ +{ + "html": "
/* Loaded after react-virtualized/styles.css */
\n
.ReactVirtualized__Table__headerRow {
\n
text-transform: none;
\n
border-bottom: 2px solid #ccc;
\n
}
\n
 
\n
.ReactVirtualized__Table__row:hover {
\n
background-color: #f5f5f5;
\n
}
\n
 
\n
.ReactVirtualized__Table__sortableHeaderIcon {
\n
fill: #2196f3;
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/CustomizingStylesImport.json b/public/generated/examples/CustomizingStylesImport.json new file mode 100644 index 000000000..eb9a231f2 --- /dev/null +++ b/public/generated/examples/CustomizingStylesImport.json @@ -0,0 +1,3 @@ +{ + "html": "
// Import the default styles once, e.g. in your app's entry point
\n
import \"react-virtualized/styles.css\";
" +} \ No newline at end of file diff --git a/public/generated/examples/CustomizingStylesInlineStyles.json b/public/generated/examples/CustomizingStylesInlineStyles.json new file mode 100644 index 000000000..4b13eaaf8 --- /dev/null +++ b/public/generated/examples/CustomizingStylesInlineStyles.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Column, Table } from \"react-virtualized\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
return (
\n
<Table
\n
headerHeight={30}
\n
headerStyle={{ color: \"#7dd3fc\" }}
\n
height={300}
\n
rowCount={items.length}
\n
rowGetter={({ index }) => items[index]}
\n
rowHeight={30}
\n
rowStyle={({ index }) =>
\n
index % 2 === 0 ? { backgroundColor: \"rgba(255, 255, 255, 0.05)\" } : {}
\n
}
\n
style={{ fontSize: \"0.875rem\" }}
\n
width={400}
\n
>
\n
<Column dataKey=\"name\" label=\"Name\" width={250} />
\n
<Column
\n
dataKey=\"color\"
\n
label=\"Color\"
\n
style={{ fontFamily: \"monospace\" }}
\n
width={100}
\n
/>
\n
</Table>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/GridBasic.json b/public/generated/examples/GridBasic.json new file mode 100644 index 000000000..d2de8c78e --- /dev/null +++ b/public/generated/examples/GridBasic.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Grid, type CellRendererParams } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
// Each row is an item in the list; each column is one of its fields
\n
const columns = [\"index\", \"name\", \"color\", \"size\", \"random\"] as const;
\n
 
\n
function cellRenderer({
\n
columnIndex,
\n
key,
\n
rowIndex,
\n
style
\n
}: CellRendererParams) {
\n
const item = list[rowIndex];
\n
const field = columns[columnIndex];
\n
 
\n
return (
\n
<div
\n
className=\"truncate border-b border-white/10 px-2 leading-[30px]\"
\n
key={key}
\n
style={style}
\n
>
\n
{item[field]}
\n
</div>
\n
);
\n
}
\n
 
\n
function GridBasic() {
\n
return (
\n
// AutoSizer measures the available space; Grid requires an explicit width and height
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={columns.length}
\n
columnWidth={150}
\n
height={height}
\n
rowCount={list.length}
\n
rowHeight={30}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/GridCellRangeRenderer.json b/public/generated/examples/GridCellRangeRenderer.json new file mode 100644 index 000000000..699b6d2dc --- /dev/null +++ b/public/generated/examples/GridCellRangeRenderer.json @@ -0,0 +1,3 @@ +{ + "html": "
import {
\n
defaultCellRangeRenderer,
\n
Grid,
\n
type CellRangeRenderer
\n
} from \"react-virtualized\";
\n
 
\n
// Highlight a range of cells (e.g. an event in a calendar or a task in a Gantt chart)
\n
const EVENT = { startColumn: 2, stopColumn: 5, startRow: 3, stopRow: 4 };
\n
 
\n
const cellRangeRenderer: CellRangeRenderer = (params) => {
\n
const {
\n
columnSizeAndPositionManager,
\n
horizontalOffsetAdjustment,
\n
rowSizeAndPositionManager,
\n
verticalOffsetAdjustment
\n
} = params;
\n
 
\n
// Render the cells as usual...
\n
const children = defaultCellRangeRenderer(params);
\n
 
\n
// ...then add the overlay, positioned using the same size and position data as the cells
\n
const start = {
\n
column: columnSizeAndPositionManager.getSizeAndPositionOfCell(
\n
EVENT.startColumn
\n
),
\n
row: rowSizeAndPositionManager.getSizeAndPositionOfCell(EVENT.startRow)
\n
};
\n
const stop = {
\n
column: columnSizeAndPositionManager.getSizeAndPositionOfCell(
\n
EVENT.stopColumn
\n
),
\n
row: rowSizeAndPositionManager.getSizeAndPositionOfCell(EVENT.stopRow)
\n
};
\n
 
\n
children.push(
\n
<div
\n
className=\"pointer-events-none absolute flex items-center justify-center rounded border border-sky-400 bg-sky-400/20 text-sky-100\"
\n
key=\"overlay\"
\n
style={{
\n
left: start.column.offset + horizontalOffsetAdjustment,
\n
top: start.row.offset + verticalOffsetAdjustment,
\n
width: stop.column.offset + stop.column.size - start.column.offset,
\n
height: stop.row.offset + stop.row.size - start.row.offset
\n
}}
\n
>
\n
My custom overlay
\n
</div>
\n
);
\n
 
\n
return children;
\n
};
\n
 
\n
function GridCellRangeRenderer({ width }: { width: number }) {
\n
return (
\n
<Grid
\n
cellRangeRenderer={cellRangeRenderer}
\n
cellRenderer={cellRenderer}
\n
columnCount={50}
\n
columnWidth={100}
\n
height={250}
\n
rowCount={100}
\n
rowHeight={40}
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/GridCellRenderer.json b/public/generated/examples/GridCellRenderer.json new file mode 100644 index 000000000..dec81da63 --- /dev/null +++ b/public/generated/examples/GridCellRenderer.json @@ -0,0 +1,3 @@ +{ + "html": "
import type { CellRendererParams } from \"react-virtualized\";
\n
 
\n
function cellRenderer({
\n
columnIndex, // Horizontal (column) index of cell
\n
isScrolling, // The Grid is currently being scrolled
\n
key, // Unique key within array of cells
\n
rowIndex, // Vertical (row) index of cell
\n
style // Style object to be applied to cell (to position it)
\n
// Also available:
\n
// isVisible: This cell is visible within the grid (eg it is not an overscanned cell)
\n
// parent: Reference to the parent Grid (instance)
\n
}: CellRendererParams) {
\n
const user = list[rowIndex][columnIndex];
\n
 
\n
// If cell content is complex, consider rendering a lighter-weight placeholder while scrolling
\n
const content = isScrolling ? \"...\" : <User user={user} />;
\n
 
\n
// Style is required since it specifies how the cell is to be sized and positioned;
\n
// Grid depends on this sizing/positioning for proper scrolling behavior.
\n
// By default it contains position, left, top, height, and width properties.
\n
// You can add class names or style properties as you like (but don't mutate the object).
\n
// Key is also required by React to more efficiently manage the array of cells.
\n
return (
\n
<div key={key} style={style}>
\n
{content}
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/GridMethodsRef.json b/public/generated/examples/GridMethodsRef.json new file mode 100644 index 000000000..317736c47 --- /dev/null +++ b/public/generated/examples/GridMethodsRef.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef } from \"react\";
\n
import { Grid, type GridImperativeAPI } from \"react-virtualized\";
\n
 
\n
function GridMethodsRef({ width }: { width: number }) {
\n
// A ref provides access to the Grid's imperative methods
\n
const gridRef = useRef<GridImperativeAPI>(null);
\n
 
\n
const scrollToMiddle = () => {
\n
gridRef.current?.scrollToCell({
\n
align: \"center\",
\n
columnIndex: 50,
\n
rowIndex: 500
\n
});
\n
};
\n
 
\n
const scrollToTop = () => {
\n
gridRef.current?.scrollToPosition({ scrollLeft: 0, scrollTop: 0 });
\n
};
\n
 
\n
return (
\n
<>
\n
<div className=\"mb-2 flex gap-2\">
\n
<button className={BUTTON_CLASS_NAME} onClick={scrollToMiddle}>
\n
Center row 500, column 50
\n
</button>
\n
<button className={BUTTON_CLASS_NAME} onClick={scrollToTop}>
\n
Scroll to top
\n
</button>
\n
</div>
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={100}
\n
columnWidth={120}
\n
height={200}
\n
ref={gridRef}
\n
rowCount={1000}
\n
rowHeight={35}
\n
width={width}
\n
/>
\n
</>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/GridOverscanIndicesGetter.json b/public/generated/examples/GridOverscanIndicesGetter.json new file mode 100644 index 000000000..f589e9d2b --- /dev/null +++ b/public/generated/examples/GridOverscanIndicesGetter.json @@ -0,0 +1,3 @@ +{ + "html": "
import type { OverscanIndicesGetter } from \"react-virtualized\";
\n
 
\n
// Overscan the same number of cells before and after the visible range,
\n
// regardless of which direction the user is scrolling
\n
const overscanIndicesGetter: OverscanIndicesGetter = ({
\n
cellCount, // Number of rows or columns in the current axis
\n
overscanCellsCount, // Maximum number of cells to over-render in either direction
\n
startIndex, // Begin of range of visible cells
\n
stopIndex // End of range of visible cells
\n
// Also available:
\n
// direction: \"horizontal\" or \"vertical\"
\n
// scrollDirection: 1 (forward) or -1 (backward)
\n
}) => ({
\n
overscanStartIndex: Math.max(0, startIndex - overscanCellsCount),
\n
overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount)
\n
});
" +} \ No newline at end of file diff --git a/public/generated/examples/GridScrollToCell.json b/public/generated/examples/GridScrollToCell.json new file mode 100644 index 000000000..f6407965d --- /dev/null +++ b/public/generated/examples/GridScrollToCell.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef, useState, type FormEvent } from \"react\";
\n
import {
\n
Grid,
\n
type Alignment,
\n
type GridImperativeAPI
\n
} from \"react-virtualized\";
\n
 
\n
const COLUMN_COUNT = 1000;
\n
const ROW_COUNT = 1000;
\n
 
\n
function GridScrollToCell({ width }: { width: number }) {
\n
const gridRef = useRef<GridImperativeAPI>(null);
\n
 
\n
const [align, setAlign] = useState<Alignment>(\"auto\");
\n
const [columnIndex, setColumnIndex] = useState(\"50\");
\n
const [rowIndex, setRowIndex] = useState(\"500\");
\n
 
\n
const onSubmit = (event: FormEvent) => {
\n
event.preventDefault();
\n
 
\n
gridRef.current?.scrollToCell({
\n
align,
\n
columnIndex: parseInt(columnIndex, 10),
\n
rowIndex: parseInt(rowIndex, 10)
\n
});
\n
};
\n
 
\n
return (
\n
<>
\n
<form className=\"mb-2 flex flex-wrap items-end gap-2\" onSubmit={onSubmit}>
\n
<label className={LABEL_CLASS_NAME}>
\n
Row
\n
<input
\n
className={INPUT_CLASS_NAME}
\n
max={ROW_COUNT - 1}
\n
min={0}
\n
onChange={(event) => setRowIndex(event.target.value)}
\n
required
\n
type=\"number\"
\n
value={rowIndex}
\n
/>
\n
</label>
\n
<label className={LABEL_CLASS_NAME}>
\n
Column
\n
<input
\n
className={INPUT_CLASS_NAME}
\n
max={COLUMN_COUNT - 1}
\n
min={0}
\n
onChange={(event) => setColumnIndex(event.target.value)}
\n
required
\n
type=\"number\"
\n
value={columnIndex}
\n
/>
\n
</label>
\n
<label className={LABEL_CLASS_NAME}>
\n
Alignment
\n
<select
\n
className={INPUT_CLASS_NAME}
\n
onChange={(event) => setAlign(event.target.value as Alignment)}
\n
value={align}
\n
>
\n
<option value=\"auto\">auto</option>
\n
<option value=\"start\">start</option>
\n
<option value=\"center\">center</option>
\n
<option value=\"end\">end</option>
\n
</select>
\n
</label>
\n
<button className={BUTTON_CLASS_NAME} type=\"submit\">
\n
Scroll
\n
</button>
\n
</form>
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={COLUMN_COUNT}
\n
columnWidth={100}
\n
height={250}
\n
ref={gridRef}
\n
rowCount={ROW_COUNT}
\n
rowHeight={40}
\n
width={width}
\n
/>
\n
</>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/GridVariableSizes.json b/public/generated/examples/GridVariableSizes.json new file mode 100644 index 000000000..86e8de46b --- /dev/null +++ b/public/generated/examples/GridVariableSizes.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Grid, type CellRendererParams } from \"react-virtualized\";
\n
 
\n
// Column widths are returned by a function rather than being a fixed number
\n
function getColumnWidth({ index }: { index: number }) {
\n
switch (index) {
\n
case 0:
\n
return 50;
\n
case 1:
\n
return 100;
\n
case 2:
\n
return 300;
\n
default:
\n
return 80;
\n
}
\n
}
\n
 
\n
// Row heights can be returned by a function too
\n
function getRowHeight({ index }: { index: number }) {
\n
return list[index % list.length].size;
\n
}
\n
 
\n
function cellRenderer({
\n
columnIndex,
\n
key,
\n
rowIndex,
\n
style
\n
}: CellRendererParams) {
\n
const item = list[rowIndex % list.length];
\n
 
\n
if (columnIndex === 0) {
\n
return (
\n
<div
\n
className=\"flex items-center justify-center border-b border-white/10 text-xl text-white\"
\n
key={key}
\n
// Don't modify the style object; Grid caches and re-uses it
\n
style={{ ...style, backgroundColor: item.color }}
\n
>
\n
{item.name.charAt(0)}
\n
</div>
\n
);
\n
}
\n
 
\n
let content;
\n
switch (columnIndex) {
\n
case 1:
\n
content = item.name;
\n
break;
\n
case 2:
\n
content = item.random;
\n
break;
\n
default:
\n
content = `r:${rowIndex}, c:${columnIndex}`;
\n
break;
\n
}
\n
 
\n
return (
\n
<div
\n
className={[
\n
\"flex items-center truncate border-r border-b border-white/10 px-2\",
\n
rowIndex % 2 === 1 ? \"bg-white/5\" : \"\",
\n
columnIndex > 2 ? \"justify-center\" : \"\"
\n
].join(\" \")}
\n
key={key}
\n
style={style}
\n
>
\n
{content}
\n
</div>
\n
);
\n
}
\n
 
\n
function GridVariableSizes({
\n
useDynamicRowHeight,
\n
width
\n
}: {
\n
useDynamicRowHeight: boolean;
\n
width: number;
\n
}) {
\n
return (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={1000}
\n
columnWidth={getColumnWidth}
\n
estimatedColumnSize={80}
\n
height={300}
\n
noContentRenderer={() => <div>No cells</div>}
\n
estimatedRowSize={75}
\n
rowCount={1000}
\n
rowHeight={useDynamicRowHeight ? getRowHeight : 40}
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/InfiniteLoaderBasic.json b/public/generated/examples/InfiniteLoaderBasic.json new file mode 100644 index 000000000..feae7ed59 --- /dev/null +++ b/public/generated/examples/InfiniteLoaderBasic.json @@ -0,0 +1,3 @@ +{ + "html": "
import {
\n
InfiniteLoader,
\n
List,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
 
\n
// This example assumes you have a way to know/load this information
\n
const remoteRowCount = 1000;
\n
 
\n
const list: string[] = [];
\n
 
\n
function isRowLoaded({ index }: { index: number }) {
\n
return !!list[index];
\n
}
\n
 
\n
async function loadMoreRows({
\n
startIndex,
\n
stopIndex
\n
}: {
\n
startIndex: number;
\n
stopIndex: number;
\n
}) {
\n
const response = await fetch(
\n
`path/to/api?startIndex=${startIndex}&stopIndex=${stopIndex}`
\n
);
\n
const names: string[] = await response.json();
\n
 
\n
// Store response data in list...
\n
names.forEach((name, offset) => {
\n
list[startIndex + offset] = name;
\n
});
\n
}
\n
 
\n
function rowRenderer({ index, key, style }: RowRendererParams) {
\n
return (
\n
<div key={key} style={style}>
\n
{list[index] ?? \"Loading...\"}
\n
</div>
\n
);
\n
}
\n
 
\n
function Example() {
\n
return (
\n
<InfiniteLoader
\n
isRowLoaded={isRowLoaded}
\n
loadMoreRows={loadMoreRows}
\n
rowCount={remoteRowCount}
\n
>
\n
{({ onRowsRendered, registerChild }) => (
\n
<List
\n
height={200}
\n
onRowsRendered={onRowsRendered}
\n
ref={registerChild}
\n
rowCount={remoteRowCount}
\n
rowHeight={20}
\n
rowRenderer={rowRenderer}
\n
width={300}
\n
/>
\n
)}
\n
</InfiniteLoader>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/InfiniteLoaderGrid.json b/public/generated/examples/InfiniteLoaderGrid.json new file mode 100644 index 000000000..f2277d383 --- /dev/null +++ b/public/generated/examples/InfiniteLoaderGrid.json @@ -0,0 +1,3 @@ +{ + "html": "
import {
\n
Grid,
\n
InfiniteLoader,
\n
type GridProps,
\n
type RenderedSection
\n
} from \"react-virtualized\";
\n
 
\n
function Example({
\n
cellRenderer,
\n
columnCount,
\n
isRowLoaded,
\n
loadMoreRows,
\n
rowCount
\n
}: {
\n
cellRenderer: GridProps[\"cellRenderer\"];
\n
columnCount: number;
\n
isRowLoaded: (params: { index: number }) => boolean;
\n
loadMoreRows: (params: {
\n
startIndex: number;
\n
stopIndex: number;
\n
}) => Promise<unknown>;
\n
rowCount: number;
\n
}) {
\n
return (
\n
<InfiniteLoader
\n
isRowLoaded={isRowLoaded}
\n
loadMoreRows={loadMoreRows}
\n
rowCount={rowCount * columnCount}
\n
>
\n
{({ onRowsRendered, registerChild }) => {
\n
// Grid cells are mapped to a flat list of \"rows\" (one per cell)
\n
const onSectionRendered = ({
\n
columnStartIndex,
\n
columnStopIndex,
\n
rowStartIndex,
\n
rowStopIndex
\n
}: RenderedSection) => {
\n
onRowsRendered({
\n
startIndex: rowStartIndex * columnCount + columnStartIndex,
\n
stopIndex: rowStopIndex * columnCount + columnStopIndex
\n
});
\n
};
\n
 
\n
return (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={columnCount}
\n
columnWidth={100}
\n
height={300}
\n
onSectionRendered={onSectionRendered}
\n
ref={registerChild}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
width={500}
\n
/>
\n
);
\n
}}
\n
</InfiniteLoader>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/InfiniteLoaderList.json b/public/generated/examples/InfiniteLoaderList.json new file mode 100644 index 000000000..18913dbc4 --- /dev/null +++ b/public/generated/examples/InfiniteLoaderList.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef, useState } from \"react\";
\n
import {
\n
InfiniteLoader,
\n
List,
\n
type InfiniteLoaderImperativeAPI,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
type RowStatus = \"loading\" | \"loaded\";
\n
 
\n
function Example() {
\n
const infiniteLoaderRef = useRef<InfiniteLoaderImperativeAPI>(null);
\n
 
\n
// InfiniteLoader doesn't track which rows have been requested;
\n
// the application must do this to avoid loading rows more than once.
\n
const rowStatusMapRef = useRef(new Map<number, RowStatus>());
\n
 
\n
// Used to re-render after row statuses change
\n
const [, setVersion] = useState(0);
\n
 
\n
const isRowLoaded = ({ index }: { index: number }) =>
\n
// Rows that are still loading are treated as loaded (to avoid requesting them again)
\n
rowStatusMapRef.current.has(index);
\n
 
\n
const loadMoreRows = async ({
\n
startIndex,
\n
stopIndex
\n
}: {
\n
startIndex: number;
\n
stopIndex: number;
\n
}) => {
\n
const rowStatusMap = rowStatusMapRef.current;
\n
for (let index = startIndex; index <= stopIndex; index++) {
\n
rowStatusMap.set(index, \"loading\");
\n
}
\n
setVersion((version) => version + 1);
\n
 
\n
// Simulate a network request
\n
await new Promise((resolve) =>
\n
setTimeout(resolve, 1000 + Math.random() * 2000)
\n
);
\n
 
\n
if (rowStatusMap === rowStatusMapRef.current) {
\n
for (let index = startIndex; index <= stopIndex; index++) {
\n
rowStatusMap.set(index, \"loaded\");
\n
}
\n
setVersion((version) => version + 1);
\n
}
\n
};
\n
 
\n
const clearData = () => {
\n
rowStatusMapRef.current = new Map();
\n
setVersion((version) => version + 1);
\n
 
\n
// Reset InfiniteLoader's cache and reload the visible rows
\n
infiniteLoaderRef.current?.resetLoadMoreRowsCache(true);
\n
};
\n
 
\n
const rowRenderer = ({ index, key, style }: RowRendererParams) => (
\n
<div
\n
className=\"flex items-center px-4 border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
{rowStatusMapRef.current.get(index) === \"loaded\" ? (
\n
list[index].name
\n
) : (
\n
<div
\n
className=\"h-3 rounded bg-white/10 animate-pulse\"
\n
style={{ width: list[index].size }}
\n
/>
\n
)}
\n
</div>
\n
);
\n
 
\n
const statuses = Array.from(rowStatusMapRef.current.values());
\n
const loadingCount = statuses.filter((status) => status === \"loading\").length;
\n
const loadedCount = statuses.length - loadingCount;
\n
 
\n
return (
\n
<div className=\"h-full flex flex-col gap-2\">
\n
<div className=\"flex items-center gap-4\">
\n
<button className={buttonClassName} onClick={clearData}>
\n
Flush cached data
\n
</button>
\n
<div className=\"text-sm text-slate-400\">
\n
{loadingCount} loading, {loadedCount} loaded
\n
</div>
\n
</div>
\n
<div className=\"grow min-h-0\">
\n
<InfiniteLoader
\n
isRowLoaded={isRowLoaded}
\n
loadMoreRows={loadMoreRows}
\n
ref={infiniteLoaderRef}
\n
rowCount={list.length}
\n
>
\n
{({ onRowsRendered, registerChild }) => (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
onRowsRendered={onRowsRendered}
\n
ref={registerChild}
\n
rowCount={list.length}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
)}
\n
</InfiniteLoader>
\n
</div>
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/InfiniteLoaderResetCache.json b/public/generated/examples/InfiniteLoaderResetCache.json new file mode 100644 index 000000000..d9ec5cdc8 --- /dev/null +++ b/public/generated/examples/InfiniteLoaderResetCache.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useEffect, useRef } from \"react\";
\n
import {
\n
InfiniteLoader,
\n
List,
\n
type InfiniteLoaderImperativeAPI,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
 
\n
function Example({
\n
isRowLoaded,
\n
items,
\n
loadMoreRows,
\n
rowCount,
\n
searchText
\n
}: {
\n
isRowLoaded: (params: { index: number }) => boolean;
\n
items: ListItem[];
\n
loadMoreRows: (params: {
\n
startIndex: number;
\n
stopIndex: number;
\n
}) => Promise<unknown>;
\n
rowCount: number;
\n
searchText: string;
\n
}) {
\n
const infiniteLoaderRef = useRef<InfiniteLoaderImperativeAPI>(null);
\n
 
\n
// When the search criteria changes, previously loaded rows are no longer valid.
\n
// Clear InfiniteLoader's cache and reload the most recently requested batch of rows.
\n
useEffect(() => {
\n
infiniteLoaderRef.current?.resetLoadMoreRowsCache(true);
\n
}, [searchText]);
\n
 
\n
const rowRenderer = ({ index, key, style }: RowRendererParams) => (
\n
<div key={key} style={style}>
\n
{items[index]?.name ?? \"Loading...\"}
\n
</div>
\n
);
\n
 
\n
return (
\n
<InfiniteLoader
\n
isRowLoaded={isRowLoaded}
\n
loadMoreRows={loadMoreRows}
\n
ref={infiniteLoaderRef}
\n
rowCount={rowCount}
\n
>
\n
{({ onRowsRendered, registerChild }) => (
\n
<List
\n
height={200}
\n
onRowsRendered={onRowsRendered}
\n
ref={registerChild}
\n
rowCount={rowCount}
\n
rowHeight={20}
\n
rowRenderer={rowRenderer}
\n
width={300}
\n
/>
\n
)}
\n
</InfiniteLoader>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/InfiniteLoadingList.json b/public/generated/examples/InfiniteLoadingList.json new file mode 100644 index 000000000..6ee9f20e7 --- /dev/null +++ b/public/generated/examples/InfiniteLoadingList.json @@ -0,0 +1,3 @@ +{ + "html": "
import {
\n
InfiniteLoader,
\n
List,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function InfiniteLoadingList({
\n
hasNextPage,
\n
isNextPageLoading,
\n
items,
\n
loadNextPage
\n
}: {
\n
/** Are there more items to load? (This information comes from the most recent API request.) */
\n
hasNextPage: boolean;
\n
/** Are we currently loading a page of items? (This may be an in-flight flag in your store, for example.) */
\n
isNextPageLoading: boolean;
\n
/** List of items loaded so far */
\n
items: ListItem[];
\n
/** Callback function responsible for loading the next page of items */
\n
loadNextPage: () => Promise<void>;
\n
}) {
\n
// If there are more items to be loaded then add an extra row to hold a loading indicator.
\n
const rowCount = hasNextPage ? items.length + 1 : items.length;
\n
 
\n
// Only load 1 page of items at a time.
\n
// Pass an empty callback to InfiniteLoader in case it asks us to load more than once.
\n
const loadMoreRows = isNextPageLoading ? () => {} : loadNextPage;
\n
 
\n
// Every row is loaded except for our loading indicator row.
\n
const isRowLoaded = ({ index }: { index: number }) =>
\n
!hasNextPage || index < items.length;
\n
 
\n
// Render a list item or a loading indicator.
\n
const rowRenderer = ({ index, key, style }: RowRendererParams) => (
\n
<div
\n
className=\"flex items-center px-4 border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
{isRowLoaded({ index }) ? (
\n
items[index].name
\n
) : (
\n
<span className=\"italic text-slate-500\">Loading...</span>
\n
)}
\n
</div>
\n
);
\n
 
\n
return (
\n
<InfiniteLoader
\n
isRowLoaded={isRowLoaded}
\n
loadMoreRows={loadMoreRows}
\n
rowCount={rowCount}
\n
>
\n
{({ onRowsRendered, registerChild }) => (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
onRowsRendered={onRowsRendered}
\n
ref={registerChild}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
)}
\n
</InfiniteLoader>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/InfiniteLoadingListDemo.json b/public/generated/examples/InfiniteLoadingListDemo.json new file mode 100644 index 000000000..7eece51cd --- /dev/null +++ b/public/generated/examples/InfiniteLoadingListDemo.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
 
\n
const PAGE_SIZE = 25;
\n
const MAX_ITEMS = 250;
\n
 
\n
function InfiniteLoadingListDemo() {
\n
const [items, setItems] = useState<ListItem[]>([]);
\n
const [isNextPageLoading, setIsNextPageLoading] = useState(false);
\n
 
\n
const hasNextPage = items.length < MAX_ITEMS;
\n
 
\n
const loadNextPage = async () => {
\n
setIsNextPageLoading(true);
\n
 
\n
const page = await fetchPage(items.length, PAGE_SIZE);
\n
 
\n
setItems((prevItems) => [...prevItems, ...page]);
\n
setIsNextPageLoading(false);
\n
};
\n
 
\n
return (
\n
<InfiniteLoadingList
\n
hasNextPage={hasNextPage}
\n
isNextPageLoading={isNextPageLoading}
\n
items={items}
\n
loadNextPage={loadNextPage}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ListBasic.json b/public/generated/examples/ListBasic.json new file mode 100644 index 000000000..d3cecc611 --- /dev/null +++ b/public/generated/examples/ListBasic.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List, type RowRendererParams } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
// List data as an array of objects, e.g.
\n
// [{ color: \"#f44336\", name: \"Brian Vaughn\" }, ...]
\n
const list = LIST;
\n
 
\n
function rowRenderer({ index, key, style }: RowRendererParams) {
\n
const item = list[index];
\n
 
\n
// Rows must apply the \"style\" parameter (it positions and sizes the row)
\n
// and the \"key\" parameter (it identifies the row to React).
\n
return (
\n
<div
\n
className=\"flex items-center gap-4 px-4 overflow-hidden border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
<div
\n
className=\"w-8 h-8 shrink-0 rounded-full flex items-center justify-center text-white\"
\n
style={{ backgroundColor: item.color }}
\n
>
\n
{item.name.charAt(0)}
\n
</div>
\n
<div>
\n
<div className=\"font-bold\">{item.name}</div>
\n
<div className=\"text-xs text-slate-400\">This is row {index}</div>
\n
</div>
\n
</div>
\n
);
\n
}
\n
 
\n
function Example() {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
rowCount={list.length}
\n
rowHeight={50}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ListMethods.json b/public/generated/examples/ListMethods.json new file mode 100644 index 000000000..355a6a5b1 --- /dev/null +++ b/public/generated/examples/ListMethods.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef } from \"react\";
\n
import {
\n
List,
\n
type ListImperativeAPI,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function rowRenderer({ index, key, style }: RowRendererParams) {
\n
return (
\n
<div
\n
className=\"flex items-center justify-between px-4 border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
{list[index].name}
\n
<div className=\"text-xs text-slate-500\">Row {index}</div>
\n
</div>
\n
);
\n
}
\n
 
\n
function Example() {
\n
const listRef = useRef<ListImperativeAPI>(null);
\n
 
\n
return (
\n
<div className=\"h-full flex flex-col gap-2\">
\n
<div className=\"flex gap-2\">
\n
<button
\n
className={buttonClassName}
\n
onClick={() => listRef.current?.scrollToRow({ index: 0 })}
\n
>
\n
Scroll to first row
\n
</button>
\n
<button
\n
className={buttonClassName}
\n
onClick={() =>
\n
listRef.current?.scrollToRow({ align: \"center\", index: 500 })
\n
}
\n
>
\n
Center row 500
\n
</button>
\n
<button
\n
className={buttonClassName}
\n
onClick={() =>
\n
listRef.current?.scrollToRow({ index: list.length - 1 })
\n
}
\n
>
\n
Scroll to last row
\n
</button>
\n
</div>
\n
<div className=\"grow\">
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
ref={listRef}
\n
rowCount={list.length}
\n
rowHeight={35}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
</div>
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ListNoRows.json b/public/generated/examples/ListNoRows.json new file mode 100644 index 000000000..25909557c --- /dev/null +++ b/public/generated/examples/ListNoRows.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function noRowsRenderer() {
\n
return (
\n
<div className=\"h-full flex items-center justify-center text-slate-500\">
\n
No rows
\n
</div>
\n
);
\n
}
\n
 
\n
function Example() {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
noRowsRenderer={noRowsRenderer}
\n
rowCount={0}
\n
rowHeight={35}
\n
rowRenderer={() => null}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ListRecomputeRowHeights.json b/public/generated/examples/ListRecomputeRowHeights.json new file mode 100644 index 000000000..f78e56874 --- /dev/null +++ b/public/generated/examples/ListRecomputeRowHeights.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useEffect, useRef } from \"react\";
\n
import {
\n
List,
\n
type ListImperativeAPI,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
const listRef = useRef<ListImperativeAPI>(null);
\n
 
\n
// List only knows about the rowHeight function, not the data behind it,
\n
// so tell it to re-measure rows whenever the underlying data changes.
\n
useEffect(() => {
\n
listRef.current?.recomputeRowHeights();
\n
}, [items]);
\n
 
\n
const rowRenderer = ({ index, key, style }: RowRendererParams) => (
\n
<div key={key} style={style}>
\n
{items[index].name}
\n
</div>
\n
);
\n
 
\n
return (
\n
<List
\n
height={300}
\n
ref={listRef}
\n
rowCount={items.length}
\n
rowHeight={({ index }) => items[index].size}
\n
rowRenderer={rowRenderer}
\n
width={300}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ListRowRenderer.json b/public/generated/examples/ListRowRenderer.json new file mode 100644 index 000000000..c11e33535 --- /dev/null +++ b/public/generated/examples/ListRowRenderer.json @@ -0,0 +1,3 @@ +{ + "html": "
import { type RowRendererParams } from \"react-virtualized\";
\n
 
\n
// The following named parameters are passed to the row renderer:
\n
// index: Index of row
\n
// isScrolling: The List is currently being scrolled
\n
// isVisible: This row is visible within the List (eg it is not an overscanned row)
\n
// key: Unique key within array of rendered rows
\n
// parent: Reference to the parent List (can be passed to CellMeasurer)
\n
// style: Style object to be applied to row (to position it)
\n
function rowRenderer({ index, isScrolling, key, style }: RowRendererParams) {
\n
const user = list[index];
\n
 
\n
// If row content is complex, consider rendering a light-weight placeholder while scrolling.
\n
const content = isScrolling ? \"...\" : user.name;
\n
 
\n
// Style is required since it specifies how the row is to be sized and positioned.
\n
// React Virtualized depends on this sizing/positioning for proper scrolling behavior.
\n
// By default, List provides the following style properties:
\n
// position, left, top, height, width
\n
// You can add additional class names or style properties as you would like.
\n
// Key is also required by React to more efficiently manage the array of rows.
\n
return (
\n
<div key={key} style={style}>
\n
{content}
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ListScrollToRow.json b/public/generated/examples/ListScrollToRow.json new file mode 100644 index 000000000..930da4238 --- /dev/null +++ b/public/generated/examples/ListScrollToRow.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef, useState, type FormEvent } from \"react\";
\n
import {
\n
List,
\n
type Alignment,
\n
type ListImperativeAPI,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function rowRenderer({ index, key, style }: RowRendererParams) {
\n
return (
\n
<div
\n
className=\"flex items-center justify-between px-4 border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
{list[index].name}
\n
<div className=\"text-xs text-slate-500\">Row {index}</div>
\n
</div>
\n
);
\n
}
\n
 
\n
function Example() {
\n
const listRef = useRef<ListImperativeAPI>(null);
\n
 
\n
const [align, setAlign] = useState<Alignment>(\"auto\");
\n
const [index, setIndex] = useState(\"500\");
\n
 
\n
const onSubmit = (event: FormEvent) => {
\n
event.preventDefault();
\n
 
\n
listRef.current?.scrollToRow({ align, index: parseInt(index, 10) });
\n
};
\n
 
\n
return (
\n
<div className=\"h-full flex flex-col gap-2\">
\n
<form className=\"flex flex-wrap items-end gap-2\" onSubmit={onSubmit}>
\n
<label className={LABEL_CLASS_NAME}>
\n
Row
\n
<input
\n
className={INPUT_CLASS_NAME}
\n
max={list.length - 1}
\n
min={0}
\n
onChange={(event) => setIndex(event.target.value)}
\n
required
\n
type=\"number\"
\n
value={index}
\n
/>
\n
</label>
\n
<label className={LABEL_CLASS_NAME}>
\n
Alignment
\n
<select
\n
className={INPUT_CLASS_NAME}
\n
onChange={(event) => setAlign(event.target.value as Alignment)}
\n
value={align}
\n
>
\n
<option value=\"auto\">auto</option>
\n
<option value=\"start\">start</option>
\n
<option value=\"center\">center</option>
\n
<option value=\"end\">end</option>
\n
</select>
\n
</label>
\n
<button className={BUTTON_CLASS_NAME} type=\"submit\">
\n
Scroll
\n
</button>
\n
</form>
\n
<div className=\"grow\">
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
ref={listRef}
\n
rowCount={list.length}
\n
rowHeight={35}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
</div>
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ListScrollingPlaceholder.json b/public/generated/examples/ListScrollingPlaceholder.json new file mode 100644 index 000000000..f2fac5142 --- /dev/null +++ b/public/generated/examples/ListScrollingPlaceholder.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List, type RowRendererParams } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function rowRenderer({ index, isScrolling, key, style }: RowRendererParams) {
\n
if (isScrolling) {
\n
// Render a light-weight placeholder while the list is being scrolled
\n
return (
\n
<div
\n
className=\"flex items-center px-4 border-b border-white/10 italic text-slate-500\"
\n
key={key}
\n
style={style}
\n
>
\n
Scrolling...
\n
</div>
\n
);
\n
}
\n
 
\n
return (
\n
<div
\n
className=\"flex items-center px-4 border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
{list[index].name}
\n
</div>
\n
);
\n
}
\n
 
\n
function Example() {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
rowCount={list.length}
\n
rowHeight={35}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ListVariableRowHeights.json b/public/generated/examples/ListVariableRowHeights.json new file mode 100644 index 000000000..23a1200e2 --- /dev/null +++ b/public/generated/examples/ListVariableRowHeights.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List, type RowRendererParams } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
// Each item specifies its own height (50, 75, or 100 pixels)
\n
const list = LIST;
\n
 
\n
function rowHeight({ index }: { index: number }) {
\n
return list[index].size;
\n
}
\n
 
\n
function rowRenderer({ index, key, style }: RowRendererParams) {
\n
const item = list[index];
\n
 
\n
return (
\n
<div
\n
className=\"flex items-center gap-4 px-4 overflow-hidden border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
<div
\n
className=\"w-8 h-8 shrink-0 rounded-full flex items-center justify-center text-white\"
\n
style={{ backgroundColor: item.color }}
\n
>
\n
{item.name.charAt(0)}
\n
</div>
\n
<div className=\"grow\">
\n
<div className=\"font-bold\">{item.name}</div>
\n
<div className=\"text-xs text-slate-400\">This is row {index}</div>
\n
{item.size >= 75 && (
\n
<div className=\"text-xs text-slate-400\">It is a taller row.</div>
\n
)}
\n
{item.size >= 100 && (
\n
<div className=\"text-xs text-slate-400\">It has a 4th line.</div>
\n
)}
\n
</div>
\n
<div className=\"text-xs text-slate-500\">{item.size}px</div>
\n
</div>
\n
);
\n
}
\n
 
\n
function Example() {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
estimatedRowSize={75}
\n
height={height}
\n
rowCount={list.length}
\n
rowHeight={rowHeight}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/MasonryCellPositioner.json b/public/generated/examples/MasonryCellPositioner.json new file mode 100644 index 000000000..b3d29b9bc --- /dev/null +++ b/public/generated/examples/MasonryCellPositioner.json @@ -0,0 +1,3 @@ +{ + "html": "
import {
\n
CellMeasurerCache,
\n
createMasonryCellPositioner
\n
} from \"react-virtualized\";
\n
 
\n
const cache = new CellMeasurerCache({
\n
defaultHeight: 250,
\n
defaultWidth: 200,
\n
fixedWidth: true
\n
});
\n
 
\n
const cellPositioner = createMasonryCellPositioner({
\n
// Contains cell measurements (eg item height); required
\n
cellMeasurerCache: cache,
\n
 
\n
// Number of columns to use in layout; required
\n
columnCount: 3,
\n
 
\n
// Width of each column; required
\n
columnWidth: 200,
\n
 
\n
// Empty space between columns (and between cells within a column);
\n
// defaults to 0
\n
spacer: 10
\n
});
" +} \ No newline at end of file diff --git a/public/generated/examples/MasonryCellPositionerReset.json b/public/generated/examples/MasonryCellPositionerReset.json new file mode 100644 index 000000000..31817f838 --- /dev/null +++ b/public/generated/examples/MasonryCellPositionerReset.json @@ -0,0 +1,3 @@ +{ + "html": "
// eg in response to a window resize
\n
function onResize({ width }: { width: number }) {
\n
const columnWidth = 250;
\n
const spacer = 15;
\n
 
\n
cellPositioner.reset({
\n
columnCount: Math.floor((width + spacer) / (columnWidth + spacer)),
\n
columnWidth,
\n
spacer
\n
});
\n
 
\n
// Discard cached positions and re-position cells using their cached sizes
\n
masonryRef.current?.recomputeCellPositions();
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/MasonryCustomPositioner.json b/public/generated/examples/MasonryCustomPositioner.json new file mode 100644 index 000000000..fb0ddcb54 --- /dev/null +++ b/public/generated/examples/MasonryCustomPositioner.json @@ -0,0 +1,3 @@ +{ + "html": "
// Places cells in columns round-robin (left to right, in index order)
\n
// rather than always choosing the shortest column
\n
function createRoundRobinPositioner(columnCount: number, columnWidth: number) {
\n
let columnHeights = new Array<number>(columnCount).fill(0);
\n
 
\n
function cellPositioner(index: number) {
\n
const column = index % columnCount;
\n
const left = column * columnWidth;
\n
const top = columnHeights[column];
\n
 
\n
// Sizes have already been measured (and cached) by the time a cell is positioned
\n
columnHeights[column] = top + cache.getHeight(index);
\n
 
\n
return { left, top };
\n
}
\n
 
\n
// recomputeCellPositions() asks for every cell's position again (starting at index 0)
\n
// so the positioner's internal state must be reset before calling it
\n
cellPositioner.reset = () => {
\n
columnHeights = new Array<number>(columnCount).fill(0);
\n
};
\n
 
\n
return cellPositioner;
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/MasonryExample.json b/public/generated/examples/MasonryExample.json new file mode 100644 index 000000000..b85be96e5 --- /dev/null +++ b/public/generated/examples/MasonryExample.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useLayoutEffect, useRef, useState } from \"react\";
\n
import {
\n
CellMeasurer,
\n
CellMeasurerCache,
\n
createMasonryCellPositioner,
\n
Masonry,
\n
type MasonryImperativeAPI
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({
\n
columnWidth,
\n
gutterSize,
\n
items
\n
}: {
\n
columnWidth: number;
\n
gutterSize: number;
\n
items: ListItem[];
\n
}) {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<MasonryLayout
\n
columnWidth={columnWidth}
\n
gutterSize={gutterSize}
\n
height={height}
\n
items={items}
\n
// Changing the column width changes how text wraps (and so the height of each cell);
\n
// the simplest way to handle that is to start over with a fresh cache and positioner
\n
key={columnWidth}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
\n
 
\n
function MasonryLayout({
\n
columnWidth,
\n
gutterSize,
\n
height,
\n
items,
\n
width
\n
}: {
\n
columnWidth: number;
\n
gutterSize: number;
\n
height: number;
\n
items: ListItem[];
\n
width: number;
\n
}) {
\n
const columnCount = Math.max(
\n
1,
\n
Math.floor((width + gutterSize) / (columnWidth + gutterSize))
\n
);
\n
 
\n
// Default sizes help Masonry decide how many cells to batch-measure
\n
const [cache] = useState(
\n
() =>
\n
new CellMeasurerCache({
\n
defaultHeight: 250,
\n
defaultWidth: columnWidth,
\n
fixedWidth: true
\n
})
\n
);
\n
 
\n
// The built-in positioner places each cell in the shortest column
\n
const [cellPositioner] = useState(() =>
\n
createMasonryCellPositioner({
\n
cellMeasurerCache: cache,
\n
columnCount,
\n
columnWidth,
\n
spacer: gutterSize
\n
})
\n
);
\n
 
\n
const masonryRef = useRef<MasonryImperativeAPI>(null);
\n
 
\n
// When the number of columns (or the space between them) changes,
\n
// update the positioner and re-position the (already measured) cells
\n
useLayoutEffect(() => {
\n
cellPositioner.reset({ columnCount, columnWidth, spacer: gutterSize });
\n
masonryRef.current?.recomputeCellPositions();
\n
}, [cellPositioner, columnCount, columnWidth, gutterSize]);
\n
 
\n
return (
\n
<Masonry
\n
cellCount={items.length}
\n
cellMeasurerCache={cache}
\n
cellPositioner={cellPositioner}
\n
cellRenderer={({ index, key, parent, style }) => {
\n
const item = items[index];
\n
 
\n
return (
\n
<CellMeasurer cache={cache} index={index} key={key} parent={parent}>
\n
<div
\n
className=\"flex flex-col gap-2 rounded-lg bg-white/5 p-2 text-sm\"
\n
style={{ ...style, width: columnWidth }}
\n
>
\n
<div
\n
className=\"flex items-center justify-center rounded-md text-xl text-white\"
\n
style={{ backgroundColor: item.color, height: item.size * 2 }}
\n
>
\n
{index}
\n
</div>
\n
{item.random}
\n
</div>
\n
</CellMeasurer>
\n
);
\n
}}
\n
height={height}
\n
ref={masonryRef}
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/MultiColumnSortTable.json b/public/generated/examples/MultiColumnSortTable.json new file mode 100644 index 000000000..8777bf751 --- /dev/null +++ b/public/generated/examples/MultiColumnSortTable.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useMemo, useState } from \"react\";
\n
import {
\n
Column,
\n
createTableMultiSort,
\n
SortIndicator,
\n
Table,
\n
type ColumnProps
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
type SortState = {
\n
sortBy: string[];
\n
sortDirection: { [dataKey: string]: \"ASC\" | \"DESC\" };
\n
};
\n
 
\n
function Example() {
\n
// Sort criteria are stored in React state so that changes re-render the Table
\n
const [{ sortBy, sortDirection }, setSortState] = useState<SortState>({
\n
sortBy: [\"name\"],
\n
sortDirection: { name: \"ASC\" }
\n
});
\n
 
\n
// createTableMultiSort tracks the sort criteria;
\n
// it calls this function (with new sortBy and sortDirection values) whenever they change.
\n
const [multiSort] = useState(() =>
\n
createTableMultiSort(
\n
({ sortBy, sortDirection }) => setSortState({ sortBy, sortDirection }),
\n
{
\n
defaultSortBy: [\"name\"],
\n
defaultSortDirection: { name: \"ASC\" }
\n
}
\n
)
\n
);
\n
 
\n
// Sort the collection by each field, in order of importance
\n
const sortedList = useMemo(
\n
() =>
\n
[...list].sort((a, b) => {
\n
for (const dataKey of sortBy) {
\n
const key = dataKey as keyof ListItem;
\n
const order = a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0;
\n
if (order !== 0) {
\n
return sortDirection[dataKey] === \"DESC\" ? -order : order;
\n
}
\n
}
\n
return 0;
\n
}),
\n
[sortBy, sortDirection]
\n
);
\n
 
\n
// Use the multi-sort state (rather than the Table's sortBy prop) to show indicators
\n
const headerRenderer: ColumnProps[\"headerRenderer\"] = ({
\n
dataKey,
\n
label
\n
}) => {
\n
const position = sortBy.indexOf(dataKey);
\n
return (
\n
<div className=\"flex items-center gap-1\">
\n
<span title={typeof label === \"string\" ? label : undefined}>
\n
{label}
\n
</span>
\n
{position >= 0 && (
\n
<>
\n
<SortIndicator sortDirection={sortDirection[dataKey]} />
\n
{sortBy.length > 1 && (
\n
<span className=\"text-xs text-slate-400\">{position + 1}</span>
\n
)}
\n
</>
\n
)}
\n
</div>
\n
);
\n
};
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Table
\n
headerHeight={36}
\n
height={250}
\n
rowClassName=\"border-b border-white/10\"
\n
rowCount={sortedList.length}
\n
rowGetter={({ index }) => sortedList[index]}
\n
rowHeight={30}
\n
// Connect the multi-sort state to Table by way of the \"sort\" prop
\n
sort={multiSort.sort}
\n
sortBy={undefined}
\n
sortDirection={undefined}
\n
width={width}
\n
>
\n
<Column
\n
dataKey=\"name\"
\n
headerRenderer={headerRenderer}
\n
label=\"Name\"
\n
width={140}
\n
/>
\n
<Column
\n
dataKey=\"size\"
\n
headerRenderer={headerRenderer}
\n
label=\"Size\"
\n
width={100}
\n
/>
\n
<Column
\n
dataKey=\"random\"
\n
flexGrow={1}
\n
headerRenderer={headerRenderer}
\n
label=\"Description\"
\n
width={200}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/MultiColumnSortTableCallback.json b/public/generated/examples/MultiColumnSortTableCallback.json new file mode 100644 index 000000000..e1c21b192 --- /dev/null +++ b/public/generated/examples/MultiColumnSortTableCallback.json @@ -0,0 +1,3 @@ +{ + "html": "
import { createTableMultiSort } from \"react-virtualized\";
\n
 
\n
const sortState = createTableMultiSort(({ sortBy, sortDirection }) => {
\n
// \"sortBy\" is an ordered array of fields, e.g. [\"lastName\", \"firstName\"]
\n
// \"sortDirection\" maps each field to \"ASC\" or \"DESC\", e.g. { lastName: \"ASC\", firstName: \"DESC\" }
\n
// Sort your collection however you'd like,
\n
// then update your component state (or store) to re-render the Table.
\n
console.log(sortBy, sortDirection);
\n
});
\n
 
\n
// sortState.sort: pass this to the Table \"sort\" prop
\n
// sortState.sortBy and sortState.sortDirection: the current sort criteria
" +} \ No newline at end of file diff --git a/public/generated/examples/MultiColumnSortTableDefaults.json b/public/generated/examples/MultiColumnSortTableDefaults.json new file mode 100644 index 000000000..fc6fd40e8 --- /dev/null +++ b/public/generated/examples/MultiColumnSortTableDefaults.json @@ -0,0 +1,3 @@ +{ + "html": "
import { createTableMultiSort } from \"react-virtualized\";
\n
 
\n
const sortState = createTableMultiSort(sortCallback, {
\n
defaultSortBy: [\"firstName\", \"lastName\"],
\n
defaultSortDirection: {
\n
firstName: \"ASC\",
\n
lastName: \"ASC\"
\n
}
\n
});
" +} \ No newline at end of file diff --git a/public/generated/examples/MultiGridFixedRowsAndColumns.json b/public/generated/examples/MultiGridFixedRowsAndColumns.json new file mode 100644 index 000000000..d76a366ff --- /dev/null +++ b/public/generated/examples/MultiGridFixedRowsAndColumns.json @@ -0,0 +1,3 @@ +{ + "html": "
import { MultiGrid, type CellRendererParams } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function cellRenderer({
\n
columnIndex,
\n
key,
\n
rowIndex,
\n
style
\n
}: CellRendererParams) {
\n
return (
\n
<div
\n
className=\"flex items-center justify-center border-r border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
{columnIndex}, {rowIndex}
\n
</div>
\n
);
\n
}
\n
 
\n
function Example({
\n
fixedColumnCount,
\n
fixedRowCount
\n
}: {
\n
fixedColumnCount: number;
\n
fixedRowCount: number;
\n
}) {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<MultiGrid
\n
cellRenderer={cellRenderer}
\n
classNameBottomLeftGrid=\"bg-white/5 border-r-2 border-white/20\"
\n
classNameTopLeftGrid=\"bg-white/10 font-bold border-r-2 border-b-2 border-white/20\"
\n
classNameTopRightGrid=\"bg-white/10 font-bold border-b-2 border-white/20\"
\n
columnCount={50}
\n
columnWidth={75}
\n
enableFixedColumnScroll
\n
enableFixedRowScroll
\n
fixedColumnCount={fixedColumnCount}
\n
fixedRowCount={fixedRowCount}
\n
height={height}
\n
hideBottomLeftGridScrollbar
\n
hideTopRightGridScrollbar
\n
rowCount={100}
\n
rowHeight={40}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/NaturalSortTable.json b/public/generated/examples/NaturalSortTable.json new file mode 100644 index 000000000..82910cae5 --- /dev/null +++ b/public/generated/examples/NaturalSortTable.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useMemo, useState } from \"react\";
\n
import {
\n
Column,
\n
SortDirection,
\n
Table,
\n
type TableProps
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example() {
\n
// Start out unsorted, in the list's \"natural\" order
\n
const [sortBy, setSortBy] = useState<TableProps[\"sortBy\"]>(undefined);
\n
const [sortDirection, setSortDirection] =
\n
useState<TableProps[\"sortDirection\"]>(undefined);
\n
 
\n
const sortedList = useMemo(() => {
\n
if (!sortBy) {
\n
return list; // Natural order
\n
}
\n
 
\n
const key = sortBy as keyof ListItem;
\n
const sorted = [...list].sort((a, b) =>
\n
a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0
\n
);
\n
 
\n
return sortDirection === SortDirection.DESC ? sorted.reverse() : sorted;
\n
}, [sortBy, sortDirection]);
\n
 
\n
const sort: TableProps[\"sort\"] = (params) => {
\n
if (sortBy === params.sortBy && sortDirection === SortDirection.DESC) {
\n
// If the list was sorted DESC by this column,
\n
// return to the \"natural\" order rather than switching to ASC.
\n
setSortBy(undefined);
\n
setSortDirection(undefined);
\n
} else {
\n
setSortBy(params.sortBy);
\n
setSortDirection(params.sortDirection);
\n
}
\n
};
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Table
\n
headerHeight={36}
\n
height={250}
\n
rowClassName=\"border-b border-white/10\"
\n
rowCount={sortedList.length}
\n
rowGetter={({ index }) => sortedList[index]}
\n
rowHeight={30}
\n
sort={sort}
\n
sortBy={sortBy}
\n
sortDirection={sortDirection}
\n
width={width}
\n
>
\n
<Column dataKey=\"index\" label=\"Index\" width={80} />
\n
<Column dataKey=\"name\" label=\"Name\" width={120} />
\n
<Column
\n
dataKey=\"random\"
\n
flexGrow={1}
\n
label=\"Description\"
\n
width={200}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/OverscanDemo.json b/public/generated/examples/OverscanDemo.json new file mode 100644 index 000000000..1e99a4c59 --- /dev/null +++ b/public/generated/examples/OverscanDemo.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Grid, type RenderedSection } from \"react-virtualized\";
\n
 
\n
function OverscanDemo({
\n
onSectionRendered,
\n
overscanIndicesGetter,
\n
overscanRowCount,
\n
width
\n
}: {
\n
onSectionRendered: (section: RenderedSection) => void;
\n
overscanIndicesGetter: OverscanIndicesGetter;
\n
overscanRowCount: number;
\n
width: number;
\n
}) {
\n
return (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={2}
\n
columnWidth={width / 2}
\n
height={200}
\n
// Reports both the visible and the rendered (overscanned) ranges
\n
onSectionRendered={onSectionRendered}
\n
overscanIndicesGetter={overscanIndicesGetter}
\n
overscanRowCount={overscanRowCount}
\n
rowCount={1000}
\n
rowHeight={30}
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ReverseList.json b/public/generated/examples/ReverseList.json new file mode 100644 index 000000000..3ee6275a5 --- /dev/null +++ b/public/generated/examples/ReverseList.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useEffect, useRef, useState } from \"react\";
\n
import {
\n
List,
\n
type ListImperativeAPI,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
const MAX_ITEMS = 100;
\n
 
\n
function ReverseList({ paused }: { paused: boolean }) {
\n
const listRef = useRef<ListImperativeAPI>(null);
\n
const [items, setItems] = useState<Item[]>([]);
\n
 
\n
useEffect(() => {
\n
if (paused) {
\n
return;
\n
}
\n
 
\n
const interval = setInterval(() => {
\n
// Add new items to the front of the list (instead of the end)
\n
setItems((prevItems) => [createItem(), ...prevItems].slice(0, MAX_ITEMS));
\n
}, 500);
\n
 
\n
return () => clearInterval(interval);
\n
}, [paused]);
\n
 
\n
useEffect(() => {
\n
// If you want to scroll to the top when items are added, you can do it like this
\n
listRef.current?.scrollToRow({ index: 0 });
\n
}, [items]);
\n
 
\n
const rowRenderer = ({ index, key, style }: RowRendererParams) => {
\n
const item = items[index];
\n
 
\n
return (
\n
<div
\n
className=\"flex items-center gap-4 px-4 overflow-hidden border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
<div
\n
className=\"w-8 h-8 shrink-0 rounded-full flex items-center justify-center text-white\"
\n
style={{ backgroundColor: item.color }}
\n
>
\n
{item.name.charAt(0)}
\n
</div>
\n
<div className=\"min-w-0\">
\n
<div className=\"font-bold\">{item.name}</div>
\n
<div className=\"text-xs text-slate-400 truncate\">
\n
{item.createdAt.toLocaleTimeString()} &middot; {item.random}
\n
</div>
\n
</div>
\n
</div>
\n
);
\n
};
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
ref={listRef}
\n
rowCount={items.length}
\n
rowHeight={60}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ScrollSyncSpreadsheet.json b/public/generated/examples/ScrollSyncSpreadsheet.json new file mode 100644 index 000000000..7e6b0aec3 --- /dev/null +++ b/public/generated/examples/ScrollSyncSpreadsheet.json @@ -0,0 +1,3 @@ +{ + "html": "
import scrollbarSize from \"dom-helpers/scrollbarSize\";
\n
import { Grid, ScrollSync } from \"react-virtualized\";
\n
 
\n
const COLUMN_COUNT = 50;
\n
const COLUMN_WIDTH = 75;
\n
const HEIGHT = 300;
\n
const ROW_COUNT = 100;
\n
const ROW_HEIGHT = 40;
\n
 
\n
function ScrollSyncSpreadsheet({ width }: { width: number }) {
\n
const bodyWidth = width - COLUMN_WIDTH;
\n
 
\n
return (
\n
<ScrollSync>
\n
{({
\n
clientHeight,
\n
clientWidth,
\n
onScroll,
\n
registerChild,
\n
scrollHeight,
\n
scrollLeft,
\n
scrollTop,
\n
scrollWidth
\n
}) => {
\n
// Scroll values can also be used to drive other UI, such as these background colors
\n
const x = scrollLeft / Math.max(1, scrollWidth - clientWidth);
\n
const y = scrollTop / Math.max(1, scrollHeight - clientHeight);
\n
 
\n
return (
\n
<div className=\"flex\">
\n
<div style={{ width: COLUMN_WIDTH }}>
\n
{/* Fixed top-left cell */}
\n
<div
\n
className=\"flex items-center justify-center border-r border-b border-white/10\"
\n
style={{ height: ROW_HEIGHT }}
\n
/>
\n
{/* Fixed first column; ScrollSync scrolls it vertically with the body */}
\n
<Grid
\n
cellRenderer={leftCellRenderer}
\n
className=\"overflow-hidden! shadow-[inset_-1px_0_0_rgb(255_255_255/0.1)]\"
\n
columnCount={1}
\n
columnWidth={COLUMN_WIDTH}
\n
height={HEIGHT - scrollbarSize()}
\n
ref={registerChild(\"left\")}
\n
rowCount={ROW_COUNT}
\n
rowHeight={ROW_HEIGHT}
\n
style={{ backgroundColor: `rgb(188 57 89 / ${0.2 + y * 0.6})` }}
\n
tabIndex={null}
\n
width={COLUMN_WIDTH}
\n
/>
\n
</div>
\n
<div>
\n
{/* Fixed header row; ScrollSync scrolls it horizontally with the body */}
\n
<Grid
\n
cellRenderer={headerCellRenderer}
\n
className=\"overflow-hidden! shadow-[inset_0_-1px_0_rgb(255_255_255/0.1)]\"
\n
columnCount={COLUMN_COUNT}
\n
columnWidth={COLUMN_WIDTH}
\n
height={ROW_HEIGHT}
\n
ref={registerChild(\"header\")}
\n
rowCount={1}
\n
rowHeight={ROW_HEIGHT}
\n
style={{ backgroundColor: `rgb(71 16 97 / ${0.2 + x * 0.6})` }}
\n
tabIndex={null}
\n
width={bodyWidth - scrollbarSize()}
\n
/>
\n
{/* Scrollable body; its onScroll drives the other two grids */}
\n
<Grid
\n
cellRenderer={bodyCellRenderer}
\n
columnCount={COLUMN_COUNT}
\n
columnWidth={COLUMN_WIDTH}
\n
height={HEIGHT}
\n
onScroll={onScroll}
\n
ref={registerChild(\"body\")}
\n
rowCount={ROW_COUNT}
\n
rowHeight={ROW_HEIGHT}
\n
width={bodyWidth}
\n
/>
\n
</div>
\n
</div>
\n
);
\n
}}
\n
</ScrollSync>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ServerRenderingInitialScroll.json b/public/generated/examples/ServerRenderingInitialScroll.json new file mode 100644 index 000000000..6ac5255c8 --- /dev/null +++ b/public/generated/examples/ServerRenderingInitialScroll.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List } from \"react-virtualized\";
\n
 
\n
function Example({
\n
rowCount,
\n
rowRenderer,
\n
savedScrollTop
\n
}: {
\n
rowCount: number;
\n
rowRenderer: RowRenderer;
\n
// E.g. restored from the URL or from session storage
\n
savedScrollTop: number;
\n
}) {
\n
return (
\n
<List
\n
height={300}
\n
// Rendered (on the server too) and scrolled to when the List mounts;
\n
// changing it later has no effect
\n
initialScrollTop={savedScrollTop}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={600}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/ServerRenderingSizes.json b/public/generated/examples/ServerRenderingSizes.json new file mode 100644 index 000000000..9f66d85e3 --- /dev/null +++ b/public/generated/examples/ServerRenderingSizes.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
// Sizes to render with on the server (e.g. a typical viewport),
\n
// before AutoSizer has measured the available space
\n
const SERVER_HEIGHT = 600;
\n
const SERVER_WIDTH = 800;
\n
 
\n
function Example({
\n
rowCount,
\n
rowRenderer
\n
}: {
\n
rowCount: number;
\n
rowRenderer: RowRenderer;
\n
}) {
\n
return (
\n
<AutoSizer
\n
renderProp={({ height = SERVER_HEIGHT, width = SERVER_WIDTH }) => (
\n
<List
\n
height={height}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
)}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/SortIndicatorHeader.json b/public/generated/examples/SortIndicatorHeader.json new file mode 100644 index 000000000..e47c52317 --- /dev/null +++ b/public/generated/examples/SortIndicatorHeader.json @@ -0,0 +1,3 @@ +{ + "html": "
import { SortIndicator, type ColumnProps } from \"react-virtualized\";
\n
 
\n
const headerRenderer: ColumnProps[\"headerRenderer\"] = ({
\n
dataKey,
\n
label,
\n
sortBy,
\n
sortDirection
\n
}) => (
\n
<div className=\"flex items-center gap-1\">
\n
<span className=\"italic\">{label}</span>
\n
{sortBy === dataKey && <SortIndicator sortDirection={sortDirection} />}
\n
</div>
\n
);
" +} \ No newline at end of file diff --git a/public/generated/examples/SortTableBasic.json b/public/generated/examples/SortTableBasic.json new file mode 100644 index 000000000..f2c05141c --- /dev/null +++ b/public/generated/examples/SortTableBasic.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useMemo, useState } from \"react\";
\n
import {
\n
Column,
\n
SortDirection,
\n
Table,
\n
type TableProps
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example() {
\n
const [sortBy, setSortBy] = useState<TableProps[\"sortBy\"]>(\"index\");
\n
const [sortDirection, setSortDirection] = useState<
\n
TableProps[\"sortDirection\"]
\n
>(SortDirection.ASC);
\n
 
\n
// Table does not sort data itself; it only tells you how to sort it
\n
const sortedList = useMemo(
\n
() => sortList(list, sortBy, sortDirection),
\n
[sortBy, sortDirection]
\n
);
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Table
\n
headerHeight={36}
\n
height={250}
\n
rowClassName=\"border-b border-white/10\"
\n
rowCount={sortedList.length}
\n
rowGetter={({ index }) => sortedList[index]}
\n
rowHeight={30}
\n
sort={({ sortBy, sortDirection }) => {
\n
setSortBy(sortBy);
\n
setSortDirection(sortDirection);
\n
}}
\n
sortBy={sortBy}
\n
sortDirection={sortDirection}
\n
width={width}
\n
>
\n
<Column dataKey=\"index\" label=\"Index\" width={80} />
\n
<Column
\n
dataKey=\"name\"
\n
// Sort descending when this column is first clicked
\n
defaultSortDirection={SortDirection.DESC}
\n
label=\"Name\"
\n
width={120}
\n
/>
\n
<Column
\n
dataKey=\"random\"
\n
// This column can't be sorted
\n
disableSort
\n
flexGrow={1}
\n
label=\"Description\"
\n
width={200}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
\n
 
\n
function sortList(
\n
list: ListItem[],
\n
sortBy: TableProps[\"sortBy\"],
\n
sortDirection: TableProps[\"sortDirection\"]
\n
) {
\n
if (!sortBy) {
\n
return list;
\n
}
\n
 
\n
const key = sortBy as keyof ListItem;
\n
const sorted = [...list].sort((a, b) =>
\n
a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0
\n
);
\n
 
\n
return sortDirection === SortDirection.DESC ? sorted.reverse() : sorted;
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/TableBasic.json b/public/generated/examples/TableBasic.json new file mode 100644 index 000000000..13298b6b8 --- /dev/null +++ b/public/generated/examples/TableBasic.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Column, Table } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
// Table data is an array of objects, e.g.
\n
// const list = [{ name: \"Brian Vaughn\", random: \"Software engineer\" }, ...];
\n
 
\n
function Example() {
\n
return (
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Table
\n
headerHeight={30}
\n
height={250}
\n
rowCount={list.length}
\n
rowGetter={({ index }) => list[index]}
\n
rowHeight={30}
\n
width={width}
\n
>
\n
<Column dataKey=\"name\" label=\"Name\" width={150} />
\n
<Column
\n
dataKey=\"random\"
\n
flexGrow={1}
\n
label=\"Description\"
\n
width={200}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/TableColumnRenderers.json b/public/generated/examples/TableColumnRenderers.json new file mode 100644 index 000000000..404ad4a74 --- /dev/null +++ b/public/generated/examples/TableColumnRenderers.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Column, defaultTableHeaderRenderer, Table } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example() {
\n
return (
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Table
\n
headerHeight={36}
\n
height={250}
\n
rowClassName=\"border-b border-white/10\"
\n
rowCount={list.length}
\n
rowGetter={({ index }) => list[index]}
\n
rowHeight={40}
\n
width={width}
\n
>
\n
<Column
\n
// Render a custom cell (a colored badge)
\n
cellRenderer={({ cellData, rowData }) => (
\n
<div
\n
className=\"w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold text-white\"
\n
style={{ backgroundColor: rowData.color }}
\n
>
\n
{cellData}
\n
</div>
\n
)}
\n
// Derive the cell's data from the row (rather than reading rowData[dataKey])
\n
cellDataGetter={({ rowData }) => rowData.name.charAt(0)}
\n
dataKey=\"initial\"
\n
label=\"\"
\n
width={30}
\n
/>
\n
<Column dataKey=\"name\" label=\"Name\" width={120} />
\n
<Column
\n
dataKey=\"random\"
\n
flexGrow={1}
\n
// Decorate the default header (label and sort indicator)
\n
headerRenderer={(props) => (
\n
<span className=\"text-sky-300\">
\n
{defaultTableHeaderRenderer(props)}
\n
</span>
\n
)}
\n
label=\"Description\"
\n
width={200}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/TableFullFeatured.json b/public/generated/examples/TableFullFeatured.json new file mode 100644 index 000000000..b6e0a84e2 --- /dev/null +++ b/public/generated/examples/TableFullFeatured.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useMemo, useState } from \"react\";
\n
import {
\n
Column,
\n
SortDirection,
\n
SortIndicator,
\n
Table,
\n
type ColumnProps,
\n
type TableProps
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({
\n
disableHeader,
\n
hideIndexColumn,
\n
list,
\n
useDynamicRowHeight
\n
}: {
\n
disableHeader: boolean;
\n
hideIndexColumn: boolean;
\n
list: ListItem[];
\n
useDynamicRowHeight: boolean;
\n
}) {
\n
const [sortBy, setSortBy] = useState(\"index\");
\n
const [sortDirection, setSortDirection] = useState<
\n
TableProps[\"sortDirection\"]
\n
>(SortDirection.ASC);
\n
 
\n
const sortedList = useMemo(() => {
\n
const key = sortBy as keyof ListItem;
\n
const sorted = [...list].sort((a, b) =>
\n
a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0
\n
);
\n
return sortDirection === SortDirection.DESC ? sorted.reverse() : sorted;
\n
}, [list, sortBy, sortDirection]);
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Table
\n
disableHeader={disableHeader}
\n
headerClassName=\"normal-case\"
\n
headerHeight={36}
\n
height={300}
\n
noRowsRenderer={() => (
\n
<div className=\"h-full flex items-center justify-center text-slate-400\">
\n
No rows
\n
</div>
\n
)}
\n
overscanRowCount={10}
\n
rowClassName={({ index }) =>
\n
index < 0
\n
? \"border-b border-white/20 bg-white/5\"
\n
: index % 2 === 0
\n
? \"border-b border-white/10\"
\n
: \"border-b border-white/10 bg-white/5\"
\n
}
\n
rowCount={sortedList.length}
\n
rowGetter={({ index }) => sortedList[index]}
\n
rowHeight={
\n
useDynamicRowHeight ? ({ index }) => sortedList[index].size : 40
\n
}
\n
sort={({ sortBy, sortDirection }) => {
\n
setSortBy(sortBy);
\n
setSortDirection(sortDirection);
\n
}}
\n
sortBy={sortBy}
\n
sortDirection={sortDirection}
\n
width={width}
\n
>
\n
{/* Falsy children are ignored, so columns can be shown or hidden */}
\n
{!hideIndexColumn && (
\n
<Column dataKey=\"index\" label=\"Index\" width={80} />
\n
)}
\n
<Column
\n
dataKey=\"name\"
\n
headerRenderer={fullNameHeaderRenderer}
\n
width={120}
\n
/>
\n
<Column
\n
cellRenderer={({ cellData }) => (
\n
<span className=\"text-slate-300\">{cellData}</span>
\n
)}
\n
className=\"truncate\"
\n
dataKey=\"random\"
\n
disableSort
\n
flexGrow={1}
\n
label=\"The description label is really long so that it will be truncated\"
\n
width={210}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
\n
 
\n
const fullNameHeaderRenderer: ColumnProps[\"headerRenderer\"] = ({
\n
dataKey,
\n
sortBy,
\n
sortDirection
\n
}) => {
\n
return (
\n
<div className=\"flex items-center\">
\n
Full Name
\n
{sortBy === dataKey && <SortIndicator sortDirection={sortDirection} />}
\n
</div>
\n
);
\n
};
" +} \ No newline at end of file diff --git a/public/generated/examples/TableImperativeMethods.json b/public/generated/examples/TableImperativeMethods.json new file mode 100644 index 000000000..8008116fe --- /dev/null +++ b/public/generated/examples/TableImperativeMethods.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef, useState, type FormEvent } from \"react\";
\n
import {
\n
Column,
\n
Table,
\n
type Alignment,
\n
type TableImperativeAPI
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example() {
\n
const tableRef = useRef<TableImperativeAPI>(null);
\n
 
\n
const [align, setAlign] = useState<Alignment>(\"center\");
\n
const [index, setIndex] = useState(\"500\");
\n
 
\n
const onSubmit = (event: FormEvent) => {
\n
event.preventDefault();
\n
 
\n
tableRef.current?.scrollToRow({ align, index: parseInt(index, 10) });
\n
};
\n
 
\n
const scrollToTop = () => {
\n
tableRef.current?.scrollToPosition({ scrollTop: 0 });
\n
};
\n
 
\n
return (
\n
<div className=\"flex flex-col gap-2\">
\n
<form className=\"flex flex-wrap items-end gap-2\" onSubmit={onSubmit}>
\n
<label className={LABEL_CLASS_NAME}>
\n
Row
\n
<input
\n
className={INPUT_CLASS_NAME}
\n
max={list.length - 1}
\n
min={0}
\n
onChange={(event) => setIndex(event.target.value)}
\n
required
\n
type=\"number\"
\n
value={index}
\n
/>
\n
</label>
\n
<label className={LABEL_CLASS_NAME}>
\n
Alignment
\n
<select
\n
className={INPUT_CLASS_NAME}
\n
onChange={(event) => setAlign(event.target.value as Alignment)}
\n
value={align}
\n
>
\n
<option value=\"auto\">auto</option>
\n
<option value=\"start\">start</option>
\n
<option value=\"center\">center</option>
\n
<option value=\"end\">end</option>
\n
</select>
\n
</label>
\n
<button className={BUTTON_CLASS_NAME} type=\"submit\">
\n
Scroll
\n
</button>
\n
<button
\n
className={BUTTON_CLASS_NAME}
\n
onClick={scrollToTop}
\n
type=\"button\"
\n
>
\n
Scroll to top
\n
</button>
\n
</form>
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Table
\n
headerHeight={30}
\n
height={200}
\n
ref={tableRef}
\n
rowClassName=\"border-b border-white/10\"
\n
rowCount={list.length}
\n
rowGetter={({ index }) => list[index]}
\n
rowHeight={30}
\n
width={width}
\n
>
\n
<Column dataKey=\"index\" label=\"Index\" width={60} />
\n
<Column dataKey=\"name\" flexGrow={1} label=\"Name\" width={120} />
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/TableRowRenderer.json b/public/generated/examples/TableRowRenderer.json new file mode 100644 index 000000000..9bad09381 --- /dev/null +++ b/public/generated/examples/TableRowRenderer.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import {
\n
Column,
\n
defaultTableRowRenderer,
\n
Table,
\n
type TableProps
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example() {
\n
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
\n
 
\n
// Decorate the default row renderer rather than re-implementing it;
\n
// it takes care of event handlers and ARIA attributes.
\n
const rowRenderer: TableProps[\"rowRenderer\"] = (props) =>
\n
defaultTableRowRenderer({
\n
...props,
\n
className:
\n
props.index === selectedIndex
\n
? `${props.className} bg-sky-500/30`
\n
: props.className
\n
});
\n
 
\n
// The header row can be customized in the same way
\n
const headerRowRenderer: TableProps[\"headerRowRenderer\"] = ({
\n
className,
\n
columns,
\n
style
\n
}) => (
\n
<div
\n
className={`${className} bg-sky-950 text-sky-200`}
\n
role=\"row\"
\n
style={style}
\n
>
\n
{columns}
\n
</div>
\n
);
\n
 
\n
return (
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Table
\n
headerHeight={36}
\n
headerRowRenderer={headerRowRenderer}
\n
height={250}
\n
onRowClick={({ index }) => setSelectedIndex(index)}
\n
rowClassName=\"border-b border-white/10 cursor-pointer\"
\n
rowCount={list.length}
\n
rowGetter={({ index }) => list[index]}
\n
rowHeight={36}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
>
\n
<Column dataKey=\"index\" label=\"Index\" width={60} />
\n
<Column dataKey=\"name\" label=\"Name\" width={120} />
\n
<Column
\n
dataKey=\"random\"
\n
flexGrow={1}
\n
label=\"Description\"
\n
width={200}
\n
/>
\n
</Table>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/UsingAutoSizerChildComponent.json b/public/generated/examples/UsingAutoSizerChildComponent.json new file mode 100644 index 000000000..8faf711b3 --- /dev/null +++ b/public/generated/examples/UsingAutoSizerChildComponent.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List, type RowRendererParams } from \"react-virtualized\";
\n
import {
\n
AutoSizer,
\n
type AutoSizerChildProps
\n
} from \"react-virtualized-auto-sizer\";
\n
 
\n
function rowRenderer({ index, key, style }: RowRendererParams) {
\n
return (
\n
<div className=\"flex items-center px-2\" key={key} style={style}>
\n
{items[index].name}
\n
</div>
\n
);
\n
}
\n
 
\n
// Define the child component outside of render so that it isn't re-created
\n
// (use renderProp instead if the list needs access to local state or props)
\n
function SizedList({ height, width }: AutoSizerChildProps) {
\n
if (height === undefined || width === undefined) {
\n
// Not measured yet (e.g. the first render, or during server rendering)
\n
return null;
\n
}
\n
 
\n
return (
\n
<List
\n
height={height}
\n
rowCount={items.length}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
);
\n
}
\n
 
\n
function Example() {
\n
return <AutoSizer ChildComponent={SizedList} />;
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/UsingAutoSizerFlexContainer.json b/public/generated/examples/UsingAutoSizerFlexContainer.json new file mode 100644 index 000000000..fef3c02fd --- /dev/null +++ b/public/generated/examples/UsingAutoSizerFlexContainer.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({
\n
rowCount,
\n
rowRenderer
\n
}: {
\n
rowCount: number;
\n
rowRenderer: RowRenderer;
\n
}) {
\n
return (
\n
<div style={{ display: \"flex\", height: \"100%\" }}>
\n
{/* Other children... */}
\n
{/* AutoSizer measures this element; min-width/min-height let it shrink below the list's size */}
\n
<div style={{ flex: \"1 1 auto\", minHeight: 0, minWidth: 0 }}>
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
</div>
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/UsingAutoSizerInfiniteLoader.json b/public/generated/examples/UsingAutoSizerInfiniteLoader.json new file mode 100644 index 000000000..7d635ff12 --- /dev/null +++ b/public/generated/examples/UsingAutoSizerInfiniteLoader.json @@ -0,0 +1,3 @@ +{ + "html": "
import { InfiniteLoader, List } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({
\n
isRowLoaded,
\n
loadMoreRows,
\n
rowCount,
\n
rowRenderer
\n
}: {
\n
isRowLoaded: (params: { index: number }) => boolean;
\n
loadMoreRows: (params: {
\n
startIndex: number;
\n
stopIndex: number;
\n
}) => Promise<unknown>;
\n
rowCount: number;
\n
rowRenderer: RowRenderer;
\n
}) {
\n
return (
\n
<InfiniteLoader
\n
isRowLoaded={isRowLoaded}
\n
loadMoreRows={loadMoreRows}
\n
rowCount={rowCount}
\n
>
\n
{({ onRowsRendered, registerChild }) => (
\n
<AutoSizer
\n
renderProp={({ height, width }) =>
\n
height !== undefined && width !== undefined ? (
\n
<List
\n
height={height}
\n
onRowsRendered={onRowsRendered}
\n
ref={registerChild}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
)}
\n
</InfiniteLoader>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/UsingAutoSizerWidthOnly.json b/public/generated/examples/UsingAutoSizerWidthOnly.json new file mode 100644 index 000000000..df697661f --- /dev/null +++ b/public/generated/examples/UsingAutoSizerWidthOnly.json @@ -0,0 +1,3 @@ +{ + "html": "
import { List } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({
\n
rowCount,
\n
rowRenderer
\n
}: {
\n
rowCount: number;
\n
rowRenderer: RowRenderer;
\n
}) {
\n
return (
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
// Only the width is used; the list has a fixed height
\n
width !== undefined ? (
\n
<List
\n
height={200}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingContentAbove.json b/public/generated/examples/WindowScrollingContentAbove.json new file mode 100644 index 000000000..bfbbe8753 --- /dev/null +++ b/public/generated/examples/WindowScrollingContentAbove.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useLayoutEffect, useRef, useState } from \"react\";
\n
import { List, type ListImperativeAPI } from \"react-virtualized\";
\n
 
\n
function Example({
\n
rowCount,
\n
rowRenderer
\n
}: {
\n
rowCount: number;
\n
rowRenderer: RowRenderer;
\n
}) {
\n
const listRef = useRef<ListImperativeAPI>(null);
\n
const [showBanner, setShowBanner] = useState(true);
\n
 
\n
// The banner above the list changed size, but the List's props didn't
\n
// (so it doesn't re-render); re-render it to re-measure its position
\n
useLayoutEffect(() => {
\n
listRef.current?.forceUpdateGrid();
\n
}, [showBanner]);
\n
 
\n
return (
\n
<>
\n
{showBanner && (
\n
<div onClick={() => setShowBanner(false)}>Click to dismiss</div>
\n
)}
\n
<List
\n
ref={listRef}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
scrollElement=\"window\"
\n
width={600}
\n
/>
\n
</>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingGrid.json b/public/generated/examples/WindowScrollingGrid.json new file mode 100644 index 000000000..c120d5aaa --- /dev/null +++ b/public/generated/examples/WindowScrollingGrid.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Grid } from \"react-virtualized\";
\n
 
\n
function Example({
\n
cellRenderer,
\n
width
\n
}: {
\n
cellRenderer: CellRenderer;
\n
width: number;
\n
}) {
\n
return (
\n
<Grid
\n
cellRenderer={cellRenderer}
\n
columnCount={20}
\n
columnWidth={150}
\n
rowCount={1000}
\n
rowHeight={30}
\n
// The window scrolls the Grid vertically;
\n
// columns wider than the width still scroll horizontally within the Grid
\n
scrollElement=\"window\"
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingList.json b/public/generated/examples/WindowScrollingList.json new file mode 100644 index 000000000..624675b89 --- /dev/null +++ b/public/generated/examples/WindowScrollingList.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef } from \"react\";
\n
import { List, type ListImperativeAPI } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({
\n
rowCount,
\n
rowRenderer
\n
}: {
\n
rowCount: number;
\n
rowRenderer: RowRenderer;
\n
}) {
\n
// A regular ref; the List's scroll methods scroll the window
\n
const listRef = useRef<ListImperativeAPI>(null);
\n
 
\n
return (
\n
// Only the width is measured; the height is that of the window
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<List
\n
ref={listRef}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
scrollElement=\"window\"
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingMasonry.json b/public/generated/examples/WindowScrollingMasonry.json new file mode 100644 index 000000000..bc00e2cce --- /dev/null +++ b/public/generated/examples/WindowScrollingMasonry.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef } from \"react\";
\n
import { Masonry, type MasonryImperativeAPI } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ cellCount }: { cellCount: number }) {
\n
const masonryRef = useRef<MasonryImperativeAPI>(null);
\n
 
\n
// When the available width changes, update the number of columns
\n
const onResize = ({ width }: { width: number }) => {
\n
const columnWidth = 200;
\n
const spacer = 10;
\n
 
\n
cellPositioner.reset({
\n
columnCount: Math.max(
\n
1,
\n
Math.floor((width + spacer) / (columnWidth + spacer))
\n
),
\n
columnWidth,
\n
spacer
\n
});
\n
masonryRef.current?.recomputeCellPositions();
\n
};
\n
 
\n
return (
\n
<AutoSizer
\n
onResize={onResize}
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<Masonry
\n
cellCount={cellCount}
\n
cellMeasurerCache={cache}
\n
cellPositioner={cellPositioner}
\n
cellRenderer={cellRenderer}
\n
ref={masonryRef}
\n
// The page (rather than the Masonry) scrolls
\n
scrollElement=\"window\"
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingMigrationAfter.json b/public/generated/examples/WindowScrollingMigrationAfter.json new file mode 100644 index 000000000..4cc21c4f9 --- /dev/null +++ b/public/generated/examples/WindowScrollingMigrationAfter.json @@ -0,0 +1,3 @@ +{ + "html": "
// react-virtualized 10
\n
import { List } from \"react-virtualized\";
\n
 
\n
function Example() {
\n
return (
\n
<List
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
scrollElement=\"window\"
\n
width={width}
\n
/>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingMigrationBefore.json b/public/generated/examples/WindowScrollingMigrationBefore.json new file mode 100644 index 000000000..cacd343da --- /dev/null +++ b/public/generated/examples/WindowScrollingMigrationBefore.json @@ -0,0 +1,3 @@ +{ + "html": "
// react-virtualized 9.x
\n
function Example() {
\n
return (
\n
<WindowScroller>
\n
{({ height, isScrolling, onChildScroll, registerChild, scrollTop }) => (
\n
<div ref={registerChild}>
\n
<List
\n
autoHeight
\n
height={height}
\n
isScrolling={isScrolling}
\n
onScroll={onChildScroll}
\n
rowCount={rowCount}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
scrollTop={scrollTop}
\n
width={width}
\n
/>
\n
</div>
\n
)}
\n
</WindowScroller>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingScrollElement.json b/public/generated/examples/WindowScrollingScrollElement.json new file mode 100644 index 000000000..76ddf275d --- /dev/null +++ b/public/generated/examples/WindowScrollingScrollElement.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useState } from \"react\";
\n
import { List, type RowRendererParams } from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
// The List needs the scrollable element itself,
\n
// so store it in state (rather than a ref) to re-render once it's mounted;
\n
// until then it's null, and the List renders its first rows
\n
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(
\n
null
\n
);
\n
 
\n
const rowRenderer = ({ index, key, style }: RowRendererParams) => (
\n
<div
\n
className=\"flex items-center px-3 border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
{items[index].name}
\n
</div>
\n
);
\n
 
\n
return (
\n
<div className=\"h-full overflow-auto\" ref={setScrollElement}>
\n
<div className=\"p-3 mb-2 rounded bg-white/10\">Content above the list</div>
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<List
\n
rowCount={items.length}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
scrollElement={scrollElement}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingScrollToRow.json b/public/generated/examples/WindowScrollingScrollToRow.json new file mode 100644 index 000000000..2841c0a35 --- /dev/null +++ b/public/generated/examples/WindowScrollingScrollToRow.json @@ -0,0 +1,3 @@ +{ + "html": "
import { useRef, useState } from \"react\";
\n
import {
\n
List,
\n
type ListImperativeAPI,
\n
type RowRendererParams
\n
} from \"react-virtualized\";
\n
import { AutoSizer } from \"react-virtualized-auto-sizer\";
\n
 
\n
function Example({ items }: { items: ListItem[] }) {
\n
// This example scrolls a container (see \"Scrolling an element\");
\n
// with scrollElement=\"window\", the List's methods scroll the window instead
\n
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(
\n
null
\n
);
\n
 
\n
// A regular ref, to call the List's methods
\n
const listRef = useRef<ListImperativeAPI>(null);
\n
 
\n
const rowRenderer = ({ index, key, style }: RowRendererParams) => (
\n
<div
\n
className=\"flex items-center gap-2 px-3 border-b border-white/10\"
\n
key={key}
\n
style={style}
\n
>
\n
<span className=\"w-10 text-right text-slate-400\">{index}</span>
\n
{items[index].name}
\n
</div>
\n
);
\n
 
\n
return (
\n
<div className=\"flex flex-col gap-2 h-full\">
\n
<div className=\"flex flex-wrap gap-2\">
\n
<button
\n
className=\"px-2 rounded bg-white/10\"
\n
onClick={() =>
\n
listRef.current?.scrollToRow({ align: \"center\", index: 500 })
\n
}
\n
>
\n
Scroll to row 500
\n
</button>
\n
<button
\n
className=\"px-2 rounded bg-white/10\"
\n
onClick={() =>
\n
listRef.current?.scrollToRow({ align: \"start\", index: 0 })
\n
}
\n
>
\n
Scroll to the first row
\n
</button>
\n
</div>
\n
<div className=\"grow min-h-0 overflow-auto\" ref={setScrollElement}>
\n
<div className=\"p-3 mb-2 rounded bg-white/10\">
\n
Content above the list
\n
</div>
\n
<AutoSizer
\n
renderProp={({ width }) =>
\n
width !== undefined ? (
\n
<List
\n
ref={listRef}
\n
rowCount={items.length}
\n
rowHeight={30}
\n
rowRenderer={rowRenderer}
\n
scrollElement={scrollElement}
\n
width={width}
\n
/>
\n
) : null
\n
}
\n
/>
\n
</div>
\n
</div>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingSmoothScroll.json b/public/generated/examples/WindowScrollingSmoothScroll.json new file mode 100644 index 000000000..d5f6a8385 --- /dev/null +++ b/public/generated/examples/WindowScrollingSmoothScroll.json @@ -0,0 +1,3 @@ +{ + "html": "
// Smoothly scrolls the window to a row of a List with scrollElement=\"window\".
\n
// (listElementRef is passed to the List's elementRef prop.)
\n
function smoothScrollToRow(index: number) {
\n
const list = listRef.current;
\n
const listElement = listElementRef.current;
\n
if (list && listElement) {
\n
// Offset of the row within the List, and of the List within the page
\n
const rowOffset = list.getOffsetForRow({ align: \"start\", index });
\n
const listOffset = listElement.getBoundingClientRect().top + window.scrollY;
\n
 
\n
window.scrollTo({ behavior: \"smooth\", top: listOffset + rowOffset });
\n
}
\n
}
" +} \ No newline at end of file diff --git a/public/generated/examples/WindowScrollingTable.json b/public/generated/examples/WindowScrollingTable.json new file mode 100644 index 000000000..974ca35af --- /dev/null +++ b/public/generated/examples/WindowScrollingTable.json @@ -0,0 +1,3 @@ +{ + "html": "
import { Column, Table } from \"react-virtualized\";
\n
 
\n
function Example({ items, width }: { items: ListItem[]; width: number }) {
\n
return (
\n
<Table
\n
headerHeight={30}
\n
rowCount={items.length}
\n
rowGetter={({ index }) => items[index]}
\n
rowHeight={30}
\n
// The header scrolls along with the page (it isn't sticky)
\n
scrollElement=\"window\"
\n
width={width}
\n
>
\n
<Column dataKey=\"name\" label=\"Name\" width={200} />
\n
<Column dataKey=\"color\" label=\"Color\" width={100} flexGrow={1} />
\n
</Table>
\n
);
\n
}
" +} \ No newline at end of file diff --git a/public/generated/site-map.json b/public/generated/site-map.json new file mode 100644 index 000000000..0f4876f1f --- /dev/null +++ b/public/generated/site-map.json @@ -0,0 +1,2045 @@ +[ + { + "path": "/", + "text": "react-virtualized is a collection of React components for efficiently rendering large lists and tabular data. Only the rows and columns visible within the viewport are rendered, which keeps the number of DOM elements small no matter how much data there is. If you're starting a new project, also take a look at react-window as a lighter-weight alternative. Installation Begin by installing the library from NPM: npm install react-virtualized TypeScript definitions are included within the published dist folder. Support Here are some ways to support this project: Become a GitHub sponsor Buy me a coffee", + "title": "Getting started with react-virtualized" + }, + { + "headings": [ + { + "id": "controlled-mode", + "title": "Controlled mode", + "text": "Controlled mode By default, ArrowKeyStepper manages the current scrollToColumn and scrollToRow values itself (the props of the same name only set their initial values). To manage them yourself, e.g. to also select a cell by clicking it, set isControlled and update the values in response to onScrollToChange. The registered child is scrolled whenever the controlled values change. Click a cell below, then use the arrow keys: import { useState } from \"react\";\nimport {\n ArrowKeyStepper,\n Grid,\n type CellRendererParams,\n type ScrollIndices\n} from \"react-virtualized\";\n \nfunction ArrowKeyStepperControlled({ width }: { width: number }) {\n const [selected, setSelected] = useState({\n scrollToColumn: 0,\n scrollToRow: 0\n });\n \n const cellRenderer = ({\n columnIndex,\n key,\n rowIndex,\n style\n }: CellRendererParams) => {\n const isSelected =\n columnIndex === selected.scrollToColumn &&\n rowIndex === selected.scrollToRow;\n \n return (\n \n setSelected({ scrollToColumn: columnIndex, scrollToRow: rowIndex })\n }\n style={style}\n >\n {`r:${rowIndex}, c:${columnIndex}`}\n
\n );\n };\n \n return (\n \n {({ onSectionRendered, registerChild }) => (\n \n )}\n \n );\n} Arrow-key stepping can be turned off with the disabled prop. An uncontrolled stepper's current values can also be overridden imperatively, through a ref (see methods below)." + }, + { + "id": "children", + "title": "children", + "text": "children: (params: ArrowKeyStepperChildProps) => ReactNode Function responsible for rendering children.\nIt is passed onSectionRendered and registerChild parameters, which should be attached to the decorated\nvirtualized component (e.g. ),\nand the scrollToColumn and scrollToRow indices of the active cell (e.g. to highlight it).\nThe active cell is scrolled into view through the registered component's scrollToCell method." + }, + { + "id": "columnCount", + "title": "columnCount", + "text": "columnCount: number Number of columns in grid; for Table and List this property should always be 1." + }, + { + "id": "rowCount", + "title": "rowCount", + "text": "rowCount: number Number of rows in grid." + }, + { + "id": "className", + "title": "className", + "text": "className?: string CSS class name to attach to the wrapper
." + }, + { + "id": "disabled", + "title": "disabled", + "text": "disabled?: boolean = false Disables all scrolling using arrow-keys." + }, + { + "id": "isControlled", + "title": "isControlled", + "text": "isControlled?: boolean = false This component is \"controlled\"; it will not update scrollToColumn or scrollToRow itself.\nThis property should be used with onScrollToChange." + }, + { + "id": "mode", + "title": "mode", + "text": "mode?: \"cells\" | \"edges\" = \"edges\" Controls behavior of stepper when arrow key direction changes.\n\"cells\" means that the index will only increment or decrement by 1;\n\"edges\" means that the opposite side of the grid will be incremented." + }, + { + "id": "onScrollToChange", + "title": "onScrollToChange", + "text": "onScrollToChange?: (params: ScrollIndices) => void Called when arrow key navigation should update the current scroll-to values." + }, + { + "id": "scrollToColumn", + "title": "scrollToColumn", + "text": "scrollToColumn?: number = 0 Default/initial scrollToColumn value.\nWhen isControlled is true, this is the current scrollToColumn value." + }, + { + "id": "scrollToRow", + "title": "scrollToRow", + "text": "scrollToRow?: number = 0 Default/initial scrollToRow value.\nWhen isControlled is true, this is the current scrollToRow value." + }, + { + "id": "methods", + "title": "Methods", + "text": "Methods Public methods available on an ArrowKeyStepper ref." + }, + { + "id": "setScrollIndexes", + "title": "setScrollIndexes", + "text": "setScrollIndexes Override the local state of the component with new values for scrollToRow and scrollToColumn.\n setScrollIndexes(params: ScrollIndices): void;" + } + ], + "path": "/arrow-key-stepper", + "section": "Higher-order components", + "text": "ArrowKeyStepper decorates a virtualized component (such as a Grid, List, or Table) and responds to arrow-key events by scrolling one row or column at a time. This provides a snap-to behavior rather than the default browser scrolling behavior. Click inside the grid below to focus it, then use the up, down, left, and right arrow keys to move around: mode: \"edges\" (default) mode: \"cells\" ArrowKeyStepper uses a render function as its children. Pass the onSectionRendered parameter through to the decorated component (it informs the stepper which range of cells is currently visible) and attach the registerChild parameter as its ref; whenever the active cell changes, the stepper scrolls it into view by calling the component's scrollToCell method. The scrollToColumn and scrollToRow parameters identify the active cell, e.g. to highlight it. import { ArrowKeyStepper, Grid } from \"react-virtualized\";\n \nfunction ArrowKeyStepperGrid({\n mode,\n width\n}: {\n mode: \"cells\" | \"edges\";\n width: number;\n}) {\n return (\n \n {({ onSectionRendered, registerChild, scrollToColumn, scrollToRow }) => (\n <>\n
\n Most-recently-stepped column: {scrollToColumn}, row: {scrollToRow}\n
\n \n renderCell({\n ...params,\n isSelected:\n params.columnIndex === scrollToColumn &&\n params.rowIndex === scrollToRow\n })\n }\n columnCount={100}\n columnWidth={getColumnWidth}\n height={200}\n onSectionRendered={onSectionRendered}\n ref={registerChild}\n rowCount={100}\n rowHeight={getRowHeight}\n width={width}\n />\n \n )}\n
\n );\n} The mode prop controls how the stepper behaves when an arrow key is pressed: in \"edges\" mode (the default), the next row or column beyond the visible edge of the grid is scrolled into view; in \"cells\" mode, the current row or column index is only incremented or decremented by 1. Unlike the other HOCs in react-virtualized, ArrowKeyStepper adds a
element around its children in order to attach a key-down event handler. The appearance of this wrapper element can be customized using the className property.", + "title": "ArrowKeyStepper" + }, + { + "headings": [ + { + "id": "dynamic-row-heights", + "title": "Dynamic row heights", + "text": "Dynamic row heights The most common use case is a List whose rows contain wrapping text. Each row is rendered at the width of the list and its height is measured: Three pieces work together to make this work: A CellMeasurerCache stores the measurements. It's configured with fixedWidth: true since only heights are measured. Each row is wrapped in a CellMeasurer, which is passed the cache along with the row's index and the parent provided to the row renderer. The cache is passed to the List as deferredMeasurementCache, and its rowHeight method is used as the rowHeight prop. import { useState } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n List,\n type RowRendererParams\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n // Rows have a fixed width (the width of the list) and a dynamic height.\n // Unmeasured rows are assumed to be 50px tall.\n const [cache] = useState(\n () => new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true })\n );\n \n const rowRenderer = ({ index, key, parent, style }: RowRendererParams) => {\n const item = items[index];\n \n return (\n \n \n
{item.name}
\n
{item.randomLong}
\n
\n \n );\n };\n \n return (\n // Text wraps differently at different widths,\n // so measurements must be cleared when the list resizes\n cache.clearAll()}\n renderProp={({ height, width }) =>\n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n} CellMeasurer attaches a ref to its child in order to find the DOM element to measure, so the child should be a DOM element (or a component that passes its ref along to one; see child requirements). The style passed to the row (or cell) renderer must still be applied to the element, in order to position it. Measurements depend on the available width when text wraps. When the width changes (e.g. on window resize), call cache.clearAll() so that cells get re-measured, as the examples on this page do in the AutoSizer onResize callback." + }, + { + "id": "dynamic-height-table-column", + "title": "Dynamic height Table column", + "text": "Dynamic height Table column A Table can mix fixed and dynamic content. Here only the last column is measured (with a Column cellRenderer); the height of each row is determined by that cell. import { useState } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n Column,\n Table\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n // Rows have a dynamic height (but are never shorter than 25px)\n const [cache] = useState(\n () => new CellMeasurerCache({ fixedWidth: true, minHeight: 25 })\n );\n \n return (\n cache.clearAll()}\n renderProp={({ height, width }) =>\n height !== undefined && width !== undefined ? (\n items[index]}\n rowHeight={cache.rowHeight}\n width={width}\n >\n \n \n {/* Only this column is measured; the others are a single line */}\n (\n \n
\n {rowData.randomLong}\n
\n \n )}\n dataKey=\"randomLong\"\n flexGrow={1}\n label=\"Description\"\n width={200}\n />\n \n ) : null\n }\n />\n );\n}" + }, + { + "id": "dynamic-row-heights-in-a-grid", + "title": "Dynamic row heights in a Grid", + "text": "Dynamic row heights in a Grid A Grid with fixed-width columns and dynamic row heights; each row is as tall as its tallest cell: import { useState } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n Grid,\n type CellRendererParams\n} from \"react-virtualized\";\n \nfunction Example({ items, width }: { items: ListItem[]; width: number }) {\n // Columns have a fixed width (150px) and rows have a dynamic height;\n // each row is as tall as its tallest cell.\n const [cache] = useState(\n () => new CellMeasurerCache({ defaultWidth: 150, fixedWidth: true })\n );\n \n const cellRenderer = ({\n columnIndex,\n key,\n parent,\n rowIndex,\n style\n }: CellRendererParams) => (\n \n
\n {items[(rowIndex + columnIndex) % items.length].randomLong}\n
\n \n );\n \n return (\n \n );\n}" + }, + { + "id": "function-children", + "title": "Function children: measure and registerChild", + "text": "Function children: measure and registerChild Instead of an element, CellMeasurer also accepts a function child. The function receives two parameters: measure: re-measures the cell. Call this when the cell's content is ready, or has changed size, after the initial render (for example when an image finishes loading). Calling it after the cell has unmounted (e.g. it scrolled out of view before the image loaded) does nothing. Both functions are stable. registerChild: specifies the DOM element to measure; it can be used as a ref. A registered element takes precedence over the element returned by the function. This example shows a List of images whose row heights can't be known until the image data has loaded: import { useState } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n List,\n type RowRendererParams\n} from \"react-virtualized\";\n \nfunction Example({\n height,\n imageUrls,\n width\n}: {\n height: number;\n imageUrls: string[];\n width: number;\n}) {\n // Average row height is assumed to be about 50px;\n // the width of each row is not dynamic.\n const [cache] = useState(\n () => new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true })\n );\n \n const rowRenderer = ({ index, key, parent, style }: RowRendererParams) => (\n \n {({ measure, registerChild }) => (\n // \"style\" is required to position the row (within the parent List)\n
\n {/* Re-measure the row once the image has loaded */}\n \n
\n )}\n \n );\n \n return (\n \n );\n}" + }, + { + "id": "child-requirements", + "title": "Child requirements", + "text": "Child requirements CellMeasurer measures the DOM element its ref is attached to. (If the child element has a ref of its own, that ref still receives the element.) The child can be: A DOM element, e.g.
. A function component that passes its ref along to a DOM element. In React 19, ref is a regular prop that can be passed to the element; in React 18, the component must use forwardRef. Anything else (e.g. a component that doesn't pass its ref along, a class component, or a fragment), as long as a function child passes registerChild to the element to be measured. An element registered with registerChild takes precedence over the child element, so it can also be used to measure an element nested within the child. import { forwardRef, useState, type CSSProperties, type Ref } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n List,\n type RowRendererParams\n} from \"react-virtualized\";\n \n// React 19: \"ref\" is a regular prop; pass it along to the DOM element\nfunction Row({\n ref,\n style,\n text\n}: {\n ref?: Ref;\n style: CSSProperties;\n text: string;\n}) {\n return (\n
\n {text}\n
\n );\n}\n \n// React 18: function components must use forwardRef to receive a ref\nconst ForwardRefRow = forwardRef<\n HTMLDivElement,\n { style: CSSProperties; text: string }\n>(function ForwardRefRow({ style, text }, ref) {\n return (\n
\n {text}\n
\n );\n});\n \n// Either version: attach registerChild to the element to be measured\nfunction RegisteredRow({\n registerChild,\n style,\n text\n}: {\n registerChild: Ref;\n style: CSSProperties;\n text: string;\n}) {\n return (\n
\n {text}\n
\n );\n}\n \nfunction Example({ items }: { items: string[] }) {\n const [cache] = useState(\n () => new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true })\n );\n \n const rowRenderer = ({ index, key, parent, style }: RowRendererParams) => {\n const text = items[index];\n \n switch (index % 3) {\n case 0:\n return (\n \n \n \n );\n case 1:\n return (\n \n \n \n );\n default:\n return (\n \n {({ registerChild }) => (\n \n )}\n \n );\n }\n };\n \n return (\n \n );\n} If there is no DOM element to measure (e.g. the child is text, an array or a fragment, or a component that doesn't pass its ref along), the child is rendered as-is, the cell keeps the cache's default size, and a warning is logged in development mode." + } + ], + "path": "/cell-measurer", + "section": "Measuring content", + "text": "CellMeasurer is a higher-order component that automatically measures a cell's contents just-in-time, e.g. for messages in a chat interface whose size isn't known until they've been rendered. Specify a fixed width to measure dynamic height (or vice versa). CellMeasurer can be used with Grid, List, Table, MultiGrid, and Masonry. If sizes can be determined ahead of time (e.g. from the data), it's simpler and more efficient to pass a rowHeight or columnWidth function than to measure cells; see variable row heights and variable sizes. Use CellMeasurer when a cell's size depends on its rendered content (e.g. wrapping text or images). This is an advanced component, with some limitations and performance considerations; see limitations and performance.", + "title": "Measuring cells" + }, + { + "headings": [ + { + "id": "defaultHeight", + "title": "defaultHeight", + "text": "defaultHeight?: number = 30 Unmeasured cells will initially report this height\n(or minHeight, if that is greater)." + }, + { + "id": "defaultWidth", + "title": "defaultWidth", + "text": "defaultWidth?: number = 100 Unmeasured cells will initially report this width\n(or minWidth, if that is greater)." + }, + { + "id": "fixedHeight", + "title": "fixedHeight", + "text": "fixedHeight?: boolean = false Rendered cells will have a fixed height and a dynamic (measured) width." + }, + { + "id": "fixedWidth", + "title": "fixedWidth", + "text": "fixedWidth?: boolean = false Rendered cells will have a fixed width and a dynamic (measured) height." + }, + { + "id": "minHeight", + "title": "minHeight", + "text": "minHeight?: number = 0 Derived row height (of multiple cells) should not be less than this value." + }, + { + "id": "minWidth", + "title": "minWidth", + "text": "minWidth?: number = 0 Derived column width (of multiple cells) should not be less than this value." + }, + { + "id": "keyMapper", + "title": "keyMapper", + "text": "keyMapper?: ((rowIndex: number, columnIndex: number) => unknown) Maps a cell's row and column index to a cache key, e.g. an item ID.\nThis prevents the cached measurements from being invalidated when the parent collection is modified\n(e.g. items are inserted or reordered).\nDefaults to a mapper that returns `${rowIndex}-${columnIndex}`.\n While all of the parameters above are optional, you should specify either fixedWidth or fixedHeight. CellMeasurerCache is not meant to measure cells that are both dynamic width and height; it would be inefficient to do so since the size of a row (or column) is equal to the largest cell within that row. A warning is logged in development mode if neither is specified. A custom keyMapper can also speed things up when every cell is known to have the same (but not known ahead of time) size: import { CellMeasurerCache } from \"react-virtualized\";\n \n// Normally every cell is measured individually, which can be slow.\n// If all rows are known to have the same (but unknown) height,\n// a keyMapper that returns a constant tells CellMeasurer that\n// every measurement after the first one will hit the cache.\nconst cache = new CellMeasurerCache({\n defaultHeight: 30,\n fixedWidth: true,\n keyMapper: () => 1\n});" + }, + { + "id": "methods", + "title": "Methods", + "text": "Methods Public API of a CellMeasurerCache instance." + }, + { + "id": "methods-clear", + "title": "clear", + "text": "clear Reset cached measurements for a specific cell.\nThis should be called when a cell needs to be re-measured to handle dynamic content\n(e.g. replacing a loading indicator with loaded content, or reacting to state changes for stateful cells).\ncolumnIndex defaults to 0.\n clear(rowIndex: number, columnIndex?: number): void;" + }, + { + "id": "methods-clearAll", + "title": "clearAll", + "text": "clearAll Reset cached measurements for all cells.\nThis should be called when a Grid, List or Table needs to reflow its content due to a resize in a responsive layout\n(e.g. a change in window width may impact the height of cells).\n clearAll(): void;" + }, + { + "id": "methods-columnWidth", + "title": "columnWidth", + "text": "columnWidth Returns the derived width of a column (its widest measured cell) or the default width.\nPass this as the columnWidth prop of a Grid (or MultiGrid);\nit's bound to the cache, so it can be passed as-is.\n columnWidth(params: { index: number }): number;" + }, + { + "id": "methods-defaultHeight", + "title": "defaultHeight", + "text": "defaultHeight Height reported for unmeasured cells (read-only).\n readonly defaultHeight: number;" + }, + { + "id": "methods-defaultWidth", + "title": "defaultWidth", + "text": "defaultWidth Width reported for unmeasured cells (read-only).\n readonly defaultWidth: number;" + }, + { + "id": "methods-getHeight", + "title": "getHeight", + "text": "getHeight Returns the (measured or default) height of the specified cell.\ncolumnIndex defaults to 0.\n getHeight(rowIndex: number, columnIndex?: number): number;" + }, + { + "id": "methods-getWidth", + "title": "getWidth", + "text": "getWidth Returns the (measured or default) width of the specified cell.\ncolumnIndex defaults to 0.\n getWidth(rowIndex: number, columnIndex?: number): number;" + }, + { + "id": "methods-has", + "title": "has", + "text": "has Returns true if the specified cell has already been measured.\ncolumnIndex defaults to 0.\n has(rowIndex: number, columnIndex?: number): boolean;" + }, + { + "id": "methods-hasFixedHeight", + "title": "hasFixedHeight", + "text": "hasFixedHeight Returns true if cells have a fixed height (and a dynamic, measured width);\nsee the fixedHeight option.\n hasFixedHeight(): boolean;" + }, + { + "id": "methods-hasFixedWidth", + "title": "hasFixedWidth", + "text": "hasFixedWidth Returns true if cells have a fixed width (and a dynamic, measured height);\nsee the fixedWidth option.\n hasFixedWidth(): boolean;" + }, + { + "id": "methods-rowHeight", + "title": "rowHeight", + "text": "rowHeight Returns the derived height of a row (its tallest measured cell) or the default height.\nPass this as the rowHeight prop of a Grid, List or Table;\nit's bound to the cache, so it can be passed as-is.\n rowHeight(params: { index: number }): number;" + }, + { + "id": "methods-set", + "title": "set", + "text": "set Stores the measured size of the specified cell.\nThis is called by CellMeasurer and doesn't usually need to be called directly.\n set(\n rowIndex: number,\n columnIndex: number,\n width: number,\n height: number\n ): void;" + }, + { + "id": "clearing-measurements", + "title": "Clearing measurements", + "text": "Clearing measurements For example, a row that can be expanded and collapsed should clear its cached measurement when it changes, and tell the List to recompute its layout (see List methods): import { useRef, useState } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n List,\n type ListImperativeAPI,\n type RowRendererParams\n} from \"react-virtualized\";\n \nfunction Example({ messages }: { messages: string[] }) {\n const [cache] = useState(\n () => new CellMeasurerCache({ defaultHeight: 50, fixedWidth: true })\n );\n const listRef = useRef(null);\n const [expanded, setExpanded] = useState>(new Set());\n \n const toggle = (index: number) => {\n setExpanded((prev) => {\n const next = new Set(prev);\n if (next.has(index)) {\n next.delete(index);\n } else {\n next.add(index);\n }\n return next;\n });\n \n // The row's content (and so its height) is about to change:\n // discard its cached measurement and tell the List to re-layout\n cache.clear(index, 0);\n listRef.current?.recomputeRowHeights(index);\n };\n \n const rowRenderer = ({ index, key, parent, style }: RowRendererParams) => (\n \n
toggle(index)} style={style}>\n {expanded.has(index) ? messages[index] : messages[index].slice(0, 25)}\n
\n \n );\n \n return (\n \n );\n}" + } + ], + "path": "/cell-measurer/cache", + "section": "Measuring content", + "text": "CellMeasurerCache stores CellMeasurer measurements and shares them with the parent Grid (or List, Table, etc.). Create one cache per virtualized component and keep it for the lifetime of that component (e.g. in useState or useMemo, or in a class instance field) rather than re-creating it on every render. It should be configured based on the type of measurements you need. The constructor accepts the following (optional) parameters:", + "title": "CellMeasurerCache" + }, + { + "headings": [ + { + "id": "multigrid", + "title": "MultiGrid", + "text": "MultiGrid A MultiGrid (with a fixed first column) whose columns have dynamic widths: import { useState } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n MultiGrid,\n type CellRendererParams\n} from \"react-virtualized\";\n \nfunction Example({ items, width }: { items: ListItem[]; width: number }) {\n // Rows have a fixed height (30px) and columns have a dynamic width;\n // each column is as wide as its widest cell.\n const [cache] = useState(\n () =>\n new CellMeasurerCache({\n defaultHeight: 30,\n defaultWidth: 150,\n fixedHeight: true\n })\n );\n \n const cellRenderer = ({\n columnIndex,\n key,\n parent,\n rowIndex,\n style\n }: CellRendererParams) => (\n \n
\n {items[(rowIndex + columnIndex) % items.length].random}\n
\n \n );\n \n return (\n \n );\n}" + } + ], + "path": "/cell-measurer/column-widths", + "section": "Measuring content", + "text": "The approach used for dynamic row heights also works in the other direction. This Grid has fixed row heights and columns whose width is determined by their (non-wrapping) content; the cache is configured with fixedHeight: true and its columnWidth method is used as the columnWidth prop. import { useState } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n Grid,\n type CellRendererParams\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n // Cells have a fixed height and a dynamic width.\n // Unmeasured columns are assumed to be 100px wide.\n const [cache] = useState(\n () => new CellMeasurerCache({ defaultWidth: 100, fixedHeight: true })\n );\n \n const cellRenderer = ({\n columnIndex,\n key,\n parent,\n rowIndex,\n style\n }: CellRendererParams) => {\n const item = items[(rowIndex + columnIndex) % items.length];\n \n let content;\n switch (columnIndex % 3) {\n case 0:\n content = item.color;\n break;\n case 1:\n content = item.name;\n break;\n default:\n content = item.random;\n break;\n }\n \n return (\n \n \n {content}\n
\n \n );\n };\n \n return (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n}", + "title": "Dynamic column widths" + }, + { + "path": "/cell-measurer/limitations", + "section": "Measuring content", + "text": "Measuring a column's width requires measuring all of the rows in order to determine the widest occurrence of that column. The same is true in reverse for measuring a row's height. For this reason it may not be a good idea to use CellMeasurer for Grids containing a large number of both columns and rows. (This is also why a CellMeasurerCache should be configured with either fixedWidth or fixedHeight.) Since cells are measured one at a time as they are rendered, performance can suffer if a user skips many rows (or columns) at once, by dragging the scrollbar or by calling a method like scrollToCell or scrollToRow. There is (unfortunately) no workaround for this limitation at the moment. If sizes can be determined ahead of time (e.g. from the data), it's more efficient to pass a rowHeight or columnWidth function than to measure cells.", + "title": "Limitations & performance" + }, + { + "headings": [ + { + "id": "cache", + "title": "cache", + "text": "cache: CellMeasureCache Cache to be shared between CellMeasurer and its parent Grid.\nTypically an instance of CellMeasurerCache." + }, + { + "id": "children", + "title": "children", + "text": "children: ReactNode | CellMeasurerChildFunction Either a React element as a child (eg
) or a function\n(eg. ({ measure, registerChild }) =>
).\n CellMeasurer attaches a ref to the child element to find the DOM element to measure\n(the child's own ref, if any, still receives the element).\nSo the child must be a DOM element (e.g.
) or a component that passes its ref along to one:\nin React 19, a function component can pass its ref prop to a DOM element;\nin React 18, function components must use forwardRef for this.\n The function receives a measure callback (to re-measure the cell, e.g. after an image has loaded)\nand a registerChild callback (to specify the DOM element to be measured; can be used as a ref).\nUse registerChild if the child doesn't pass its ref along to a DOM element,\nor to measure an element nested within the child.\n Children that can't be measured (e.g. text, an array or a fragment, without registerChild) are rendered as-is,\nand a warning is logged in development mode." + }, + { + "id": "parent", + "title": "parent", + "text": "parent: CellMeasurerParent Reference to the parent Grid (or List, Table, MultiGrid, Masonry);\nthis value is passed by Grid to the cellRenderer and should be passed along as-is." + }, + { + "id": "columnIndex", + "title": "columnIndex", + "text": "columnIndex?: number = 0 Index of column being measured (within the parent Grid) or 0 (if used within a List or Table)." + }, + { + "id": "index", + "title": "index", + "text": "index?: number Index of row being measured; alias of rowIndex (as passed to a List row renderer).\nOnly used if rowIndex is not specified." + }, + { + "id": "rowIndex", + "title": "rowIndex", + "text": "rowIndex?: number Index of row being measured (within the parent Grid).\nDefaults to index (or 0 if neither is specified)." + } + ], + "path": "/cell-measurer/props", + "section": "Measuring content", + "text": "Wraps a cell and measures its rendered content.\nMeasurements are stored in a per-cell cache.\nCached-content is not be re-measured.\n The child must be a DOM element (e.g.
) or a component that passes its ref along to one;\notherwise use a function child and pass registerChild as the ref of the element to be measured.", + "title": "CellMeasurer props" + }, + { + "headings": [ + { + "id": "children", + "title": "children", + "text": "children: (params: ColumnSizerChildProps) => ReactNode Function responsible for rendering a virtualized Grid.\nIt is passed { adjustedWidth, columnWidth, getColumnWidth, registerChild }.\n The columnWidth value should be passed to the Grid's columnWidth property,\nand registerChild should be passed to the Grid's ref property.\nThe adjustedWidth value is optional; it reflects the lesser of the overall width or the width of all columns." + }, + { + "id": "columnCount", + "title": "columnCount", + "text": "columnCount: number Number of columns in the Grid or Table child." + }, + { + "id": "width", + "title": "width", + "text": "width: number Width of the Grid or Table child." + }, + { + "id": "columnMaxWidth", + "title": "columnMaxWidth", + "text": "columnMaxWidth?: number Optional maximum allowed column width." + }, + { + "id": "columnMinWidth", + "title": "columnMinWidth", + "text": "columnMinWidth?: number Optional minimum allowed column width." + } + ], + "path": "/column-sizer", + "section": "Higher-order components", + "text": "ColumnSizer decorates a Grid and calculates the width of its columns based on the available width, the number of columns, and optional minimum and maximum column widths. Change the values below (or resize the window) to see the columns resize: Number of columns Min column width Max column width ColumnSizer uses a render function as its children. It is passed the following parameters: columnWidth: the calculated column width; pass it to the Grid's columnWidth prop registerChild: pass it to the Grid's ref prop, so that column sizes can be recomputed when the ColumnSizer props change adjustedWidth: the lesser of the overall width or the width of all columns; use it as the Grid's width to make the grid shrink to fit sparse content (as in the example above) import { ColumnSizer, Grid } from \"react-virtualized\";\n \nfunction ColumnSizerGrid({\n columnCount,\n columnMaxWidth,\n columnMinWidth,\n width\n}: {\n columnCount: number;\n columnMaxWidth?: number;\n columnMinWidth?: number;\n width: number;\n}) {\n return (\n \n {({ adjustedWidth, columnWidth, registerChild }) => (\n \n )}\n \n );\n} A getColumnWidth function is also passed to the children function. It is a legacy parameter; use columnWidth instead. ColumnSizer needs to know the width of the grid; it is typically used together with AutoSizer.", + "title": "ColumnSizer" + }, + { + "headings": [ + { + "id": "upgrading-from-9", + "title": "How do I upgrade from version 9?", + "text": "How do I upgrade from version 9? Version 10 requires React 18 or newer, removes a few components (AutoSizer, Collection and WindowScroller), and replaces the controlled scroll props (e.g. scrollToIndex) with methods. The migration guide in the changelog covers each change, with before/after examples." + }, + { + "id": "auto-sizer-zero-height", + "title": "Why is nothing rendered (or why does AutoSizer report a height of 0)?", + "text": "Why is nothing rendered (or why does AutoSizer report a height of 0)? List, Grid, Table, and Masonry need an explicit height and width (only a width when they are scrolled by the window). AutoSizer (from the react-virtualized-auto-sizer package) can provide them, but it only fills its parent; it won't stretch it. If the parent (or one of its ancestors) has no height, AutoSizer reports 0 and nothing is rendered. Give the parent a size (e.g. height: 100% or flex: 1); inside a flex container, wrap AutoSizer in a div with flex: 1 1 auto. See sizing components for more." + }, + { + "id": "scroll-to-row", + "title": "How do I scroll to a row (or cell)?", + "text": "How do I scroll to a row (or cell)? Call scrollToRow({ index, align }) on a List or Table, or scrollToCell({ columnIndex, rowIndex, align }) on a Grid, through a ref. In version 10, these methods replace the scrollToIndex and scrollToRow props. They scroll once: the row isn't kept in view if sizes change afterwards, so to scroll to a row that is being added, call them once it has rendered (e.g. in an effect). To render a list at a scroll position from the start (e.g. when restoring it), use the initialScrollTop prop instead." + }, + { + "id": "window-scrolling", + "title": "How do I make a list scroll with the rest of the page?", + "text": "How do I make a list scroll with the rest of the page? Set the scrollElement prop of a List, Grid, Table or Masonry to \"window\" (or to a scrollable element). The component then renders at its full height, renders only the rows visible within the window, and its scroll methods scroll the window; it only needs a width. In version 10, this replaces WindowScroller. See window scrolling." + }, + { + "id": "styles-css", + "title": "Why do my Table and other components look unstyled?", + "text": "Why do my Table and other components look unstyled? react-virtualized ships a small set of default styles that must be imported once, e.g. when your application starts: import \"react-virtualized/styles.css\";. You can also skip them and provide your own; see customizing styles." + }, + { + "id": "data-changed-not-rerendering", + "title": "Why doesn't my list update when my data changes?", + "text": "Why doesn't my list update when my data changes? The components only re-render when their props change (shallow comparison). If your data changes but props like rowCount don't (e.g. after re-sorting), either pass the changed value through as an extra prop (e.g. sortBy={sortBy}) or force an update: forceUpdateGrid() for List and Table, forceUpdate() for Grid and Masonry." + }, + { + "id": "row-sizes-changed", + "title": "My row heights (or column widths) changed. Why is the layout stale?", + "text": "My row heights (or column widths) changed. Why is the layout stale? Sizes are cached once they have been computed, because the components only know rowCount/columnCount and can't tell when the underlying data changes. Call recomputeRowHeights(index) on List or Table, or recomputeGridSize({ columnIndex, rowIndex }) on Grid, starting from the first index whose size changed. (This is only needed when sizes come from a function; changes to a numeric rowHeight or columnWidth are applied automatically.)" + }, + { + "id": "cell-measurer-cache", + "title": "When should I clear a CellMeasurerCache?", + "text": "When should I clear a CellMeasurerCache? CellMeasurer measures each cell once and caches the result. Call cache.clear(rowIndex, columnIndex) when a cell's content changes (e.g. a loading indicator is replaced with content), and cache.clearAll() when all cells need to reflow (e.g. the width changed and text wraps differently); then tell the parent to recompute its sizes (recomputeRowHeights() on a List or Table, recomputeGridSize() on a Grid). See CellMeasurerCache. If items can be added, removed, or re-ordered, pass a keyMapper (e.g. mapping indices to item IDs) so cached sizes follow the items rather than their indices." + }, + { + "id": "cell-measurer-performance", + "title": "Can CellMeasurer measure both widths and heights?", + "text": "Can CellMeasurer measure both widths and heights? Not efficiently: a row's height is the tallest cell in that row (and a column's width is the widest cell in that column), so measuring requires rendering every cell in it. Use fixedWidth or fixedHeight on the CellMeasurerCache to measure only one dimension. Measuring can also be slow when users jump far ahead with the scrollbar, so provide sizes up front when you can." + }, + { + "id": "react-window", + "title": "Should I use react-virtualized or react-window?", + "text": "Should I use react-virtualized or react-window? react-window is a smaller, lighter-weight library by the same author, with a simpler API, and is a good choice for new projects that need a list or grid. react-virtualized offers more components (e.g. Table, Masonry, MultiGrid, CellMeasurer, and InfiniteLoader), and version 10 keeps existing applications working on modern React." + } + ], + "path": "/common-questions", + "text": "", + "title": "Common questions" + }, + { + "headings": [ + { + "id": "rendering-cells", + "title": "Rendering cells", + "text": "Rendering cells The cellRenderer function is responsible for rendering a single cell, given its row and column index. It receives the following parameters: import type { CellRendererParams } from \"react-virtualized\";\n \nfunction cellRenderer({\n columnIndex, // Horizontal (column) index of cell\n isScrolling, // The Grid is currently being scrolled\n key, // Unique key within array of cells\n rowIndex, // Vertical (row) index of cell\n style // Style object to be applied to cell (to position it)\n // Also available:\n // isVisible: This cell is visible within the grid (eg it is not an overscanned cell)\n // parent: Reference to the parent Grid (instance)\n}: CellRendererParams) {\n const user = list[rowIndex][columnIndex];\n \n // If cell content is complex, consider rendering a lighter-weight placeholder while scrolling\n const content = isScrolling ? \"...\" : ;\n \n // Style is required since it specifies how the cell is to be sized and positioned;\n // Grid depends on this sizing/positioning for proper scrolling behavior.\n // By default it contains position, left, top, height, and width properties.\n // You can add class names or style properties as you like (but don't mutate the object).\n // Key is also required by React to more efficiently manage the array of cells.\n return (\n
\n {content}\n
\n );\n} The style parameter must be applied to the rendered cell; it specifies how the cell is sized and positioned, and the grid depends on it to scroll correctly. Don't modify the style object (it is cached and re-used by the grid); create a new object if you need to add style properties. Rows and columns don't all need to be the same size; see variable sizes. Grid supports the following static class names, in addition to the className, id, and style props (and containerStyle for the inner container): ReactVirtualized__Grid: main (outer) element ReactVirtualized__Grid__innerScrollContainer: inner scrollable area See customizing styles for more information." + } + ], + "path": "/grid", + "section": "Grid", + "text": "Grid renders tabular data with virtualization along both the vertical and horizontal axes. Only the cells needed to fill the grid (based on its current horizontal and vertical scroll position) are rendered. To render a grid, specify its size (width and height), the number of rows and columns (rowCount and columnCount), the size of each (rowHeight and columnWidth), and a function that renders a cell (cellRenderer). The example above uses AutoSizer to fill the available space: import { Grid, type CellRendererParams } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \n// Each row is an item in the list; each column is one of its fields\nconst columns = [\"index\", \"name\", \"color\", \"size\", \"random\"] as const;\n \nfunction cellRenderer({\n columnIndex,\n key,\n rowIndex,\n style\n}: CellRendererParams) {\n const item = list[rowIndex];\n const field = columns[columnIndex];\n \n return (\n \n {item[field]}\n
\n );\n}\n \nfunction GridBasic() {\n return (\n // AutoSizer measures the available space; Grid requires an explicit width and height\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n} Grid is also the building block for the other components in this library; List, Table, and MultiGrid all render a Grid internally. See Grid props and Grid methods for the full API.", + "title": "Rendering a grid" + }, + { + "headings": [ + { + "id": "parameters", + "title": "Parameters", + "text": "Parameters The cellRangeRenderer function receives the following parameters. If you require greater customization, you may want to fork defaultCellRangeRenderer and use them to render cells yourself. cellCache, styleCache: temporary cell and style caches used while scrolling cellRenderer: the cellRenderer prop supplied to the grid columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex: indices of the first and last columns and rows (inclusive) to render, including overscanned cells visibleColumnIndices, visibleRowIndices: the range of visible (not overscanned) columns and rows columnSizeAndPositionManager, rowSizeAndPositionManager: their getSizeAndPositionOfCell(index) method returns the offset (left or top) and size (width or height) of a column or row horizontalOffsetAdjustment, verticalOffsetAdjustment: pixel offsets to add to cell positions. Browsers limit the size of an element, so for very large grids, cell offsets are scaled down and cells are shifted as the user scrolls. isScrolling: the grid is currently being scrolled; isScrollingOptOut: the value of the grid prop scrollLeft, scrollTop: the current scroll offsets of the grid deferredMeasurementCache: the CellMeasurer cache, if one was passed to the grid parent: the grid instance (or the component that wraps it) Each rendered element needs a unique key. Cell positions must include the offset adjustments; otherwise cells will be mispositioned in very large grids." + } + ], + "path": "/grid/cell-range-renderer", + "section": "Grid", + "text": "The cellRangeRenderer prop is an advanced property, responsible for rendering the group of cells within a range of row and column indices. It is useful for situations where the grid requires additional, overlaid UI, such as a Gantt chart or a calendar application. Many use cases can be solved more easily using the onScroll callback or the ScrollSync component. If you do want to override cellRangeRenderer, the easiest way is to decorate the defaultCellRangeRenderer function (exported by the library) and add your own elements to the array it returns. Elements are positioned within the grid's inner scrollable container: import {\n defaultCellRangeRenderer,\n Grid,\n type CellRangeRenderer\n} from \"react-virtualized\";\n \n// Highlight a range of cells (e.g. an event in a calendar or a task in a Gantt chart)\nconst EVENT = { startColumn: 2, stopColumn: 5, startRow: 3, stopRow: 4 };\n \nconst cellRangeRenderer: CellRangeRenderer = (params) => {\n const {\n columnSizeAndPositionManager,\n horizontalOffsetAdjustment,\n rowSizeAndPositionManager,\n verticalOffsetAdjustment\n } = params;\n \n // Render the cells as usual...\n const children = defaultCellRangeRenderer(params);\n \n // ...then add the overlay, positioned using the same size and position data as the cells\n const start = {\n column: columnSizeAndPositionManager.getSizeAndPositionOfCell(\n EVENT.startColumn\n ),\n row: rowSizeAndPositionManager.getSizeAndPositionOfCell(EVENT.startRow)\n };\n const stop = {\n column: columnSizeAndPositionManager.getSizeAndPositionOfCell(\n EVENT.stopColumn\n ),\n row: rowSizeAndPositionManager.getSizeAndPositionOfCell(EVENT.stopRow)\n };\n \n children.push(\n \n My custom overlay\n
\n );\n \n return children;\n};\n \nfunction GridCellRangeRenderer({ width }: { width: number }) {\n return (\n \n );\n}", + "title": "Custom cell range rendering" + }, + { + "path": "/grid/fixed-rows-and-columns", + "section": "Grid", + "text": "MultiGrid decorates Grid and adds fixed columns and/or rows. This is already possible using ScrollSync and two or more Grids, but MultiGrid reduces the boilerplate. Fixed columns Fixed rows import { MultiGrid, type CellRendererParams } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction cellRenderer({\n columnIndex,\n key,\n rowIndex,\n style\n}: CellRendererParams) {\n return (\n \n {columnIndex}, {rowIndex}\n
\n );\n}\n \nfunction Example({\n fixedColumnCount,\n fixedRowCount\n}: {\n fixedColumnCount: number;\n fixedRowCount: number;\n}) {\n return (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n} MultiGrid accepts most of the Grid props. The majority of them (e.g. cellRenderer) are relayed to all of the child Grids, while some (e.g. columnCount and rowCount) are adjusted slightly to support fixed rows and columns. Row and column indices passed to cellRenderer (and to columnWidth and rowHeight functions) are always relative to the full grid, including the fixed rows and columns, and the parent passed to cellRenderer is the MultiGrid itself (so it can be passed to CellMeasurer as-is). A MultiGrid always scrolls itself, so (unlike Grid) it has no scrollElement prop and can't be scrolled by the window. Each of the four inner Grids (top-left, top-right, bottom-left, and bottom-right) can be styled separately using the className*Grid and style*Grid props listed (see MultiGrid props). Use enableFixedColumnScroll and enableFixedRowScroll to allow the user to scroll (e.g. with a mouse wheel) while the cursor is over the fixed column or row. The extra scrollbars this adds can be hidden with hideBottomLeftGridScrollbar and hideTopRightGridScrollbar. MultiGrid also works with CellMeasurer to measure dynamic column widths or row heights. To scroll a MultiGrid, call its scrollToCell or scrollToPosition methods through a ref; the fixed rows and columns are kept in sync with the scrollable section.", + "title": "Fixed rows and columns" + }, + { + "headings": [ + { + "id": "forceUpdate", + "title": "forceUpdate", + "text": "forceUpdate Forcefully re-render the Grid (and its visible cells),\ne.g. because the data rendered by cellRenderer has changed but the Grid's props have not.\n forceUpdate(): void;" + }, + { + "id": "getOffsetForCell", + "title": "getOffsetForCell", + "text": "getOffsetForCell Gets offsets for a given cell and alignment (see scrollToCell for the alignment values).\nThe alignment defaults to \"auto\"; an index of -1 (the default) refers to the last column or row.\n getOffsetForCell(params?: {\n alignment?: Alignment;\n columnIndex?: number;\n rowIndex?: number;\n }): { scrollLeft: number; scrollTop: number };" + }, + { + "id": "getTotalRowsHeight", + "title": "getTotalRowsHeight", + "text": "getTotalRowsHeight Gets estimated total rows' height.\n getTotalRowsHeight(): number;" + }, + { + "id": "getTotalColumnsWidth", + "title": "getTotalColumnsWidth", + "text": "getTotalColumnsWidth Gets estimated total columns' width.\n getTotalColumnsWidth(): number;" + }, + { + "id": "handleScrollEvent", + "title": "handleScrollEvent", + "text": "handleScrollEvent This method handles a scroll event originating from an external scroll control.\nIt's an advanced method and should probably not be used unless you're implementing a custom scroll-bar solution.\n handleScrollEvent(params: ScrollPosition): void;" + }, + { + "id": "invalidateCellSizeAfterRender", + "title": "invalidateCellSizeAfterRender", + "text": "invalidateCellSizeAfterRender Invalidate the size of a cell and recompute visible cells after the current render has been committed.\nThis is a deferred version of recomputeGridSize() (batching the invalidations of a render);\nit is intended for advanced use-cases like CellMeasurer.\n invalidateCellSizeAfterRender(params: CellPosition): void;" + }, + { + "id": "measureAllCells", + "title": "measureAllCells", + "text": "measureAllCells Pre-measure all columns and rows in a Grid.\nTypically cells are only measured as needed and estimated sizes are used for cells that have not yet been measured.\nThis method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one).\n measureAllCells(): void;" + }, + { + "id": "recomputeGridSize", + "title": "recomputeGridSize", + "text": "recomputeGridSize Recomputes row heights and column widths after the specified index (both default to 0).\nThis function should be called if dynamic column or row sizes have changed but nothing else has.\nSince Grid only receives columnCount and rowCount it has no way of detecting when the underlying data changes.\nThis method will also force a render cycle to ensure that the updated measurements are reflected in the rendered grid.\n recomputeGridSize(params?: { columnIndex?: number; rowIndex?: number }): void;" + }, + { + "id": "scrollToCell", + "title": "scrollToCell", + "text": "scrollToCell Ensure the specified column and/or row is visible (scrolling if necessary).\nThis method can be used to safely scroll back to a cell that a user has scrolled away from even if it was previously scrolled to.\nThe align parameter (default \"auto\") controls the alignment of the cell:\n\"auto\" scrolls the least amount possible to ensure that the specified cell is fully visible\n(a cell larger than the Grid is aligned to its start, so that its start is visible);\n\"start\" aligns the cell to the top/left of the Grid and \"end\" to the bottom/right;\n\"center\" aligns the cell in the middle of the Grid.\nThis is a one-time scroll: the cell is not kept in view afterwards (e.g. if the Grid's size or its cell sizes change).\nCells that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated sizes.\nNothing is scrolled while the Grid's width or height is 0.\nThe column is ignored for single-column grids (e.g. List, Table).\nIf the Grid has a scrollElement, the scroll element (e.g. the window) is scrolled vertically instead.\n scrollToCell(params: {\n align?: Alignment;\n columnIndex?: number;\n rowIndex?: number;\n }): void;" + }, + { + "id": "scrollToPosition", + "title": "scrollToPosition", + "text": "scrollToPosition Scroll to the specified offset(s); an omitted offset is left unchanged.\nOffsets are clamped to the scrollable range.\nCalling this method with the current offsets does nothing.\nIf the Grid has a scrollElement, scrollTop scrolls the scroll element (e.g. the window) instead\n(to the position at which the Grid is scrolled by scrollTop).\n scrollToPosition(params: ScrollPosition): void;" + }, + { + "id": "calling-methods", + "title": "Calling methods", + "text": "Calling methods Grid's methods can be called through a ref (typed as GridImperativeAPI). In version 10, these methods replace the scrollToRow, scrollToColumn, scrollToAlignment, scrollTop and scrollLeft props; see scrolling to a cell: import { useRef } from \"react\";\nimport { Grid, type GridImperativeAPI } from \"react-virtualized\";\n \nfunction GridMethodsRef({ width }: { width: number }) {\n // A ref provides access to the Grid's imperative methods\n const gridRef = useRef(null);\n \n const scrollToMiddle = () => {\n gridRef.current?.scrollToCell({\n align: \"center\",\n columnIndex: 50,\n rowIndex: 500\n });\n };\n \n const scrollToTop = () => {\n gridRef.current?.scrollToPosition({ scrollLeft: 0, scrollTop: 0 });\n };\n \n return (\n <>\n
\n \n \n
\n \n \n );\n}" + } + ], + "path": "/grid/methods", + "section": "Grid", + "text": "Public (imperative) methods of a Grid (its ref handle).", + "title": "Grid methods" + }, + { + "headings": [ + { + "id": "forceUpdateGrids", + "title": "forceUpdateGrids", + "text": "forceUpdateGrids Pass-thru that calls forceUpdate on all child Grids.\n forceUpdateGrids(): void;" + }, + { + "id": "invalidateCellSizeAfterRender", + "title": "invalidateCellSizeAfterRender", + "text": "invalidateCellSizeAfterRender Invalidate the size of a cell (after the current render has been committed); see Grid#invalidateCellSizeAfterRender.\nThe invalidated cells (of all child Grids) are recomputed, and the MultiGrid re-rendered, once the current render has been committed.\nThis method is intended for CellMeasurer.\n invalidateCellSizeAfterRender(params?: Partial): void;" + }, + { + "id": "measureAllCells", + "title": "measureAllCells", + "text": "measureAllCells Pass-thru that calls measureAllCells on all child Grids.\n measureAllCells(): void;" + }, + { + "id": "recomputeGridSize", + "title": "recomputeGridSize", + "text": "recomputeGridSize Pass-thru that calls recomputeGridSize on all child Grids\n(adjusting the specified indices for fixed rows and columns).\nBoth indices default to 0.\nThis also recomputes the MultiGrid's own layout (the height of the fixed rows and the width of the fixed columns)\nand re-renders it.\n recomputeGridSize(params?: { columnIndex?: number; rowIndex?: number }): void;" + }, + { + "id": "scrollToCell", + "title": "scrollToCell", + "text": "scrollToCell Ensure the specified column and/or row is visible (scrolling if necessary); see Grid#scrollToCell.\nIndices include the fixed columns and rows (which are always visible, so they are not scrolled to).\nThe fixed rows and columns are kept in sync with the scrolled section.\n scrollToCell(params: {\n align?: Alignment;\n columnIndex?: number;\n rowIndex?: number;\n }): void;" + }, + { + "id": "scrollToPosition", + "title": "scrollToPosition", + "text": "scrollToPosition Scroll to the specified offset(s) of the scrollable (bottom-right) section; see Grid#scrollToPosition.\nThe fixed rows and columns are kept in sync with it.\nThis allows a MultiGrid to be used with ScrollSync.\n scrollToPosition(params: ScrollPosition): void; MultiGrid's methods can be called through a ref (typed as MultiGridImperativeAPI). Indices passed to scrollToCell include the fixed rows and columns, and the fixed rows and columns are kept in sync with the scrollable section. (In version 10, these methods replace the scrollToRow, scrollToColumn, scrollToAlignment, scrollTop and scrollLeft props.) See Grid methods for more information about the underlying Grid methods." + } + ], + "path": "/grid/multi-grid-methods", + "section": "Grid", + "text": "Public (imperative) methods of a MultiGrid instance.", + "title": "MultiGrid methods" + }, + { + "headings": [ + { + "id": "cellRenderer", + "title": "cellRenderer", + "text": "cellRenderer: CellRenderer Responsible for rendering a cell given an row and column index.\nIt receives columnIndex, isScrolling, isVisible, key, parent, rowIndex, and style parameters.\n The style parameter specifies how the cell is to be sized and positioned and must be passed through to the rendered cell element.\nThe key parameter must also be applied to the rendered element." + }, + { + "id": "columnCount", + "title": "columnCount", + "text": "columnCount: number Number of columns in grid." + }, + { + "id": "columnWidth", + "title": "columnWidth", + "text": "columnWidth: CellSize Either a fixed column width (number) or a function that returns the width of a column given its index: ({ index: number }): number.\nIf function is used, specify estimatedColumnSize for more consistent scrolling behavior." + }, + { + "id": "rowCount", + "title": "rowCount", + "text": "rowCount: number Number of rows in grid." + }, + { + "id": "rowHeight", + "title": "rowHeight", + "text": "rowHeight: CellSize Either a fixed row height (number) or a function that returns the height of a row given its index: ({ index: number }): number.\nIf function is used, specify estimatedRowSize for more consistent scrolling behavior." + }, + { + "id": "width", + "title": "width", + "text": "width: number Width of Grid; this property determines the number of visible (vs virtualized) columns." + }, + { + "id": "height", + "title": "height", + "text": "height: number Height of the MultiGrid (including its fixed rows); determines the number of visible (vs virtualized) rows." + }, + { + "id": "classNameBottomLeftGrid", + "title": "classNameBottomLeftGrid", + "text": "classNameBottomLeftGrid?: string = \"\" Optional custom className to attach to bottom-left Grid element." + }, + { + "id": "classNameBottomRightGrid", + "title": "classNameBottomRightGrid", + "text": "classNameBottomRightGrid?: string = \"\" Optional custom className to attach to bottom-right Grid element." + }, + { + "id": "classNameTopLeftGrid", + "title": "classNameTopLeftGrid", + "text": "classNameTopLeftGrid?: string = \"\" Optional custom className to attach to top-left Grid element." + }, + { + "id": "classNameTopRightGrid", + "title": "classNameTopRightGrid", + "text": "classNameTopRightGrid?: string = \"\" Optional custom className to attach to top-right Grid element." + }, + { + "id": "enableFixedColumnScroll", + "title": "enableFixedColumnScroll", + "text": "enableFixedColumnScroll?: boolean = false Fixed columns can be actively scrolled (e.g. with a mouse wheel while the cursor is over them)." + }, + { + "id": "enableFixedRowScroll", + "title": "enableFixedRowScroll", + "text": "enableFixedRowScroll?: boolean = false Fixed rows can be actively scrolled (e.g. with a mouse wheel while the cursor is over them)." + }, + { + "id": "fixedColumnCount", + "title": "fixedColumnCount", + "text": "fixedColumnCount?: number = 0 Number of fixed columns." + }, + { + "id": "fixedRowCount", + "title": "fixedRowCount", + "text": "fixedRowCount?: number = 0 Number of fixed rows." + }, + { + "id": "hideBottomLeftGridScrollbar", + "title": "hideBottomLeftGridScrollbar", + "text": "hideBottomLeftGridScrollbar?: boolean = false Optionally hides the bottom-left Grid scrollbar by adding an additional wrapper.\nOnly useful if enableFixedColumnScroll is set to true." + }, + { + "id": "hideTopRightGridScrollbar", + "title": "hideTopRightGridScrollbar", + "text": "hideTopRightGridScrollbar?: boolean = false Optionally hides the top-right Grid scrollbar by adding an additional wrapper.\nOnly useful if enableFixedRowScroll is set to true." + }, + { + "id": "style", + "title": "style", + "text": "style?: CSSProperties = {} Optional custom inline style to attach to root MultiGrid element." + }, + { + "id": "styleBottomLeftGrid", + "title": "styleBottomLeftGrid", + "text": "styleBottomLeftGrid?: CSSProperties = {} Optional custom inline style to attach to bottom-left Grid element." + }, + { + "id": "styleBottomRightGrid", + "title": "styleBottomRightGrid", + "text": "styleBottomRightGrid?: CSSProperties = {} Optional custom inline style to attach to bottom-right Grid element." + }, + { + "id": "styleTopLeftGrid", + "title": "styleTopLeftGrid", + "text": "styleTopLeftGrid?: CSSProperties = {} Optional custom inline style to attach to top-left Grid element." + }, + { + "id": "styleTopRightGrid", + "title": "styleTopRightGrid", + "text": "styleTopRightGrid?: CSSProperties = {} Optional custom inline style to attach to top-right Grid element.\n All other optional Grid props are also supported." + } + ], + "path": "/grid/multi-grid-props", + "section": "Grid", + "text": "MultiGrid accepts most of the Grid props (except scrollElement); see fixed rows and columns. The props below are specific to MultiGrid (or are handled differently than by Grid).", + "title": "MultiGrid props" + }, + { + "headings": [ + { + "id": "choosing-a-value", + "title": "Choosing a value", + "text": "Choosing a value Overscanning has performance implications: the higher the value, the more cells need to be rendered in reaction to each scroll event. (See this slide for a visual example of how windowing works.) Setting an overscan value too high will erase the performance gains achieved by virtualization. Use the default value (10 rows) as a starting point and lower it if possible; aim for the lowest value that still generally avoids empty space appearing during normal scrolling. The same advice applies to overscanColumnCount. Consider tuning these values when: Cells are expensive to render: lower the overscan count so that less work is done per scroll event (and consider rendering a light-weight placeholder using the isScrolling parameter). Cells are cheap to render but blank space appears while scrolling quickly: raise the overscan count a little. A grid scrolls horizontally: overscanColumnCount defaults to 0, so a small value may help with horizontal scrolling." + }, + { + "id": "custom-overscan-behavior", + "title": "Custom overscan behavior", + "text": "Custom overscan behavior Which cells are overscanned is determined by the overscanIndicesGetter prop, an advanced property that calculates the range of cells to render before and after the visible range, along one axis at a time (it is called once for rows and once for columns). By default (defaultOverscanIndicesGetter), cells are only overscanned in the direction the user is scrolling, which is where content will be needed next. The library also exports an accessibilityOverscanIndicesGetter, which always renders at least one extra cell in both directions. This is necessary to support keyboard navigation (Tab / Shift+Tab) between cells in some cases. You can also provide your own function; for example, this one overscans the same number of cells in both directions (it is the \"custom\" option in the demo above): import type { OverscanIndicesGetter } from \"react-virtualized\";\n \n// Overscan the same number of cells before and after the visible range,\n// regardless of which direction the user is scrolling\nconst overscanIndicesGetter: OverscanIndicesGetter = ({\n cellCount, // Number of rows or columns in the current axis\n overscanCellsCount, // Maximum number of cells to over-render in either direction\n startIndex, // Begin of range of visible cells\n stopIndex // End of range of visible cells\n // Also available:\n // direction: \"horizontal\" or \"vertical\"\n // scrollDirection: 1 (forward) or -1 (backward)\n}) => ({\n overscanStartIndex: Math.max(0, startIndex - overscanCellsCount),\n overscanStopIndex: Math.min(cellCount - 1, stopIndex + overscanCellsCount)\n}); The function returns an object with the (inclusive) overscanStartIndex and overscanStopIndex of the cells to render. Make sure the returned range stays within 0 and cellCount - 1." + } + ], + "path": "/grid/overscan", + "section": "Grid", + "text": "Virtualized components only render the rows and columns that are visible. \"Overscanning\" means rendering a few additional rows or columns just outside of the visible area, so that they are already in place when the user scrolls to them. This reduces the chance of a user scrolling faster than virtualized content can be rendered, which would briefly show empty space (or cause flickering on certain browsers and devices). See this slide for a visual example of why overscanning exists. Grid (and the components built on top of it, like List and Table) supports two overscan props: overscanRowCount: the number of rows to render above/below the visible rows; defaults to 10 overscanColumnCount: the number of columns to render before/after the visible columns; defaults to 0 By default, extra cells are only rendered in the direction the user is scrolling (e.g. below the visible rows when scrolling down), since that is where content will be needed next. Scroll the grid below to see the range of rendered rows change: defaultOverscanIndicesGetter import { Grid, type RenderedSection } from \"react-virtualized\";\n \nfunction OverscanDemo({\n onSectionRendered,\n overscanIndicesGetter,\n overscanRowCount,\n width\n}: {\n onSectionRendered: (section: RenderedSection) => void;\n overscanIndicesGetter: OverscanIndicesGetter;\n overscanRowCount: number;\n width: number;\n}) {\n return (\n \n );\n}", + "title": "Overscan" + }, + { + "headings": [ + { + "id": "cellRenderer", + "title": "cellRenderer", + "text": "cellRenderer: CellRenderer Responsible for rendering a cell given an row and column index.\nIt receives columnIndex, isScrolling, isVisible, key, parent, rowIndex, and style parameters.\n The style parameter specifies how the cell is to be sized and positioned and must be passed through to the rendered cell element.\nThe key parameter must also be applied to the rendered element." + }, + { + "id": "columnCount", + "title": "columnCount", + "text": "columnCount: number Number of columns in grid." + }, + { + "id": "columnWidth", + "title": "columnWidth", + "text": "columnWidth: CellSize Either a fixed column width (number) or a function that returns the width of a column given its index: ({ index: number }): number.\nIf function is used, specify estimatedColumnSize for more consistent scrolling behavior." + }, + { + "id": "rowCount", + "title": "rowCount", + "text": "rowCount: number Number of rows in grid." + }, + { + "id": "rowHeight", + "title": "rowHeight", + "text": "rowHeight: CellSize Either a fixed row height (number) or a function that returns the height of a row given its index: ({ index: number }): number.\nIf function is used, specify estimatedRowSize for more consistent scrolling behavior." + }, + { + "id": "width", + "title": "width", + "text": "width: number Width of Grid; this property determines the number of visible (vs virtualized) columns." + }, + { + "id": "aria-label", + "title": "aria-label", + "text": "aria-label?: string = \"grid\" ARIA label for the grid element." + }, + { + "id": "aria-readonly", + "title": "aria-readonly", + "text": "aria-readonly?: boolean = true ARIA readonly attribute for the grid element." + }, + { + "id": "autoContainerWidth", + "title": "autoContainerWidth", + "text": "autoContainerWidth?: boolean = false Set the width of the inner scrollable container to 'auto'.\nThis is useful for single-column Grids to ensure that the column doesn't extend below a vertical scrollbar." + }, + { + "id": "cellRangeRenderer", + "title": "cellRangeRenderer", + "text": "cellRangeRenderer?: CellRangeRenderer Responsible for rendering a group of cells given their index ranges.\n This is an advanced property.\nIt is useful for situations where the Grid requires additional, overlayed UI (such as a Gantt chart or a calendar application).\nThe easiest way to override it is to decorate defaultCellRangeRenderer.\n Defaults to defaultCellRangeRenderer." + }, + { + "id": "className", + "title": "className", + "text": "className?: string Optional custom CSS class name to attach to root Grid element." + }, + { + "id": "containerProps", + "title": "containerProps", + "text": "containerProps?: HTMLAttributes Additional props (eg onWheel) to spread onto the outer Grid element." + }, + { + "id": "containerRole", + "title": "containerRole", + "text": "containerRole?: string = \"row\" ARIA role for the inner cell-container." + }, + { + "id": "containerStyle", + "title": "containerStyle", + "text": "containerStyle?: CSSProperties = {} Optional custom inline style to attach to inner cell-container element." + }, + { + "id": "deferredMeasurementCache", + "title": "deferredMeasurementCache", + "text": "deferredMeasurementCache?: CellMeasureCache If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache.\nA shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data." + }, + { + "id": "elementRef", + "title": "elementRef", + "text": "elementRef?: Ref Reference to the outer (scrolling) DOM element." + }, + { + "id": "estimatedColumnSize", + "title": "estimatedColumnSize", + "text": "estimatedColumnSize?: number = 100 Used to estimate the total width of a Grid before all of its columns have actually been measured.\nThe estimated total width is adjusted as columns are rendered." + }, + { + "id": "estimatedRowSize", + "title": "estimatedRowSize", + "text": "estimatedRowSize?: number = 30 Used to estimate the total height of a Grid before all of its rows have actually been measured.\nThe estimated total height is adjusted as rows are rendered." + }, + { + "id": "getScrollbarSize", + "title": "getScrollbarSize", + "text": "getScrollbarSize?: () => number Returns the size of the browser's scrollbar.\nExposed for testing purposes only.\n Defaults to dom-helpers' scrollbarSize (which returns undefined during server rendering)." + }, + { + "id": "height", + "title": "height", + "text": "height?: number Height of Grid; this property determines the number of visible (vs virtualized) rows.\nRequired, unless a scrollElement is specified.\n With a scrollElement, the height of the scroll element's viewport is used instead;\nthis prop is then only used until the scroll element has been measured (e.g. during server rendering)." + }, + { + "id": "id", + "title": "id", + "text": "id?: string Optional custom id to attach to root Grid element." + }, + { + "id": "initialScrollLeft", + "title": "initialScrollLeft", + "text": "initialScrollLeft?: number Horizontal scroll offset to initially render (and scroll to) the Grid at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the cells at this offset are rendered on the server,\nand they stay in place when the page is hydrated." + }, + { + "id": "initialScrollTop", + "title": "initialScrollTop", + "text": "initialScrollTop?: number Vertical scroll offset to initially render (and scroll to) the Grid at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the cells at this offset are rendered on the server,\nand they stay in place when the page is hydrated.\nIgnored if a scrollElement is specified (which then determines the vertical offset)." + }, + { + "id": "isScrollingOptOut", + "title": "isScrollingOptOut", + "text": "isScrollingOptOut?: boolean = false Prevents re-rendering of visible cells on scroll end.\n(Opts out of the isScrolling param passed to cellRangeRenderer, to avoid the extra render when scroll stops.)\nVisible cells are still re-rendered when the Grid's props change or forceUpdate() is called." + }, + { + "id": "noContentRenderer", + "title": "noContentRenderer", + "text": "noContentRenderer?: NoContentRenderer Optional renderer to be rendered inside the grid when either rowCount or columnCount is empty.\nRenders nothing by default." + }, + { + "id": "onScroll", + "title": "onScroll", + "text": "onScroll?: (params: Scroll) => void Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, clientWidth: number, scrollHeight: number, scrollLeft: number, scrollTop: number, scrollWidth: number }): void.\nThis callback can be used to sync scrolling between lists, tables, or grids." + }, + { + "id": "onScrollbarPresenceChange", + "title": "onScrollbarPresenceChange", + "text": "onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void Called whenever a horizontal or vertical scrollbar is added or removed:\n({ horizontal: boolean, size: number, vertical: boolean }): void.\nThis prop is not intended for end-user use;\nit is used by MultiGrid to support fixed-row/fixed-column scroll syncing." + }, + { + "id": "onSectionRendered", + "title": "onSectionRendered", + "text": "onSectionRendered?: (params: RenderedSection) => void Callback invoked with information about the section of the Grid that was just rendered.\nThis callback is only invoked when visible rows have changed:\n({ columnOverscanStartIndex: number, columnOverscanStopIndex: number, columnStartIndex: number, columnStopIndex: number, rowOverscanStartIndex: number, rowOverscanStopIndex: number, rowStartIndex: number, rowStopIndex: number }): void." + }, + { + "id": "overscanColumnCount", + "title": "overscanColumnCount", + "text": "overscanColumnCount?: number = 0 Number of columns to render before/after the visible slice of the grid.\nThis can help reduce flickering during scrolling on certain browsers/devices.\n Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event.\nAim to use the lowest value possible that still generally avoids any empty space from appearing during normal scrolling." + }, + { + "id": "overscanIndicesGetter", + "title": "overscanIndicesGetter", + "text": "overscanIndicesGetter?: OverscanIndicesGetter Responsible for calculating the number of cells to overscan before and after a specified range.\nBy default, cells are overscanned in the direction the user is scrolling.\nThis function receives direction, cellCount, scrollDirection, overscanCellsCount, startIndex, and stopIndex parameters\nand returns an { overscanStartIndex, overscanStopIndex } object.\n Defaults to defaultOverscanIndicesGetter." + }, + { + "id": "overscanRowCount", + "title": "overscanRowCount", + "text": "overscanRowCount?: number = 10 Number of rows to render above/below the visible slice of the grid.\nThis can help reduce flickering during scrolling on certain browsers/devices.\n Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event.\nThe default value (10) is a good starting point; lower it if possible." + }, + { + "id": "role", + "title": "role", + "text": "role?: string = \"grid\" Optional override of ARIA role default." + }, + { + "id": "scrollElement", + "title": "scrollElement", + "text": "scrollElement?: ScrollElement | null Element that scrolls the Grid (instead of the Grid scrolling itself): \"window\", or a scrollable DOM element\n(e.g. a container that the Grid is rendered within, along with other content).\n The Grid then renders at its full height (it may still scroll horizontally itself), renders only the rows visible within the scroll element's viewport,\nand its scroll methods scroll the scroll element.\nIts position within the scroll element is measured when the scroll element scrolls or is resized, and when the Grid re-renders\n(e.g. call forceUpdate() if content above the Grid changes size without the Grid re-rendering).\nPass null while the scroll element isn't available yet (e.g. before a ref to it has been attached);\nthe Grid then renders the rows at its top." + }, + { + "id": "scrollingResetTimeInterval", + "title": "scrollingResetTimeInterval", + "text": "scrollingResetTimeInterval?: number = 150 Wait this amount of time (in milliseconds) after the last scroll event before resetting Grid pointer-events." + }, + { + "id": "style", + "title": "style", + "text": "style?: CSSProperties = {} Optional custom inline style to attach to root Grid element." + }, + { + "id": "tabIndex", + "title": "tabIndex", + "text": "tabIndex?: number | null = 0 Optional override of tab index default." + } + ], + "path": "/grid/props", + "section": "Grid", + "text": "Renders tabular data with virtualization along the vertical and horizontal axes.\nRow heights and column widths must be known ahead of time and specified as properties.\n Scrolling is done through the imperative handle (ref): see scrollToCell and scrollToPosition.", + "title": "Grid component props" + }, + { + "path": "/grid/scroll-to-cell", + "section": "Grid", + "text": "The scrollToCell method ensures that a row and/or column is visible, scrolling the grid if necessary. Call it through a ref to the grid. Enter a row and column index below and click \"Scroll\": import { useRef, useState, type FormEvent } from \"react\";\nimport {\n Grid,\n type Alignment,\n type GridImperativeAPI\n} from \"react-virtualized\";\n \nconst COLUMN_COUNT = 1000;\nconst ROW_COUNT = 1000;\n \nfunction GridScrollToCell({ width }: { width: number }) {\n const gridRef = useRef(null);\n \n const [align, setAlign] = useState(\"auto\");\n const [columnIndex, setColumnIndex] = useState(\"50\");\n const [rowIndex, setRowIndex] = useState(\"500\");\n \n const onSubmit = (event: FormEvent) => {\n event.preventDefault();\n \n gridRef.current?.scrollToCell({\n align,\n columnIndex: parseInt(columnIndex, 10),\n rowIndex: parseInt(rowIndex, 10)\n });\n };\n \n return (\n <>\n
\n \n \n \n \n
\n \n \n );\n} By default (\"auto\"), the grid scrolls the least amount possible to make the cell fully visible. Use the align parameter to align the cell to the \"start\" (top/left), \"center\", or \"end\" (bottom/right) of the grid instead. Pass only a rowIndex (or only a columnIndex) to scroll in one direction. scrollToCell is a one-time scroll: the grid doesn't remember the cell, so it isn't kept in view if the grid's size, its cell sizes or its row and column counts change afterwards. To scroll to a cell that is being added, call scrollToCell once the grid has rendered it (e.g. in an effect). Cells that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated sizes. Use scrollToPosition to scroll to specific offsets instead. In version 10, the scrollToRow, scrollToColumn, scrollToAlignment, scrollTop and scrollLeft props were replaced by the scrollToCell and scrollToPosition methods. The autoHeight, autoWidth and isScrolling props were removed as well: to scroll a Grid with the window, set its scrollElement prop to \"window\", in which case scrollToCell and scrollToPosition scroll the window (vertically).", + "title": "Scrolling to a cell" + }, + { + "path": "/grid/variable-sizes", + "section": "Grid", + "text": "Row heights and column widths must be known ahead of time, but they don't all need to be the same. Instead of a number, rowHeight and columnWidth can be functions that return the size of a row or column given its index. The example below renders 1,000 rows and 1,000 columns with variable column widths and (optionally) variable row heights: Use variable row heights import { Grid, type CellRendererParams } from \"react-virtualized\";\n \n// Column widths are returned by a function rather than being a fixed number\nfunction getColumnWidth({ index }: { index: number }) {\n switch (index) {\n case 0:\n return 50;\n case 1:\n return 100;\n case 2:\n return 300;\n default:\n return 80;\n }\n}\n \n// Row heights can be returned by a function too\nfunction getRowHeight({ index }: { index: number }) {\n return list[index % list.length].size;\n}\n \nfunction cellRenderer({\n columnIndex,\n key,\n rowIndex,\n style\n}: CellRendererParams) {\n const item = list[rowIndex % list.length];\n \n if (columnIndex === 0) {\n return (\n \n {item.name.charAt(0)}\n
\n );\n }\n \n let content;\n switch (columnIndex) {\n case 1:\n content = item.name;\n break;\n case 2:\n content = item.random;\n break;\n default:\n content = `r:${rowIndex}, c:${columnIndex}`;\n break;\n }\n \n return (\n 2 ? \"justify-center\" : \"\"\n ].join(\" \")}\n key={key}\n style={style}\n >\n {content}\n
\n );\n}\n \nfunction GridVariableSizes({\n useDynamicRowHeight,\n width\n}: {\n useDynamicRowHeight: boolean;\n width: number;\n}) {\n return (\n
No cells
}\n estimatedRowSize={75}\n rowCount={1000}\n rowHeight={useDynamicRowHeight ? getRowHeight : 40}\n width={width}\n />\n );\n} When sizes are functions, also specify estimatedRowSize and/or estimatedColumnSize. Cells are only measured as they are rendered, so these estimates are used to calculate the total size of the grid (and the size of its scrollbars) before all of its cells have been measured, which makes scrolling more consistent. Grid has no way of knowing when the data behind a rowHeight or columnWidth function changes. If sizes change, call the recomputeGridSize method. For sizes that depend on the rendered content, see CellMeasurer; to calculate column widths from the available width, see ColumnSizer.", + "title": "Variable row and column sizes" + }, + { + "headings": [ + { + "id": "forked-styles", + "title": "Forked styles", + "text": "Forked styles Copy the react-virtualized styles.css file into your project and load your own, totally custom version of it instead of the default one." + }, + { + "id": "global-css", + "title": "Global CSS", + "text": "Global CSS Load an additional stylesheet (after the default one) that overrides or adds to the global class names that react-virtualized components render with. For example: /* Loaded after react-virtualized/styles.css */\n.ReactVirtualized__Table__headerRow {\n text-transform: none;\n border-bottom: 2px solid #ccc;\n}\n \n.ReactVirtualized__Table__row:hover {\n background-color: #f5f5f5;\n}\n \n.ReactVirtualized__Table__sortableHeaderIcon {\n fill: #2196f3;\n} The following global class names are used: Grid ReactVirtualized__Grid ReactVirtualized__Grid__innerScrollContainer List ReactVirtualized__List Table ReactVirtualized__Table ReactVirtualized__Table__Grid ReactVirtualized__Table__headerRow ReactVirtualized__Table__headerColumn ReactVirtualized__Table__headerTruncatedText ReactVirtualized__Table__sortableHeaderColumn ReactVirtualized__Table__sortableHeaderIcon ReactVirtualized__Table__sortableHeaderIcon--ASC ReactVirtualized__Table__sortableHeaderIcon--DESC ReactVirtualized__Table__row ReactVirtualized__Table__rowColumn Masonry ReactVirtualized__Masonry ReactVirtualized__Masonry__innerScrollContainer" + }, + { + "id": "custom-class-names", + "title": "Custom class names", + "text": "Custom class names Components also accept custom class names, which are added alongside the default ones. For example Table supports className, gridClassName, headerClassName, and rowClassName (which can also be a function of the row index), and Column supports className and headerClassName. These work well with utility CSS frameworks; the table below is styled with Tailwind CSS classes: import { Column, Table } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n return (\n \n height !== undefined && width !== undefined ? (\n \n index < 0\n ? \"border-b-2 border-white/20\"\n : index % 2\n ? \"hover:bg-white/10\"\n : \"bg-white/5 hover:bg-white/10\"\n }\n rowCount={items.length}\n rowGetter={({ index }) => items[index]}\n rowHeight={30}\n width={width}\n >\n \n \n (\n \n )}\n dataKey=\"color\"\n label=\"Color\"\n style={{ display: \"flex\", alignItems: \"center\" }}\n width={60}\n />\n \n ) : null\n }\n />\n );\n} They also work well with CSS modules: .table {\n font-size: 0.875rem;\n}\n \n.headerColumn {\n color: #7dd3fc;\n}\n \n.row {\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n} import { Column, Table } from \"react-virtualized\";\nimport styles from \"./CustomizingStylesCssModules.module.css\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n return (\n items[index]}\n rowHeight={30}\n width={400}\n >\n \n \n \n );\n}" + }, + { + "id": "inline-styles", + "title": "Inline styles", + "text": "Inline styles Components also support inline style props for style overrides (e.g. Table supports style, gridStyle, headerStyle, and rowStyle): import { Column, Table } from \"react-virtualized\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n return (\n items[index]}\n rowHeight={30}\n rowStyle={({ index }) =>\n index % 2 === 0 ? { backgroundColor: \"rgba(255, 255, 255, 0.05)\" } : {}\n }\n style={{ fontSize: \"0.875rem\" }}\n width={400}\n >\n \n \n \n );\n} See the props pages (e.g. List props, Grid props, Table props, and Column props) to learn which class name and style props each component supports." + } + ], + "path": "/guides/customizing-styles", + "section": "Guides", + "text": "react-virtualized components position their rows and cells using inline styles, but the rest of their default appearance is defined in a separate stylesheet. Keeping these styles in their own file simplifies universal (server-side rendered) usage while also allowing them to be customized. This stylesheet should be imported once, usually while bootstrapping your application: // Import the default styles once, e.g. in your app's entry point\nimport \"react-virtualized/styles.css\"; The default stylesheet is small. Most of it is for Table (laying out header and row columns, truncating header text, and the sort indicator). List, Grid, and the other components work without it. Component styles can be customized in any of the ways below.", + "title": "Customizing styles" + }, + { + "path": "/guides/infinite-loading-list", + "section": "Guides", + "text": "The InfiniteLoader component was created to help break large data sets down into chunks that can be just-in-time loaded as they are scrolled into view. It can also be used to create an infinite-loading list (like a social media feed), where the total number of items isn't known up front and more items are loaded as the user reaches the end of the list. The trick is to render one extra row at the end of the list (as long as there are more items to load) to hold a loading indicator. That row is the only one that is not \"loaded\", so when it scrolls into view (or within InfiniteLoader's threshold), InfiniteLoader asks for the next page to be loaded. Here's a basic example of how you might implement that: import {\n InfiniteLoader,\n List,\n type RowRendererParams\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction InfiniteLoadingList({\n hasNextPage,\n isNextPageLoading,\n items,\n loadNextPage\n}: {\n /** Are there more items to load? (This information comes from the most recent API request.) */\n hasNextPage: boolean;\n /** Are we currently loading a page of items? (This may be an in-flight flag in your store, for example.) */\n isNextPageLoading: boolean;\n /** List of items loaded so far */\n items: ListItem[];\n /** Callback function responsible for loading the next page of items */\n loadNextPage: () => Promise;\n}) {\n // If there are more items to be loaded then add an extra row to hold a loading indicator.\n const rowCount = hasNextPage ? items.length + 1 : items.length;\n \n // Only load 1 page of items at a time.\n // Pass an empty callback to InfiniteLoader in case it asks us to load more than once.\n const loadMoreRows = isNextPageLoading ? () => {} : loadNextPage;\n \n // Every row is loaded except for our loading indicator row.\n const isRowLoaded = ({ index }: { index: number }) =>\n !hasNextPage || index < items.length;\n \n // Render a list item or a loading indicator.\n const rowRenderer = ({ index, key, style }: RowRendererParams) => (\n \n {isRowLoaded({ index }) ? (\n items[index].name\n ) : (\n Loading...\n )}\n
\n );\n \n return (\n \n {({ onRowsRendered, registerChild }) => (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n )}\n \n );\n} The component above doesn't care where its data comes from. The demo on this page manages it with React state and simulates a network request for each page of items: import { useState } from \"react\";\n \nconst PAGE_SIZE = 25;\nconst MAX_ITEMS = 250;\n \nfunction InfiniteLoadingListDemo() {\n const [items, setItems] = useState([]);\n const [isNextPageLoading, setIsNextPageLoading] = useState(false);\n \n const hasNextPage = items.length < MAX_ITEMS;\n \n const loadNextPage = async () => {\n setIsNextPageLoading(true);\n \n const page = await fetchPage(items.length, PAGE_SIZE);\n \n setItems((prevItems) => [...prevItems, ...page]);\n setIsNextPageLoading(false);\n };\n \n return (\n \n );\n} Only one page is loaded at a time: while a page is loading, loadMoreRows is a no-op, in case InfiniteLoader asks for more rows to be loaded more than once.", + "title": "Infinite-loading lists" + }, + { + "headings": [ + { + "id": "how-it-works", + "title": "How it works", + "text": "How it works createTableMultiSort is passed a callback function, which it calls whenever the sort criteria change. It returns an object with three properties: sort: a function to pass to the Table sort prop; it updates the sort criteria in response to header clicks. sortBy: an ordered array of the fields data is sorted by, in order of importance. sortDirection: a map of field name to its sort direction (\"ASC\" or \"DESC\"). import { createTableMultiSort } from \"react-virtualized\";\n \nconst sortState = createTableMultiSort(({ sortBy, sortDirection }) => {\n // \"sortBy\" is an ordered array of fields, e.g. [\"lastName\", \"firstName\"]\n // \"sortDirection\" maps each field to \"ASC\" or \"DESC\", e.g. { lastName: \"ASC\", firstName: \"DESC\" }\n // Sort your collection however you'd like,\n // then update your component state (or store) to re-render the Table.\n console.log(sortBy, sortDirection);\n});\n \n// sortState.sort: pass this to the Table \"sort\" prop\n// sortState.sortBy and sortState.sortDirection: the current sort criteria Because the Table sortBy and sortDirection props only describe a single column, they should be left undefined. Instead, use a custom headerRenderer to show a SortIndicator for each of the sorted columns: import { useMemo, useState } from \"react\";\nimport {\n Column,\n createTableMultiSort,\n SortIndicator,\n Table,\n type ColumnProps\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \ntype SortState = {\n sortBy: string[];\n sortDirection: { [dataKey: string]: \"ASC\" | \"DESC\" };\n};\n \nfunction Example() {\n // Sort criteria are stored in React state so that changes re-render the Table\n const [{ sortBy, sortDirection }, setSortState] = useState({\n sortBy: [\"name\"],\n sortDirection: { name: \"ASC\" }\n });\n \n // createTableMultiSort tracks the sort criteria;\n // it calls this function (with new sortBy and sortDirection values) whenever they change.\n const [multiSort] = useState(() =>\n createTableMultiSort(\n ({ sortBy, sortDirection }) => setSortState({ sortBy, sortDirection }),\n {\n defaultSortBy: [\"name\"],\n defaultSortDirection: { name: \"ASC\" }\n }\n )\n );\n \n // Sort the collection by each field, in order of importance\n const sortedList = useMemo(\n () =>\n [...list].sort((a, b) => {\n for (const dataKey of sortBy) {\n const key = dataKey as keyof ListItem;\n const order = a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0;\n if (order !== 0) {\n return sortDirection[dataKey] === \"DESC\" ? -order : order;\n }\n }\n return 0;\n }),\n [sortBy, sortDirection]\n );\n \n // Use the multi-sort state (rather than the Table's sortBy prop) to show indicators\n const headerRenderer: ColumnProps[\"headerRenderer\"] = ({\n dataKey,\n label\n }) => {\n const position = sortBy.indexOf(dataKey);\n return (\n
\n \n {label}\n \n {position >= 0 && (\n <>\n \n {sortBy.length > 1 && (\n {position + 1}\n )}\n \n )}\n
\n );\n };\n \n return (\n \n width !== undefined ? (\n sortedList[index]}\n rowHeight={30}\n // Connect the multi-sort state to Table by way of the \"sort\" prop\n sort={multiSort.sort}\n sortBy={undefined}\n sortDirection={undefined}\n width={width}\n >\n \n \n \n \n ) : null\n }\n />\n );\n} New sortBy and sortDirection values are created each time the sort criteria change (previous values are never mutated), so they can be stored in React state as they are. In version 9, they were mutated in place and had to be copied first." + }, + { + "id": "default-sort-criteria", + "title": "Default sort criteria", + "text": "Default sort criteria createTableMultiSort also accepts default sort-by values. Fields without a default direction are sorted in ascending order. import { createTableMultiSort } from \"react-virtualized\";\n \nconst sortState = createTableMultiSort(sortCallback, {\n defaultSortBy: [\"firstName\", \"lastName\"],\n defaultSortDirection: {\n firstName: \"ASC\",\n lastName: \"ASC\"\n }\n});" + } + ], + "path": "/guides/multi-column-sort-table", + "section": "Guides", + "text": "By default, Table assumes that its data will be sorted by a single attribute, in either ascending or descending order. For advanced use cases, you may want to sort by multiple fields. This can be accomplished using the createTableMultiSort utility. Try it out in the table above: Click a column header to sort by that column only (or to reverse its sort direction). SHIFT + click a column header to add it to the existing sort criteria (or to reverse its direction if it is already included). CONTROL + click (or META + click) a column header to remove it from the sort criteria.", + "title": "Multi-column sort tables" + }, + { + "path": "/guides/natural-sort-table", + "section": "Guides", + "text": "By default, Table assumes that sortable rows can be sorted in ascending or descending order. Clicking a column header the first time calls the sort callback with a suggested ordering of ascending. Clicking it a second time suggests descending, and clicking a third time suggests ascending again. This is often desirable, but it has a subtle side effect: once a Table has been sorted, there is no way to return to the unsorted, “natural” order. Fortunately this is easy to implement in your application code. In the table below, clicking a column header a third time returns the rows to their natural order: Rather than always applying the suggested direction, the sort callback checks whether the table was already sorted in descending order by the clicked column. If it was, it clears the sort state (so that sortBy and sortDirection are undefined) and the unsorted list is rendered: import { useMemo, useState } from \"react\";\nimport {\n Column,\n SortDirection,\n Table,\n type TableProps\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example() {\n // Start out unsorted, in the list's \"natural\" order\n const [sortBy, setSortBy] = useState(undefined);\n const [sortDirection, setSortDirection] =\n useState(undefined);\n \n const sortedList = useMemo(() => {\n if (!sortBy) {\n return list; // Natural order\n }\n \n const key = sortBy as keyof ListItem;\n const sorted = [...list].sort((a, b) =>\n a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0\n );\n \n return sortDirection === SortDirection.DESC ? sorted.reverse() : sorted;\n }, [sortBy, sortDirection]);\n \n const sort: TableProps[\"sort\"] = (params) => {\n if (sortBy === params.sortBy && sortDirection === SortDirection.DESC) {\n // If the list was sorted DESC by this column,\n // return to the \"natural\" order rather than switching to ASC.\n setSortBy(undefined);\n setSortDirection(undefined);\n } else {\n setSortBy(params.sortBy);\n setSortDirection(params.sortDirection);\n }\n };\n \n return (\n \n width !== undefined ? (\n sortedList[index]}\n rowHeight={30}\n sort={sort}\n sortBy={sortBy}\n sortDirection={sortDirection}\n width={width}\n >\n \n \n \n \n ) : null\n }\n />\n );\n} See sorting for more about how Table sorting works.", + "title": "Natural sort tables" + }, + { + "path": "/guides/reverse-list", + "section": "Guides", + "text": "Sometimes it is desirable to display a list in reverse order, with the newest items at the top (like a feed of incoming messages). The simplest way to do this is to add new items to the front of the array (like unshift) instead of the end (like push). The List below has a new item added to it every half second: Pause new items Here is a template for doing this. It also shows how to scroll back to the top of the list (using the scrollToRow method) when new items are added: import { useEffect, useRef, useState } from \"react\";\nimport {\n List,\n type ListImperativeAPI,\n type RowRendererParams\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nconst MAX_ITEMS = 100;\n \nfunction ReverseList({ paused }: { paused: boolean }) {\n const listRef = useRef(null);\n const [items, setItems] = useState([]);\n \n useEffect(() => {\n if (paused) {\n return;\n }\n \n const interval = setInterval(() => {\n // Add new items to the front of the list (instead of the end)\n setItems((prevItems) => [createItem(), ...prevItems].slice(0, MAX_ITEMS));\n }, 500);\n \n return () => clearInterval(interval);\n }, [paused]);\n \n useEffect(() => {\n // If you want to scroll to the top when items are added, you can do it like this\n listRef.current?.scrollToRow({ index: 0 });\n }, [items]);\n \n const rowRenderer = ({ index, key, style }: RowRendererParams) => {\n const item = items[index];\n \n return (\n \n \n {item.name.charAt(0)}\n
\n
\n
{item.name}
\n
\n {item.createdAt.toLocaleTimeString()} · {item.random}\n
\n
\n
\n );\n };\n \n return (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n}", + "title": "Displaying items in reverse order" + }, + { + "headings": [ + { + "id": "sizes", + "title": "Sizes", + "text": "Sizes List, Grid, and Table need a height and width to know how many rows to render. AutoSizer can't measure anything on the server, so the sizes it provides are undefined there (and during hydration). Render with fallback sizes until it has measured the available space; otherwise nothing is rendered on the server: import { List } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \n// Sizes to render with on the server (e.g. a typical viewport),\n// before AutoSizer has measured the available space\nconst SERVER_HEIGHT = 600;\nconst SERVER_WIDTH = 800;\n \nfunction Example({\n rowCount,\n rowRenderer\n}: {\n rowCount: number;\n rowRenderer: RowRenderer;\n}) {\n return (\n (\n \n )}\n />\n );\n} Once the page is hydrated, AutoSizer measures the available space and the list re-renders at its actual size. A component with a scrollElement uses its height prop the same way: as the height of the viewport until the scroll element has been measured." + }, + { + "id": "rendering-at-an-initial-scroll-position", + "title": "Rendering at an initial scroll position", + "text": "Rendering at an initial scroll position To render a list scrolled to a position (e.g. one restored from the URL when the user navigates back), use the initialScrollTop prop (and initialScrollLeft for a Grid or MultiGrid). The rows at that offset are rendered on the server, and the list is scrolled to it when it mounts: import { List } from \"react-virtualized\";\n \nfunction Example({\n rowCount,\n rowRenderer,\n savedScrollTop\n}: {\n rowCount: number;\n rowRenderer: RowRenderer;\n // E.g. restored from the URL or from session storage\n savedScrollTop: number;\n}) {\n return (\n \n );\n} For example, this list is rendered scrolled to row 500 (an initialScrollTop of 15,000 pixels): The browser can't scroll an element before the page has been hydrated, so until then the server-rendered rows are shifted into view instead. After hydration the list is scrolled to the offset, and the rows stay in place. (Until then the scrollbar is shown at the top; if the user scrolls before the page has been hydrated, the rows they see stay in place as well.) Use initialScrollTop rather than calling scrollToRow in an effect: the effect only runs after hydration, so the page would first show the top of the list and then jump. Changing initialScrollTop after mounting has no effect; use scrollToPosition or scrollToRow to scroll a list that has already been rendered." + }, + { + "id": "measured-content", + "title": "Measured content", + "text": "Measured content CellMeasurer can only measure cells in the browser, so on the server (and during hydration) rows are rendered at the cache's default sizes, and they are re-positioned once they have been measured. Choose default sizes close to the typical size of your content to minimize the shift." + }, + { + "id": "limitations", + "title": "Limitations", + "text": "Limitations A component with a scrollElement is rendered from its top on the server, using its height prop as the height of the viewport; once hydrated, it renders the rows visible within the scroll element (e.g. the window). Its vertical position is determined by the scroll element, so initialScrollTop doesn't apply. Masonry doesn't support an initial scroll offset: cell positions are only known once the cells before them have been measured, which can't happen on the server. The scrollbar size is measured after hydration, so the space reserved for scrollbars may change slightly at that point." + } + ], + "path": "/guides/server-rendering", + "section": "Guides", + "text": "react-virtualized components can be rendered on the server (e.g. with renderToString or a framework like Next.js) and hydrated on the client. The server renders the rows (or cells) that would be visible, so the page shows content before JavaScript has loaded. This guide covers the things to keep in mind.", + "title": "Server rendering" + }, + { + "headings": [ + { + "id": "filling-the-available-space", + "title": "Filling the available space", + "text": "Filling the available space In the example below, the list fills its container. Drag the bottom edge of the container to resize it; the list will resize to match. import { List, type RowRendererParams } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n const rowRenderer = ({ index, key, style }: RowRendererParams) => (\n \n {items[index].name}\n
\n );\n \n return (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n} Grid and Table work the same way: import { Grid, type CellRendererParams } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nconst columns = [\"name\", \"color\", \"random\"] as const;\n \nfunction Example({ items }: { items: ListItem[] }) {\n const cellRenderer = ({\n columnIndex,\n key,\n rowIndex,\n style\n }: CellRendererParams) => (\n
\n {items[rowIndex][columns[columnIndex]]}\n
\n );\n \n return (\n \n // Width and height are undefined until AutoSizer has measured its parent\n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n} import { Column, Table } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n return (\n \n height !== undefined && width !== undefined ? (\n items[index]}\n rowHeight={30}\n width={width}\n >\n \n \n \n ) : null\n }\n />\n );\n}" + }, + { + "id": "why-are-width-and-height-undefined", + "title": "Why are width and height undefined?", + "text": "Why are width and height undefined? AutoSizer can't know how much space is available until it has been mounted and has measured its parent, so the sizes are undefined during the first render. react-virtualized components require numbers, so render nothing (or a placeholder) until the values are defined. This can be done inside of a renderProp (as the other examples on this page do) or by passing a ChildComponent: import { List, type RowRendererParams } from \"react-virtualized\";\nimport {\n AutoSizer,\n type AutoSizerChildProps\n} from \"react-virtualized-auto-sizer\";\n \nfunction rowRenderer({ index, key, style }: RowRendererParams) {\n return (\n
\n {items[index].name}\n
\n );\n}\n \n// Define the child component outside of render so that it isn't re-created\n// (use renderProp instead if the list needs access to local state or props)\nfunction SizedList({ height, width }: AutoSizerChildProps) {\n if (height === undefined || width === undefined) {\n // Not measured yet (e.g. the first render, or during server rendering)\n return null;\n }\n \n return (\n \n );\n}\n \nfunction Example() {\n return ;\n}" + }, + { + "id": "why-is-my-autosizer-setting-a-height-of-0", + "title": "Why is my AutoSizer setting a height of 0?", + "text": "Why is my AutoSizer setting a height of 0? AutoSizer expands to fill its parent but it will not stretch the parent. If AutoSizer is reporting a height (or width) of 0, then it's likely that the parent element (or one of its parents) has a height of 0. One easy way to test this is to temporarily add a style to the parent (e.g. background-color: red) to check that it is the size you expect. You may need to add height: 100% or flex: 1 to the parent." + }, + { + "id": "can-i-use-autosizer-to-manage-only-width-or-height-not-both", + "title": "Can I use AutoSizer to manage only width or height (not both)?", + "text": "Can I use AutoSizer to manage only width or height (not both)? Yes. Only use the dimension you need, and only wait for that one to be defined. For example, a fixed-height list that grows to fill the available width can be created like so: import { List } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({\n rowCount,\n rowRenderer\n}: {\n rowCount: number;\n rowRenderer: RowRenderer;\n}) {\n return (\n \n // Only the width is used; the list has a fixed height\n width !== undefined ? (\n \n ) : null\n }\n />\n );\n} A component that is scrolled by the window (with the scrollElement prop) only needs the width, too: its height is that of the window's viewport." + }, + { + "id": "can-i-use-autosizer-within-a-flex-container", + "title": "Can I use AutoSizer within a flex container?", + "text": "Can I use AutoSizer within a flex container? AutoSizer measures its parent element, so when the space to fill is a flex item, wrap AutoSizer in a div that grows to fill that space. Also set min-height: 0 and min-width: 0 on that div; otherwise flex items can't shrink below the size of their content (the list), so the list would grow but never shrink. import { List } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({\n rowCount,\n rowRenderer\n}: {\n rowCount: number;\n rowRenderer: RowRenderer;\n}) {\n return (\n
\n {/* Other children... */}\n {/* AutoSizer measures this element; min-width/min-height let it shrink below the list's size */}\n
\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n
\n
\n );\n} Don't make AutoSizer's parent size itself to its content (e.g. height: auto or width: fit-content); the size it reports would then depend on the size of the list, and nothing would be rendered." + }, + { + "id": "can-i-use-autosizer-with-other-hocs-like-infiniteloader", + "title": "Can I use AutoSizer with other HOCs like InfiniteLoader?", + "text": "Can I use AutoSizer with other HOCs like InfiniteLoader? Yes. AutoSizer can be used within other react-virtualized HOCs such as InfiniteLoader or ScrollSync like so: import { InfiniteLoader, List } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({\n isRowLoaded,\n loadMoreRows,\n rowCount,\n rowRenderer\n}: {\n isRowLoaded: (params: { index: number }) => boolean;\n loadMoreRows: (params: {\n startIndex: number;\n stopIndex: number;\n }) => Promise;\n rowCount: number;\n rowRenderer: RowRenderer;\n}) {\n return (\n \n {({ onRowsRendered, registerChild }) => (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n )}\n \n );\n} See the InfiniteLoader page for a live example of this." + }, + { + "id": "responding-to-size-changes", + "title": "Responding to size changes", + "text": "Responding to size changes Use the onResize prop to be notified when the size changes, e.g. to clear a CellMeasurer cache when the width changes and text wraps differently. See react-virtualized-auto-sizer for its other props." + }, + { + "id": "migrating-from-react-virtualized-9x", + "title": "Migrating from react-virtualized 9.x", + "text": "Migrating from react-virtualized 9.x Earlier versions of react-virtualized included an AutoSizer component. It has been removed in version 10. To migrate to react-virtualized-auto-sizer (version 2): Import AutoSizer from \"react-virtualized-auto-sizer\" rather than \"react-virtualized\". Replace the children render function with the renderProp prop (or with the ChildComponent prop; see below). Handle undefined sizes: width and height are undefined until the parent has been measured (including during server rendering). Remove disableHeight and disableWidth; just ignore the dimension that you don't need. Remove defaultHeight and defaultWidth; render a placeholder while the size is undefined instead." + } + ], + "path": "/guides/sizing-components", + "section": "Guides", + "text": "Many react-virtualized components (e.g. List, Grid, and Table) require explicit pixel dimensions, but sometimes you just want a component to grow to fill all of the available space. The react-virtualized-auto-sizer package measures its parent element and passes the available width and height to its child. (A component that is scrolled by the window only needs a width; see below.) As of version 10, AutoSizer is no longer part of react-virtualized; use the react-virtualized-auto-sizer package instead. It works with all react-virtualized components (and with react-window). See migrating from react-virtualized 9.x below. npm install react-virtualized-auto-sizer", + "title": "Sizing components" + }, + { + "headings": [ + { + "id": "usage", + "title": "Usage", + "text": "Usage InfiniteLoader uses a render function as its child. That function receives two parameters, which should be passed to the virtualized component it renders: onRowsRendered should be passed as the child's onRowsRendered prop. It informs the loader when the user is scrolling. registerChild should be set as the child's ref. It enables a set of rows to be refreshed once their data has finished loading. The isRowLoaded prop tells the loader which rows have already been loaded, and loadMoreRows is called to load rows that haven't been. loadMoreRows should return a Promise that resolves once the data has finished loading; it is used to determine when to refresh the list with the newly-loaded data. import {\n InfiniteLoader,\n List,\n type RowRendererParams\n} from \"react-virtualized\";\n \n// This example assumes you have a way to know/load this information\nconst remoteRowCount = 1000;\n \nconst list: string[] = [];\n \nfunction isRowLoaded({ index }: { index: number }) {\n return !!list[index];\n}\n \nasync function loadMoreRows({\n startIndex,\n stopIndex\n}: {\n startIndex: number;\n stopIndex: number;\n}) {\n const response = await fetch(\n `path/to/api?startIndex=${startIndex}&stopIndex=${stopIndex}`\n );\n const names: string[] = await response.json();\n \n // Store response data in list...\n names.forEach((name, offset) => {\n list[startIndex + offset] = name;\n });\n}\n \nfunction rowRenderer({ index, key, style }: RowRendererParams) {\n return (\n
\n {list[index] ?? \"Loading...\"}\n
\n );\n}\n \nfunction Example() {\n return (\n \n {({ onRowsRendered, registerChild }) => (\n \n )}\n \n );\n} The number of rows to load at a time can be tuned with the minimumBatchSize prop (e.g. to reduce the number of HTTP requests), and how early rows are pre-fetched with the threshold prop. To build a list that loads more items as the user reaches its end (e.g. a feed), see infinite-loading lists." + }, + { + "id": "using-infiniteloader-with-grid", + "title": "Using InfiniteLoader with Grid", + "text": "Using InfiniteLoader with Grid It is not common to use InfiniteLoader and Grid together but it is possible, by mapping the range of rendered cells to a range of \"rows\" (one per cell): import {\n Grid,\n InfiniteLoader,\n type GridProps,\n type RenderedSection\n} from \"react-virtualized\";\n \nfunction Example({\n cellRenderer,\n columnCount,\n isRowLoaded,\n loadMoreRows,\n rowCount\n}: {\n cellRenderer: GridProps[\"cellRenderer\"];\n columnCount: number;\n isRowLoaded: (params: { index: number }) => boolean;\n loadMoreRows: (params: {\n startIndex: number;\n stopIndex: number;\n }) => Promise;\n rowCount: number;\n}) {\n return (\n \n {({ onRowsRendered, registerChild }) => {\n // Grid cells are mapped to a flat list of \"rows\" (one per cell)\n const onSectionRendered = ({\n columnStartIndex,\n columnStopIndex,\n rowStartIndex,\n rowStopIndex\n }: RenderedSection) => {\n onRowsRendered({\n startIndex: rowStartIndex * columnCount + columnStartIndex,\n stopIndex: rowStopIndex * columnCount + columnStopIndex\n });\n };\n \n return (\n \n );\n }}\n \n );\n}" + }, + { + "id": "edge-cases-and-considerations", + "title": "Edge cases and considerations", + "text": "Edge cases and considerations Tracking loaded (and loading) rows InfiniteLoader is not a stateful component, meaning that it does not keep track of which rows it has requested to be loaded. Your component will need to track this information itself to avoid loading rows multiple times. One way to do this is to use a map to track the status of each row: rows with no entry have never been requested, and rows with an entry (either \"loading\" or \"loaded\") can be treated as loaded as far as InfiniteLoader is concerned. The demo at the top of this page does this: import { useRef, useState } from \"react\";\nimport {\n InfiniteLoader,\n List,\n type InfiniteLoaderImperativeAPI,\n type RowRendererParams\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \ntype RowStatus = \"loading\" | \"loaded\";\n \nfunction Example() {\n const infiniteLoaderRef = useRef(null);\n \n // InfiniteLoader doesn't track which rows have been requested;\n // the application must do this to avoid loading rows more than once.\n const rowStatusMapRef = useRef(new Map());\n \n // Used to re-render after row statuses change\n const [, setVersion] = useState(0);\n \n const isRowLoaded = ({ index }: { index: number }) =>\n // Rows that are still loading are treated as loaded (to avoid requesting them again)\n rowStatusMapRef.current.has(index);\n \n const loadMoreRows = async ({\n startIndex,\n stopIndex\n }: {\n startIndex: number;\n stopIndex: number;\n }) => {\n const rowStatusMap = rowStatusMapRef.current;\n for (let index = startIndex; index <= stopIndex; index++) {\n rowStatusMap.set(index, \"loading\");\n }\n setVersion((version) => version + 1);\n \n // Simulate a network request\n await new Promise((resolve) =>\n setTimeout(resolve, 1000 + Math.random() * 2000)\n );\n \n if (rowStatusMap === rowStatusMapRef.current) {\n for (let index = startIndex; index <= stopIndex; index++) {\n rowStatusMap.set(index, \"loaded\");\n }\n setVersion((version) => version + 1);\n }\n };\n \n const clearData = () => {\n rowStatusMapRef.current = new Map();\n setVersion((version) => version + 1);\n \n // Reset InfiniteLoader's cache and reload the visible rows\n infiniteLoaderRef.current?.resetLoadMoreRowsCache(true);\n };\n \n const rowRenderer = ({ index, key, style }: RowRendererParams) => (\n \n {rowStatusMapRef.current.get(index) === \"loaded\" ? (\n list[index].name\n ) : (\n \n )}\n
\n );\n \n const statuses = Array.from(rowStatusMapRef.current.values());\n const loadingCount = statuses.filter((status) => status === \"loading\").length;\n const loadedCount = statuses.length - loadingCount;\n \n return (\n
\n
\n \n
\n {loadingCount} loading, {loadedCount} loaded\n
\n
\n
\n \n {({ onRowsRendered, registerChild }) => (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n )}\n \n
\n
\n );\n} Memoization and data changes InfiniteLoader memoizes calls to loadMoreRows in order to avoid multiple calls with the same parameters while a user is scrolling. This can have an unexpected impact if the underlying data changes: InfiniteLoader may not know to call loadMoreRows again because, from its point of view, it already made that call for a given range of rows. (React Virtualized components know nothing about the underlying data, only the number of items in the collection.) To address this, call the resetLoadMoreRowsCache method (through a ref) when the underlying data may have changed, e.g. a filtered list whose search criteria changes. (Alternately, your application can call its own loadMoreRows function again with the most recently requested range.) import { useEffect, useRef } from \"react\";\nimport {\n InfiniteLoader,\n List,\n type InfiniteLoaderImperativeAPI,\n type RowRendererParams\n} from \"react-virtualized\";\n \nfunction Example({\n isRowLoaded,\n items,\n loadMoreRows,\n rowCount,\n searchText\n}: {\n isRowLoaded: (params: { index: number }) => boolean;\n items: ListItem[];\n loadMoreRows: (params: {\n startIndex: number;\n stopIndex: number;\n }) => Promise;\n rowCount: number;\n searchText: string;\n}) {\n const infiniteLoaderRef = useRef(null);\n \n // When the search criteria changes, previously loaded rows are no longer valid.\n // Clear InfiniteLoader's cache and reload the most recently requested batch of rows.\n useEffect(() => {\n infiniteLoaderRef.current?.resetLoadMoreRowsCache(true);\n }, [searchText]);\n \n const rowRenderer = ({ index, key, style }: RowRendererParams) => (\n
\n {items[index]?.name ?? \"Loading...\"}\n
\n );\n \n return (\n \n {({ onRowsRendered, registerChild }) => (\n \n )}\n \n );\n}" + }, + { + "id": "methods", + "title": "Methods", + "text": "Methods Imperative API exposed by InfiniteLoader (e.g. via useRef(null))." + }, + { + "id": "resetLoadMoreRowsCache", + "title": "resetLoadMoreRowsCache", + "text": "resetLoadMoreRowsCache Reset any cached data about already-loaded rows.\nThis method should be called if any/all loaded data needs to be refetched (e.g. a filtered list where the search criteria changes).\nIf autoReload is true, the last loaded batch will be automatically reloaded.\n resetLoadMoreRowsCache(autoReload?: boolean): void;" + }, + { + "id": "children", + "title": "children", + "text": "children: (params: InfiniteLoaderChildProps) => ReactNode Function responsible for rendering a virtualized component.\nIt is passed onRowsRendered and registerChild parameters.\nThe onRowsRendered function should be passed through to the child's onRowsRendered property.\nThe registerChild callback should be set as the virtualized component's ref." + }, + { + "id": "isRowLoaded", + "title": "isRowLoaded", + "text": "isRowLoaded: (params: { index: number; }) => boolean Function responsible for tracking the loaded state of each row.\nRows that are currently loading can be treated as loaded to avoid requesting them multiple times." + }, + { + "id": "loadMoreRows", + "title": "loadMoreRows", + "text": "loadMoreRows: (params: IndexRange) => void | Promise Callback to be invoked when more rows must be loaded.\nThe returned Promise should be resolved once row data has finished loading.\nIt will be used to determine when to refresh the list with the newly-loaded data.\nThis callback may be called multiple times in reaction to a single scroll event." + }, + { + "id": "minimumBatchSize", + "title": "minimumBatchSize", + "text": "minimumBatchSize?: number = 10 Minimum number of rows to be loaded at a time.\nThis property can be used to batch requests to reduce HTTP requests." + }, + { + "id": "rowCount", + "title": "rowCount", + "text": "rowCount?: number = 0 Number of rows in list; can be arbitrary high number if actual number is unknown." + }, + { + "id": "threshold", + "title": "threshold", + "text": "threshold?: number = 15 Threshold at which to pre-fetch data.\nA threshold X means that data will start loading when a user scrolls within X rows." + } + ], + "path": "/infinite-loader", + "section": "Higher-order components", + "text": "InfiniteLoader manages just-in-time fetching of data as a user scrolls up or down in a list. It ensures that all visible rows have been loaded, and uses a threshold to determine how early to pre-fetch rows (before a user scrolls to them). This component is intended to assist with row-loading, so it is best suited for use with List and Table (although it can also be used with Grid). This is an advanced component and can be confusing in certain situations. See the edge cases and considerations section below for more information.", + "title": "InfiniteLoader" + }, + { + "headings": [ + { + "id": "empty-lists", + "title": "Empty lists", + "text": "Empty lists Use the noRowsRenderer prop to render placeholder content when rowCount is 0: import { List } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction noRowsRenderer() {\n return (\n
\n No rows\n
\n );\n}\n \nfunction Example() {\n return (\n \n height !== undefined && width !== undefined ? (\n null}\n width={width}\n />\n ) : null\n }\n />\n );\n}" + } + ], + "path": "/list", + "section": "List", + "text": "List renders a windowed (or \"virtualized\") list of rows, meaning that only the rows visible within the list (plus a few overscanned rows) are rendered. This keeps the number of DOM elements small, no matter how many rows the list contains. To render a list, specify its size (width and height), how many rows it contains (rowCount), the height of each row (rowHeight), and a function that renders a row (rowRenderer; see rendering rows). Rows are rendered through a function, rather than as children, for performance reasons. The example above uses AutoSizer to fill the available space: import { List, type RowRendererParams } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \n// List data as an array of objects, e.g.\n// [{ color: \"#f44336\", name: \"Brian Vaughn\" }, ...]\nconst list = LIST;\n \nfunction rowRenderer({ index, key, style }: RowRendererParams) {\n const item = list[index];\n \n // Rows must apply the \"style\" parameter (it positions and sizes the row)\n // and the \"key\" parameter (it identifies the row to React).\n return (\n \n \n {item.name.charAt(0)}\n
\n
\n
{item.name}
\n
This is row {index}
\n
\n
\n );\n}\n \nfunction Example() {\n return (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n} List uses a Grid internally to render its rows, and all props are relayed to that inner Grid. That means List also accepts Grid props in addition to its own List props. By default List renders 10 extra rows above and below the visible ones (overscanRowCount), which can help reduce flickering during scrolling on certain browsers and devices. See overscan for an important note about this property. The outer element of a list has the static class name ReactVirtualized__List; a custom className, id, and style can be specified too. See customizing styles for more information.", + "title": "Rendering a list" + }, + { + "headings": [ + { + "id": "forceUpdateGrid", + "title": "forceUpdateGrid", + "text": "forceUpdateGrid Forcefully re-render the inner Grid component.\nRe-rendering a List with the same props does not re-render its rows (both List and the inner Grid are memoized).\nUse this method if you want to manually trigger a re-render.\nThis may be appropriate if the underlying row data has changed but the row sizes themselves have not.\n forceUpdateGrid(): void;" + }, + { + "id": "getOffsetForRow", + "title": "getOffsetForRow", + "text": "getOffsetForRow Gets the vertical offset (scrollTop) for a given row and alignment (see scrollToRow for the align values).\nThe alignment defaults to \"auto\"; an index of -1 (the default) refers to the last row.\nalignment is a deprecated alias of align (align takes precedence if both are specified).\n getOffsetForRow(params?: {\n align?: Alignment;\n /** @deprecated Use `align` instead. */\n alignment?: Alignment;\n index?: number;\n }): number;" + }, + { + "id": "invalidateCellSizeAfterRender", + "title": "invalidateCellSizeAfterRender", + "text": "invalidateCellSizeAfterRender Invalidate the size of a row (after the current render has been committed); see Grid#invalidateCellSizeAfterRender.\nThis method is intended for CellMeasurer.\n invalidateCellSizeAfterRender(params: CellPosition): void;" + }, + { + "id": "measureAllRows", + "title": "measureAllRows", + "text": "measureAllRows Pre-measure all rows in a List.\nTypically rows are only measured as needed and estimated heights are used for cells that have not yet been measured.\nThis method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one).\n measureAllRows(): void;" + }, + { + "id": "recomputeRowHeights", + "title": "recomputeRowHeights", + "text": "recomputeRowHeights Recompute row heights and offsets after the specified index (defaults to 0).\nList has no way of knowing when its underlying list data has changed since it only receives a rowHeight property.\nIf the rowHeight is a number it can compare before and after values but if it is a function that comparison is error prone.\nIn the event that a dynamic rowHeight function is in use and the row heights have changed this function should be manually called by the \"smart\" container parent.\nThis method will also force a render cycle (via forceUpdate) to ensure that the updated measurements are reflected in the rendered list.\n recomputeRowHeights(index?: number): void;" + }, + { + "id": "recomputeGridSize", + "title": "recomputeGridSize", + "text": "recomputeGridSize Recompute row heights and offsets after the specified row index (defaults to 0); see Grid#recomputeGridSize.\n(The column index is ignored.) This method is intended for CellMeasurer; also see recomputeRowHeights.\n recomputeGridSize(params?: Partial): void;" + }, + { + "id": "scrollToCell", + "title": "scrollToCell", + "text": "scrollToCell Ensure the specified row is visible (scrolling if necessary); the column index is ignored.\nThis allows a List to be used with ArrowKeyStepper; also see scrollToRow.\n scrollToCell(params: {\n align?: Alignment;\n columnIndex?: number;\n rowIndex?: number;\n }): void;" + }, + { + "id": "scrollToPosition", + "title": "scrollToPosition", + "text": "scrollToPosition Scroll to the specified vertical offset; scrollLeft is ignored (lists don't scroll horizontally).\nOffsets are clamped to the scrollable range. This allows a List to be used with ScrollSync.\nUseful for animating position changes.\nIf the List has a scrollElement, the scroll element (e.g. the window) is scrolled instead.\n scrollToPosition(params: ScrollPosition): void;" + }, + { + "id": "scrollToRow", + "title": "scrollToRow", + "text": "scrollToRow Ensure the row at the specified index is visible (scrolling if necessary).\nThis method can be used to safely scroll back to a row that a user has scrolled away from even if it was previously scrolled to.\nThe align parameter (default \"auto\") controls the alignment of the row:\n\"auto\" scrolls the least amount possible to ensure that the specified row is fully visible\n(a row taller than the list is aligned to its start, so that its start is visible);\n\"start\" aligns the row to the top of the list and \"end\" to the bottom; \"center\" aligns it in the middle.\nThis is a one-time scroll: the row is not kept in view afterwards (e.g. if the list's size or its row heights change).\nRows that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated heights.\nIf the List has a scrollElement, the scroll element (e.g. the window) is scrolled instead.\n scrollToRow(params: { align?: Alignment; index: number }): void;" + }, + { + "id": "calling-methods", + "title": "Calling methods", + "text": "Calling methods List's methods can be called through a ref. The example below uses scrollToRow to scroll the list. In version 10, these methods replace the scrollToIndex, scrollToAlignment and scrollTop props; see scrolling to a row: import { useRef } from \"react\";\nimport {\n List,\n type ListImperativeAPI,\n type RowRendererParams\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction rowRenderer({ index, key, style }: RowRendererParams) {\n return (\n \n {list[index].name}\n
Row {index}
\n
\n );\n}\n \nfunction Example() {\n const listRef = useRef(null);\n \n return (\n
\n
\n listRef.current?.scrollToRow({ index: 0 })}\n >\n Scroll to first row\n \n \n listRef.current?.scrollToRow({ align: \"center\", index: 500 })\n }\n >\n Center row 500\n \n \n listRef.current?.scrollToRow({ index: list.length - 1 })\n }\n >\n Scroll to last row\n \n
\n
\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n
\n
\n );\n} To compute the offset of a row without scrolling to it, use getOffsetForRow; it accepts the same align parameter as scrollToRow (e.g. getOffsetForRow({ align: \"center\", index: 500 })). The alignment parameter of earlier versions is deprecated but still supported." + }, + { + "id": "recomputing-row-heights", + "title": "Recomputing row heights", + "text": "Recomputing row heights When a rowHeight function is used and the data behind it changes, call recomputeRowHeights so that the list re-measures its rows: import { useEffect, useRef } from \"react\";\nimport {\n List,\n type ListImperativeAPI,\n type RowRendererParams\n} from \"react-virtualized\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n const listRef = useRef(null);\n \n // List only knows about the rowHeight function, not the data behind it,\n // so tell it to re-measure rows whenever the underlying data changes.\n useEffect(() => {\n listRef.current?.recomputeRowHeights();\n }, [items]);\n \n const rowRenderer = ({ index, key, style }: RowRendererParams) => (\n
\n {items[index].name}\n
\n );\n \n return (\n items[index].size}\n rowRenderer={rowRenderer}\n width={300}\n />\n );\n}" + } + ], + "path": "/list/methods", + "section": "List", + "text": "Public (imperative) methods of a List (its ref handle).\nThe handle is also passed to rowRenderer as parent (e.g. for CellMeasurer).", + "title": "List methods" + }, + { + "headings": [ + { + "id": "rowCount", + "title": "rowCount", + "text": "rowCount: number Number of rows in list." + }, + { + "id": "rowHeight", + "title": "rowHeight", + "text": "rowHeight: CellSize Either a fixed row height (number) or a function that returns the height of a row given its index: ({ index: number }): number.\nIf function is used, specify estimatedRowSize for more consistent scrolling behavior." + }, + { + "id": "rowRenderer", + "title": "rowRenderer", + "text": "rowRenderer: RowRenderer Responsible for rendering a row given its index.\nIt receives index, isScrolling, isVisible, key, parent, and style parameters.\n The style parameter specifies how the row is to be sized and positioned and must be passed through to the rendered row element.\nThe key parameter must also be applied to the rendered element." + }, + { + "id": "width", + "title": "width", + "text": "width: number Width of the list." + }, + { + "id": "aria-label", + "title": "aria-label", + "text": "aria-label?: string ARIA label for the list element. There is no default label.\n(With role=\"grid\", it defaults to \"grid\", as in version 9.)" + }, + { + "id": "aria-readonly", + "title": "aria-readonly", + "text": "aria-readonly?: boolean ARIA readonly attribute for the list element.\nNot set by default (it doesn't apply to role=\"list\"); with role=\"grid\", it defaults to true." + }, + { + "id": "cellRangeRenderer", + "title": "cellRangeRenderer", + "text": "cellRangeRenderer?: CellRangeRenderer Responsible for rendering a group of cells given their index ranges.\n This is an advanced property.\nIt is useful for situations where the Grid requires additional, overlayed UI (such as a Gantt chart or a calendar application).\nThe easiest way to override it is to decorate defaultCellRangeRenderer.\n Defaults to defaultCellRangeRenderer." + }, + { + "id": "className", + "title": "className", + "text": "className?: string Optional custom CSS class name to attach to root List element." + }, + { + "id": "containerProps", + "title": "containerProps", + "text": "containerProps?: HTMLAttributes Additional props (eg onWheel) to spread onto the outer Grid element." + }, + { + "id": "containerRole", + "title": "containerRole", + "text": "containerRole?: string ARIA role for the inner container of the rows.\nDefaults to \"presentation\" (so that the rows belong to the list), or to \"row\" with role=\"grid\"." + }, + { + "id": "containerStyle", + "title": "containerStyle", + "text": "containerStyle?: CSSProperties = {} Optional custom inline style to attach to inner cell-container element." + }, + { + "id": "deferredMeasurementCache", + "title": "deferredMeasurementCache", + "text": "deferredMeasurementCache?: CellMeasureCache If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache.\nA shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data." + }, + { + "id": "elementRef", + "title": "elementRef", + "text": "elementRef?: Ref Reference to the outer (scrolling) DOM element." + }, + { + "id": "estimatedRowSize", + "title": "estimatedRowSize", + "text": "estimatedRowSize?: number = 30 Used to estimate the total height of a List before all of its rows have actually been measured.\nThe estimated total height is adjusted as rows are rendered." + }, + { + "id": "getScrollbarSize", + "title": "getScrollbarSize", + "text": "getScrollbarSize?: () => number Returns the size of the browser's scrollbar.\nExposed for testing purposes only.\n Defaults to dom-helpers' scrollbarSize (which returns undefined during server rendering)." + }, + { + "id": "height", + "title": "height", + "text": "height?: number Height constraint for list (determines how many actual rows are rendered).\nRequired, unless a scrollElement is specified.\n With a scrollElement, the height of the scroll element's viewport is used instead;\nthis prop is then only used until the scroll element has been measured (e.g. during server rendering)." + }, + { + "id": "id", + "title": "id", + "text": "id?: string Optional custom id to attach to root List element." + }, + { + "id": "initialScrollTop", + "title": "initialScrollTop", + "text": "initialScrollTop?: number Vertical scroll offset to initially render (and scroll to) the Grid at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the cells at this offset are rendered on the server,\nand they stay in place when the page is hydrated.\nIgnored if a scrollElement is specified (which then determines the vertical offset)." + }, + { + "id": "isScrollingOptOut", + "title": "isScrollingOptOut", + "text": "isScrollingOptOut?: boolean = false Prevents re-rendering of visible cells on scroll end.\n(Opts out of the isScrolling param passed to cellRangeRenderer, to avoid the extra render when scroll stops.)\nVisible cells are still re-rendered when the Grid's props change or forceUpdate() is called." + }, + { + "id": "noRowsRenderer", + "title": "noRowsRenderer", + "text": "noRowsRenderer?: NoContentRenderer Callback used to render placeholder content when rowCount is 0.\nBy default, nothing is rendered." + }, + { + "id": "onRowsRendered", + "title": "onRowsRendered", + "text": "onRowsRendered?: (params: RenderedRows) => void Callback invoked with information about the slice of rows that were just rendered:\n({ overscanStartIndex: number, overscanStopIndex: number, startIndex: number, stopIndex: number }): void." + }, + { + "id": "onScroll", + "title": "onScroll", + "text": "onScroll?: (params: Scroll) => void Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, scrollHeight: number, scrollTop: number }): void.\nThis callback can be used to sync scrolling between lists, tables, or grids." + }, + { + "id": "onScrollbarPresenceChange", + "title": "onScrollbarPresenceChange", + "text": "onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void Called whenever a horizontal or vertical scrollbar is added or removed:\n({ horizontal: boolean, size: number, vertical: boolean }): void.\nThis prop is not intended for end-user use;\nit is used by MultiGrid to support fixed-row/fixed-column scroll syncing." + }, + { + "id": "overscanIndicesGetter", + "title": "overscanIndicesGetter", + "text": "overscanIndicesGetter?: OverscanIndicesGetter Responsible for calculating the number of rows to overscan before and after a specified range.\nBy default (accessibilityOverscanIndicesGetter), at least one row is overscanned in each direction (for accessibility).\nSee Grid overscanIndicesGetter for details." + }, + { + "id": "overscanRowCount", + "title": "overscanRowCount", + "text": "overscanRowCount?: number = 10 Number of rows to render above/below the visible bounds of the list.\nThis can help reduce flickering during scrolling on certain browsers/devices.\n Note that this property has performance implications: the higher the value, the more work needs to be done in reaction to each scroll event." + }, + { + "id": "role", + "title": "role", + "text": "role?: string = \"list\" ARIA role for the list element.\nWith the default \"list\", rows that don't specify a role get role=\"listitem\" (#1657).\nPass \"grid\" for the version 9 roles (a grid element whose rows are gridcells within a single row)." + }, + { + "id": "scrollElement", + "title": "scrollElement", + "text": "scrollElement?: ScrollElement | null Element that scrolls the List (instead of the List scrolling itself): \"window\", or a scrollable DOM element\n(e.g. a container that the List is rendered within, along with other content).\n The List then renders at its full height, renders only the rows visible within the scroll element's viewport,\nand its scroll methods scroll the scroll element.\nIts position within the scroll element is measured when the scroll element scrolls or is resized, and when the List re-renders\n(e.g. call forceUpdateGrid() if content above the List changes size without the List re-rendering).\nPass null while the scroll element isn't available yet (e.g. before a ref to it has been attached);\nthe List then renders the rows at its top." + }, + { + "id": "scrollingResetTimeInterval", + "title": "scrollingResetTimeInterval", + "text": "scrollingResetTimeInterval?: number = 150 Wait this amount of time (in milliseconds) after the last scroll event before resetting Grid pointer-events." + }, + { + "id": "style", + "title": "style", + "text": "style?: CSSProperties = {} Optional custom inline style to attach to root List element." + }, + { + "id": "tabIndex", + "title": "tabIndex", + "text": "tabIndex?: number | null = 0 Optional override of tab index default." + } + ], + "path": "/list/props", + "section": "List", + "text": "It is inefficient to create and manage a large list of DOM elements within a scrolling container\nif only a few of those elements are visible. The primary purpose of this component is to improve\nperformance by only rendering the DOM nodes that a user is able to see based on their current\nscroll position.\n This component renders a virtualized list of elements with either fixed or dynamic heights.", + "title": "List component props" + }, + { + "headings": [ + { + "id": "scrolling-placeholders", + "title": "Scrolling placeholders", + "text": "Scrolling placeholders If row content is expensive to render, consider rendering a light-weight placeholder while the list is being scrolled. The isScrolling parameter passed to rowRenderer makes this possible: import { List, type RowRendererParams } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction rowRenderer({ index, isScrolling, key, style }: RowRendererParams) {\n if (isScrolling) {\n // Render a light-weight placeholder while the list is being scrolled\n return (\n \n Scrolling...\n
\n );\n }\n \n return (\n \n {list[index].name}\n
\n );\n}\n \nfunction Example() {\n return (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n}" + } + ], + "path": "/list/rendering-rows", + "section": "List", + "text": "The rowRenderer function is responsible for rendering a single row, given its index. It must return an element that applies the style parameter (which positions and sizes the row) and has a unique key, and whose content fits within the row's height: import { type RowRendererParams } from \"react-virtualized\";\n \n// The following named parameters are passed to the row renderer:\n// index: Index of row\n// isScrolling: The List is currently being scrolled\n// isVisible: This row is visible within the List (eg it is not an overscanned row)\n// key: Unique key within array of rendered rows\n// parent: Reference to the parent List (can be passed to CellMeasurer)\n// style: Style object to be applied to row (to position it)\nfunction rowRenderer({ index, isScrolling, key, style }: RowRendererParams) {\n const user = list[index];\n \n // If row content is complex, consider rendering a light-weight placeholder while scrolling.\n const content = isScrolling ? \"...\" : user.name;\n \n // Style is required since it specifies how the row is to be sized and positioned.\n // React Virtualized depends on this sizing/positioning for proper scrolling behavior.\n // By default, List provides the following style properties:\n // position, left, top, height, width\n // You can add additional class names or style properties as you would like.\n // Key is also required by React to more efficiently manage the array of rows.\n return (\n
\n {content}\n
\n );\n} It is very important that rows do not have vertical overflow. Overflow would make scrolling the list difficult (as individual rows would intercept the scroll events). For this reason it is recommended that rows use a style like overflow-y: hidden.", + "title": "Rendering rows" + }, + { + "path": "/list/scroll-to-row", + "section": "List", + "text": "The scrollToRow method ensures that a row is visible, scrolling the list if necessary. Call it through a ref to the list. By default (\"auto\") the list scrolls the least amount possible to make the row fully visible (a row taller than the list is scrolled to its start); use the align parameter to align the row to the \"start\", \"center\", or \"end\" of the list instead. import { useRef, useState, type FormEvent } from \"react\";\nimport {\n List,\n type Alignment,\n type ListImperativeAPI,\n type RowRendererParams\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction rowRenderer({ index, key, style }: RowRendererParams) {\n return (\n \n {list[index].name}\n
Row {index}
\n
\n );\n}\n \nfunction Example() {\n const listRef = useRef(null);\n \n const [align, setAlign] = useState(\"auto\");\n const [index, setIndex] = useState(\"500\");\n \n const onSubmit = (event: FormEvent) => {\n event.preventDefault();\n \n listRef.current?.scrollToRow({ align, index: parseInt(index, 10) });\n };\n \n return (\n
\n
\n \n \n \n
\n
\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n
\n
\n );\n} scrollToRow is a one-time scroll: the list doesn't remember the row, so it isn't kept in view if the list's size, its row heights or its row count change afterwards. To scroll to a row that is being added, call scrollToRow once the list has rendered it (e.g. in an effect). Rows that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated heights. In version 10, the scrollToIndex, scrollToAlignment and scrollTop props were replaced by the scrollToRow and scrollToPosition methods, and the autoHeight and isScrolling props were removed: to scroll a List with the window, set its scrollElement prop to \"window\", in which case scrollToRow scrolls the window. The same applies to Table.", + "title": "Scrolling to a row" + }, + { + "path": "/list/variable-row-heights", + "section": "List", + "text": "Rows don't all need to be the same height. Instead of a number, rowHeight can be a function that returns the height of a row given its index. In that case, also specify an estimatedRowSize; it is used to estimate the total height of the list before all of its rows have been measured, which makes scrolling more consistent. import { List, type RowRendererParams } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \n// Each item specifies its own height (50, 75, or 100 pixels)\nconst list = LIST;\n \nfunction rowHeight({ index }: { index: number }) {\n return list[index].size;\n}\n \nfunction rowRenderer({ index, key, style }: RowRendererParams) {\n const item = list[index];\n \n return (\n \n \n {item.name.charAt(0)}\n
\n
\n
{item.name}
\n
This is row {index}
\n {item.size >= 75 && (\n
It is a taller row.
\n )}\n {item.size >= 100 && (\n
It has a 4th line.
\n )}\n
\n
{item.size}px
\n
\n );\n}\n \nfunction Example() {\n return (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n} List has no way of knowing when the data behind a rowHeight function changes. If row heights change, call the recomputeRowHeights method. For row heights that depend on the rendered content, see CellMeasurer.", + "title": "Variable row heights" + }, + { + "path": "/masonry", + "section": "Masonry", + "text": "Masonry efficiently displays dynamically-sized, user-positioned cells using windowing techniques. Cells are measured just-in-time and laid out as the user scrolls. Sizes are cached so that resizing (and re-flowing) the layout is fast and does not require re-measuring. Cell positions are controlled by an injected cellPositioner prop. Windowing is vertical; Masonry does not support horizontal scrolling. 200px columns 10px gutter A Masonry requires a CellMeasurerCache (to measure and cache the size of each cell), a cell positioner (to lay out the measured cells), and a cellRenderer that wraps each cell in a CellMeasurer: import { useLayoutEffect, useRef, useState } from \"react\";\nimport {\n CellMeasurer,\n CellMeasurerCache,\n createMasonryCellPositioner,\n Masonry,\n type MasonryImperativeAPI\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({\n columnWidth,\n gutterSize,\n items\n}: {\n columnWidth: number;\n gutterSize: number;\n items: ListItem[];\n}) {\n return (\n \n height !== undefined && width !== undefined ? (\n \n ) : null\n }\n />\n );\n}\n \nfunction MasonryLayout({\n columnWidth,\n gutterSize,\n height,\n items,\n width\n}: {\n columnWidth: number;\n gutterSize: number;\n height: number;\n items: ListItem[];\n width: number;\n}) {\n const columnCount = Math.max(\n 1,\n Math.floor((width + gutterSize) / (columnWidth + gutterSize))\n );\n \n // Default sizes help Masonry decide how many cells to batch-measure\n const [cache] = useState(\n () =>\n new CellMeasurerCache({\n defaultHeight: 250,\n defaultWidth: columnWidth,\n fixedWidth: true\n })\n );\n \n // The built-in positioner places each cell in the shortest column\n const [cellPositioner] = useState(() =>\n createMasonryCellPositioner({\n cellMeasurerCache: cache,\n columnCount,\n columnWidth,\n spacer: gutterSize\n })\n );\n \n const masonryRef = useRef(null);\n \n // When the number of columns (or the space between them) changes,\n // update the positioner and re-position the (already measured) cells\n useLayoutEffect(() => {\n cellPositioner.reset({ columnCount, columnWidth, spacer: gutterSize });\n masonryRef.current?.recomputeCellPositions();\n }, [cellPositioner, columnCount, columnWidth, gutterSize]);\n \n return (\n {\n const item = items[index];\n \n return (\n \n \n \n {index}\n
\n {item.random}\n
\n \n );\n }}\n height={height}\n ref={masonryRef}\n width={width}\n />\n );\n} The style passed to cellRenderer positions the cell, so it must be passed through to the rendered element. The parent parameter is the Masonry's imperative handle and should be passed to CellMeasurer. See the props page for the full list of supported props, and measuring and layout for how (and with which constraints) cells are measured and positioned. Masonry renders the following static class names, which can be used for styling: ReactVirtualized__Masonry: main (outer) element ReactVirtualized__Masonry__innerScrollContainer: inner scrollable area", + "title": "Rendering a masonry layout" + }, + { + "headings": [ + { + "id": "createmasonrycellpositioner", + "title": "createMasonryCellPositioner", + "text": "createMasonryCellPositioner Masonry provides a built-in positioner, createMasonryCellPositioner, for a simple column layout. It places each cell into the column that is currently the shortest. It requires a few configuration settings: import {\n CellMeasurerCache,\n createMasonryCellPositioner\n} from \"react-virtualized\";\n \nconst cache = new CellMeasurerCache({\n defaultHeight: 250,\n defaultWidth: 200,\n fixedWidth: true\n});\n \nconst cellPositioner = createMasonryCellPositioner({\n // Contains cell measurements (eg item height); required\n cellMeasurerCache: cache,\n \n // Number of columns to use in layout; required\n columnCount: 3,\n \n // Width of each column; required\n columnWidth: 200,\n \n // Empty space between columns (and between cells within a column);\n // defaults to 0\n spacer: 10\n});" + }, + { + "id": "updating-the-layout", + "title": "Updating the layout", + "text": "Updating the layout If any of these settings change due to external changes (eg a window resize event), update them using the positioner's reset method and then tell Masonry to re-position its cells using recomputeCellPositions() : // eg in response to a window resize\nfunction onResize({ width }: { width: number }) {\n const columnWidth = 250;\n const spacer = 15;\n \n cellPositioner.reset({\n columnCount: Math.floor((width + spacer) / (columnWidth + spacer)),\n columnWidth,\n spacer\n });\n \n // Discard cached positions and re-position cells using their cached sizes\n masonryRef.current?.recomputeCellPositions();\n} Unlike createMasonryCellPositioner, reset does not default spacer to 0; always specify it." + }, + { + "id": "custom-positioners", + "title": "Custom positioners", + "text": "Custom positioners Custom layouts can be implemented with a custom positioner. Because the positioner tracks layout state as it goes (eg the height of each column), it needs a way to be reset before Masonry re-positions its cells: // Places cells in columns round-robin (left to right, in index order)\n// rather than always choosing the shortest column\nfunction createRoundRobinPositioner(columnCount: number, columnWidth: number) {\n let columnHeights = new Array(columnCount).fill(0);\n \n function cellPositioner(index: number) {\n const column = index % columnCount;\n const left = column * columnWidth;\n const top = columnHeights[column];\n \n // Sizes have already been measured (and cached) by the time a cell is positioned\n columnHeights[column] = top + cache.getHeight(index);\n \n return { left, top };\n }\n \n // recomputeCellPositions() asks for every cell's position again (starting at index 0)\n // so the positioner's internal state must be reset before calling it\n cellPositioner.reset = () => {\n columnHeights = new Array(columnCount).fill(0);\n };\n \n return cellPositioner;\n}" + } + ], + "path": "/masonry/cell-positioners", + "section": "Masonry", + "text": "A cell positioner is a function that is given a cell index and returns the cell's position: (index) => ({ left, top }). It is called in index order, once for each cell, after that cell has been measured; so it can read the cell's height (and the heights of all earlier cells) from the CellMeasurerCache. The positions it returns are cached by Masonry.", + "title": "Cell positioners" + }, + { + "path": "/masonry/images", + "section": "Masonry", + "text": "Masonry layouts are often used to display images. Because cell measurements must be synchronous, the size of each image must be known before its cell is rendered (eg by setting an explicit height and width on the , using dimensions provided by the server). Otherwise the cell would be measured before the image loads, and the measurement would be wrong. In the basic example, colored blocks with a known height stand in for images; an image with known dimensions works the same way. Scale the height to the column width to preserve the image's aspect ratio, eg columnWidth * (imageHeight / imageWidth). Items can only be measured once; measurements are cached permanently (until the cache is cleared). If image sizes aren't known up front (eg the server can't provide them), they need to be pre-measured before they are passed to Masonry. To preserve a correct layout, images can only be displayed one by one in the order they appear in the list (not in the order they finish loading). The react-virtualized-image-measurer library handles this; it loads the images, then passes each item along with its size to a render function that renders the Masonry (live demo).", + "title": "Images" + }, + { + "headings": [ + { + "id": "layout-constraints", + "title": "Layout constraints", + "text": "Layout constraints Multi-column layouts are supported. Each cell can have a unique, lazily-measured height. Cells may not span multiple columns: the width of all cells in a column should be equal (and must not exceed the column's width), and the left position of all cells within a column must align. Cell measurements must be synchronous. A cell's size impacts the layout, and asynchronous measurements would require frequent layout invalidation. The asynchronous measure function passed to CellMeasurer's render callback is not supported by Masonry. (See images for how to handle content such as images whose size isn't known up front.)" + }, + { + "id": "animation-constraints", + "title": "Animation constraints", + "text": "Animation constraints Simple animations are supported (eg translating or sliding cells into place when they are first revealed). More complex animations are not (eg cells flying from one position to another on resize)." + } + ], + "path": "/masonry/measuring-and-layout", + "section": "Masonry", + "text": "Rendering occurs in two phases: Measurement: Estimated cell sizes (the defaultHeight and defaultWidth of the cellMeasurerCache) are used to decide how many cells to measure in a batch. The batch size is chosen using a fast, naive layout algorithm that stacks cells in order until the viewport has been filled. After measurement is complete, Masonry evaluates the positioned cells to determine whether another measurement pass is required (eg if actual cell sizes were smaller than the estimated sizes). All measurements are permanently cached (keyed by keyMapper) for performance purposes. Layout: The external cellPositioner is used to position cells. At this point the positioner has access to cached size measurements for all of the cells it is asked to position. The positions it returns are cached by Masonry for fast access later. The measurement phase is repeated if the user scrolls beyond the bounds of the current layout. If the layout is invalidated (eg due to a resize), cached positions can be cleared using recomputeCellPositions() or clearCellPositions() . By default, measurements and positions are cached by index. If cells can be re-ordered (or inserted), provide a keyMapper that maps an index to a unique id, and use the same mapping for the CellMeasurerCache's keyMapper, so that cached measurements aren't associated with the wrong cells.", + "title": "Measuring and layout" + }, + { + "headings": [ + { + "id": "clearCellPositions", + "title": "clearCellPositions", + "text": "clearCellPositions Clears the internal position cache and re-renders.\nCells are positioned again (by the cellPositioner) as they are rendered.\n clearCellPositions(): void;" + }, + { + "id": "forceUpdate", + "title": "forceUpdate", + "text": "forceUpdate Forcefully re-renders the Masonry (and its visible cells).\nRe-rendering a Masonry with the same props does not re-render its cells (it is memoized).\nUse this method if the underlying cell data has changed but the props have not.\n forceUpdate(): void;" + }, + { + "id": "invalidateCellSizeAfterRender", + "title": "invalidateCellSizeAfterRender", + "text": "invalidateCellSizeAfterRender Notifies the Masonry that the specified cell has been measured for the first time (while rendering);\nthe cell is positioned (by the cellPositioner) once the current render has been committed, then the Masonry re-renders.\nOnly rowIndex (the cell's index) is used.\nThis method is intended for CellMeasurer.\n invalidateCellSizeAfterRender(params: MasonryCellPosition): void;" + }, + { + "id": "recomputeCellPositions", + "title": "recomputeCellPositions", + "text": "recomputeCellPositions Resets the internal position cache, synchronously re-computes the positions of all measured cells\n(by calling the cellPositioner for each of them, in index order), then re-renders.\nReset the cellPositioner (e.g. with its reset method) before calling this method.\n recomputeCellPositions(): void;" + }, + { + "id": "recomputeGridSize", + "title": "recomputeGridSize", + "text": "recomputeGridSize Re-renders the Masonry, e.g. after more cells have been loaded (this method is called by InfiniteLoader)\nor a cell has been re-measured (it is called by CellMeasurer's measure function).\nParameters (the index of the first changed cell, as rowIndex) are accepted for compatibility with Grid and ignored.\nCached cell positions are not changed: to re-position cells after their sizes have changed,\nreset the cellPositioner and call recomputeCellPositions().\n recomputeGridSize(params?: Partial): void;" + }, + { + "id": "usage", + "title": "Usage", + "text": "Usage Masonry exposes these methods through an imperative handle, which can be accessed using a ref (eg useRef(null)). The cell positioning methods are typically called after updating the cell positioner, eg when the container is resized: // eg in response to a window resize\nfunction onResize({ width }: { width: number }) {\n const columnWidth = 250;\n const spacer = 15;\n \n cellPositioner.reset({\n columnCount: Math.floor((width + spacer) / (columnWidth + spacer)),\n columnWidth,\n spacer\n });\n \n // Discard cached positions and re-position cells using their cached sizes\n masonryRef.current?.recomputeCellPositions();\n} recomputeCellPositions() synchronously re-positions every cell that has already been measured, using cached measurements. Use it when the layout changes but cell sizes don't (eg the number of columns or the space between them). clearCellPositions() discards cached positions; cells are positioned again only as they are measured. If cell sizes have changed too (eg the column width changed, so text wraps differently), clear the CellMeasurerCache (cache.clearAll()) before calling it, otherwise cells that were already measured won't be re-positioned. invalidateCellSizeAfterRender and recomputeGridSize are called by CellMeasurer (through the parent passed to cellRenderer, which is this handle) and by InfiniteLoader (to re-render cells once they have loaded); they don't need to be called directly." + } + ], + "path": "/masonry/methods", + "section": "Masonry", + "text": "Imperative API of a Masonry (accessed using a ref, eg useRef(null)).\nThis is also the parent value passed to cellRenderer (for CellMeasurer).", + "title": "Masonry methods" + }, + { + "headings": [ + { + "id": "cellCount", + "title": "cellCount", + "text": "cellCount: number Total number of items." + }, + { + "id": "cellMeasurerCache", + "title": "cellMeasurerCache", + "text": "cellMeasurerCache: MasonryCellMeasurerCache Caches item measurements (typically an instance of CellMeasurerCache).\nDefault sizes help Masonry decide how many images to batch-measure." + }, + { + "id": "cellPositioner", + "title": "cellPositioner", + "text": "cellPositioner: Positioner Positions a cell given an index: (index: number) => ({ left: number, top: number }).\nSee createMasonryCellPositioner for a built-in positioner." + }, + { + "id": "cellRenderer", + "title": "cellRenderer", + "text": "cellRenderer: MasonryCellRenderer Responsible for rendering a cell given an index:\n({ index: number, isScrolling: boolean, key: Key, parent: MasonryImperativeAPI, style: CSSProperties }) => ReactNode.\nThe style param must be passed through to the rendered cell element.\nThe cell should typically be wrapped in a CellMeasurer." + }, + { + "id": "width", + "title": "width", + "text": "width: number Width of the component; this value determines the number of visible items." + }, + { + "id": "aria-label", + "title": "aria-label", + "text": "aria-label?: string Optional ARIA label to attach to root Masonry element." + }, + { + "id": "className", + "title": "className", + "text": "className?: string | null Optional custom CSS class name to attach to root Masonry element." + }, + { + "id": "height", + "title": "height", + "text": "height?: number Height of the component; this value determines the number of visible items.\nRequired, unless a scrollElement is specified.\n With a scrollElement, the height of the scroll element's viewport is used instead;\nthis prop is then only used until the scroll element has been measured (e.g. during server rendering)." + }, + { + "id": "id", + "title": "id", + "text": "id?: string | null Optional custom id to attach to root Masonry element." + }, + { + "id": "keyMapper", + "title": "keyMapper", + "text": "keyMapper?: MasonryKeyMapper Maps an index to a unique id to store cached measurement and position info for a cell.\nThis prevents eg cached measurements from being invalidated when a collection is re-ordered.\nDefaults to the identity function (index => index)." + }, + { + "id": "onCellsRendered", + "title": "onCellsRendered", + "text": "onCellsRendered?: MasonryOnCellsRenderedCallback | null Callback invoked with information about the cells that were most recently rendered.\nThis callback is only invoked when visible cells have changed:\n({ startIndex: number, stopIndex: number }): void.\nDoes nothing by default." + }, + { + "id": "onScroll", + "title": "onScroll", + "text": "onScroll?: MasonryOnScrollCallback | null Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, scrollHeight: number, scrollTop: number }): void.\nDoes nothing by default." + }, + { + "id": "overscanByPixels", + "title": "overscanByPixels", + "text": "overscanByPixels?: number = 20 Render this many additional pixels above and below the viewport.\nThis helps reduce flicker when a user scrolls quickly." + }, + { + "id": "role", + "title": "role", + "text": "role?: string = \"grid\" Optional override of ARIA role default." + }, + { + "id": "rowDirection", + "title": "rowDirection", + "text": "rowDirection?: string = \"ltr\" Row direction of items; can be \"ltr\" or \"rtl\"." + }, + { + "id": "scrollElement", + "title": "scrollElement", + "text": "scrollElement?: ScrollElement | null Element that scrolls the Masonry (instead of the Masonry scrolling itself): \"window\", or a scrollable DOM element\n(e.g. a container that the Masonry is rendered within, along with other content).\n The Masonry then renders at its full height, and renders only the cells visible within the scroll element's viewport.\nIts position within the scroll element is measured when the scroll element scrolls or is resized, and when the Masonry re-renders\n(e.g. call forceUpdate() if content above the Masonry changes size without the Masonry re-rendering).\nPass null while the scroll element isn't available yet (e.g. before a ref to it has been attached);\nthe Masonry then renders the cells at its top." + }, + { + "id": "scrollingResetTimeInterval", + "title": "scrollingResetTimeInterval", + "text": "scrollingResetTimeInterval?: number = 150 Wait this amount of time (in milliseconds) after the last scroll event before resetting pointer-events\n(and the isScrolling parameter passed to cellRenderer)." + }, + { + "id": "style", + "title": "style", + "text": "style?: CSSProperties = {} Optional custom inline style to attach to root Masonry element." + }, + { + "id": "tabIndex", + "title": "tabIndex", + "text": "tabIndex?: number = 0 Optional override of tab index default." + } + ], + "path": "/masonry/props", + "section": "Masonry", + "text": "This component efficiently displays arbitrarily positioned cells using windowing techniques.\nCell position is determined by an injected cellPositioner property.\nWindowing is vertical; this component does not support horizontal scrolling.\n Rendering occurs in two phases:\n\nFirst pass uses estimated cell sizes (provided by the cache) to determine how many cells to measure in a batch.\nBatch size is chosen using a fast, naive layout algorithm that stacks images in order until the viewport has been filled.\nAfter measurement is complete (once the render has been committed) this component evaluates positioned cells\nin order to determine if another measurement pass is required (eg if actual cell sizes were less than estimated sizes).\nAll measurements are permanently cached (keyed by keyMapper) for performance purposes.\nSecond pass uses the external cellPositioner to layout cells.\nAt this time the positioner has access to cached size measurements for all cells.\nThe positions it returns are cached by Masonry for fast access later.\nPhase one is repeated if the user scrolls beyond the current layout's bounds.\nIf the layout is invalidated due to eg a resize, cached positions can be cleared using recomputeCellPositions().\n\n Animation constraints:\nSimple animations are supported (eg translate/slide into place on initial reveal).\nMore complex animations are not (eg flying from one position to another on resize).\n Layout constraints:\nThis component supports multi-column layout.\nThe height of each item may vary.\nThe width of each item must not exceed the width of the column it is \"in\".\nThe left position of all items within a column must align.\n(Items may not span multiple columns.)", + "title": "Masonry component props" + }, + { + "headings": [ + { + "id": "children", + "title": "children", + "text": "children: (params: ScrollSyncChildProps) => ReactNode Function responsible for rendering 2 or more virtualized components.\nIt is passed the current scroll and size values (clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth),\nan onScroll callback that should be passed through to the virtualized children that the user can scroll,\nand a registerChild function that returns a ref callback for each virtualized child that should be kept in sync\n(e.g. ).\nThe scroll and size values can also be used for custom rendering (e.g. shading based on the scroll offset)." + } + ], + "path": "/scroll-sync", + "section": "Higher-order components", + "text": "ScrollSync simplifies the process of synchronizing scrolling between two or more virtualized components. The example below uses three Grids to mimic a spreadsheet with a fixed header row and a fixed first column. Scroll the body of the spreadsheet; the header and first column follow along. It also shows how the scroll values can be used to control other UI, such as background colors. ScrollSync uses a render function as its children. Use the registerChild parameter to register each virtualized component that should be kept in sync (e.g. ), and pass the onScroll parameter to the component(s) the user scrolls. Whenever a scroll offset is reported through onScroll, ScrollSync scrolls the other registered components to match it (only the axes that changed are synced) and calls the render function again with the updated values. The render function receives: registerChild(key): returns a ref callback that registers a component (such as a Grid) under a unique key onScroll: pass this to the scrollable component(s) scrollLeft, scrollTop: the current scroll offsets (e.g. for custom rendering) clientHeight, clientWidth: the size of the visible portion of the scrolled component scrollHeight, scrollWidth: the total size of all rows and columns in the scrolled component Version 10 removed the scrollLeft and scrollTop props from Grid; register the components with registerChild instead of passing the scroll offsets to them. import scrollbarSize from \"dom-helpers/scrollbarSize\";\nimport { Grid, ScrollSync } from \"react-virtualized\";\n \nconst COLUMN_COUNT = 50;\nconst COLUMN_WIDTH = 75;\nconst HEIGHT = 300;\nconst ROW_COUNT = 100;\nconst ROW_HEIGHT = 40;\n \nfunction ScrollSyncSpreadsheet({ width }: { width: number }) {\n const bodyWidth = width - COLUMN_WIDTH;\n \n return (\n \n {({\n clientHeight,\n clientWidth,\n onScroll,\n registerChild,\n scrollHeight,\n scrollLeft,\n scrollTop,\n scrollWidth\n }) => {\n // Scroll values can also be used to drive other UI, such as these background colors\n const x = scrollLeft / Math.max(1, scrollWidth - clientWidth);\n const y = scrollTop / Math.max(1, scrollHeight - clientHeight);\n \n return (\n
\n
\n {/* Fixed top-left cell */}\n \n {/* Fixed first column; ScrollSync scrolls it vertically with the body */}\n \n
\n
\n {/* Fixed header row; ScrollSync scrolls it horizontally with the body */}\n \n {/* Scrollable body; its onScroll drives the other two grids */}\n \n
\n
\n );\n }}\n
\n );\n} For the common case of fixed header rows and/or columns, also take a look at MultiGrid, which handles this layout for you.", + "title": "ScrollSync" + }, + { + "path": "/support", + "text": "GitHub is the easiest place to look for help, but it's probably not the fastest. This project is maintained by a single developer so there is limited bandwidth for answering questions. I recommend asking questions on Stack Overflow or Reddit to start with. Both sites have active communities who often respond quickly. If you don't find an answer there you can try opening a GitHub issue- but please take a moment first to see if your question has has already been answered before opening a new one.", + "title": "Support" + }, + { + "headings": [ + { + "id": "column-sizes", + "title": "Column sizes", + "text": "Column sizes A column's width is its flex basis. Columns can grow (flexGrow, defaults to 0) to fill unused space, or shrink (flexShrink, defaults to 1) when there is not enough room, within the bounds of minWidth and maxWidth. See the Column props for more." + }, + { + "id": "column-components", + "title": "Column components", + "text": "Column components Column doesn't render anything itself: Table reads the props of its children (applying the Column defaults, also exported as defaultColumnProps) to render headers and cells. Falsy children are ignored, so columns can be shown or hidden conditionally. Because the defaults are applied to any child element, a component that wraps Column can be used as a column as well, e.g. const NameColumn = (props: ColumnProps) => rendered as . (The wrapper itself is never rendered, so it should only forward or compute props.) In version 10, Column is a function component; subclassing Column (class MyColumn extends Column) is no longer supported. Use a wrapper component instead. Next, learn how to style rows and use dynamic row heights, customize cells and headers or entire rows, or sort a table." + } + ], + "path": "/table", + "section": "Table", + "text": "Table renders tabular data with a fixed header and windowed rows, for improved performance with large data sets. Its layout is created with flexboxes, and it uses a Grid internally to render only the rows that are visible. A table requires an explicit width and height, the height of its header (headerHeight) and rows (rowHeight), the number of rows (rowCount), and a rowGetter function that returns the data for a given row index. Its children are one or more Columns, each describing a column's header and the row attribute (dataKey) its cells display. import { Column, Table } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \n// Table data is an array of objects, e.g.\n// const list = [{ name: \"Brian Vaughn\", random: \"Software engineer\" }, ...];\n \nfunction Example() {\n return (\n \n width !== undefined ? (\n list[index]}\n rowHeight={30}\n width={width}\n >\n \n \n \n ) : null\n }\n />\n );\n} The default table styles are in react-virtualized/styles.css; they only need to be imported once. The customizing styles guide explains how to override them. The example above uses AutoSizer to fill the available width. Table content can scroll vertically, but it is not meant to scroll horizontally. Columns shrink and grow to fit the table's width; consider using a MultiGrid for data with many columns.", + "title": "Rendering a table" + }, + { + "path": "/table/cell-renderers", + "section": "Table", + "text": "Each Column accepts three functions that control what its cells and header render: cellDataGetter returns a cell's data, given its dataKey, rowData, and columnData. The default (defaultTableCellDataGetter) reads rowData[dataKey] (or calls rowData.get(dataKey) for Map-like objects). Override it if your data is calculated or requires custom processing. cellRenderer renders a cell's contents, given its cellData, columnData, columnIndex, dataKey, isScrolling, parent, rowData, and rowIndex. The default (defaultTableCellRenderer) displays the cell data as a string. Override it if your data is some other type of object or requires custom formatting. headerRenderer renders the column header, given its columnData, dataKey, disableSort, label, sortBy, and sortDirection. The default (defaultTableHeaderRenderer) displays the label along with a sort indicator if the table is sorted by this column. import { Column, defaultTableHeaderRenderer, Table } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example() {\n return (\n \n width !== undefined ? (\n list[index]}\n rowHeight={40}\n width={width}\n >\n (\n \n {cellData}\n
\n )}\n // Derive the cell's data from the row (rather than reading rowData[dataKey])\n cellDataGetter={({ rowData }) => rowData.name.charAt(0)}\n dataKey=\"initial\"\n label=\"\"\n width={30}\n />\n \n (\n \n {defaultTableHeaderRenderer(props)}\n \n )}\n label=\"Description\"\n width={200}\n />\n \n ) : null\n }\n />\n );\n} The columnData prop can be used to pass additional data (e.g. action creators or relational data) to all three of these functions.", + "title": "Custom cells and headers" + }, + { + "headings": [ + { + "id": "dataKey", + "title": "dataKey", + "text": "dataKey: string Uniquely identifies the row-data attribute corresponding to this cell\n(eg this might be \"name\" in an array of user objects)." + }, + { + "id": "width", + "title": "width", + "text": "width: number Flex basis (width) for this column;\nThis value can grow or shrink based on flexGrow and flexShrink properties." + }, + { + "id": "aria-label", + "title": "aria-label", + "text": "aria-label?: string Optional aria-label value to set on the column header." + }, + { + "id": "cellDataGetter", + "title": "cellDataGetter", + "text": "cellDataGetter?: TableCellDataGetter Callback responsible for returning a cell's data, given its dataKey:\n({ columnData: any, dataKey: string, rowData: any }): any.\n The default cellDataGetter expects to operate on either a vanilla Object or a Map-like object with a get method.\nYou should override it if your data is calculated or requires any custom processing.\nDefaults to defaultTableCellDataGetter." + }, + { + "id": "cellRenderer", + "title": "cellRenderer", + "text": "cellRenderer?: TableCellRenderer Callback responsible for rendering a cell's contents:\n({ cellData: any, columnData: any, columnIndex: number, dataKey: string, isScrolling: boolean, parent: TableImperativeAPI, rowData: any, rowIndex: number }): ReactNode.\n The default cellRenderer (defaultTableCellRenderer) displays an attribute as a simple string.\nYou should override it if your data is some other type of object or requires custom formatting." + }, + { + "id": "className", + "title": "className", + "text": "className?: string Optional CSS class to apply to the rendered cell container." + }, + { + "id": "columnData", + "title": "columnData", + "text": "columnData?: any Additional data passed to this column's cellDataGetter, cellRenderer, and headerRenderer.\nUse this object to relay action-creators or relational data." + }, + { + "id": "defaultSortDirection", + "title": "defaultSortDirection", + "text": "defaultSortDirection?: SortDirectionType = \"ASC\" Default sort order when clicked for the first time." + }, + { + "id": "disableSort", + "title": "disableSort", + "text": "disableSort?: boolean If sort is enabled for the table at large, disable it for this column." + }, + { + "id": "flexGrow", + "title": "flexGrow", + "text": "flexGrow?: number = 0 Flex grow style." + }, + { + "id": "flexShrink", + "title": "flexShrink", + "text": "flexShrink?: number = 1 Flex shrink style." + }, + { + "id": "headerClassName", + "title": "headerClassName", + "text": "headerClassName?: string Optional CSS class to apply to this column's header." + }, + { + "id": "headerRenderer", + "title": "headerRenderer", + "text": "headerRenderer?: TableHeaderRenderer Optional callback responsible for rendering a column's header contents:\n({ columnData: any, dataKey: string, disableSort: boolean, label: ReactNode, sortBy: string, sortDirection: SortDirection }): ReactNode.\n The default headerRenderer (defaultTableHeaderRenderer) displays the column label along with a sort indicator if the column is sort-enabled and active." + }, + { + "id": "headerStyle", + "title": "headerStyle", + "text": "headerStyle?: CSSProperties Optional inline style to apply to this column's header." + }, + { + "id": "id", + "title": "id", + "text": "id?: string Optional id to set on the column header; used for the cells' aria-describedby." + }, + { + "id": "label", + "title": "label", + "text": "label?: ReactNode Header label for this column." + }, + { + "id": "maxWidth", + "title": "maxWidth", + "text": "maxWidth?: number Maximum width of column; this property will only be used if flexGrow is greater than 0." + }, + { + "id": "minWidth", + "title": "minWidth", + "text": "minWidth?: number Minimum width of column." + }, + { + "id": "style", + "title": "style", + "text": "style?: CSSProperties = {} Optional inline style to apply to the rendered cell container.\n Table reads these props from each of its children (with the defaults above, exported as defaultColumnProps, applied), so components that wrap Column work as columns too; subclassing Column is no longer supported. See column components." + } + ], + "path": "/table/column-props", + "section": "Table", + "text": "Describes the header and cell contents of a table column.\n This component does not render anything itself;\nTable reads the props of its children to render headers and cells\n(applying defaultColumnProps to each child's props).\nComponents that render a Column (e.g. const MyColumn = (props) => ) can be used as Table children too;\ntheir props are read the same way (the wrapper component itself is never rendered).", + "title": "Column component props" + }, + { + "headings": [ + { + "id": "forceUpdateGrid", + "title": "forceUpdateGrid", + "text": "forceUpdateGrid Forcefully re-render the inner Grid component.\nCalling forceUpdate on Table may not re-render the inner Grid since it uses shallowCompare as a performance optimization.\nUse this method if you want to manually trigger a re-render.\nThis may be appropriate if the underlying row data has changed but the row sizes themselves have not.\n forceUpdateGrid(): void;" + }, + { + "id": "getOffsetForRow", + "title": "getOffsetForRow", + "text": "getOffsetForRow Gets offset for a given row and alignment (align; alignment is a deprecated alias).\nThe alignment defaults to \"auto\"; an index of -1 (the default) refers to the last row.\n getOffsetForRow(params?: {\n align?: Alignment;\n /** @deprecated Use `align` instead. */\n alignment?: Alignment;\n index?: number;\n }): number;" + }, + { + "id": "getScrollbarWidth", + "title": "getScrollbarWidth", + "text": "getScrollbarWidth Gets the scrollbar width used to pad the table-header.\n getScrollbarWidth(): number;" + }, + { + "id": "invalidateCellSizeAfterRender", + "title": "invalidateCellSizeAfterRender", + "text": "invalidateCellSizeAfterRender Invalidate the size of a row (after the current render has been committed); see Grid#invalidateCellSizeAfterRender.\nThis method is intended for CellMeasurer.\n invalidateCellSizeAfterRender(params: CellPosition): void;" + }, + { + "id": "measureAllRows", + "title": "measureAllRows", + "text": "measureAllRows Pre-measure all rows in a Table.\nTypically rows are only measured as needed and estimated heights are used for cells that have not yet been measured.\nThis method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one).\n measureAllRows(): void;" + }, + { + "id": "recomputeRowHeights", + "title": "recomputeRowHeights", + "text": "recomputeRowHeights Recompute row heights and offsets after the specified index (defaults to 0).\nTable has no way of knowing when its underlying list data has changed since it only receives a rowHeight property.\nIf the rowHeight is a number it can compare before and after values but if it is a function that comparison is error prone.\nIn the event that a dynamic rowHeight function is in use and the row heights have changed this function should be manually called by the \"smart\" container parent.\nThis method will also force a render cycle (via forceUpdate) to ensure that the updated measurements are reflected in the rendered table.\n recomputeRowHeights(index?: number): void;" + }, + { + "id": "recomputeGridSize", + "title": "recomputeGridSize", + "text": "recomputeGridSize Recompute row heights and offsets after the specified row index (defaults to 0); see Grid#recomputeGridSize.\n(The column index is ignored.) This method is intended for CellMeasurer; also see recomputeRowHeights.\n recomputeGridSize(params?: Partial): void;" + }, + { + "id": "scrollToCell", + "title": "scrollToCell", + "text": "scrollToCell Ensure the specified row is visible (scrolling if necessary); the column index is ignored.\nThis allows a Table to be used with ArrowKeyStepper; also see scrollToRow.\n scrollToCell(params: {\n align?: Alignment;\n columnIndex?: number;\n rowIndex?: number;\n }): void;" + }, + { + "id": "scrollToPosition", + "title": "scrollToPosition", + "text": "scrollToPosition Scroll to the specified vertical offset; scrollLeft is ignored (tables don't scroll horizontally).\nOffsets are clamped to the scrollable range. This allows a Table to be used with ScrollSync.\nUseful for animating position changes.\nIf the Table has a scrollElement, the scroll element (e.g. the window) is scrolled instead.\n scrollToPosition(params: ScrollPosition): void;" + }, + { + "id": "scrollToRow", + "title": "scrollToRow", + "text": "scrollToRow Ensure the row at the specified index is visible (scrolling if necessary).\nThis method can be used to safely scroll back to a row that a user has scrolled away from even if it was previously scrolled to.\nThe align parameter (default \"auto\") controls the alignment of the row:\n\"auto\" scrolls the least amount possible to ensure that the specified row is fully visible\n(a row taller than the table is aligned to its start, so that its start is visible);\n\"start\" aligns the row to the top of the table and \"end\" to the bottom; \"center\" aligns it in the middle.\nThis is a one-time scroll: the row is not kept in view afterwards (e.g. if the table's size or its row heights change).\nRows that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated heights.\nIf the Table has a scrollElement, the scroll element (e.g. the window) is scrolled instead.\n scrollToRow(params: { align?: Alignment; index: number }): void;" + }, + { + "id": "calling-methods", + "title": "Calling methods", + "text": "Calling methods Table's methods can be called through a ref. The example below uses scrollToRow (with an align parameter) and scrollToPosition. As with List, these are one-time scrolls (the row isn't kept in view if the table's size or row heights change afterwards). In version 10, these methods replace the scrollToIndex, scrollToAlignment and scrollTop props. import { useRef, useState, type FormEvent } from \"react\";\nimport {\n Column,\n Table,\n type Alignment,\n type TableImperativeAPI\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example() {\n const tableRef = useRef(null);\n \n const [align, setAlign] = useState(\"center\");\n const [index, setIndex] = useState(\"500\");\n \n const onSubmit = (event: FormEvent) => {\n event.preventDefault();\n \n tableRef.current?.scrollToRow({ align, index: parseInt(index, 10) });\n };\n \n const scrollToTop = () => {\n tableRef.current?.scrollToPosition({ scrollTop: 0 });\n };\n \n return (\n
\n
\n \n \n \n \n Scroll to top\n \n \n \n width !== undefined ? (\n list[index]}\n rowHeight={30}\n width={width}\n >\n \n \n \n ) : null\n }\n />\n
\n );\n}" + } + ], + "path": "/table/methods", + "section": "Table", + "text": "Public (imperative) methods of a Table instance.", + "title": "Table methods" + }, + { + "headings": [ + { + "id": "rowCount", + "title": "rowCount", + "text": "rowCount: number Number of rows in table." + }, + { + "id": "rowGetter", + "title": "rowGetter", + "text": "rowGetter: (params: { index: number; }) => any Callback responsible for returning a data row given an index:\n({ index: number }): any." + }, + { + "id": "rowHeight", + "title": "rowHeight", + "text": "rowHeight: CellSize Either a fixed row height (number) or a function that returns the height of a row given its index:\n({ index: number }): number." + }, + { + "id": "width", + "title": "width", + "text": "width: number Width of the table." + }, + { + "id": "aria-label", + "title": "aria-label", + "text": "aria-label?: string ARIA label set on the outer Table element." + }, + { + "id": "aria-labelledby", + "title": "aria-labelledby", + "text": "aria-labelledby?: string ARIA labelledby attribute set on the outer Table element." + }, + { + "id": "cellRangeRenderer", + "title": "cellRangeRenderer", + "text": "cellRangeRenderer?: CellRangeRenderer Responsible for rendering a group of cells given their index ranges.\n This is an advanced property.\nIt is useful for situations where the Grid requires additional, overlayed UI (such as a Gantt chart or a calendar application).\nThe easiest way to override it is to decorate defaultCellRangeRenderer.\n Defaults to defaultCellRangeRenderer." + }, + { + "id": "children", + "title": "children", + "text": "children?: ReactNode One or more Columns describing the data displayed in this table.\nFalsy children are ignored, to allow easier dynamic showing/hiding of columns.\n Table reads the props of its children (with defaultColumnProps applied) without rendering them,\nso components that wrap Column (e.g. const MyColumn = (props: ColumnProps) => ) can be used as children too.\n(Subclassing Column is no longer supported.)" + }, + { + "id": "className", + "title": "className", + "text": "className?: string Optional custom CSS class name to attach to root Table element." + }, + { + "id": "containerProps", + "title": "containerProps", + "text": "containerProps?: HTMLAttributes Additional props (eg onWheel) to spread onto the outer Grid element." + }, + { + "id": "containerRole", + "title": "containerRole", + "text": "containerRole?: string = \"row\" ARIA role for the inner cell-container." + }, + { + "id": "containerStyle", + "title": "containerStyle", + "text": "containerStyle?: CSSProperties = {} Optional custom inline style to attach to inner cell-container element." + }, + { + "id": "deferredMeasurementCache", + "title": "deferredMeasurementCache", + "text": "deferredMeasurementCache?: CellMeasureCache If CellMeasurer is used to measure this Table's cells, this should be a pointer to its CellMeasurerCache.\nIt is passed through to the inner Grid." + }, + { + "id": "disableHeader", + "title": "disableHeader", + "text": "disableHeader?: boolean = false Do not render the table header (only the rows)." + }, + { + "id": "estimatedRowSize", + "title": "estimatedRowSize", + "text": "estimatedRowSize?: number = 30 Used to estimate the total height of a Table before all of its rows have actually been measured.\nThe estimated total height is adjusted as rows are rendered." + }, + { + "id": "getScrollbarSize", + "title": "getScrollbarSize", + "text": "getScrollbarSize?: () => number Returns the size of the browser's scrollbar.\nExposed for testing purposes only.\n Defaults to dom-helpers' scrollbarSize (which returns undefined during server rendering)." + }, + { + "id": "gridClassName", + "title": "gridClassName", + "text": "gridClassName?: string Optional custom CSS class name to attach to inner Grid element." + }, + { + "id": "gridStyle", + "title": "gridStyle", + "text": "gridStyle?: CSSProperties Optional inline style to attach to inner Grid element." + }, + { + "id": "headerClassName", + "title": "headerClassName", + "text": "headerClassName?: string Optional CSS class to apply to all column headers." + }, + { + "id": "headerHeight", + "title": "headerHeight", + "text": "headerHeight?: number = 0 Fixed height of header row." + }, + { + "id": "headerRowRenderer", + "title": "headerRowRenderer", + "text": "headerRowRenderer?: TableHeaderRowRenderer Responsible for rendering the table header row given an array of columns:\n({ className: string, columns: ReactNode[], style: CSSProperties }): ReactNode.\n This is an advanced property.\nIt is useful for situations where you require additional hooks into Table to render additional custom UI elements.\nYou may want to start by forking the defaultTableHeaderRowRenderer function (the default)." + }, + { + "id": "headerStyle", + "title": "headerStyle", + "text": "headerStyle?: CSSProperties = {} Optional custom inline style to attach to table header columns." + }, + { + "id": "height", + "title": "height", + "text": "height?: number Fixed/available height for the outer DOM element.\nRequired, unless a scrollElement is specified.\n With a scrollElement, the height of the scroll element's viewport is used instead;\nthis prop is then only used until the scroll element has been measured (e.g. during server rendering)." + }, + { + "id": "id", + "title": "id", + "text": "id?: string Optional custom id to attach to root Table element." + }, + { + "id": "initialScrollTop", + "title": "initialScrollTop", + "text": "initialScrollTop?: number Vertical scroll offset to initially render (and scroll to) the Table at.\nOnly the initial value is used: changing this prop later has no effect (use scrollToPosition to scroll).\nThis also works with server rendering: the rows at this offset are rendered on the server,\nand they stay in place when the page is hydrated.\nIgnored if a scrollElement is specified (which then determines the vertical offset)." + }, + { + "id": "isScrollingOptOut", + "title": "isScrollingOptOut", + "text": "isScrollingOptOut?: boolean = false Prevents re-rendering of visible cells on scroll end.\n(Opts out of the isScrolling param passed to cellRangeRenderer, to avoid the extra render when scroll stops.)\nVisible cells are still re-rendered when the Grid's props change or forceUpdate() is called." + }, + { + "id": "noRowsRenderer", + "title": "noRowsRenderer", + "text": "noRowsRenderer?: () => ReactNode Optional renderer to be used in place of table body rows when rowCount is 0." + }, + { + "id": "onColumnClick", + "title": "onColumnClick", + "text": "onColumnClick?: (params: TableColumnEventHandlerParams) => void Callback invoked when a user clicks on a table column (cell):\n({ columnData: any, dataKey: string, event: Event }): void." + }, + { + "id": "onHeaderClick", + "title": "onHeaderClick", + "text": "onHeaderClick?: (params: TableColumnEventHandlerParams) => void Callback invoked when a user clicks on (or activates via the ENTER or SPACE key) a table header:\n({ columnData: any, dataKey: string, event: Event }): void." + }, + { + "id": "onRowClick", + "title": "onRowClick", + "text": "onRowClick?: TableRowMouseEventHandler Callback invoked when a user clicks on a table row:\n({ event: Event, index: number, rowData: any }): void." + }, + { + "id": "onRowDoubleClick", + "title": "onRowDoubleClick", + "text": "onRowDoubleClick?: TableRowMouseEventHandler Callback invoked when a user double-clicks on a table row:\n({ event: Event, index: number, rowData: any }): void." + }, + { + "id": "onRowMouseOut", + "title": "onRowMouseOut", + "text": "onRowMouseOut?: TableRowMouseEventHandler Callback invoked when the mouse leaves a table row:\n({ event: Event, index: number, rowData: any }): void." + }, + { + "id": "onRowMouseOver", + "title": "onRowMouseOver", + "text": "onRowMouseOver?: TableRowMouseEventHandler Callback invoked when a user moves the mouse over a table row:\n({ event: Event, index: number, rowData: any }): void." + }, + { + "id": "onRowRightClick", + "title": "onRowRightClick", + "text": "onRowRightClick?: TableRowMouseEventHandler Callback invoked when a user right-clicks on a table row:\n({ event: Event, index: number, rowData: any }): void." + }, + { + "id": "onRowsRendered", + "title": "onRowsRendered", + "text": "onRowsRendered?: (params: TableRowsRenderedParams) => void Callback invoked with information about the slice of rows that were just rendered:\n({ overscanStartIndex: number, overscanStopIndex: number, startIndex: number, stopIndex: number }): void." + }, + { + "id": "onScroll", + "title": "onScroll", + "text": "onScroll?: (params: TableScrollParams) => void Callback invoked whenever the scroll offset changes within the inner scrollable region:\n({ clientHeight: number, scrollHeight: number, scrollTop: number }): void.\nThis callback can be used to sync scrolling between lists, tables, or grids." + }, + { + "id": "onScrollbarPresenceChange", + "title": "onScrollbarPresenceChange", + "text": "onScrollbarPresenceChange?: (params: ScrollbarPresenceChange) => void Called whenever a horizontal or vertical scrollbar is added or removed:\n({ horizontal: boolean, size: number, vertical: boolean }): void.\nThis prop is not intended for end-user use;\nit is used by MultiGrid to support fixed-row/fixed-column scroll syncing." + }, + { + "id": "overscanIndicesGetter", + "title": "overscanIndicesGetter", + "text": "overscanIndicesGetter?: OverscanIndicesGetter Responsible for calculating the number of rows to overscan before and after a specified range (see Grid overscanIndicesGetter).\nBy default (accessibilityOverscanIndicesGetter), Table overscans by at least one row in each direction (for accessibility)." + }, + { + "id": "overscanRowCount", + "title": "overscanRowCount", + "text": "overscanRowCount?: number = 10 Number of rows to render above/below the visible bounds of the list.\nThis can help reduce flickering during scrolling on certain browsers/devices." + }, + { + "id": "rowClassName", + "title": "rowClassName", + "text": "rowClassName?: string | ((params: { index: number; }) => string) Optional CSS class to apply to all table rows (including the header row).\nThis value may be either a static string or a function with the signature ({ index: number }): string.\nNote that for the header row an index of -1 is provided." + }, + { + "id": "rowRenderer", + "title": "rowRenderer", + "text": "rowRenderer?: TableRowRenderer Responsible for rendering a table row given an array of columns.\n This is an advanced property.\nIt is useful for situations where you require additional hooks into Table (eg integration with a library like react-sortable-hoc).\nThe easiest way to override it is to decorate (or fork) the defaultTableRowRenderer function (the default)." + }, + { + "id": "rowStyle", + "title": "rowStyle", + "text": "rowStyle?: CSSProperties | ((params: { index: number; }) => CSSProperties) = {} Optional custom inline style to attach to table rows (including the header row).\nThis value may be either a style object or a function with the signature ({ index: number }): CSSProperties.\nNote that for the header row an index of -1 is provided." + }, + { + "id": "scrollElement", + "title": "scrollElement", + "text": "scrollElement?: ScrollElement | null Element that scrolls the Table (instead of the Table scrolling itself): \"window\", or a scrollable DOM element\n(e.g. a container that the Table is rendered within, along with other content).\n The Table then renders all of its rows at their full height (below its header, which scrolls along with them), renders only the rows visible within the scroll element's viewport,\nand its scroll methods scroll the scroll element.\nIts position within the scroll element is measured when the scroll element scrolls or is resized, and when the Table re-renders\n(e.g. call forceUpdateGrid() if content above the Table changes size without the Table re-rendering).\nPass null while the scroll element isn't available yet (e.g. before a ref to it has been attached);\nthe Table then renders the rows at its top." + }, + { + "id": "scrollingResetTimeInterval", + "title": "scrollingResetTimeInterval", + "text": "scrollingResetTimeInterval?: number = 150 Wait this amount of time (in milliseconds) after the last scroll event before resetting Grid pointer-events." + }, + { + "id": "sort", + "title": "sort", + "text": "sort?: (params: TableSortParams) => void Sort function to be called if a sortable header is clicked:\n({ defaultSortDirection: SortDirection, event: Event, sortBy: string, sortDirection: SortDirection }): void." + }, + { + "id": "sortBy", + "title": "sortBy", + "text": "sortBy?: string Data is currently sorted by this dataKey (if it is sorted at all)." + }, + { + "id": "sortDirection", + "title": "sortDirection", + "text": "sortDirection?: SortDirectionType Data is currently sorted in this direction (if it is sorted at all)." + }, + { + "id": "style", + "title": "style", + "text": "style?: CSSProperties = {} Optional custom inline style to attach to root Table element." + }, + { + "id": "tabIndex", + "title": "tabIndex", + "text": "tabIndex?: number | null = 0 Optional override of inner Grid tab index default." + } + ], + "path": "/table/props", + "section": "Table", + "text": "Table component with fixed headers and virtualized rows for improved performance with large data sets.\nThis component expects explicit width, height, and padding parameters.\n Columns are described by Column children (or components that render a Column).\nScrolling is done through the imperative handle (ref): see scrollToRow, scrollToCell, and scrollToPosition.", + "title": "Table component props" + }, + { + "path": "/table/row-renderers", + "section": "Table", + "text": "For more control than rowClassName and rowStyle offer, Table accepts a rowRenderer and a headerRowRenderer. These are advanced properties, useful when you need additional hooks into Table (e.g. integrating with a drag-and-drop library like react-sortable-hoc) or need to render additional UI elements. The easiest way to override them is to decorate the default implementations, defaultTableRowRenderer and defaultTableHeaderRowRenderer, or to fork them. Click a row below to select it: import { useState } from \"react\";\nimport {\n Column,\n defaultTableRowRenderer,\n Table,\n type TableProps\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example() {\n const [selectedIndex, setSelectedIndex] = useState(null);\n \n // Decorate the default row renderer rather than re-implementing it;\n // it takes care of event handlers and ARIA attributes.\n const rowRenderer: TableProps[\"rowRenderer\"] = (props) =>\n defaultTableRowRenderer({\n ...props,\n className:\n props.index === selectedIndex\n ? `${props.className} bg-sky-500/30`\n : props.className\n });\n \n // The header row can be customized in the same way\n const headerRowRenderer: TableProps[\"headerRowRenderer\"] = ({\n className,\n columns,\n style\n }) => (\n \n {columns}\n
\n );\n \n return (\n \n width !== undefined ? (\n setSelectedIndex(index)}\n rowClassName=\"border-b border-white/10 cursor-pointer\"\n rowCount={list.length}\n rowGetter={({ index }) => list[index]}\n rowHeight={36}\n rowRenderer={rowRenderer}\n width={width}\n >\n \n \n \n \n ) : null\n }\n />\n );\n} A headerRowRenderer is passed the header row className, style, and the rendered columns. A rowRenderer is passed the same values plus the row index, key, rowData, isScrolling, and the onRowClick, onRowDoubleClick, onRowMouseOut, onRowMouseOver, and onRowRightClick handlers. A custom row renderer must apply the style it is passed to the row element; it positions the row within the table.", + "title": "Custom rows" + }, + { + "headings": [ + { + "id": "class-names", + "title": "Class names", + "text": "Class names Table renders the following static class names, which can be targeted by your styles: ReactVirtualized__Table: main (outer) element ReactVirtualized__Table__Grid: inner Grid (the scrollable body) ReactVirtualized__Table__headerRow: header row (similar to thead > tr) ReactVirtualized__Table__headerColumn: header cell (similar to thead > tr > th) ReactVirtualized__Table__sortableHeaderColumn: applied to header cells that are sortable ReactVirtualized__Table__headerTruncatedText: header label rendered by the default header renderer ReactVirtualized__Table__sortableHeaderIcon: SVG sort indicator (plus --ASC or --DESC modifier) ReactVirtualized__Table__row: table row (similar to tbody > tr) ReactVirtualized__Table__rowColumn: table cell (similar to tbody > tr > td)" + } + ], + "path": "/table/row-styles", + "section": "Table", + "text": "Rows can be styled using the rowClassName and rowStyle props, and their heights can be fixed or vary from row to row. The table below has sortable headers, a custom header and cell renderer, alternating row styles, optional dynamic row heights, and a placeholder that is shown when there are no rows. Dynamic row heights Hide index column Hide header No rows import { useMemo, useState } from \"react\";\nimport {\n Column,\n SortDirection,\n SortIndicator,\n Table,\n type ColumnProps,\n type TableProps\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({\n disableHeader,\n hideIndexColumn,\n list,\n useDynamicRowHeight\n}: {\n disableHeader: boolean;\n hideIndexColumn: boolean;\n list: ListItem[];\n useDynamicRowHeight: boolean;\n}) {\n const [sortBy, setSortBy] = useState(\"index\");\n const [sortDirection, setSortDirection] = useState<\n TableProps[\"sortDirection\"]\n >(SortDirection.ASC);\n \n const sortedList = useMemo(() => {\n const key = sortBy as keyof ListItem;\n const sorted = [...list].sort((a, b) =>\n a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0\n );\n return sortDirection === SortDirection.DESC ? sorted.reverse() : sorted;\n }, [list, sortBy, sortDirection]);\n \n return (\n \n width !== undefined ? (\n (\n
\n No rows\n
\n )}\n overscanRowCount={10}\n rowClassName={({ index }) =>\n index < 0\n ? \"border-b border-white/20 bg-white/5\"\n : index % 2 === 0\n ? \"border-b border-white/10\"\n : \"border-b border-white/10 bg-white/5\"\n }\n rowCount={sortedList.length}\n rowGetter={({ index }) => sortedList[index]}\n rowHeight={\n useDynamicRowHeight ? ({ index }) => sortedList[index].size : 40\n }\n sort={({ sortBy, sortDirection }) => {\n setSortBy(sortBy);\n setSortDirection(sortDirection);\n }}\n sortBy={sortBy}\n sortDirection={sortDirection}\n width={width}\n >\n {/* Falsy children are ignored, so columns can be shown or hidden */}\n {!hideIndexColumn && (\n \n )}\n \n (\n {cellData}\n )}\n className=\"truncate\"\n dataKey=\"random\"\n disableSort\n flexGrow={1}\n label=\"The description label is really long so that it will be truncated\"\n width={210}\n />\n \n ) : null\n }\n />\n );\n}\n \nconst fullNameHeaderRenderer: ColumnProps[\"headerRenderer\"] = ({\n dataKey,\n sortBy,\n sortDirection\n}) => {\n return (\n
\n Full Name\n {sortBy === dataKey && }\n
\n );\n}; rowClassName (and rowStyle) can be a static value or a function of the row index. They are also applied to the header row, which is passed an index of -1. rowHeight can be a fixed number or a function of the row index. If row heights change after they have been measured, call recomputeRowHeights . Falsy children are ignored, so columns can be shown or hidden conditionally. noRowsRenderer renders placeholder content when rowCount is 0. The headerClassName prop applies a class to every column header (normal-case above overrides the default uppercase style). Sorting is covered in more detail on the sorting page.", + "title": "Row styles and heights" + }, + { + "headings": [ + { + "id": "sortdirection", + "title": "SortDirection", + "text": "SortDirection The SortDirection constant specifies which direction data is sorted in: SortDirection.ASC (\"ASC\") sorts items in ascending order, from the lowest value to the highest (e.g. a-z, 0-9). SortDirection.DESC (\"DESC\") sorts items in descending order, from the highest value to the lowest (e.g. z-a, 9-0)." + }, + { + "id": "sortindicator", + "title": "SortIndicator", + "text": "SortIndicator The default header renderer shows a SortIndicator (an arrow icon) beside the label of the column the table is sorted by. If you render custom headers, you can use SortIndicator to show the sort direction yourself: import { SortIndicator, type ColumnProps } from \"react-virtualized\";\n \nconst headerRenderer: ColumnProps[\"headerRenderer\"] = ({\n dataKey,\n label,\n sortBy,\n sortDirection\n}) => (\n
\n {label}\n {sortBy === dataKey && }\n
\n);" + }, + { + "id": "returning-to-the-natural-order", + "title": "Returning to the natural order", + "text": "Returning to the natural order Once a Table has been sorted, the built-in sort directions only toggle between ascending and descending; there is no way to return to the unsorted, “natural” order. The natural sort tables guide shows how to implement this in your application code." + }, + { + "id": "sorting-by-multiple-columns", + "title": "Sorting by multiple columns", + "text": "Sorting by multiple columns By default, Table assumes that its data will be sorted by a single attribute. The createTableMultiSort utility can be used to sort by multiple fields; see the multi-column sort tables guide. Sorting large data sets can be expensive; memoize the sorted data (e.g. with useMemo) so that it is only re-sorted when the sort criteria change." + } + ], + "path": "/table/sorting", + "section": "Table", + "text": "Table headers can be made sortable. Click a column header below to sort by it; click it again to reverse the sort direction. Table does not sort data itself. Instead, it tells you how the data should be sorted, and renders the current sort state: sort is called when a sortable header is clicked (or activated with the ENTER or SPACE key). It is passed the sortBy (the clicked column's dataKey) and the suggested sortDirection, as well as the column's defaultSortDirection and the DOM event. Sort your data accordingly, and update your state to re-render the table. sortBy is the dataKey that data is currently sorted by (if it is sorted at all). sortDirection is the direction data is currently sorted in (if it is sorted at all). import { useMemo, useState } from \"react\";\nimport {\n Column,\n SortDirection,\n Table,\n type TableProps\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example() {\n const [sortBy, setSortBy] = useState(\"index\");\n const [sortDirection, setSortDirection] = useState<\n TableProps[\"sortDirection\"]\n >(SortDirection.ASC);\n \n // Table does not sort data itself; it only tells you how to sort it\n const sortedList = useMemo(\n () => sortList(list, sortBy, sortDirection),\n [sortBy, sortDirection]\n );\n \n return (\n \n width !== undefined ? (\n sortedList[index]}\n rowHeight={30}\n sort={({ sortBy, sortDirection }) => {\n setSortBy(sortBy);\n setSortDirection(sortDirection);\n }}\n sortBy={sortBy}\n sortDirection={sortDirection}\n width={width}\n >\n \n \n \n \n ) : null\n }\n />\n );\n}\n \nfunction sortList(\n list: ListItem[],\n sortBy: TableProps[\"sortBy\"],\n sortDirection: TableProps[\"sortDirection\"]\n) {\n if (!sortBy) {\n return list;\n }\n \n const key = sortBy as keyof ListItem;\n const sorted = [...list].sort((a, b) =>\n a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0\n );\n \n return sortDirection === SortDirection.DESC ? sorted.reverse() : sorted;\n} Headers are only sortable if a sort function is provided. Individual columns can opt out with the disableSort prop. A column's defaultSortDirection (\"ASC\" by default) controls the suggested direction when it is first clicked; clicking it again suggests the opposite direction. See Column props for more.", + "title": "Sorting" + }, + { + "headings": [ + { + "id": "grid-table-and-masonry", + "title": "Grid, Table and Masonry", + "text": "Grid, Table and Masonry The scrollElement prop works the same way for a Grid. The window only scrolls a Grid vertically; if its columns are wider than its width, it still scrolls horizontally itself: import { Grid } from \"react-virtualized\";\n \nfunction Example({\n cellRenderer,\n width\n}: {\n cellRenderer: CellRenderer;\n width: number;\n}) {\n return (\n \n );\n} A Table's header row is scrolled along with its rows (it doesn't stay at the top of the window): import { Column, Table } from \"react-virtualized\";\n \nfunction Example({ items, width }: { items: ListItem[]; width: number }) {\n return (\n items[index]}\n rowHeight={30}\n // The header scrolls along with the page (it isn't sticky)\n scrollElement=\"window\"\n width={width}\n >\n \n \n \n );\n} For a Masonry, reset the cell positioner when the available width changes (e.g. in AutoSizer's onResize), so that the number of columns fits the new width: import { useRef } from \"react\";\nimport { Masonry, type MasonryImperativeAPI } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ cellCount }: { cellCount: number }) {\n const masonryRef = useRef(null);\n \n // When the available width changes, update the number of columns\n const onResize = ({ width }: { width: number }) => {\n const columnWidth = 200;\n const spacer = 10;\n \n cellPositioner.reset({\n columnCount: Math.max(\n 1,\n Math.floor((width + spacer) / (columnWidth + spacer))\n ),\n columnWidth,\n spacer\n });\n masonryRef.current?.recomputeCellPositions();\n };\n \n return (\n \n width !== undefined ? (\n \n ) : null\n }\n />\n );\n} MultiGrid always scrolls itself, so it doesn't have a scrollElement prop." + }, + { + "id": "scrolling-to-a-row", + "title": "Scrolling to a row", + "text": "Scrolling to a row A component with a scrollElement is scrolled with its regular methods: the List or Table's scrollToRow and scrollToPosition, or the Grid's scrollToCell and scrollToPosition. They scroll the scroll element (e.g. the window) instead of the component itself, so a regular ref is all that's needed. (This page is itself part of a larger layout, so the example below scrolls a container; see scrolling an element.) import { useRef, useState } from \"react\";\nimport {\n List,\n type ListImperativeAPI,\n type RowRendererParams\n} from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n // This example scrolls a container (see \"Scrolling an element\");\n // with scrollElement=\"window\", the List's methods scroll the window instead\n const [scrollElement, setScrollElement] = useState(\n null\n );\n \n // A regular ref, to call the List's methods\n const listRef = useRef(null);\n \n const rowRenderer = ({ index, key, style }: RowRendererParams) => (\n \n {index}\n {items[index].name}\n
\n );\n \n return (\n
\n
\n \n listRef.current?.scrollToRow({ align: \"center\", index: 500 })\n }\n >\n Scroll to row 500\n \n \n listRef.current?.scrollToRow({ align: \"start\", index: 0 })\n }\n >\n Scroll to the first row\n \n
\n
\n
\n Content above the list\n
\n \n width !== undefined ? (\n \n ) : null\n }\n />\n
\n
\n );\n} Things to keep in mind: These are one-time scrolls: the row isn't kept in view if row heights change afterwards. Offsets passed to scrollToPosition (and returned by getOffsetForRow) are relative to the top of the list, not to the top of the page. To scroll to a row that is being added, call scrollToRow once the list has rendered it (e.g. in an effect). Rows that haven't been measured yet (e.g. by CellMeasurer) are positioned using their estimated heights, so the row may not end up exactly aligned. The list's position within the scroll element is measured again each time it scrolls it, so content above the list that has changed size is taken into account. A Grid is only scrolled vertically by its scroll element; if it is wider than its width, it still scrolls horizontally itself. scrollToRow scrolls immediately. For a smooth scroll, compute the row's offset on the page and scroll the window yourself: // Smoothly scrolls the window to a row of a List with scrollElement=\"window\".\n// (listElementRef is passed to the List's elementRef prop.)\nfunction smoothScrollToRow(index: number) {\n const list = listRef.current;\n const listElement = listElementRef.current;\n if (list && listElement) {\n // Offset of the row within the List, and of the List within the page\n const rowOffset = list.getOffsetForRow({ align: \"start\", index });\n const listOffset = listElement.getBoundingClientRect().top + window.scrollY;\n \n window.scrollTo({ behavior: \"smooth\", top: listOffset + rowOffset });\n }\n}" + }, + { + "id": "height", + "title": "Height", + "text": "Height The height prop is optional with a scroll element: the height of the scroll element's viewport (e.g. the window's) is used to determine which rows to render. If a height is passed, it is only used until the scroll element has been measured, e.g. during server rendering, or while scrollElement is null because the element isn't available yet. (The component then renders its top rows, at its full height.)" + }, + { + "id": "content-above-the-component", + "title": "Content above the component", + "text": "Content above the component The component's position within the scroll element (e.g. how far it is from the top of the page) is measured again whenever the scroll element scrolls or is resized, and whenever the component re-renders. If content above it changes size without the component re-rendering (e.g. a banner is dismissed, and the component's props don't change), re-render it with forceUpdateGrid() (List and Table) or forceUpdate() (Grid and Masonry): import { useLayoutEffect, useRef, useState } from \"react\";\nimport { List, type ListImperativeAPI } from \"react-virtualized\";\n \nfunction Example({\n rowCount,\n rowRenderer\n}: {\n rowCount: number;\n rowRenderer: RowRenderer;\n}) {\n const listRef = useRef(null);\n const [showBanner, setShowBanner] = useState(true);\n \n // The banner above the list changed size, but the List's props didn't\n // (so it doesn't re-render); re-render it to re-measure its position\n useLayoutEffect(() => {\n listRef.current?.forceUpdateGrid();\n }, [showBanner]);\n \n return (\n <>\n {showBanner && (\n
setShowBanner(false)}>Click to dismiss
\n )}\n \n \n );\n} Until the component re-renders (or the scroll element scrolls), it renders the rows for its previous position, so part of the viewport may be left empty. How likely this is depends on the browser: while the page is scrolled, browsers that support scroll anchoring (e.g. Chrome and Firefox) adjust the scroll position when content above changes size, which re-measures the component; browsers without scroll anchoring (e.g. Safari) don't. The rows rendered beyond the viewport (see the overscanRowCount prop) hide small changes, but not content above the component growing. So call forceUpdateGrid() or forceUpdate() whenever content above the component may change size." + }, + { + "id": "migrating-from-windowscroller", + "title": "Migrating from WindowScroller", + "text": "Migrating from WindowScroller In version 10, the WindowScroller component has been replaced by the scrollElement prop. Instead of wrapping the component in a WindowScroller and wiring its render parameters to the autoHeight, height, isScrolling, onScroll and scrollTop props (all of which have been removed, except height and onScroll), pass scrollElement directly: // react-virtualized 9.x\nfunction Example() {\n return (\n \n {({ height, isScrolling, onChildScroll, registerChild, scrollTop }) => (\n
\n \n
\n )}\n
\n );\n} // react-virtualized 10\nimport { List } from \"react-virtualized\";\n \nfunction Example() {\n return (\n \n );\n} What replaced each part of the WindowScroller API: registerChild (and the wrapper element it was attached to): not needed; the component measures its position from its own element. Use a regular ref to call its methods. height (and width): the viewport height is measured by the component itself. Use AutoSizer for the width (the window's width usually isn't the width available to the component). scrollTop, isScrolling and onChildScroll: handled by the component. isScrolling is still passed to the rowRenderer (or cellRenderer). scrollLeft: horizontal window scrolling is no longer supported. The scrollElement prop: moved to the component, and accepts \"window\" (instead of the window object) or an element. updatePosition(): not needed; the position is measured again on every scroll, on resize, and whenever the component re-renders (see \"Content above the component\" above). updateScrollTopOnUpdatePosition is no longer needed either. onScroll: use the component's own onScroll prop, which reports how far the scroll element has been scrolled past the top of the component. onResize: use a resize event listener on the window (or a ResizeObserver on the scroll element). serverHeight and serverWidth: pass height (used until the scroll element has been measured) and width to the component. scrollingResetTimeInterval: use the scrollingResetTimeInterval prop of the Grid, List or Masonry. IS_SCROLLING_TIMEOUT and defaultWindowScrollerProps: removed." + } + ], + "path": "/window-scrolling", + "section": "Window scrolling", + "text": "A List, Grid, Table or Masonry can be scrolled by the window (rather than having a scrollbar of its own) by setting its scrollElement prop to \"window\". This can be used to create layouts similar to Facebook or Twitter news feeds, where the list scrolls along with the rest of the page. With a scroll element, the component: renders at its full height (it doesn't scroll itself vertically), renders only the rows (or cells) visible within the window's viewport, and its scroll methods (scrollToRow, scrollToCell and scrollToPosition) scroll the window; see scrolling to a row. A regular ref can be used to call its methods. Only the width needs to be provided, e.g. by AutoSizer: import { useRef } from \"react\";\nimport { List, type ListImperativeAPI } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({\n rowCount,\n rowRenderer\n}: {\n rowCount: number;\n rowRenderer: RowRenderer;\n}) {\n // A regular ref; the List's scroll methods scroll the window\n const listRef = useRef(null);\n \n return (\n // Only the width is measured; the height is that of the window\n \n width !== undefined ? (\n \n ) : null\n }\n />\n );\n} The window is the most common scroll element, but any scrollable element can be used instead; see scrolling an element (which also has a live example).", + "title": "Scrolling with the page" + }, + { + "path": "/window-scrolling/scroll-element", + "section": "Window scrolling", + "text": "Instead of \"window\", the scrollElement prop can be a scrollable element (e.g. a container with overflow: auto that the component is rendered within, along with other content). The component then scrolls along with the rest of that element's content, and renders only the rows visible within the element. The component needs the element itself (not a ref object), so store it in state with a ref callback; this re-renders the component once the element has been mounted. Until then, the scrollElement is null, and the component renders its top rows (at its full height). In the example below, the content above the list and the list itself share a single scrollbar (the container's); the list doesn't have a scrollbar of its own. import { useState } from \"react\";\nimport { List, type RowRendererParams } from \"react-virtualized\";\nimport { AutoSizer } from \"react-virtualized-auto-sizer\";\n \nfunction Example({ items }: { items: ListItem[] }) {\n // The List needs the scrollable element itself,\n // so store it in state (rather than a ref) to re-render once it's mounted;\n // until then it's null, and the List renders its first rows\n const [scrollElement, setScrollElement] = useState(\n null\n );\n \n const rowRenderer = ({ index, key, style }: RowRendererParams) => (\n \n {items[index].name}\n
\n );\n \n return (\n
\n
Content above the list
\n \n width !== undefined ? (\n \n ) : null\n }\n />\n
\n );\n} Size changes of the scroll element are detected with a ResizeObserver, and the component's position within it is measured again whenever it scrolls.", + "title": "Scrolling an element" + } +] \ No newline at end of file diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..c2a49f4fb --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index e3e73016b..000000000 --- a/rollup.config.js +++ /dev/null @@ -1,38 +0,0 @@ -import nodeResolve from 'rollup-plugin-node-resolve'; -import commonjs from 'rollup-plugin-commonjs'; -import babel from 'rollup-plugin-babel'; -import replace from 'rollup-plugin-replace'; -import {uglify} from 'rollup-plugin-uglify'; - -export default { - input: './source/index.js', - output: { - file: 'dist/umd/react-virtualized.js', - format: 'umd', - name: 'ReactVirtualized', - globals: { - react: 'React', - 'react-dom': 'ReactDOM', - }, - }, - external: ['react', 'react-dom'], - plugins: [ - nodeResolve(), - commonjs({ - include: 'node_modules/**', - }), - babel({ - exclude: 'node_modules/**', - }), - replace({ - 'process.env.NODE_ENV': JSON.stringify('development'), - }), - uglify({ - mangle: false, - output: { - comments: true, - beautify: true, - }, - }), - ], -}; diff --git a/scripts/compile-docs.ts b/scripts/compile-docs.ts new file mode 100644 index 000000000..c63fc11a4 --- /dev/null +++ b/scripts/compile-docs.ts @@ -0,0 +1,135 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { withCompilerOptions } from "react-docgen-typescript"; +import { compileDocs } from "react-lib-tools/scripts/compile-docs.ts"; +import { compileComponent } from "react-lib-tools/scripts/utils/docs/compileComponent.ts"; +import { parseDescription } from "react-lib-tools/scripts/utils/docs/parseDescription.ts"; +import { syntaxHighlight } from "react-lib-tools/scripts/utils/syntax-highlight.ts"; +import type { + ComponentMetadata, + ImperativeHandleMetadata +} from "react-lib-tools"; +import ts from "typescript"; + +const outputDir = resolve("public", "generated", "docs"); + +await compileDocs({ + componentNames: [ + "ArrowKeyStepper", + "CellMeasurer", + "ColumnSizer", + "Grid", + "InfiniteLoader", + "List", + "Masonry", + "MultiGrid", + "ScrollSync", + "Column", + "Table" + ], + imperativeHandleNames: [ + "ArrowKeyStepperImperativeAPI", + "CellMeasurerCacheAPI", + "GridImperativeAPI", + "InfiniteLoaderImperativeAPI", + "ListImperativeAPI", + "MasonryImperativeAPI", + "MultiGridImperativeAPI", + "TableImperativeAPI" + ], + readmePath: false +}); + +// The CellMeasurerCache constructor options are a plain type (not component props), +// so they're compiled through a docs-only stub component whose props are those options. +// (This runs after compileDocs, which deletes the generated JSON files before compiling.) +{ + const parser = withCompilerOptions( + { + jsx: ts.JsxEmit.ReactJSX, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + skipLibCheck: true, + strict: true, + target: ts.ScriptTarget.ES2022 + }, + { + savePropValueAsString: true, + shouldExtractLiteralValuesFromEnum: true, + shouldExtractValuesFromUnion: true, + shouldRemoveUndefinedFromOptional: true + } + ); + + await compileComponent({ + filePath: resolve("scripts", "docs-stubs", "CellMeasurerCacheOptions.tsx"), + outputDir, + parser + }); + + // Point the "source code" link at the class rather than the stub + const outputFile = resolve(outputDir, "CellMeasurerCacheOptions.json"); + const json = JSON.parse( + await readFile(outputFile, "utf-8") + ) as ComponentMetadata; + json.filePath = "lib/components/CellMeasurer/CellMeasurerCache.ts"; + await writeFile(outputFile, JSON.stringify(json, null, 2)); +} + +// compileDocs only documents the methods of an interface; +// add its (read-only) properties too, e.g. CellMeasurerCacheAPI's defaultHeight and defaultWidth getters. +await addInterfaceProperties( + "lib/components/CellMeasurer/types.ts", + "CellMeasurerCacheAPI" +); + +async function addInterfaceProperties(filePath: string, name: string) { + const outputFile = resolve(outputDir, `${name}.json`); + const json = JSON.parse( + await readFile(outputFile, "utf-8") + ) as ImperativeHandleMetadata; + + const sourceFile = ts.createSourceFile( + filePath, + await readFile(filePath, "utf-8"), + ts.ScriptTarget.Latest, + true + ); + const interfaceDeclaration = sourceFile.statements.find( + (statement): statement is ts.InterfaceDeclaration => + ts.isInterfaceDeclaration(statement) && statement.name.text === name + ); + if (!interfaceDeclaration) { + throw Error(`Interface ${name} not found in ${filePath}`); + } + + // Keep the declaration order + const methods: ImperativeHandleMetadata["methods"] = []; + for (const member of interfaceDeclaration.members) { + const memberName = member.name?.getText(sourceFile) ?? ""; + if (ts.isPropertySignature(member) || ts.isGetAccessor(member)) { + const comment = ts + .getJSDocCommentsAndTags(member) + .filter(ts.isJSDoc) + .map((jsDoc) => ts.getTextOfJSDocComment(jsDoc.comment) ?? "") + .join("\n\n"); + if (!comment) { + throw Error(`Missing JSDoc comment for ${name}.${memberName}`); + } + + methods.push({ + description: await parseDescription(comment), + html: await syntaxHighlight(member.getText(sourceFile), "TS"), + name: memberName + }); + } else { + const method = json.methods.find((method) => method.name === memberName); + if (method) { + methods.push(method); + } + } + } + json.methods = methods; + + await writeFile(outputFile, JSON.stringify(json, null, 2)); +} diff --git a/scripts/compile-examples.ts b/scripts/compile-examples.ts new file mode 100644 index 000000000..0b0c76d4c --- /dev/null +++ b/scripts/compile-examples.ts @@ -0,0 +1,3 @@ +import { compileExamples } from "react-lib-tools/scripts/compile-examples.ts"; + +await compileExamples(); diff --git a/scripts/compile-search-index.ts b/scripts/compile-search-index.ts new file mode 100644 index 000000000..ab31c073e --- /dev/null +++ b/scripts/compile-search-index.ts @@ -0,0 +1,23 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { compileSearchIndex } from "react-lib-tools/scripts/compile-search-index.ts"; + +await compileSearchIndex({ + chromeExecutablePath: process.env.CHROME_PATH, + // Exclude the (generated) contents of live demos from the index + filterSelector: [ + ".ReactVirtualized__Grid", + ".ReactVirtualized__Masonry", + ".ReactVirtualized__Table" + ].join(", "), + // Defaults to the dev server (its port is configured in package.json) + host: process.env.SEARCH_INDEX_HOST +}); + +// Pages are crawled concurrently, so they're added to the site map in a different order each time; +// sort them by path so the output only changes when the content does +const siteMapPath = "public/generated/site-map.json"; +const siteMap: { path: string }[] = JSON.parse( + await readFile(siteMapPath, "utf8") +); +siteMap.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +await writeFile(siteMapPath, JSON.stringify(siteMap, null, 2)); diff --git a/scripts/docs-stubs/CellMeasurerCacheOptions.tsx b/scripts/docs-stubs/CellMeasurerCacheOptions.tsx new file mode 100644 index 000000000..5f4f6a503 --- /dev/null +++ b/scripts/docs-stubs/CellMeasurerCacheOptions.tsx @@ -0,0 +1,13 @@ +// Docs-only stub (not part of the library). +// react-docgen-typescript only documents component props, +// so this "component" exposes the `CellMeasurerCache` constructor options as its props; +// see scripts/compile-docs.ts. +import type { CellMeasurerCacheParams } from "../../lib/components/CellMeasurer/types"; + +/** + * Options accepted by the `CellMeasurerCache` constructor (all optional). + */ +export function CellMeasurerCacheOptions(options: CellMeasurerCacheParams) { + void options; + return null; +} diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.example.css b/source/ArrowKeyStepper/ArrowKeyStepper.example.css deleted file mode 100644 index 5ed691585..000000000 --- a/source/ArrowKeyStepper/ArrowKeyStepper.example.css +++ /dev/null @@ -1,32 +0,0 @@ -.Grid { - border: 1px solid #e0e0e0; -} - -.Cell { - height: 100%; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - border: none; - border-right: 1px solid #e0e0e0; - border-bottom: 1px solid #e0e0e0; - outline: none; -} - -.FocusedCell { - background-color: #e0e0e0; - font-weight: bold; -} - -.Radio { - margin-left: 0.5rem; -} - -.checkboxLabel { - display: flex; - align-items: center; -} -.checkbox { - margin-right: 5px; -} diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.example.js b/source/ArrowKeyStepper/ArrowKeyStepper.example.js deleted file mode 100644 index cfc9fbd22..000000000 --- a/source/ArrowKeyStepper/ArrowKeyStepper.example.js +++ /dev/null @@ -1,208 +0,0 @@ -/** @flow */ - -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import ArrowKeyStepper, {type ScrollIndices} from './'; -import AutoSizer from '../AutoSizer'; -import Grid from '../Grid'; -import clsx from 'clsx'; -import styles from './ArrowKeyStepper.example.css'; - -type State = { - mode: 'edges' | 'cells', - isClickable: boolean, - scrollToColumn: number, - scrollToRow: number, -}; - -export default class ArrowKeyStepperExample extends React.PureComponent< - {}, - State, -> { - state = { - mode: 'edges', - isClickable: true, - scrollToColumn: 0, - scrollToRow: 0, - }; - - render() { - const {mode, isClickable, scrollToColumn, scrollToRow} = this.state; - - return ( - - - - - This high-order component decorates a List,{' '} - Table, or Grid and responds to arrow-key - events by scrolling one row or column at a time. Focus in the `Grid` - below and use the left, right, up, or down arrow keys to move around - within the grid. - - - - Note that unlike the other HOCs in react-virtualized, the{' '} - ArrowKeyStepper adds a <div> element - around its children in order to attach a key-down event handler. - - - - mode: - - - - - - - - - - {({onSectionRendered, scrollToColumn, scrollToRow}) => ( -
- - {`Most-recently-stepped column: ${scrollToColumn}, row: ${scrollToRow}`} - - - - {({width}) => ( - - this._cellRenderer({ - columnIndex, - key, - rowIndex, - scrollToColumn, - scrollToRow, - style, - }) - } - rowHeight={this._getRowHeight} - rowCount={100} - scrollToColumn={scrollToColumn} - scrollToRow={scrollToRow} - width={width} - /> - )} - -
- )} -
-
- ); - } - - _getColumnWidth = ({index}: {index: number}) => { - return (1 + (index % 3)) * 60; - }; - - _getRowHeight = ({index}: {index: number}) => { - return (1 + (index % 3)) * 30; - }; - - _cellRenderer = ({ - columnIndex, - key, - rowIndex, - scrollToColumn, - scrollToRow, - style, - }: { - columnIndex: number, - key: string, - rowIndex: number, - scrollToColumn: number, - scrollToRow: number, - style: Object, - }) => { - const className = clsx(styles.Cell, { - [styles.FocusedCell]: - columnIndex === scrollToColumn && rowIndex === scrollToRow, - }); - - return ( - - this._selectCell({ - scrollToColumn: columnIndex, - scrollToRow: rowIndex, - })) - } - style={style}> - {`r:${rowIndex}, c:${columnIndex}`} - - ); - }; - - _selectCell = ({scrollToColumn, scrollToRow}: ScrollIndices) => { - this.setState({scrollToColumn, scrollToRow}); - }; - - _onClickableChange = (event: Event) => { - if (event.target instanceof HTMLInputElement) { - this.setState({ - isClickable: event.target.checked, - scrollToColumn: 0, - scrollToRow: 0, - }); - } - }; -} diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.jest.js b/source/ArrowKeyStepper/ArrowKeyStepper.jest.js deleted file mode 100644 index 902266850..000000000 --- a/source/ArrowKeyStepper/ArrowKeyStepper.jest.js +++ /dev/null @@ -1,269 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import ArrowKeyStepper from './ArrowKeyStepper'; -import {Simulate} from 'react-dom/test-utils'; - -function renderTextContent(scrollToColumn, scrollToRow) { - return `scrollToColumn:${scrollToColumn}, scrollToRow:${scrollToRow}`; -} - -function ChildComponent({scrollToColumn, scrollToRow}) { - return
{renderTextContent(scrollToColumn, scrollToRow)}
; -} - -describe('ArrowKeyStepper', () => { - function renderHelper(props = {}) { - let onSectionRenderedCallback; - - const component = render( - - {({onSectionRendered, scrollToColumn, scrollToRow}) => { - onSectionRenderedCallback = onSectionRendered; - - return ( - - ); - }} - , - ); - const node = findDOMNode(component); - - return { - component, - node, - onSectionRendered: onSectionRenderedCallback, - }; - } - - function assertCurrentScrollTo(node, scrollToColumn, scrollToRow) { - expect(node.textContent).toEqual( - renderTextContent(scrollToColumn, scrollToRow), - ); - } - - it('should use a custom :className if one is specified', () => { - const {node} = renderHelper({className: 'foo'}); - expect(node.className).toEqual('foo'); - }); - - it('should update :scrollToColumn and :scrollToRow in response to arrow keys', () => { - const {node} = renderHelper(); - assertCurrentScrollTo(node, 0, 0); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 0, 1); - Simulate.keyDown(node, {key: 'ArrowRight'}); - assertCurrentScrollTo(node, 1, 1); - Simulate.keyDown(node, {key: 'ArrowUp'}); - assertCurrentScrollTo(node, 1, 0); - Simulate.keyDown(node, {key: 'ArrowLeft'}); - assertCurrentScrollTo(node, 0, 0); - }); - - it('should not scroll past the row and column boundaries provided', () => { - const {node} = renderHelper({ - columnCount: 2, - rowCount: 2, - }); - Simulate.keyDown(node, {key: 'ArrowDown'}); - Simulate.keyDown(node, {key: 'ArrowDown'}); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 0, 1); - Simulate.keyDown(node, {key: 'ArrowUp'}); - Simulate.keyDown(node, {key: 'ArrowUp'}); - Simulate.keyDown(node, {key: 'ArrowUp'}); - assertCurrentScrollTo(node, 0, 0); - Simulate.keyDown(node, {key: 'ArrowRight'}); - Simulate.keyDown(node, {key: 'ArrowRight'}); - Simulate.keyDown(node, {key: 'ArrowRight'}); - assertCurrentScrollTo(node, 1, 0); - Simulate.keyDown(node, {key: 'ArrowLeft'}); - Simulate.keyDown(node, {key: 'ArrowLeft'}); - Simulate.keyDown(node, {key: 'ArrowLeft'}); - assertCurrentScrollTo(node, 0, 0); - }); - - it('should accept initial :scrollToColumn and :scrollToRow values via props', () => { - const {node} = renderHelper({ - mode: 'cells', - scrollToColumn: 2, - scrollToRow: 4, - }); - assertCurrentScrollTo(node, 2, 4); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 2, 5); - Simulate.keyDown(node, {key: 'ArrowRight'}); - assertCurrentScrollTo(node, 3, 5); - }); - - it('should accept updated :scrollToColumn and :scrollToRow values via props', () => { - const {node} = renderHelper({ - mode: 'cells', - scrollToColumn: 2, - scrollToRow: 4, - }); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 2, 5); - renderHelper({ - mode: 'cells', - scrollToColumn: 1, - scrollToRow: 1, - }); - Simulate.keyDown(node, {key: 'ArrowRight'}); - assertCurrentScrollTo(node, 2, 1); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 2, 2); - }); - - it('should accept updated :scrollToColumn and :scrollToRow values via setScrollIndexes()', () => { - const {component, node} = renderHelper({ - mode: 'cells', - scrollToColumn: 2, - scrollToRow: 4, - }); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 2, 5); - component.setScrollIndexes({ - scrollToColumn: 1, - scrollToRow: 1, - }); - Simulate.keyDown(node, {key: 'ArrowRight'}); - assertCurrentScrollTo(node, 2, 1); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 2, 2); - }); - - it('should not update :scrollToColumn or :scrollToRow when :disabled', () => { - const {node} = renderHelper({ - disabled: true, - }); - assertCurrentScrollTo(node, 0, 0); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 0, 0); - Simulate.keyDown(node, {key: 'ArrowRight'}); - assertCurrentScrollTo(node, 0, 0); - }); - - it('should call :onScrollToChange for key down', () => { - [true, false].forEach(() => { - const onScrollToChange = jest.fn(); - const {node} = renderHelper({ - isControlled: true, - onScrollToChange, - }); - - expect(onScrollToChange.mock.calls).toHaveLength(0); - - Simulate.keyDown(node, {key: 'ArrowDown'}); - - expect(onScrollToChange.mock.calls).toHaveLength(1); - - const {scrollToColumn, scrollToRow} = onScrollToChange.mock.calls[0][0]; - expect(scrollToColumn).toEqual(0); - expect(scrollToRow).toEqual(1); - }); - }); - - it('should not call :onScrollToChange for prop update', () => { - let numCalls = 0; - const onScrollToChange = () => { - numCalls++; - }; - const {node} = renderHelper({ - onScrollToChange, - scrollToColumn: 0, - scrollToRow: 0, - }); - - renderHelper({ - isControlled: true, - onScrollToChange, - node, - scrollToColumn: 0, - scrollToRow: 1, - }); - expect(numCalls).toEqual(0); - }); - - describe('mode === "edges"', () => { - it('should update :scrollToColumn and :scrollToRow relative to the most recent :onSectionRendered event', () => { - const {node, onSectionRendered} = renderHelper(); - onSectionRendered({ - // Simulate a scroll - columnStartIndex: 0, - columnStopIndex: 4, - rowStartIndex: 4, - rowStopIndex: 6, - }); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 0, 7); - - onSectionRendered({ - // Simulate a scroll - columnStartIndex: 5, - columnStopIndex: 10, - rowStartIndex: 2, - rowStopIndex: 4, - }); - Simulate.keyDown(node, {key: 'ArrowUp'}); - assertCurrentScrollTo(node, 0, 1); - - onSectionRendered({ - // Simulate a scroll - columnStartIndex: 4, - columnStopIndex: 8, - rowStartIndex: 5, - rowStopIndex: 10, - }); - Simulate.keyDown(node, {key: 'ArrowRight'}); - assertCurrentScrollTo(node, 9, 1); - - onSectionRendered({ - // Simulate a scroll - columnStartIndex: 2, - columnStopIndex: 4, - rowStartIndex: 2, - rowStopIndex: 4, - }); - Simulate.keyDown(node, {key: 'ArrowLeft'}); - assertCurrentScrollTo(node, 1, 1); - }); - }); - - describe('mode === "cells"', () => { - it('should update :scrollToColumn and :scrollToRow relative to the most recent :onSectionRendered event', () => { - const {node, onSectionRendered} = renderHelper({ - mode: 'cells', - scrollToColumn: 5, - scrollToRow: 5, - }); - - onSectionRendered({ - // Simulate a scroll - columnStartIndex: 10, - columnStopIndex: 10, - rowStartIndex: 15, - rowStopIndex: 15, - }); - Simulate.keyDown(node, {key: 'ArrowUp'}); - assertCurrentScrollTo(node, 5, 4); - Simulate.keyDown(node, {key: 'ArrowDown'}); - assertCurrentScrollTo(node, 5, 5); - - onSectionRendered({ - // Simulate a scroll - columnStartIndex: 10, - columnStopIndex: 10, - rowStartIndex: 15, - rowStopIndex: 15, - }); - Simulate.keyDown(node, {key: 'ArrowRight'}); - assertCurrentScrollTo(node, 6, 5); - Simulate.keyDown(node, {key: 'ArrowLeft'}); - assertCurrentScrollTo(node, 5, 5); - }); - }); -}); diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.js b/source/ArrowKeyStepper/ArrowKeyStepper.js deleted file mode 100644 index e26758841..000000000 --- a/source/ArrowKeyStepper/ArrowKeyStepper.js +++ /dev/null @@ -1,194 +0,0 @@ -/** @flow */ - -import type {RenderedSection} from '../Grid'; -import type {ScrollIndices} from './types'; - -import * as React from 'react'; -import {polyfill} from 'react-lifecycles-compat'; - -/** - * This HOC decorates a virtualized component and responds to arrow-key events by scrolling one row or column at a time. - */ - -type ChildrenParams = { - onSectionRendered: (params: RenderedSection) => void, - scrollToColumn: number, - scrollToRow: number, -}; - -type Props = { - children: (params: ChildrenParams) => React.Element<*>, - className?: string, - columnCount: number, - disabled: boolean, - isControlled: boolean, - mode: 'cells' | 'edges', - onScrollToChange?: (params: ScrollIndices) => void, - rowCount: number, - scrollToColumn: number, - scrollToRow: number, -}; - -type State = ScrollIndices & { - instanceProps: { - prevScrollToColumn: number, - prevScrollToRow: number, - }, -}; - -class ArrowKeyStepper extends React.PureComponent { - static defaultProps = { - disabled: false, - isControlled: false, - mode: 'edges', - scrollToColumn: 0, - scrollToRow: 0, - }; - - state = { - scrollToColumn: 0, - scrollToRow: 0, - instanceProps: { - prevScrollToColumn: 0, - prevScrollToRow: 0, - }, - }; - - _columnStartIndex = 0; - _columnStopIndex = 0; - _rowStartIndex = 0; - _rowStopIndex = 0; - - static getDerivedStateFromProps( - nextProps: Props, - prevState: State, - ): $Shape { - if (nextProps.isControlled) { - return {}; - } - - if ( - nextProps.scrollToColumn !== prevState.instanceProps.prevScrollToColumn || - nextProps.scrollToRow !== prevState.instanceProps.prevScrollToRow - ) { - return { - ...prevState, - scrollToColumn: nextProps.scrollToColumn, - scrollToRow: nextProps.scrollToRow, - instanceProps: { - prevScrollToColumn: nextProps.scrollToColumn, - prevScrollToRow: nextProps.scrollToRow, - }, - }; - } - - return {}; - } - - setScrollIndexes({scrollToColumn, scrollToRow}: ScrollIndices) { - this.setState({ - scrollToRow, - scrollToColumn, - }); - } - - render() { - const {className, children} = this.props; - const {scrollToColumn, scrollToRow} = this._getScrollState(); - - return ( -
- {children({ - onSectionRendered: this._onSectionRendered, - scrollToColumn, - scrollToRow, - })} -
- ); - } - - _onKeyDown = (event: KeyboardEvent) => { - const {columnCount, disabled, mode, rowCount} = this.props; - - if (disabled) { - return; - } - - const { - scrollToColumn: scrollToColumnPrevious, - scrollToRow: scrollToRowPrevious, - } = this._getScrollState(); - - let {scrollToColumn, scrollToRow} = this._getScrollState(); - - // The above cases all prevent default event event behavior. - // This is to keep the grid from scrolling after the snap-to update. - switch (event.key) { - case 'ArrowDown': - scrollToRow = - mode === 'cells' - ? Math.min(scrollToRow + 1, rowCount - 1) - : Math.min(this._rowStopIndex + 1, rowCount - 1); - break; - case 'ArrowLeft': - scrollToColumn = - mode === 'cells' - ? Math.max(scrollToColumn - 1, 0) - : Math.max(this._columnStartIndex - 1, 0); - break; - case 'ArrowRight': - scrollToColumn = - mode === 'cells' - ? Math.min(scrollToColumn + 1, columnCount - 1) - : Math.min(this._columnStopIndex + 1, columnCount - 1); - break; - case 'ArrowUp': - scrollToRow = - mode === 'cells' - ? Math.max(scrollToRow - 1, 0) - : Math.max(this._rowStartIndex - 1, 0); - break; - } - - if ( - scrollToColumn !== scrollToColumnPrevious || - scrollToRow !== scrollToRowPrevious - ) { - event.preventDefault(); - - this._updateScrollState({scrollToColumn, scrollToRow}); - } - }; - - _onSectionRendered = ({ - columnStartIndex, - columnStopIndex, - rowStartIndex, - rowStopIndex, - }: RenderedSection) => { - this._columnStartIndex = columnStartIndex; - this._columnStopIndex = columnStopIndex; - this._rowStartIndex = rowStartIndex; - this._rowStopIndex = rowStopIndex; - }; - - _getScrollState() { - return this.props.isControlled ? this.props : this.state; - } - - _updateScrollState({scrollToColumn, scrollToRow}: ScrollIndices) { - const {isControlled, onScrollToChange} = this.props; - - if (typeof onScrollToChange === 'function') { - onScrollToChange({scrollToColumn, scrollToRow}); - } - - if (!isControlled) { - this.setState({scrollToColumn, scrollToRow}); - } - } -} - -polyfill(ArrowKeyStepper); - -export default ArrowKeyStepper; diff --git a/source/ArrowKeyStepper/index.js b/source/ArrowKeyStepper/index.js deleted file mode 100644 index fe13226d3..000000000 --- a/source/ArrowKeyStepper/index.js +++ /dev/null @@ -1,6 +0,0 @@ -// @flow - -export type {ScrollIndices} from './types'; - -export {default} from './ArrowKeyStepper'; -export {default as ArrowKeyStepper} from './ArrowKeyStepper'; diff --git a/source/ArrowKeyStepper/types.js b/source/ArrowKeyStepper/types.js deleted file mode 100644 index 9bfc2af61..000000000 --- a/source/ArrowKeyStepper/types.js +++ /dev/null @@ -1,6 +0,0 @@ -// @flow - -export type ScrollIndices = { - scrollToColumn: number, - scrollToRow: number, -}; diff --git a/source/AutoSizer/AutoSizer.example.css b/source/AutoSizer/AutoSizer.example.css deleted file mode 100644 index 9a3b5373a..000000000 --- a/source/AutoSizer/AutoSizer.example.css +++ /dev/null @@ -1,24 +0,0 @@ -.AutoSizerWrapper { - flex: 1 1 auto; -} - -.List { - border: 1px solid #e0e0e0; -} - -.row { - display: flex; - flex-direction: row; - align-items: center; - padding: 0 25px; - background-color: #fff; - border-bottom: 1px solid #e0e0e0; -} - -.checkboxLabel { - display: flex; - align-items: center; -} -.checkbox { - margin-right: 5px; -} diff --git a/source/AutoSizer/AutoSizer.example.js b/source/AutoSizer/AutoSizer.example.js deleted file mode 100644 index 677dd55b9..000000000 --- a/source/AutoSizer/AutoSizer.example.js +++ /dev/null @@ -1,103 +0,0 @@ -/** @flow */ - -import {List as ImmutableList} from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import AutoSizer from './AutoSizer'; -import List, {type RowRendererParams} from '../List'; -import styles from './AutoSizer.example.css'; - -type State = { - hideDescription: boolean, -}; - -export default class AutoSizerExample extends React.PureComponent<{}, State> { - static contextTypes = { - list: PropTypes.instanceOf(ImmutableList).isRequired, - }; - - state = { - hideDescription: false, - }; - - render() { - const {list} = this.context; - const {hideDescription} = this.state; - - return ( - - - - - - - - {!hideDescription && ( - - This component decorates List, Table, or - any other component and automatically manages its width and height. - It uses Sebastian Decima's{' '} - - element resize event - {' '} - to determine the appropriate size. In this example{' '} - AutoSizer grows to fill the remaining width and height - of this flex column. - - )} - -
- - {({width, height}) => ( - - )} - -
-
- ); - } - - _rowRenderer = ({index, key, style}: RowRendererParams) => { - const {list} = this.context; - const row = list.get(index); - - return ( -
- {row.name} -
- ); - }; -} diff --git a/source/AutoSizer/AutoSizer.jest.js b/source/AutoSizer/AutoSizer.jest.js deleted file mode 100644 index 356685d60..000000000 --- a/source/AutoSizer/AutoSizer.jest.js +++ /dev/null @@ -1,238 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import AutoSizer from './AutoSizer'; - -function DefaultChildComponent({height, width, foo, bar}) { - return ( -
{`width:${width}, height:${height}, foo:${foo}, bar:${bar}`}
- ); -} - -describe('AutoSizer', () => { - function getMarkup({ - bar = 123, - ChildComponent = DefaultChildComponent, - className = undefined, - defaultHeight = undefined, - defaultWidth = undefined, - disableHeight = false, - disableWidth = false, - foo = 456, - height = 100, - onResize, - paddingBottom = 0, - paddingLeft = 0, - paddingRight = 0, - paddingTop = 0, - style = undefined, - width = 200, - } = {}) { - const wrapperStyle = { - boxSizing: 'border-box', - height, - paddingBottom, - paddingLeft, - paddingRight, - paddingTop, - width, - }; - - mockOffsetSize(width, height); - - return ( -
- - {({height, width}) => ( - - )} - -
- ); - } - - // AutoSizer uses offsetWidth and offsetHeight. - // Jest runs in JSDom which doesn't support measurements APIs. - function mockOffsetSize(width, height) { - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { - configurable: true, - value: height, - }); - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { - configurable: true, - value: width, - }); - } - - it('should relay properties to ChildComponent or React child', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.textContent).toContain('foo:456'); - expect(rendered.textContent).toContain('bar:123'); - }); - - it('should set the correct initial width and height of ChildComponent or React child', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.textContent).toContain('height:100'); - expect(rendered.textContent).toContain('width:200'); - }); - - it('should account for padding when calculating the available width and height', () => { - const rendered = findDOMNode( - render( - getMarkup({ - paddingBottom: 10, - paddingLeft: 4, - paddingRight: 4, - paddingTop: 15, - }), - ), - ); - expect(rendered.textContent).toContain('height:75'); - expect(rendered.textContent).toContain('width:192'); - }); - - it('should not update :width if :disableWidth is true', () => { - const rendered = findDOMNode(render(getMarkup({disableWidth: true}))); - expect(rendered.textContent).toContain('height:100'); - expect(rendered.textContent).toContain('width:undefined'); - }); - - it('should not update :height if :disableHeight is true', () => { - const rendered = findDOMNode(render(getMarkup({disableHeight: true}))); - expect(rendered.textContent).toContain('height:undefined'); - expect(rendered.textContent).toContain('width:200'); - }); - - async function simulateResize({element, height, width}) { - mockOffsetSize(width, height); - - // Trigger detectElementResize library by faking a scroll event - // TestUtils Simulate doesn't work here in JSDom so we manually dispatch - const trigger = element.querySelector('.contract-trigger'); - trigger.dispatchEvent(new Event('scroll')); - - // Allow requestAnimationFrame to be invoked before continuing - await new Promise(resolve => setTimeout(resolve, 100)); - } - - it('should update :height after a resize event', async done => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 100, - width: 200, - }), - ), - ); - expect(rendered.textContent).toContain('height:100'); - expect(rendered.textContent).toContain('width:200'); - await simulateResize({element: rendered, height: 400, width: 300}); - expect(rendered.textContent).toContain('height:400'); - expect(rendered.textContent).toContain('width:300'); - done(); - }); - - describe('onResize and (re)render', () => { - it('should trigger when size changes', async done => { - const onResize = jest.fn(); - const ChildComponent = jest - .fn() - .mockImplementation(DefaultChildComponent); - const rendered = findDOMNode( - render( - getMarkup({ - ChildComponent, - height: 100, - onResize, - width: 200, - }), - ), - ); - ChildComponent.mockClear(); // TODO Improve initial check in version 10; see AutoSizer render() - expect(onResize).toHaveBeenCalledTimes(1); - await simulateResize({element: rendered, height: 400, width: 300}); - expect(ChildComponent).toHaveBeenCalledTimes(1); - expect(onResize).toHaveBeenCalledTimes(2); - done(); - }); - - it('should only trigger when height changes for disableWidth == true', async done => { - const onResize = jest.fn(); - const ChildComponent = jest - .fn() - .mockImplementation(DefaultChildComponent); - const rendered = findDOMNode( - render( - getMarkup({ - ChildComponent, - disableWidth: true, - height: 100, - onResize, - width: 200, - }), - ), - ); - ChildComponent.mockClear(); // TODO Improve initial check in version 10; see AutoSizer render() - expect(onResize).toHaveBeenCalledTimes(1); - await simulateResize({element: rendered, height: 100, width: 300}); - expect(ChildComponent).toHaveBeenCalledTimes(0); - expect(onResize).toHaveBeenCalledTimes(1); - await simulateResize({element: rendered, height: 200, width: 300}); - expect(ChildComponent).toHaveBeenCalledTimes(1); - expect(onResize).toHaveBeenCalledTimes(2); - done(); - }); - - it('should only trigger when width changes for disableHeight == true', async done => { - const onResize = jest.fn(); - const ChildComponent = jest - .fn() - .mockImplementation(DefaultChildComponent); - const rendered = findDOMNode( - render( - getMarkup({ - ChildComponent, - disableHeight: true, - height: 100, - onResize, - width: 200, - }), - ), - ); - ChildComponent.mockClear(); // TODO Improve initial check in version 10; see AutoSizer render() - expect(onResize).toHaveBeenCalledTimes(1); - await simulateResize({element: rendered, height: 200, width: 200}); - expect(ChildComponent).toHaveBeenCalledTimes(0); - expect(onResize).toHaveBeenCalledTimes(1); - await simulateResize({element: rendered, height: 200, width: 300}); - expect(ChildComponent).toHaveBeenCalledTimes(1); - expect(onResize).toHaveBeenCalledTimes(2); - done(); - }); - }); - - describe('className and style', () => { - it('should use a custom :className if specified', () => { - const rendered = findDOMNode(render(getMarkup({className: 'foo'}))); - expect(rendered.firstChild.className).toContain('foo'); - }); - - it('should use a custom :style if specified', () => { - const style = {backgroundColor: 'red'}; - const rendered = findDOMNode(render(getMarkup({style}))); - expect(rendered.firstChild.style.backgroundColor).toEqual('red'); - }); - }); -}); diff --git a/source/AutoSizer/AutoSizer.js b/source/AutoSizer/AutoSizer.js deleted file mode 100644 index 7b0a142cc..000000000 --- a/source/AutoSizer/AutoSizer.js +++ /dev/null @@ -1,200 +0,0 @@ -/** @flow */ - -import * as React from 'react'; -import createDetectElementResize from '../vendor/detectElementResize'; - -type Size = { - height: number, - width: number, -}; - -type Props = { - /** Function responsible for rendering children.*/ - children: Size => React.Element<*>, - - /** Optional custom CSS class name to attach to root AutoSizer element. */ - className?: string, - - /** Default height to use for initial render; useful for SSR */ - defaultHeight?: number, - - /** Default width to use for initial render; useful for SSR */ - defaultWidth?: number, - - /** Disable dynamic :height property */ - disableHeight: boolean, - - /** Disable dynamic :width property */ - disableWidth: boolean, - - /** Nonce of the inlined stylesheet for Content Security Policy */ - nonce?: string, - - /** Callback to be invoked on-resize */ - onResize: Size => void, - - /** Optional inline style */ - style: ?Object, -}; - -type State = { - height: number, - width: number, -}; - -type ResizeHandler = (element: HTMLElement, onResize: () => void) => void; - -type DetectElementResize = { - addResizeListener: ResizeHandler, - removeResizeListener: ResizeHandler, -}; - -export default class AutoSizer extends React.Component { - static defaultProps = { - onResize: () => {}, - disableHeight: false, - disableWidth: false, - style: {}, - }; - - state = { - height: this.props.defaultHeight || 0, - width: this.props.defaultWidth || 0, - }; - - _parentNode: ?HTMLElement; - _autoSizer: ?HTMLElement; - _window: ?any; // uses any instead of Window because Flow doesn't have window type - _detectElementResize: DetectElementResize; - - componentDidMount() { - const {nonce} = this.props; - if ( - this._autoSizer && - this._autoSizer.parentNode && - this._autoSizer.parentNode.ownerDocument && - this._autoSizer.parentNode.ownerDocument.defaultView && - this._autoSizer.parentNode instanceof - this._autoSizer.parentNode.ownerDocument.defaultView.HTMLElement - ) { - // Delay access of parentNode until mount. - // This handles edge-cases where the component has already been unmounted before its ref has been set, - // As well as libraries like react-lite which have a slightly different lifecycle. - this._parentNode = this._autoSizer.parentNode; - this._window = this._autoSizer.parentNode.ownerDocument.defaultView; - - // Defer requiring resize handler in order to support server-side rendering. - // See issue #41 - this._detectElementResize = createDetectElementResize( - nonce, - this._window, - ); - this._detectElementResize.addResizeListener( - this._parentNode, - this._onResize, - ); - - this._onResize(); - } - } - - componentWillUnmount() { - if (this._detectElementResize && this._parentNode) { - this._detectElementResize.removeResizeListener( - this._parentNode, - this._onResize, - ); - } - } - - render() { - const { - children, - className, - disableHeight, - disableWidth, - style, - } = this.props; - const {height, width} = this.state; - - // Outer div should not force width/height since that may prevent containers from shrinking. - // Inner component should overflow and use calculated width/height. - // See issue #68 for more information. - const outerStyle: Object = {overflow: 'visible'}; - const childParams: Object = {}; - - if (!disableHeight) { - outerStyle.height = 0; - childParams.height = height; - } - - if (!disableWidth) { - outerStyle.width = 0; - childParams.width = width; - } - - /** - * TODO: Avoid rendering children before the initial measurements have been collected. - * At best this would just be wasting cycles. - * Add this check into version 10 though as it could break too many ref callbacks in version 9. - * Note that if default width/height props were provided this would still work with SSR. - if ( - height !== 0 && - width !== 0 - ) { - child = children({ height, width }) - } - */ - - return ( -
- {children(childParams)} -
- ); - } - - _onResize = () => { - const {disableHeight, disableWidth, onResize} = this.props; - - if (this._parentNode) { - // Guard against AutoSizer component being removed from the DOM immediately after being added. - // This can result in invalid style values which can result in NaN values if we don't handle them. - // See issue #150 for more context. - - const height = this._parentNode.offsetHeight || 0; - const width = this._parentNode.offsetWidth || 0; - - const win = this._window || window; - const style = win.getComputedStyle(this._parentNode) || {}; - const paddingLeft = parseInt(style.paddingLeft, 10) || 0; - const paddingRight = parseInt(style.paddingRight, 10) || 0; - const paddingTop = parseInt(style.paddingTop, 10) || 0; - const paddingBottom = parseInt(style.paddingBottom, 10) || 0; - - const newHeight = height - paddingTop - paddingBottom; - const newWidth = width - paddingLeft - paddingRight; - - if ( - (!disableHeight && this.state.height !== newHeight) || - (!disableWidth && this.state.width !== newWidth) - ) { - this.setState({ - height: height - paddingTop - paddingBottom, - width: width - paddingLeft - paddingRight, - }); - - onResize({height, width}); - } - } - }; - - _setRef = (autoSizer: ?HTMLElement) => { - this._autoSizer = autoSizer; - }; -} diff --git a/source/AutoSizer/AutoSizer.ssr.js b/source/AutoSizer/AutoSizer.ssr.js deleted file mode 100644 index 9f45bd225..000000000 --- a/source/AutoSizer/AutoSizer.ssr.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @jest-environment node - */ - -import * as React from 'react'; -import * as ReactDOMServer from 'react-dom/server'; -import AutoSizer from './AutoSizer'; - -test('should render content with default widths and heights initially', () => { - const rendered = ReactDOMServer.renderToString( - - {({height, width}) =>
{`height:${height};width:${width}`}
} -
, - ); - expect(rendered).toContain('height:100'); - expect(rendered).toContain('width:200'); -}); diff --git a/source/AutoSizer/index.js b/source/AutoSizer/index.js deleted file mode 100644 index 27514059c..000000000 --- a/source/AutoSizer/index.js +++ /dev/null @@ -1,4 +0,0 @@ -// @flow - -export {default} from './AutoSizer'; -export {default as AutoSizer} from './AutoSizer'; diff --git a/source/CellMeasurer/CellMeasurer.DynamicHeightGrid.example.js b/source/CellMeasurer/CellMeasurer.DynamicHeightGrid.example.js deleted file mode 100644 index 685e9b7cf..000000000 --- a/source/CellMeasurer/CellMeasurer.DynamicHeightGrid.example.js +++ /dev/null @@ -1,73 +0,0 @@ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import CellMeasurer from './CellMeasurer'; -import CellMeasurerCache from './CellMeasurerCache'; -import Grid from '../Grid'; -import styles from './CellMeasurer.example.css'; - -export default class DynamicHeightGrid extends React.PureComponent { - static propTypes = { - getClassName: PropTypes.func.isRequired, - getContent: PropTypes.func.isRequired, - list: PropTypes.instanceOf(Immutable.List).isRequired, - width: PropTypes.number.isRequired, - }; - - constructor(props, context) { - super(props, context); - - this._cache = new CellMeasurerCache({ - defaultWidth: 150, - fixedWidth: true, - }); - - this._cellRenderer = this._cellRenderer.bind(this); - } - - render() { - const {width} = this.props; - - return ( - - ); - } - - _cellRenderer({columnIndex, key, parent, rowIndex, style}) { - const {getClassName, getContent, list} = this.props; - - const datum = list.get((rowIndex + columnIndex) % list.size); - const classNames = getClassName({columnIndex, rowIndex}); - const content = getContent({index: rowIndex, datum}); - - return ( - -
- {content} -
-
- ); - } -} diff --git a/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js b/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js deleted file mode 100644 index e78facecf..000000000 --- a/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js +++ /dev/null @@ -1,76 +0,0 @@ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import CellMeasurer from './CellMeasurer'; -import CellMeasurerCache from './CellMeasurerCache'; -import List from '../List'; -import styles from './CellMeasurer.example.css'; - -export default class DynamicHeightList extends React.PureComponent { - static propTypes = { - getClassName: PropTypes.func.isRequired, - list: PropTypes.instanceOf(Immutable.List).isRequired, - width: PropTypes.number.isRequired, - }; - - constructor(props, context) { - super(props, context); - - this._cache = new CellMeasurerCache({ - fixedWidth: true, - minHeight: 50, - }); - - this._rowRenderer = this._rowRenderer.bind(this); - } - - render() { - const {width} = this.props; - - return ( - - ); - } - - _rowRenderer({index, key, parent, style}) { - const {getClassName, list} = this.props; - - const datum = list.get(index % list.size); - const classNames = getClassName({columnIndex: 0, rowIndex: index}); - - const imageWidth = 300; - const imageHeight = datum.size * (1 + (index % 3)); - - const source = `https://www.fillmurray.com/${imageWidth}/${imageHeight}`; - - return ( - - {({measure, registerChild}) => ( -
- -
- )} -
- ); - } -} diff --git a/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js b/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js deleted file mode 100644 index b61bc33ec..000000000 --- a/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js +++ /dev/null @@ -1,92 +0,0 @@ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import CellMeasurer from './CellMeasurer'; -import CellMeasurerCache from './CellMeasurerCache'; -import {Column, Table} from '../Table'; -import styles from './CellMeasurer.example.css'; - -export default class DynamicHeightTableColumn extends React.PureComponent { - static propTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - width: PropTypes.number.isRequired, - }; - - _cache = new CellMeasurerCache({ - fixedWidth: true, - minHeight: 25, - }); - - _lastRenderedWidth = this.props.width; - - render() { - const {width} = this.props; - - if (this._lastRenderedWidth !== this.props.width) { - this._lastRenderedWidth = this.props.width; - this._cache.clearAll(); - } - - return ( - - - - -
- ); - } - - _columnCellRenderer = ({dataKey, parent, rowIndex}) => { - const {list} = this.props; - - const datum = list.get(rowIndex % list.size); - const content = rowIndex % 5 === 0 ? '' : datum.randomLong; - - return ( - -
- {content} -
-
- ); - }; - - _rowGetter = ({index}) => { - const {list} = this.props; - - return list.get(index % list.size); - }; -} diff --git a/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js b/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js deleted file mode 100644 index 8fd5e1771..000000000 --- a/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js +++ /dev/null @@ -1,74 +0,0 @@ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import CellMeasurer from './CellMeasurer'; -import CellMeasurerCache from './CellMeasurerCache'; -import Grid from '../Grid'; -import styles from './CellMeasurer.example.css'; - -export default class DynamicWidthGrid extends React.PureComponent { - static propTypes = { - getClassName: PropTypes.func.isRequired, - getContent: PropTypes.func.isRequired, - list: PropTypes.instanceOf(Immutable.List).isRequired, - width: PropTypes.number.isRequired, - }; - - constructor(props, context) { - super(props, context); - - this._cache = new CellMeasurerCache({ - defaultWidth: 100, - fixedHeight: true, - }); - - this._cellRenderer = this._cellRenderer.bind(this); - } - - render() { - const {width} = this.props; - - return ( - - ); - } - - _cellRenderer({columnIndex, key, parent, rowIndex, style}) { - const {getClassName, getContent, list} = this.props; - - const datum = list.get((rowIndex + columnIndex) % list.size); - const classNames = getClassName({columnIndex, rowIndex}); - const content = getContent({index: columnIndex, datum, long: false}); - - return ( - -
- {content} -
-
- ); - } -} diff --git a/source/CellMeasurer/CellMeasurer.DynamicWidthMultiGrid.example.js b/source/CellMeasurer/CellMeasurer.DynamicWidthMultiGrid.example.js deleted file mode 100644 index 601741469..000000000 --- a/source/CellMeasurer/CellMeasurer.DynamicWidthMultiGrid.example.js +++ /dev/null @@ -1,80 +0,0 @@ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import CellMeasurer from './CellMeasurer'; -import CellMeasurerCache from './CellMeasurerCache'; -import MultiGrid from '../MultiGrid'; -import styles from './CellMeasurer.example.css'; - -export default class DynamicWidthMultiGrid extends React.PureComponent { - static propTypes = { - getClassName: PropTypes.func.isRequired, - getContent: PropTypes.func.isRequired, - list: PropTypes.instanceOf(Immutable.List).isRequired, - width: PropTypes.number.isRequired, - }; - - constructor(props, context) { - super(props, context); - - this._cache = new CellMeasurerCache({ - defaultHeight: 30, - defaultWidth: 150, - fixedHeight: true, - }); - - this._cellRenderer = this._cellRenderer.bind(this); - } - - render() { - const {width} = this.props; - - return ( - - ); - } - - _cellRenderer({columnIndex, key, parent, rowIndex, style}) { - const {getClassName, getContent, list} = this.props; - - const datum = list.get((rowIndex + columnIndex) % list.size); - const classNames = getClassName({columnIndex, rowIndex}); - let content = getContent({index: rowIndex, datum, long: false}); - - if (columnIndex === 0) { - content = content.substr(0, 50); - } - - return ( - -
- {content} -
-
- ); - } -} diff --git a/source/CellMeasurer/CellMeasurer.example.css b/source/CellMeasurer/CellMeasurer.example.css deleted file mode 100644 index 861ee4717..000000000 --- a/source/CellMeasurer/CellMeasurer.example.css +++ /dev/null @@ -1,70 +0,0 @@ -.GridRow { - margin-top: 15px; - display: flex; - flex-direction: row; -} -.GridColumn { - display: flex; - flex-direction: column; - flex: 1 1 auto; -} -.LeftSideGridContainer { - flex: 0 0 50px; -} - -.BodyGrid { - width: 100%; - border: 1px solid #e0e0e0; -} - -.evenRow, -.oddRow { - border-bottom: 1px solid #e0e0e0; -} -.oddRow { - background-color: #fafafa; -} - -.cell { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - padding: 0.5em 1em; -} -.cell { - border-right: 1px solid #e0e0e0; - border-bottom: 1px solid #e0e0e0; -} - -.uniformSizeCell { - padding: 0.5rem; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; -} - -.tableRow { - border-bottom: 1px solid #eee; -} -.tableColumn { - padding: 5px 15px 5px 0; -} - -.Tab { - border: 1px solid #ddd; - border-radius: 4px; - padding: 4px 6px; - outline: none; - background: #eee; - margin: 4px; - cursor: pointer; -} - -.ActiveTab { - background-color: #4db6ac; - border: 1px solid #3ca59b; - color: rgba(255, 255, 255, 0.8); - cursor: default; -} \ No newline at end of file diff --git a/source/CellMeasurer/CellMeasurer.example.js b/source/CellMeasurer/CellMeasurer.example.js deleted file mode 100644 index 20287eae5..000000000 --- a/source/CellMeasurer/CellMeasurer.example.js +++ /dev/null @@ -1,139 +0,0 @@ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import AutoSizer from '../AutoSizer'; -import clsx from 'clsx'; -import styles from './CellMeasurer.example.css'; -import DynamicWidthGrid from './CellMeasurer.DynamicWidthGrid.example.js'; -import DynamicHeightGrid from './CellMeasurer.DynamicHeightGrid.example.js'; -import DynamicWidthMultiGrid from './CellMeasurer.DynamicWidthMultiGrid.example.js'; -import DynamicHeightList from './CellMeasurer.DynamicHeightList.example.js'; -import DynamicHeightTableColumn from './CellMeasurer.DynamicHeightTableColumn.example.js'; - -const demoComponents = [ - DynamicWidthGrid, - DynamicHeightGrid, - DynamicWidthMultiGrid, - DynamicHeightList, - DynamicHeightTableColumn, -]; - -export default class CellMeasurerExample extends React.PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - currentTab: 0, - }; - - this._onClick = this._onClick.bind(this); - } - - render() { - const {list} = this.context; - const {currentTab} = this.state; - - const buttonProps = { - currentTab, - onClick: this._onClick, - }; - - const DemoComponent = demoComponents[currentTab]; - - return ( - - - - - This component can be used to just-in-time measure dynamic content - (eg. messages in a chat interface). - - - - {({width}) => ( -
-
- Grid: - - dynamic width text - - - dynamic height text - - MultiGrid: - - dynamic width text - - List: - - dynamic height image - - Table: - - mixed fixed and dynamic height text - -
- - -
- )} -
-
- ); - } - - _onClick(id) { - this.setState({ - currentTab: id, - }); - } -} - -function getClassName({columnIndex, rowIndex}) { - const rowClass = rowIndex % 2 === 0 ? styles.evenRow : styles.oddRow; - - return clsx(rowClass, styles.cell, { - [styles.centeredCell]: columnIndex > 2, - }); -} - -function getContent({index, datum, long = true}) { - switch (index % 3) { - case 0: - return datum.color; - case 1: - return datum.name; - case 2: - return long ? datum.randomLong : datum.random; - } -} - -function Tab({children, currentTab, id, onClick}) { - const classNames = clsx(styles.Tab, { - [styles.ActiveTab]: currentTab === id, - }); - - return ( - - ); -} diff --git a/source/CellMeasurer/CellMeasurer.jest.js b/source/CellMeasurer/CellMeasurer.jest.js deleted file mode 100644 index d42fc3022..000000000 --- a/source/CellMeasurer/CellMeasurer.jest.js +++ /dev/null @@ -1,331 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import CellMeasurer from './CellMeasurer'; -import CellMeasurerCache, { - DEFAULT_HEIGHT, - DEFAULT_WIDTH, -} from './CellMeasurerCache'; - -// Accounts for the fact that JSDom doesn't support measurements. -function mockClientWidthAndHeight( - {height, width}, - object = HTMLElement.prototype, -) { - const heightFn = jest.fn().mockReturnValue(height); - const widthFn = jest.fn().mockReturnValue(width); - - Object.defineProperty(object, 'offsetHeight', { - configurable: true, - get: heightFn, - }); - - Object.defineProperty(object, 'offsetWidth', { - configurable: true, - get: widthFn, - }); - - return { - heightFn, - widthFn, - }; -} - -function createParent({cache, invalidateCellSizeAfterRender = jest.fn()} = {}) { - return { - invalidateCellSizeAfterRender, - props: { - deferredMeasurementCache: cache, - }, - }; -} - -function renderHelper({ - cache = new CellMeasurerCache({ - fixedWidth: true, - }), - children =
, - parent, -} = {}) { - render( - - {children} - , - ); -} - -describe('CellMeasurer', () => { - it('componentDidMount() should measure content that is not already in the cache', () => { - const cache = new CellMeasurerCache({fixedWidth: true}); - const parent = createParent({cache}); - - const {heightFn, widthFn} = mockClientWidthAndHeight({ - height: 20, - width: 100, - }); - - expect(heightFn).toHaveBeenCalledTimes(0); - expect(widthFn).toHaveBeenCalledTimes(0); - expect(cache.has(0, 0)).toBe(false); - - renderHelper({cache, parent}); - - expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled(); - expect(heightFn).toHaveBeenCalledTimes(1); - expect(widthFn).toHaveBeenCalledTimes(1); - expect(cache.has(0, 0)).toBe(true); - expect(cache.getWidth(0, 0)).toBe(100); - expect(cache.getHeight(0, 0)).toBe(20); - }); - - it('componentDidMount() should not measure content that is already in the cache', () => { - const cache = new CellMeasurerCache({fixedWidth: true}); - cache.set(0, 0, 100, 20); - - const parent = createParent({cache}); - - const {heightFn, widthFn} = mockClientWidthAndHeight({ - height: 20, - width: 100, - }); - - expect(cache.has(0, 0)).toBe(true); - - renderHelper({cache, parent}); - - expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled(); - expect(heightFn).toHaveBeenCalledTimes(0); - expect(widthFn).toHaveBeenCalledTimes(0); - }); - - it('componentDidUpdate() should measure content that is not already in the cache', () => { - const cache = new CellMeasurerCache({fixedWidth: true}); - const parent = createParent({cache}); - - renderHelper({cache, parent}); - - cache.clear(0, 0); - parent.invalidateCellSizeAfterRender.mockReset(); - - expect(cache.has(0, 0)).toBe(false); - expect(cache.getWidth(0, 0)).toBe(DEFAULT_WIDTH); - expect(cache.getHeight(0, 0)).toBe(DEFAULT_HEIGHT); - - const {heightFn, widthFn} = mockClientWidthAndHeight({ - height: 20, - width: 100, - }); - - renderHelper({cache, parent}); - - expect(cache.has(0, 0)).toBe(true); - - expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled(); - expect(heightFn).toHaveBeenCalledTimes(1); - expect(widthFn).toHaveBeenCalledTimes(1); - expect(cache.getWidth(0, 0)).toBe(100); - expect(cache.getHeight(0, 0)).toBe(20); - }); - - it('componentDidUpdate() should not measure content that is already in the cache', () => { - const cache = new CellMeasurerCache({fixedWidth: true}); - cache.set(0, 0, 100, 20); - - const parent = createParent({cache}); - - expect(cache.has(0, 0)).toBe(true); - - const {heightFn, widthFn} = mockClientWidthAndHeight({ - height: 20, - width: 100, - }); - - renderHelper({cache, parent}); - renderHelper({cache, parent}); - - expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled(); - expect(heightFn).toHaveBeenCalledTimes(0); - expect(widthFn).toHaveBeenCalledTimes(0); - }); - - it('registerChild() should measure content that is not already in the cache', () => { - const cache = new CellMeasurerCache({fixedWidth: true}); - const parent = createParent({cache}); - - const element = document.createElement('div'); - const {heightFn, widthFn} = mockClientWidthAndHeight( - { - height: 20, - width: 100, - }, - element, - ); - - expect(heightFn).toHaveBeenCalledTimes(0); - expect(widthFn).toHaveBeenCalledTimes(0); - expect(cache.has(0, 0)).toBe(false); - - renderHelper({ - cache, - parent, - children({registerChild}) { - registerChild(element); - return null; - }, - }); - - expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled(); - expect(heightFn).toHaveBeenCalledTimes(1); - expect(widthFn).toHaveBeenCalledTimes(1); - expect(cache.has(0, 0)).toBe(true); - expect(cache.getWidth(0, 0)).toBe(100); - expect(cache.getHeight(0, 0)).toBe(20); - }); - - it('registerChild() should not measure content that is already in the cache', () => { - const cache = new CellMeasurerCache({fixedWidth: true}); - cache.set(0, 0, 100, 20); - - const parent = createParent({cache}); - - const element = document.createElement('div'); - const {heightFn, widthFn} = mockClientWidthAndHeight( - { - height: 20, - width: 100, - }, - element, - ); - - expect(cache.has(0, 0)).toBe(true); - - renderHelper({ - cache, - parent, - children({registerChild}) { - registerChild(element); - return null; - }, - }); - - expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled(); - expect(heightFn).toHaveBeenCalledTimes(0); - expect(widthFn).toHaveBeenCalledTimes(0); - }); - - it('should pass a :measure param to a function child', () => { - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - - const children = jest.fn().mockReturnValue(
); - - renderHelper({cache, children}); - - expect(children).toHaveBeenCalled(); - - const params = children.mock.calls[0][0]; - - expect(typeof params.measure === 'function').toBe(true); - }); - - it('should still update cache without a parent Grid', () => { - jest.spyOn(console, 'warn'); - - mockClientWidthAndHeight({height: 20, width: 100}); - - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - - renderHelper({cache}); // No parent Grid - - expect(cache.has(0, 0)).toBe(true); - - expect(console.warn).not.toHaveBeenCalled(); - }); - - // See issue #593 - it('should explicitly set width/height style to "auto" before re-measuring', () => { - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - const parent = createParent({cache}); - const child = jest - .fn() - .mockReturnValue(
); - - let measurer; - const node = findDOMNode( - render( - { - measurer = ref; - }} - cache={cache} - columnIndex={0} - parent={parent} - rowIndex={0} - style={{}}> - {child} - , - ), - ); - - const styleHeights = [30]; - const styleWidths = [100]; - Object.defineProperties(node.style, { - height: { - get: () => styleHeights[styleHeights.length - 1], - set: value => styleHeights.push(value), - }, - width: { - get: () => styleWidths[styleWidths.length - 1], - set: value => styleWidths.push(value), - }, - }); - - const {height, width} = measurer._getCellMeasurements(node); - expect(height).toBeGreaterThan(0); - expect(width).toBeGreaterThan(0); - expect(styleHeights).toEqual([30, 'auto', 30]); - expect(styleWidths).toEqual([100, 100]); - }); - - // See issue #660 - it('should reset width/height style values after measuring with style "auto"', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - }); - const parent = createParent({cache}); - const child = jest - .fn() - .mockReturnValue(
); - - const node = findDOMNode( - render( - - {child} - , - ), - ); - - node.style.width = 200; - node.style.height = 60; - - child.mock.calls[0][0].measure(); - - expect(node.style.height).toBe('30px'); - expect(node.style.width).toBe('100px'); - }); -}); diff --git a/source/CellMeasurer/CellMeasurer.js b/source/CellMeasurer/CellMeasurer.js deleted file mode 100644 index 57f118050..000000000 --- a/source/CellMeasurer/CellMeasurer.js +++ /dev/null @@ -1,183 +0,0 @@ -/** @flow */ -import * as React from 'react'; -import type {CellMeasureCache} from './types'; -import {cloneElement} from 'react'; - -type Children = (params: {measure: () => void}) => React.Element<*>; - -type Cell = { - columnIndex: number, - rowIndex: number, -}; - -type Props = { - cache: CellMeasureCache, - children: Children | React.Element<*>, - columnIndex?: number, - index?: number, - parent: { - invalidateCellSizeAfterRender?: (cell: Cell) => void, - recomputeGridSize?: (cell: Cell) => void, - }, - rowIndex?: number, -}; - -/** - * Wraps a cell and measures its rendered content. - * Measurements are stored in a per-cell cache. - * Cached-content is not be re-measured. - */ -export default class CellMeasurer extends React.PureComponent { - static __internalCellMeasurerFlag = false; - - _child: {current: HTMLElement | null} = React.createRef(); - - componentDidMount() { - this._maybeMeasureCell(); - } - - componentDidUpdate() { - this._maybeMeasureCell(); - } - - render() { - const {children} = this.props; - - const resolvedChildren = - typeof children === 'function' - ? children({measure: this._measure, registerChild: this._registerChild}) - : children; - - if (resolvedChildren === null) { - return resolvedChildren; - } - - return cloneElement(resolvedChildren, { - ref: node => { - if (typeof resolvedChildren.ref === 'function') { - resolvedChildren.ref(node); - } else if (resolvedChildren.ref) { - resolvedChildren.ref.current = node; - } - this._child.current = node; - }, - }); - } - - _getCellMeasurements() { - const {cache} = this.props; - - const node = this._child.current; - - // TODO Check for a bad combination of fixedWidth and missing numeric width or vice versa with height - - if ( - node && - node.ownerDocument && - node.ownerDocument.defaultView && - node instanceof node.ownerDocument.defaultView.HTMLElement - ) { - const styleWidth = node.style.width; - const styleHeight = node.style.height; - - // If we are re-measuring a cell that has already been measured, - // It will have a hard-coded width/height from the previous measurement. - // The fact that we are measuring indicates this measurement is probably stale, - // So explicitly clear it out (eg set to "auto") so we can recalculate. - // See issue #593 for more info. - // Even if we are measuring initially- if we're inside of a MultiGrid component, - // Explicitly clear width/height before measuring to avoid being tainted by another Grid. - // eg top/left Grid renders before bottom/right Grid - // Since the CellMeasurerCache is shared between them this taints derived cell size values. - if (!cache.hasFixedWidth()) { - node.style.width = 'auto'; - } - if (!cache.hasFixedHeight()) { - node.style.height = 'auto'; - } - - const height = Math.ceil(node.offsetHeight); - const width = Math.ceil(node.offsetWidth); - - // Reset after measuring to avoid breaking styles; see #660 - if (styleWidth) { - node.style.width = styleWidth; - } - if (styleHeight) { - node.style.height = styleHeight; - } - - return {height, width}; - } else { - return {height: 0, width: 0}; - } - } - - _maybeMeasureCell() { - const { - cache, - columnIndex = 0, - parent, - rowIndex = this.props.index || 0, - } = this.props; - - if (!cache.has(rowIndex, columnIndex)) { - const {height, width} = this._getCellMeasurements(); - - cache.set(rowIndex, columnIndex, width, height); - - // If size has changed, let Grid know to re-render. - if ( - parent && - typeof parent.invalidateCellSizeAfterRender === 'function' - ) { - parent.invalidateCellSizeAfterRender({ - columnIndex, - rowIndex, - }); - } - } - } - - _measure = () => { - const { - cache, - columnIndex = 0, - parent, - rowIndex = this.props.index || 0, - } = this.props; - - const {height, width} = this._getCellMeasurements(); - - if ( - height !== cache.getHeight(rowIndex, columnIndex) || - width !== cache.getWidth(rowIndex, columnIndex) - ) { - cache.set(rowIndex, columnIndex, width, height); - - if (parent && typeof parent.recomputeGridSize === 'function') { - parent.recomputeGridSize({ - columnIndex, - rowIndex, - }); - } - } - }; - - _registerChild = element => { - if (element && !(element instanceof Element)) { - console.warn( - 'CellMeasurer registerChild expects to be passed Element or null', - ); - } - this._child.current = element; - if (element) { - this._maybeMeasureCell(); - } - }; -} - -// Used for DEV mode warning check -if (process.env.NODE_ENV !== 'production') { - CellMeasurer.__internalCellMeasurerFlag = true; -} diff --git a/source/CellMeasurer/CellMeasurerCache.jest.js b/source/CellMeasurer/CellMeasurerCache.jest.js deleted file mode 100644 index de0eb73e2..000000000 --- a/source/CellMeasurer/CellMeasurerCache.jest.js +++ /dev/null @@ -1,275 +0,0 @@ -import CellMeasurerCache, { - DEFAULT_HEIGHT, - DEFAULT_WIDTH, -} from './CellMeasurerCache'; - -describe('CellMeasurerCache', () => { - it('should override defaultHeight/defaultWidth if minHeight/minWidth are greater', () => { - const cache = new CellMeasurerCache({ - defaultHeight: 20, - defaultWidth: 100, - fixedHeight: true, - fixedWidth: true, - minHeight: 30, - minWidth: 150, - }); - cache.set(0, 0, 50, 10); - expect(cache.getHeight(0, 0)).toBe(30); - expect(cache.getWidth(0, 0)).toBe(150); - expect(cache.rowHeight({index: 0})).toBe(30); - expect(cache.columnWidth({index: 0})).toBe(150); - }); - - it('should correctly report cache status', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - fixedWidth: true, - }); - expect(cache.has(0, 0)).toBe(false); - }); - - it('should cache cells', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - fixedWidth: true, - }); - cache.set(0, 0, 100, 20); - expect(cache.has(0, 0)).toBe(true); - }); - - it('should return the correct default sizes for uncached cells if specified', () => { - spyOn(console, 'warn'); // Ignore warning about variable width and height - - const cache = new CellMeasurerCache({ - defaultHeight: 20, - defaultWidth: 100, - minHeight: 15, - minWidth: 80, - }); - expect(cache.getWidth(0, 0)).toBe(100); - expect(cache.getHeight(0, 0)).toBe(20); - cache.set(0, 0, 70, 10); - expect(cache.getWidth(0, 0)).toBe(80); - expect(cache.getHeight(0, 0)).toBe(15); - }); - - it('should clear a single cached cell', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - fixedWidth: true, - }); - cache.set(0, 0, 100, 20); - cache.set(1, 0, 100, 20); - expect(cache.has(0, 0)).toBe(true); - expect(cache.has(1, 0)).toBe(true); - cache.clear(0, 0); - expect(cache.has(0, 0)).toBe(false); - expect(cache.has(1, 0)).toBe(true); - }); - - it('should clear a single cached row cell in column 0 when columnIndex param is absent', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - fixedWidth: true, - }); - cache.set(0, 0, 100, 20); - cache.set(1, 0, 100, 20); - expect(cache.has(0, 0)).toBe(true); - expect(cache.has(1, 0)).toBe(true); - cache.clear(0); - expect(cache.has(0, 0)).toBe(false); - expect(cache.has(1, 0)).toBe(true); - }); - - it('should clear all cached cells', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - fixedWidth: true, - }); - cache.set(0, 0, 100, 20); - cache.set(1, 0, 100, 20); - expect(cache.has(0, 0)).toBe(true); - expect(cache.has(1, 0)).toBe(true); - cache.clearAll(); - expect(cache.has(0, 0)).toBe(false); - expect(cache.has(1, 0)).toBe(false); - }); - - it('should clear row and column counts when clearing all cells', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - fixedWidth: true, - }); - cache.set(0, 0, 100, 20); - cache.set(1, 0, 100, 20); - expect(cache._rowCount).toBe(2); - expect(cache._columnCount).toBe(1); - cache.clearAll(); - expect(cache._rowCount).toBe(0); - expect(cache._columnCount).toBe(0); - }); - - it('should support a custom :keyMapper', () => { - const keyMapper = jest.fn(); - keyMapper.mockReturnValue('a'); - - spyOn(console, 'warn'); // Ignore warning about variable width and height - - const cache = new CellMeasurerCache({ - defaultHeight: 30, - defaultWidth: 50, - keyMapper, - }); - cache.set(0, 0, 100, 20); - expect(cache.has(0, 0)).toBe(true); - - // Changing the returned key should cause cache misses - keyMapper.mockReset(); - keyMapper.mockReturnValue('b'); - expect(cache.has(0, 0)).toBe(false); - expect(cache.columnWidth(0)).toBe(50); - expect(cache.rowHeight(0)).toBe(30); - expect(keyMapper.mock.calls).toHaveLength(3); - - // Restoring it should fix - keyMapper.mockReset(); - keyMapper.mockReturnValue('a'); - expect(cache.has(0, 0)).toBe(true); - expect(cache.columnWidth(0)).toBe(100); - expect(cache.rowHeight(0)).toBe(20); - expect(keyMapper.mock.calls).toHaveLength(3); - }); - - it('should provide a Grid-compatible :columnWidth method', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - }); - expect(cache.columnWidth({index: 0})).toBe(DEFAULT_WIDTH); - cache.set(0, 0, 100, 50); - expect(cache.columnWidth({index: 0})).toBe(100); - expect(cache.columnWidth({index: 1})).toBe(DEFAULT_WIDTH); - cache.set(1, 0, 75, 50); - expect(cache.columnWidth({index: 0})).toBe(100); - cache.set(2, 0, 125, 50); - expect(cache.columnWidth({index: 0})).toBe(125); - }); - - it('should provide a Grid-compatible :rowHeight method', () => { - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - expect(cache.rowHeight({index: 0})).toBe(DEFAULT_HEIGHT); - cache.set(0, 0, 100, 50); - expect(cache.rowHeight({index: 0})).toBe(50); - expect(cache.rowHeight({index: 1})).toBe(DEFAULT_HEIGHT); - cache.set(0, 1, 100, 25); - expect(cache.rowHeight({index: 0})).toBe(50); - cache.set(0, 2, 100, 75); - expect(cache.rowHeight({index: 0})).toBe(75); - }); - - it('should return the :defaultWidth for :columnWidth if not measured', () => { - const cache = new CellMeasurerCache({ - defaultWidth: 25, - fixedHeight: true, - fixedWidth: true, - }); - expect(cache.columnWidth({index: 0})).toBe(25); - }); - - it('should return the :defaultHeight for :rowHeight if not measured', () => { - const cache = new CellMeasurerCache({ - defaultHeight: 25, - fixedHeight: true, - fixedWidth: true, - }); - expect(cache.rowHeight({index: 0})).toBe(25); - }); - - it('should recalculate cached :columnWidth when cells are cleared', () => { - const cache = new CellMeasurerCache({ - fixedHeight: true, - }); - expect(cache.columnWidth({index: 0})).toBe(DEFAULT_WIDTH); - cache.set(0, 0, 125, 50); - expect(cache.columnWidth({index: 0})).toBe(125); - cache.set(1, 0, 150, 50); - expect(cache.columnWidth({index: 0})).toBe(150); - cache.clear(1, 0); - expect(cache.columnWidth({index: 0})).toBe(125); - cache.clear(0, 0); - expect(cache.columnWidth({index: 0})).toBe(DEFAULT_WIDTH); - cache.set(0, 0, 125, 50); - expect(cache.columnWidth({index: 0})).toBe(125); - cache.clearAll(); - expect(cache.columnWidth({index: 0})).toBe(DEFAULT_WIDTH); - }); - - it('should recalculate cached :rowHeight when cells are cleared', () => { - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - expect(cache.rowHeight({index: 0})).toBe(DEFAULT_HEIGHT); - cache.set(0, 0, 125, 50); - expect(cache.rowHeight({index: 0})).toBe(50); - cache.set(0, 1, 150, 75); - expect(cache.rowHeight({index: 0})).toBe(75); - cache.clear(0, 1); - expect(cache.rowHeight({index: 0})).toBe(50); - cache.clear(0, 0); - expect(cache.rowHeight({index: 0})).toBe(DEFAULT_HEIGHT); - cache.set(0, 0, 125, 50); - expect(cache.rowHeight({index: 0})).toBe(50); - cache.clearAll(); - expect(cache.rowHeight({index: 0})).toBe(DEFAULT_HEIGHT); - }); - - describe('DEV mode', () => { - it('should warn about dynamic width and height configurations', () => { - spyOn(console, 'warn'); - - const cache = new CellMeasurerCache({ - fixedHeight: false, - fixedWidth: false, - }); - - expect(cache.hasFixedHeight()).toBe(false); - expect(cache.hasFixedWidth()).toBe(false); - expect(console.warn).toHaveBeenCalledWith( - "CellMeasurerCache should only measure a cell's width or height. " + - 'You have configured CellMeasurerCache to measure both. ' + - 'This will result in poor performance.', - ); - }); - - it('should warn about dynamic width with a defaultWidth of 0', () => { - spyOn(console, 'warn'); - - const cache = new CellMeasurerCache({ - defaultWidth: 0, - fixedHeight: true, - }); - - expect(cache.getWidth(0, 0)).toBe(0); - expect(console.warn).toHaveBeenCalledWith( - 'Fixed width CellMeasurerCache should specify a :defaultWidth greater than 0. ' + - 'Failing to do so will lead to unnecessary layout and poor performance.', - ); - }); - - it('should warn about dynamic height with a defaultHeight of 0', () => { - spyOn(console, 'warn'); - - const cache = new CellMeasurerCache({ - defaultHeight: 0, - fixedWidth: true, - }); - - expect(cache.getHeight(0, 0)).toBe(0); - expect(console.warn).toHaveBeenCalledWith( - 'Fixed height CellMeasurerCache should specify a :defaultHeight greater than 0. ' + - 'Failing to do so will lead to unnecessary layout and poor performance.', - ); - }); - }); -}); diff --git a/source/CellMeasurer/CellMeasurerCache.js b/source/CellMeasurer/CellMeasurerCache.js deleted file mode 100644 index 05975fdc9..000000000 --- a/source/CellMeasurer/CellMeasurerCache.js +++ /dev/null @@ -1,227 +0,0 @@ -/** @flow */ - -import type {CellMeasureCache} from './types'; - -export const DEFAULT_HEIGHT = 30; -export const DEFAULT_WIDTH = 100; - -// Enables more intelligent mapping of a given column and row index to an item ID. -// This prevents a cell cache from being invalidated when its parent collection is modified. -type KeyMapper = (rowIndex: number, columnIndex: number) => any; - -type CellMeasurerCacheParams = { - defaultHeight?: number, - defaultWidth?: number, - fixedHeight?: boolean, - fixedWidth?: boolean, - minHeight?: number, - minWidth?: number, - keyMapper?: KeyMapper, -}; - -type Cache = { - [key: any]: number, -}; - -type IndexParam = { - index: number, -}; - -/** - * Caches measurements for a given cell. - */ -export default class CellMeasurerCache implements CellMeasureCache { - _cellHeightCache: Cache = {}; - _cellWidthCache: Cache = {}; - _columnWidthCache: Cache = {}; - _rowHeightCache: Cache = {}; - _defaultHeight: number; - _defaultWidth: number; - _minHeight: number; - _minWidth: number; - _keyMapper: KeyMapper; - _hasFixedHeight: boolean; - _hasFixedWidth: boolean; - _columnCount = 0; - _rowCount = 0; - - constructor(params: CellMeasurerCacheParams = {}) { - const { - defaultHeight, - defaultWidth, - fixedHeight, - fixedWidth, - keyMapper, - minHeight, - minWidth, - } = params; - - this._hasFixedHeight = fixedHeight === true; - this._hasFixedWidth = fixedWidth === true; - this._minHeight = minHeight || 0; - this._minWidth = minWidth || 0; - this._keyMapper = keyMapper || defaultKeyMapper; - - this._defaultHeight = Math.max( - this._minHeight, - typeof defaultHeight === 'number' ? defaultHeight : DEFAULT_HEIGHT, - ); - this._defaultWidth = Math.max( - this._minWidth, - typeof defaultWidth === 'number' ? defaultWidth : DEFAULT_WIDTH, - ); - - if (process.env.NODE_ENV !== 'production') { - if (this._hasFixedHeight === false && this._hasFixedWidth === false) { - console.warn( - "CellMeasurerCache should only measure a cell's width or height. " + - 'You have configured CellMeasurerCache to measure both. ' + - 'This will result in poor performance.', - ); - } - - if (this._hasFixedHeight === false && this._defaultHeight === 0) { - console.warn( - 'Fixed height CellMeasurerCache should specify a :defaultHeight greater than 0. ' + - 'Failing to do so will lead to unnecessary layout and poor performance.', - ); - } - - if (this._hasFixedWidth === false && this._defaultWidth === 0) { - console.warn( - 'Fixed width CellMeasurerCache should specify a :defaultWidth greater than 0. ' + - 'Failing to do so will lead to unnecessary layout and poor performance.', - ); - } - } - } - - clear(rowIndex: number, columnIndex: number = 0) { - const key = this._keyMapper(rowIndex, columnIndex); - - delete this._cellHeightCache[key]; - delete this._cellWidthCache[key]; - - this._updateCachedColumnAndRowSizes(rowIndex, columnIndex); - } - - clearAll() { - this._cellHeightCache = {}; - this._cellWidthCache = {}; - this._columnWidthCache = {}; - this._rowHeightCache = {}; - this._rowCount = 0; - this._columnCount = 0; - } - - columnWidth = ({index}: IndexParam) => { - const key = this._keyMapper(0, index); - - return this._columnWidthCache[key] !== undefined - ? this._columnWidthCache[key] - : this._defaultWidth; - }; - - get defaultHeight(): number { - return this._defaultHeight; - } - - get defaultWidth(): number { - return this._defaultWidth; - } - - hasFixedHeight(): boolean { - return this._hasFixedHeight; - } - - hasFixedWidth(): boolean { - return this._hasFixedWidth; - } - - getHeight(rowIndex: number, columnIndex: number = 0): number { - if (this._hasFixedHeight) { - return this._defaultHeight; - } else { - const key = this._keyMapper(rowIndex, columnIndex); - - return this._cellHeightCache[key] !== undefined - ? Math.max(this._minHeight, this._cellHeightCache[key]) - : this._defaultHeight; - } - } - - getWidth(rowIndex: number, columnIndex: number = 0): number { - if (this._hasFixedWidth) { - return this._defaultWidth; - } else { - const key = this._keyMapper(rowIndex, columnIndex); - - return this._cellWidthCache[key] !== undefined - ? Math.max(this._minWidth, this._cellWidthCache[key]) - : this._defaultWidth; - } - } - - has(rowIndex: number, columnIndex: number = 0): boolean { - const key = this._keyMapper(rowIndex, columnIndex); - - return this._cellHeightCache[key] !== undefined; - } - - rowHeight = ({index}: IndexParam) => { - const key = this._keyMapper(index, 0); - - return this._rowHeightCache[key] !== undefined - ? this._rowHeightCache[key] - : this._defaultHeight; - }; - - set( - rowIndex: number, - columnIndex: number, - width: number, - height: number, - ): void { - const key = this._keyMapper(rowIndex, columnIndex); - - if (columnIndex >= this._columnCount) { - this._columnCount = columnIndex + 1; - } - if (rowIndex >= this._rowCount) { - this._rowCount = rowIndex + 1; - } - - // Size is cached per cell so we don't have to re-measure if cells are re-ordered. - this._cellHeightCache[key] = height; - this._cellWidthCache[key] = width; - - this._updateCachedColumnAndRowSizes(rowIndex, columnIndex); - } - - _updateCachedColumnAndRowSizes(rowIndex: number, columnIndex: number) { - // :columnWidth and :rowHeight are derived based on all cells in a column/row. - // Pre-cache these derived values for faster lookup later. - // Reads are expected to occur more frequently than writes in this case. - // Only update non-fixed dimensions though to avoid doing unnecessary work. - if (!this._hasFixedWidth) { - let columnWidth = 0; - for (let i = 0; i < this._rowCount; i++) { - columnWidth = Math.max(columnWidth, this.getWidth(i, columnIndex)); - } - const columnKey = this._keyMapper(0, columnIndex); - this._columnWidthCache[columnKey] = columnWidth; - } - if (!this._hasFixedHeight) { - let rowHeight = 0; - for (let i = 0; i < this._columnCount; i++) { - rowHeight = Math.max(rowHeight, this.getHeight(rowIndex, i)); - } - const rowKey = this._keyMapper(rowIndex, 0); - this._rowHeightCache[rowKey] = rowHeight; - } - } -} - -function defaultKeyMapper(rowIndex: number, columnIndex: number) { - return `${rowIndex}-${columnIndex}`; -} diff --git a/source/CellMeasurer/index.js b/source/CellMeasurer/index.js deleted file mode 100644 index b287ce45b..000000000 --- a/source/CellMeasurer/index.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @flow */ -import CellMeasurer from './CellMeasurer'; -import CellMeasurerCache from './CellMeasurerCache'; - -export default CellMeasurer; -export {CellMeasurer, CellMeasurerCache}; diff --git a/source/CellMeasurer/types.js b/source/CellMeasurer/types.js deleted file mode 100644 index a673ed53e..000000000 --- a/source/CellMeasurer/types.js +++ /dev/null @@ -1,15 +0,0 @@ -// @flow - -export interface CellMeasureCache { - hasFixedWidth(): boolean; - hasFixedHeight(): boolean; - has(rowIndex: number, columnIndex: number): boolean; - set( - rowIndex: number, - columnIndex: number, - width: number, - height: number, - ): void; - getHeight(rowIndex: number, columnIndex?: number): number; - getWidth(rowIndex: number, columnIndex?: number): number; -} diff --git a/source/Collection/Collection.example.css b/source/Collection/Collection.example.css deleted file mode 100644 index a74540e61..000000000 --- a/source/Collection/Collection.example.css +++ /dev/null @@ -1,26 +0,0 @@ -.collection { - background-color: #fff; -} - -.cell { - width: 100%; - height: 100%; - display: flex; - justify-content: center; - align-items: center; - border-radius: .25rem; - color: #fff; -} - -.noCells { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 1em; - color: #bdbdbd; -} diff --git a/source/Collection/Collection.example.js b/source/Collection/Collection.example.js deleted file mode 100644 index 1101b13bc..000000000 --- a/source/Collection/Collection.example.js +++ /dev/null @@ -1,241 +0,0 @@ -/** @flow */ -import PropTypes from 'prop-types'; -import * as React from 'react'; -import Immutable from 'immutable'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import {LabeledInput, InputRow} from '../demo/LabeledInput'; -import AutoSizer from '../AutoSizer'; -import Collection from './Collection'; -import styles from './Collection.example.css'; - -// Defines a pattern of sizes and positions for a range of 10 rotating cells -// These cells cover an area of 600 (wide) x 400 (tall) -const GUTTER_SIZE = 3; -const CELL_WIDTH = 75; - -export default class CollectionExample extends React.PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - cellCount: context.list.size, - columnCount: this._getColumnCount(context.list.size), - height: 300, - horizontalOverscanSize: 0, - scrollToCell: undefined, - showScrollingPlaceholder: false, - verticalOverscanSize: 0, - }; - - this._columnYMap = []; - - this._cellRenderer = this._cellRenderer.bind(this); - this._cellSizeAndPositionGetter = this._cellSizeAndPositionGetter.bind( - this, - ); - this._noContentRenderer = this._noContentRenderer.bind(this); - this._onCellCountChange = this._onCellCountChange.bind(this); - this._onHeightChange = this._onHeightChange.bind(this); - this._onHorizontalOverscanSizeChange = this._onHorizontalOverscanSizeChange.bind( - this, - ); - this._onScrollToCellChange = this._onScrollToCellChange.bind(this); - this._onVerticalOverscanSizeChange = this._onVerticalOverscanSizeChange.bind( - this, - ); - } - - render() { - const { - cellCount, - height, - horizontalOverscanSize, - scrollToCell, - showScrollingPlaceholder, - verticalOverscanSize, - } = this.state; - - return ( - - - - - Renders scattered or non-linear data. Unlike Grid, which - renders checkerboard data, Collection can render - arbitrarily positioned- even overlapping- data. - - - - - - - - - - - - - - - - {({width}) => ( - - )} - - - ); - } - - _cellRenderer({index, isScrolling, key, style}) { - const {list} = this.context; - const {showScrollingPlaceholder} = this.state; - - const datum = list.get(index % list.size); - - // Customize style - style.backgroundColor = datum.color; - - return ( -
- {showScrollingPlaceholder && isScrolling ? '...' : index} -
- ); - } - - _cellSizeAndPositionGetter({index}) { - const {list} = this.context; - const {columnCount} = this.state; - - const columnPosition = index % (columnCount || 1); - const datum = list.get(index % list.size); - - // Poor man's Masonry layout; columns won't all line up equally with the bottom. - const height = datum.size; - const width = CELL_WIDTH; - const x = columnPosition * (GUTTER_SIZE + width); - const y = this._columnYMap[columnPosition] || 0; - - this._columnYMap[columnPosition] = y + height + GUTTER_SIZE; - - return { - height, - width, - x, - y, - }; - } - - _getColumnCount(cellCount) { - return Math.round(Math.sqrt(cellCount)); - } - - _onHorizontalOverscanSizeChange(event) { - const horizontalOverscanSize = parseInt(event.target.value, 10) || 0; - - this.setState({horizontalOverscanSize}); - } - - _noContentRenderer() { - return
No cells
; - } - - _onCellCountChange(event) { - const cellCount = parseInt(event.target.value, 10) || 0; - - this._columnYMap = []; - - this.setState({ - cellCount, - columnCount: this._getColumnCount(cellCount), - }); - } - - _onHeightChange(event) { - const height = parseInt(event.target.value, 10) || 0; - - this.setState({height}); - } - - _onScrollToCellChange(event) { - const {cellCount} = this.state; - - let scrollToCell = Math.min( - cellCount - 1, - parseInt(event.target.value, 10), - ); - - if (isNaN(scrollToCell)) { - scrollToCell = undefined; - } - - this.setState({scrollToCell}); - } - - _onVerticalOverscanSizeChange(event) { - const verticalOverscanSize = parseInt(event.target.value, 10) || 0; - - this.setState({verticalOverscanSize}); - } -} diff --git a/source/Collection/Collection.jest.js b/source/Collection/Collection.jest.js deleted file mode 100644 index 6999720c7..000000000 --- a/source/Collection/Collection.jest.js +++ /dev/null @@ -1,846 +0,0 @@ -/** - * Tests Collection and CollectionView. - * @flow - */ -import getScrollbarSize from 'dom-helpers/scrollbarSize'; -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {Simulate} from 'react-dom/test-utils'; -import {render} from '../TestUtils'; -import Collection from './Collection'; -import {CELLS, SECTION_SIZE} from './TestData'; - -describe('Collection', () => { - function defaultCellRenderer({index, key, style}) { - return ( -
- cell:{index} -
- ); - } - - function getMarkup(props = {}) { - const {cellCount = CELLS.length} = props; - - function defaultCellSizeAndPositionGetter({index}) { - index %= cellCount; - - return CELLS[index]; - } - - return ( - - ); - } - - function simulateScroll({collection, scrollLeft = 0, scrollTop = 0}) { - const target = {scrollLeft, scrollTop}; - collection._collectionView._scrollingContainer = target; // HACK to work around _onScroll target check - Simulate.scroll(findDOMNode(collection), {target}); - } - - function compareArrays(array1, array2) { - expect(array1.length).toEqual(array2.length); - - array2.forEach(value => { - expect(array1).toContain(value); - }); - } - - describe('number of rendered children', () => { - it('should render enough children to fill the available area', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.querySelectorAll('.cell').length).toEqual(4); - }); - - it('should not render more cells than available if the area is not filled', () => { - const rendered = findDOMNode(render(getMarkup({cellCount: 2}))); - expect(rendered.querySelectorAll('.cell').length).toEqual(2); - }); - - // Small performance tweak added in 5.5.6 - it('should not render/parent cells that are null or false', () => { - function cellRenderer({index, key, style}) { - if (index > 2) { - return null; - } else { - return ( -
- {index} -
- ); - } - } - const rendered = findDOMNode(render(getMarkup({cellRenderer}))); - expect(rendered.querySelectorAll('.cell').length).toEqual(3); - }); - }); - - describe('shows and hides scrollbars based on rendered content', () => { - let scrollbarSize; - - beforeAll(() => { - scrollbarSize = getScrollbarSize(); - }); - - it('should set overflowX:hidden if columns fit within the available width and y-axis has no scrollbar', () => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 4, - width: 6, - }), - ), - ); - expect(rendered.style.overflowX).toEqual('hidden'); - }); - - it('should set overflowX:hidden if columns and y-axis scrollbar fit within the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 1, - width: 6 + scrollbarSize, - }), - ), - ); - expect(rendered.style.overflowX).toEqual('hidden'); - }); - - it('should leave overflowX:auto if columns require more than the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - width: 1, - }), - ), - ); - expect(rendered.style.overflowX).not.toEqual('hidden'); - }); - - it('should leave overflowX:auto if columns and y-axis scrollbar require more than the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 1, - width: 6 + scrollbarSize - 1, - }), - ), - ); - expect(rendered.style.overflowX).not.toEqual('hidden'); - }); - - it('should set overflowY:hidden if rows fit within the available width and xaxis has no scrollbar', () => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 4, - width: 6, - }), - ), - ); - expect(rendered.style.overflowY).toEqual('hidden'); - }); - - it('should set overflowY:hidden if rows and x-axis scrollbar fit within the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 4 + scrollbarSize, - width: 1, - }), - ), - ); - expect(rendered.style.overflowY).toEqual('hidden'); - }); - - it('should leave overflowY:auto if rows require more than the available height', () => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 1, - }), - ), - ); - expect(rendered.style.overflowY).not.toEqual('hidden'); - }); - - it('should leave overflowY:auto if rows and y-axis scrollbar require more than the available height', () => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 4 + scrollbarSize - 1, - width: 1, - }), - ), - ); - expect(rendered.style.overflowY).not.toEqual('hidden'); - }); - - it('should accept styles that overwrite calculated ones', () => { - const rendered = findDOMNode( - render( - getMarkup({ - height: 1, - style: { - overflowX: 'auto', - overflowY: 'auto', - }, - width: 1, - }), - ), - ); - expect(rendered.style.overflowX).toEqual('auto'); - expect(rendered.style.overflowY).toEqual('auto'); - }); - }); - - describe('autoHeight', () => { - it('should set the container height to auto to adjust to innerScrollContainer height', () => { - const props = { - autoHeight: true, - }; - const rendered = findDOMNode(render(getMarkup(props))); - expect(rendered.style.height).toEqual('auto'); - }); - - it('should have container height still affecting number of rows rendered', () => { - let indices; - const props = { - autoHeight: true, - height: 500, - onSectionRendered: params => { - indices = params.indices; - }, - }; - findDOMNode(render(getMarkup(props))); - compareArrays(indices, [0, 1, 2, 3, 4, 5]); - }); - - it('should have innerScrollContainer height to be equal number of rows * rowHeight', () => { - const props = { - autoHeight: true, - }; - const rendered = findDOMNode(render(getMarkup(props))); - expect( - rendered.querySelector( - '.ReactVirtualized__Collection__innerScrollContainer', - ).style.height, - ).toEqual('4px'); - }); - }); - - describe(':scrollToCell', () => { - it('should scroll to the top/left', () => { - const collection = render(getMarkup({scrollToCell: 0})); - expect(collection._collectionView.state.scrollLeft).toEqual(0); - expect(collection._collectionView.state.scrollTop).toEqual(0); - }); - - it('should scroll over to the middle', () => { - const collection = render(getMarkup({scrollToCell: 7})); - expect(collection._collectionView.state.scrollLeft).toEqual(1); - expect(collection._collectionView.state.scrollTop).toEqual(1); - }); - - it('should scroll to the bottom/right', () => { - const collection = render(getMarkup({scrollToCell: 9})); - expect(collection._collectionView.state.scrollLeft).toEqual(2); - expect(collection._collectionView.state.scrollTop).toEqual(2); - }); - - it('should honor the specified :scrollToAlignment', () => { - let collection = render( - getMarkup({ - scrollToAlignment: 'start', - scrollToCell: 2, - width: SECTION_SIZE, - }), - ); - // Minimum amount of scrolling ("auto") would be 0,0 - expect(collection._collectionView.state.scrollLeft).toEqual(2); - expect(collection._collectionView.state.scrollTop).toEqual(1); - - collection = render( - getMarkup({ - scrollToAlignment: 'end', - scrollToCell: 2, - width: SECTION_SIZE, - }), - ); - // This cell would already by visible by "auto" rules - expect(collection._collectionView.state.scrollLeft).toEqual(1); - expect(collection._collectionView.state.scrollTop).toEqual(0); - - collection = render( - getMarkup({ - scrollToAlignment: 'center', - scrollToCell: 4, - width: SECTION_SIZE, - }), - ); - // This cell doesn't fit entirely in the viewport but we center it anyway. - expect(collection._collectionView.state.scrollLeft).toEqual(0.5); - expect(collection._collectionView.state.scrollTop).toEqual(2); - }); - - it('should scroll to a cell just added', () => { - let collection = render( - getMarkup({ - cellCount: 4, - }), - ); - expect(collection._collectionView.state.scrollLeft).toEqual(0); - expect(collection._collectionView.state.scrollTop).toEqual(0); - collection = render( - getMarkup({ - cellCount: 8, - scrollToCell: 7, - }), - ); - expect(collection._collectionView.state.scrollLeft).toEqual(1); - expect(collection._collectionView.state.scrollTop).toEqual(1); - }); - }); - - describe('property updates', () => { - it('should update :scrollToCell position when :width changes', () => { - let collection = findDOMNode(render(getMarkup({scrollToCell: 3}))); - expect(collection.textContent).toContain('cell:3'); - // Making the collection narrower leaves only room for 1 item - collection = findDOMNode(render(getMarkup({scrollToCell: 3, width: 1}))); - expect(collection.textContent).toContain('cell:3'); - }); - - it('should update :scrollToCell position when :height changes', () => { - let collection = findDOMNode(render(getMarkup({scrollToCell: 4}))); - expect(collection.textContent).toContain('cell:4'); - // Making the collection shorter leaves only room for 1 item - collection = findDOMNode(render(getMarkup({scrollToCell: 4, height: 1}))); - expect(collection.textContent).toContain('cell:4'); - }); - - it('should update scroll position when :scrollToCell changes', () => { - let collection = findDOMNode(render(getMarkup())); - expect(collection.textContent).not.toContain('cell:9'); - collection = findDOMNode(render(getMarkup({scrollToCell: 9}))); - expect(collection.textContent).toContain('cell:9'); - }); - }); - - describe('noContentRenderer', () => { - it('should call :noContentRenderer if :cellCount is 0', () => { - let list = findDOMNode( - render( - getMarkup({ - noContentRenderer: () =>
No data
, - cellCount: 0, - }), - ), - ); - expect(list.textContent).toEqual('No data'); - }); - - it('should render an empty body if :cellCount is 0 and there is no :noContentRenderer', () => { - let list = findDOMNode( - render( - getMarkup({ - cellCount: 0, - }), - ), - ); - expect(list.textContent).toEqual(''); - }); - - it('should not show the :noContentRenderer when there are children, even if no children are currently visible (sparse)', () => { - const offscreenSizeAndPosition = { - x: SECTION_SIZE * 3, - y: SECTION_SIZE * 3, - width: 1, - height: 1, - }; - - function cellSizeAndPositionGetter() { - return offscreenSizeAndPosition; - } - - let list = findDOMNode( - render( - getMarkup({ - cellCount: 1, - cellSizeAndPositionGetter, - noContentRenderer: () =>
No data
, - }), - ), - ); - expect(list.textContent).not.toEqual('No data'); - }); - }); - - describe('onSectionRendered', () => { - it('should call :onSectionRendered if at least one cell is rendered', () => { - let indices; - render( - getMarkup({ - onSectionRendered: params => { - indices = params.indices; - }, - }), - ); - compareArrays(indices, [0, 1, 2, 3]); - }); - - it('should not call :onSectionRendered unless the rendered indices have changed', () => { - let numCalls = 0; - let indices; - const onSectionRendered = params => { - indices = params.indices; - numCalls++; - }; - render(getMarkup({onSectionRendered})); - expect(numCalls).toEqual(1); - compareArrays(indices, [0, 1, 2, 3]); - render(getMarkup({onSectionRendered})); - expect(numCalls).toEqual(1); - compareArrays(indices, [0, 1, 2, 3]); - }); - - it('should call :onSectionRendered if the rendered indices have changed', () => { - let numCalls = 0; - let indices; - const onSectionRendered = params => { - indices = params.indices; - numCalls++; - }; - render(getMarkup({onSectionRendered})); - expect(numCalls).toEqual(1); - compareArrays(indices, [0, 1, 2, 3]); - render( - getMarkup({ - height: SECTION_SIZE * 2, - onSectionRendered, - }), - ); - expect(numCalls).toEqual(2); - compareArrays(indices, [0, 1, 2, 3, 4, 5]); - render( - getMarkup({ - height: SECTION_SIZE * 2, - onSectionRendered, - width: SECTION_SIZE, - }), - ); - expect(numCalls).toEqual(3); - expect(indices).toEqual([0, 4]); - }); - - it('should not call :onSectionRendered if no cells are rendered', () => { - let numCalls = 0; - render( - getMarkup({ - height: 0, - onSectionRendered: () => numCalls++, - }), - ); - expect(numCalls).toEqual(0); - }); - }); - - describe(':scrollLeft and :scrollTop properties', () => { - it('should render correctly when an initial :scrollLeft and :scrollTop properties are specified', () => { - let indices; - const collection = render( - getMarkup({ - onSectionRendered: params => { - indices = params.indices; - }, - scrollLeft: 2, - scrollTop: 2, - }), - ); - compareArrays(indices, [3, 4, 5, 7, 8, 9]); - expect( - collection._collectionView.state.scrollPositionChangeReason, - ).toEqual('requested'); - }); - - it('should render correctly when :scrollLeft and :scrollTop properties are updated', () => { - let indices; - render( - getMarkup({ - onSectionRendered: params => { - indices = params.indices; - }, - }), - ); - compareArrays(indices, [0, 1, 2, 3]); - const collection = render( - getMarkup({ - onSectionRendered: params => { - indices = params.indices; - }, - scrollLeft: 2, - scrollTop: 2, - }), - ); - compareArrays(indices, [3, 4, 5, 7, 8, 9]); - expect( - collection._collectionView.state.scrollPositionChangeReason, - ).toEqual('requested'); - }); - }); - - describe('styles, classNames, and ids', () => { - it('should use the expected global CSS classNames', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.className).toEqual('ReactVirtualized__Collection'); - }); - - it('should use a custom :className if specified', () => { - const rendered = findDOMNode(render(getMarkup({className: 'foo'}))); - expect(rendered.className).toContain('foo'); - }); - - it('should use a custom :id if specified', () => { - const rendered = findDOMNode(render(getMarkup({id: 'bar'}))); - expect(rendered.getAttribute('id')).toEqual('bar'); - }); - - it('should use a custom :style if specified', () => { - const style = {backgroundColor: 'red'}; - const rendered = findDOMNode(render(getMarkup({style}))); - expect(rendered.style.backgroundColor).toEqual('red'); - }); - }); - - describe('onScroll', () => { - it('should trigger callback when component is mounted', () => { - const onScrollCalls = []; - render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - scrollLeft: 2, - scrollTop: 1, - }), - ); - expect(onScrollCalls).toEqual([ - { - clientHeight: SECTION_SIZE, - clientWidth: SECTION_SIZE * 2, - scrollHeight: 4, - scrollLeft: 2, - scrollTop: 1, - scrollWidth: 6, - }, - ]); - }); - - it('should trigger callback when component scrolls horizontally', () => { - const onScrollCalls = []; - const collection = render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - simulateScroll({ - collection, - scrollLeft: 1, - scrollTop: 0, - }); - expect(onScrollCalls.length).toEqual(2); - expect(onScrollCalls[1]).toEqual({ - clientHeight: SECTION_SIZE, - clientWidth: SECTION_SIZE * 2, - scrollHeight: 4, - scrollLeft: 1, - scrollTop: 0, - scrollWidth: 6, - }); - }); - - it('should trigger callback when component scrolls vertically', () => { - const onScrollCalls = []; - const collection = render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - simulateScroll({ - collection, - scrollLeft: 0, - scrollTop: 2, - }); - expect(onScrollCalls.length).toEqual(2); - expect(onScrollCalls[1]).toEqual({ - clientHeight: SECTION_SIZE, - clientWidth: SECTION_SIZE * 2, - scrollHeight: 4, - scrollLeft: 0, - scrollTop: 2, - scrollWidth: 6, - }); - }); - - it('should not allow negative scroll values', () => { - const onScrollCalls = []; - const collection = render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - simulateScroll({ - collection, - scrollLeft: -1, - scrollTop: -1, - }); - expect(onScrollCalls.length).toEqual(1); - expect(onScrollCalls[0].scrollLeft).toEqual(0); - expect(onScrollCalls[0].scrollTop).toEqual(0); - }); - }); - - describe('cellGroupRenderer', () => { - it('should use a custom :cellGroupRenderer if specified', () => { - let cellGroupRendererCalled = 0; - let cellGroupRendererParams; - const cellRenderer = ({index, key, style}) => ( -
- {index} -
- ); - findDOMNode( - render( - getMarkup({ - cellRenderer, - cellGroupRenderer: params => { - cellGroupRendererParams = params; - cellGroupRendererCalled++; - - return [
Fake content
]; - }, - }), - ), - ); - expect(cellGroupRendererCalled).toEqual(1); - expect(cellGroupRendererParams.cellRenderer).toEqual(cellRenderer); - expect(typeof cellGroupRendererParams.cellSizeAndPositionGetter).toEqual( - 'function', - ); - compareArrays(cellGroupRendererParams.indices, [0, 1, 2, 3]); - }); - }); - - it('should pass the cellRenderer an :isScrolling flag when scrolling is in progress', async done => { - const cellRendererCalls = []; - function cellRenderer({index, isScrolling, key, style}) { - cellRendererCalls.push(isScrolling); - return defaultCellRenderer({index, key, style}); - } - - const collection = render( - getMarkup({ - cellRenderer, - }), - ); - - expect(cellRendererCalls[0]).toEqual(false); - - cellRendererCalls.splice(0); - - simulateScroll({collection, scrollTop: 1}); - - // Give React time to process the queued setState() - await new Promise(resolve => setTimeout(resolve, 1)); - - expect(cellRendererCalls[0]).toEqual(true); - - done(); - }); - - describe('horizontalOverscanSize and verticalOverscanSize', () => { - it('should include the horizontal and vertical overscan size when rendering cells', () => { - let indices; - render( - getMarkup({ - onSectionRendered: params => { - indices = params.indices; - }, - height: 1, - horizontalOverscanSize: 2, - sectionSize: 1, - scrollLeft: 2, - scrollTop: 2, - width: 1, - verticalOverscanSize: 1, - }), - ); - compareArrays(indices, [0, 2, 3, 4, 5, 6, 7, 9]); - }); - - it('should not exceed the top/left borders regardless of overscan size', () => { - let indices; - render( - getMarkup({ - onSectionRendered: params => { - indices = params.indices; - }, - height: 2, - horizontalOverscanSize: 1, - sectionSize: 1, - scrollLeft: 0, - scrollTop: 0, - width: 1, - verticalOverscanSize: 2, - }), - ); - compareArrays(indices, [0, 4]); - }); - - it('should not exceed the bottom/right borders regardless of overscan size', () => { - let indices; - render( - getMarkup({ - onSectionRendered: params => { - indices = params.indices; - }, - height: 2, - horizontalOverscanSize: 1, - sectionSize: 1, - scrollLeft: 5, - scrollTop: 2, - width: 1, - verticalOverscanSize: 2, - }), - ); - compareArrays(indices, [6, 7, 8, 9]); - }); - }); - - describe('cell caching', () => { - it('should not cache cells if the Grid is not scrolling', () => { - const cellRendererCalls = []; - function cellRenderer({isScrolling, index, key, style}) { - cellRendererCalls.push({isScrolling, index}); - return defaultCellRenderer({index, key, style}); - } - - const props = { - cellRenderer, - scrollLeft: 0, - scrollTop: 0, - }; - - findDOMNode(render(getMarkup(props))); - expect(cellRendererCalls.length).toEqual(4); - cellRendererCalls.forEach(call => - expect(call.isScrolling).toEqual(false), - ); - - cellRendererCalls.splice(0); - - render( - getMarkup({ - ...props, - foo: 'bar', // Force re-render - }), - ); - expect(cellRendererCalls.length).toEqual(4); - cellRendererCalls.forEach(call => - expect(call.isScrolling).toEqual(false), - ); - }); - - it.skip('should cache a cell once it has been rendered while scrolling', () => { - const cellRendererCalls = []; - function cellRenderer({isScrolling, index, key, style}) { - cellRendererCalls.push({isScrolling, index}); - return defaultCellRenderer({index, key, style}); - } - - const props = { - cellRenderer, - scrollLeft: 0, - scrollTop: 0, - }; - - const collection = render(getMarkup(props)); - expect(cellRendererCalls.length).toEqual(4); - cellRendererCalls.forEach(call => - expect(call.isScrolling).toEqual(false), - ); - - // FIXME: simulate scroll is not triggering cells to render in cache - // Scroll a little bit; newly-rendered cells will be cached. - simulateScroll({collection, scrollTop: 2}); - - cellRendererCalls.splice(0); - - // At this point cells 4 and 5 have been rendered, - // But cells 7, 8, and 9 have not. - render( - getMarkup({ - ...props, - scrollLeft: 1, - scrollTop: 3, - }), - ); - expect(cellRendererCalls.length).toEqual(3); - cellRendererCalls.forEach(call => expect(call.isScrolling).toEqual(true)); - }); - - it('should clear cache once :isScrolling is false', async done => { - const cellRendererCalls = []; - function cellRenderer({isScrolling, index, key, style}) { - cellRendererCalls.push({isScrolling, index}); - return defaultCellRenderer({isScrolling, index, key, style}); - } - - const props = { - cellRenderer, - scrollLeft: 0, - scrollTop: 0, - }; - - const collection = render(getMarkup(props)); - simulateScroll({collection, scrollTop: 1}); - - // Allow scrolling timeout to complete so that cell cache is reset - await new Promise(resolve => setTimeout(resolve, 500)); - - cellRendererCalls.splice(0); - - render( - getMarkup({ - ...props, - scrollTop: 1, - }), - ); - expect(cellRendererCalls.length).not.toEqual(0); - - done(); - }); - }); - - // See issue #568 for more - it('forceUpdate will also forceUpdate the inner CollectionView', () => { - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(({key}) =>
); - - const rendered = render(getMarkup({cellRenderer})); - - expect(cellRenderer).toHaveBeenCalled(); - - cellRenderer.mockReset(); - rendered.forceUpdate(); - - expect(cellRenderer).toHaveBeenCalled(); - }); -}); diff --git a/source/Collection/Collection.js b/source/Collection/Collection.js deleted file mode 100644 index 8d21a1677..000000000 --- a/source/Collection/Collection.js +++ /dev/null @@ -1,238 +0,0 @@ -/** @flow */ -import PropTypes from 'prop-types'; -import * as React from 'react'; -import CollectionView from './CollectionView'; -import calculateSizeAndPositionData from './utils/calculateSizeAndPositionData'; -import getUpdatedOffsetForIndex from '../utils/getUpdatedOffsetForIndex'; -import type {ScrollPosition, SizeInfo} from './types'; - -/** - * Renders scattered or non-linear data. - * Unlike Grid, which renders checkerboard data, Collection can render arbitrarily positioned- even overlapping- data. - */ -export default class Collection extends React.PureComponent { - static propTypes = { - 'aria-label': PropTypes.string, - - /** - * Number of cells in Collection. - */ - cellCount: PropTypes.number.isRequired, - - /** - * Responsible for rendering a group of cells given their indices. - * Should implement the following interface: ({ - * cellSizeAndPositionGetter:Function, - * indices: Array, - * cellRenderer: Function - * }): Array - */ - cellGroupRenderer: PropTypes.func.isRequired, - - /** - * Responsible for rendering a cell given an row and column index. - * Should implement the following interface: ({ index: number, key: string, style: object }): PropTypes.element - */ - cellRenderer: PropTypes.func.isRequired, - - /** - * Callback responsible for returning size and offset/position information for a given cell (index). - * ({ index: number }): { height: number, width: number, x: number, y: number } - */ - cellSizeAndPositionGetter: PropTypes.func.isRequired, - - /** - * Optionally override the size of the sections a Collection's cells are split into. - */ - sectionSize: PropTypes.number, - }; - - static defaultProps = { - 'aria-label': 'grid', - cellGroupRenderer: defaultCellGroupRenderer, - }; - - constructor(props, context) { - super(props, context); - - this._cellMetadata = []; - this._lastRenderedCellIndices = []; - - // Cell cache during scroll (for performance) - this._cellCache = []; - - this._isScrollingChange = this._isScrollingChange.bind(this); - this._setCollectionViewRef = this._setCollectionViewRef.bind(this); - } - - forceUpdate() { - if (this._collectionView !== undefined) { - this._collectionView.forceUpdate(); - } - } - - /** See Collection#recomputeCellSizesAndPositions */ - recomputeCellSizesAndPositions() { - this._cellCache = []; - this._collectionView.recomputeCellSizesAndPositions(); - } - - /** React lifecycle methods */ - - render() { - const {...props} = this.props; - - return ( - - ); - } - - /** CellLayoutManager interface */ - - calculateSizeAndPositionData() { - const {cellCount, cellSizeAndPositionGetter, sectionSize} = this.props; - - const data = calculateSizeAndPositionData({ - cellCount, - cellSizeAndPositionGetter, - sectionSize, - }); - - this._cellMetadata = data.cellMetadata; - this._sectionManager = data.sectionManager; - this._height = data.height; - this._width = data.width; - } - - /** - * Returns the most recently rendered set of cell indices. - */ - getLastRenderedIndices() { - return this._lastRenderedCellIndices; - } - - /** - * Calculates the minimum amount of change from the current scroll position to ensure the specified cell is (fully) visible. - */ - getScrollPositionForCell({ - align, - cellIndex, - height, - scrollLeft, - scrollTop, - width, - }): ScrollPosition { - const {cellCount} = this.props; - - if (cellIndex >= 0 && cellIndex < cellCount) { - const cellMetadata = this._cellMetadata[cellIndex]; - - scrollLeft = getUpdatedOffsetForIndex({ - align, - cellOffset: cellMetadata.x, - cellSize: cellMetadata.width, - containerSize: width, - currentOffset: scrollLeft, - targetIndex: cellIndex, - }); - - scrollTop = getUpdatedOffsetForIndex({ - align, - cellOffset: cellMetadata.y, - cellSize: cellMetadata.height, - containerSize: height, - currentOffset: scrollTop, - targetIndex: cellIndex, - }); - } - - return { - scrollLeft, - scrollTop, - }; - } - - getTotalSize(): SizeInfo { - return { - height: this._height, - width: this._width, - }; - } - - cellRenderers({height, isScrolling, width, x, y}) { - const {cellGroupRenderer, cellRenderer} = this.props; - - // Store for later calls to getLastRenderedIndices() - this._lastRenderedCellIndices = this._sectionManager.getCellIndices({ - height, - width, - x, - y, - }); - - return cellGroupRenderer({ - cellCache: this._cellCache, - cellRenderer, - cellSizeAndPositionGetter: ({index}) => - this._sectionManager.getCellMetadata({index}), - indices: this._lastRenderedCellIndices, - isScrolling, - }); - } - - _isScrollingChange(isScrolling) { - if (!isScrolling) { - this._cellCache = []; - } - } - - _setCollectionViewRef(ref) { - this._collectionView = ref; - } -} - -function defaultCellGroupRenderer({ - cellCache, - cellRenderer, - cellSizeAndPositionGetter, - indices, - isScrolling, -}) { - return indices - .map(index => { - const cellMetadata = cellSizeAndPositionGetter({index}); - - let cellRendererProps = { - index, - isScrolling, - key: index, - style: { - height: cellMetadata.height, - left: cellMetadata.x, - position: 'absolute', - top: cellMetadata.y, - width: cellMetadata.width, - }, - }; - - // Avoid re-creating cells while scrolling. - // This can lead to the same cell being created many times and can cause performance issues for "heavy" cells. - // If a scroll is in progress- cache and reuse cells. - // This cache will be thrown away once scrolling complets. - if (isScrolling) { - if (!(index in cellCache)) { - cellCache[index] = cellRenderer(cellRendererProps); - } - - return cellCache[index]; - } else { - return cellRenderer(cellRendererProps); - } - }) - .filter(renderedCell => !!renderedCell); -} diff --git a/source/Collection/CollectionView.js b/source/Collection/CollectionView.js deleted file mode 100644 index 2525990df..000000000 --- a/source/Collection/CollectionView.js +++ /dev/null @@ -1,598 +0,0 @@ -/** @flow */ -import clsx from 'clsx'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import {polyfill} from 'react-lifecycles-compat'; -import createCallbackMemoizer from '../utils/createCallbackMemoizer'; -import getScrollbarSize from 'dom-helpers/scrollbarSize'; - -// @TODO Merge Collection and CollectionView - -/** - * Specifies the number of milliseconds during which to disable pointer events while a scroll is in progress. - * This improves performance and makes scrolling smoother. - */ -const IS_SCROLLING_TIMEOUT = 150; - -/** - * Controls whether the Grid updates the DOM element's scrollLeft/scrollTop based on the current state or just observes it. - * This prevents Grid from interrupting mouse-wheel animations (see issue #2). - */ -const SCROLL_POSITION_CHANGE_REASONS = { - OBSERVED: 'observed', - REQUESTED: 'requested', -}; - -/** - * Monitors changes in properties (eg. cellCount) and state (eg. scroll offsets) to determine when rendering needs to occur. - * This component does not render any visible content itself; it defers to the specified :cellLayoutManager. - */ -class CollectionView extends React.PureComponent { - static propTypes = { - 'aria-label': PropTypes.string, - - /** - * Removes fixed height from the scrollingContainer so that the total height - * of rows can stretch the window. Intended for use with WindowScroller - */ - autoHeight: PropTypes.bool, - - /** - * Number of cells in collection. - */ - cellCount: PropTypes.number.isRequired, - - /** - * Calculates cell sizes and positions and manages rendering the appropriate cells given a specified window. - */ - cellLayoutManager: PropTypes.object.isRequired, - - /** - * Optional custom CSS class name to attach to root Collection element. - */ - className: PropTypes.string, - - /** - * Height of Collection; this property determines the number of visible (vs virtualized) rows. - */ - height: PropTypes.number.isRequired, - - /** - * Optional custom id to attach to root Collection element. - */ - id: PropTypes.string, - - /** - * Enables the `Collection` to horiontally "overscan" its content similar to how `Grid` does. - * This can reduce flicker around the edges when a user scrolls quickly. - */ - horizontalOverscanSize: PropTypes.number.isRequired, - - isScrollingChange: PropTypes.func, - - /** - * Optional renderer to be used in place of rows when either :rowCount or :cellCount is 0. - */ - noContentRenderer: PropTypes.func.isRequired, - - /** - * Callback invoked whenever the scroll offset changes within the inner scrollable region. - * This callback can be used to sync scrolling between lists, tables, or grids. - * ({ clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth }): void - */ - onScroll: PropTypes.func.isRequired, - - /** - * Callback invoked with information about the section of the Collection that was just rendered. - * This callback is passed a named :indices parameter which is an Array of the most recently rendered section indices. - */ - onSectionRendered: PropTypes.func.isRequired, - - /** - * Horizontal offset. - */ - scrollLeft: PropTypes.number, - - /** - * Controls scroll-to-cell behavior of the Grid. - * The default ("auto") scrolls the least amount possible to ensure that the specified cell is fully visible. - * Use "start" to align cells to the top/left of the Grid and "end" to align bottom/right. - */ - scrollToAlignment: PropTypes.oneOf(['auto', 'end', 'start', 'center']) - .isRequired, - - /** - * Cell index to ensure visible (by forcefully scrolling if necessary). - */ - scrollToCell: PropTypes.number.isRequired, - - /** - * Vertical offset. - */ - scrollTop: PropTypes.number, - - /** - * Optional custom inline style to attach to root Collection element. - */ - style: PropTypes.object, - - /** - * Enables the `Collection` to vertically "overscan" its content similar to how `Grid` does. - * This can reduce flicker around the edges when a user scrolls quickly. - */ - verticalOverscanSize: PropTypes.number.isRequired, - - /** - * Width of Collection; this property determines the number of visible (vs virtualized) columns. - */ - width: PropTypes.number.isRequired, - }; - - static defaultProps = { - 'aria-label': 'grid', - horizontalOverscanSize: 0, - noContentRenderer: () => null, - onScroll: () => null, - onSectionRendered: () => null, - scrollToAlignment: 'auto', - scrollToCell: -1, - style: {}, - verticalOverscanSize: 0, - }; - - state = { - isScrolling: false, - scrollLeft: 0, - scrollTop: 0, - }; - - _calculateSizeAndPositionDataOnNextUpdate = false; - - // Invokes callbacks only when their values have changed. - _onSectionRenderedMemoizer = createCallbackMemoizer(); - _onScrollMemoizer = createCallbackMemoizer(false); - - constructor(...args) { - super(...args); - - // If this component is being rendered server-side, getScrollbarSize() will return undefined. - // We handle this case in componentDidMount() - this._scrollbarSize = getScrollbarSize(); - if (this._scrollbarSize === undefined) { - this._scrollbarSizeMeasured = false; - this._scrollbarSize = 0; - } else { - this._scrollbarSizeMeasured = true; - } - } - - /** - * Forced recompute of cell sizes and positions. - * This function should be called if cell sizes have changed but nothing else has. - * Since cell positions are calculated by callbacks, the collection view has no way of detecting when the underlying data has changed. - */ - recomputeCellSizesAndPositions() { - this._calculateSizeAndPositionDataOnNextUpdate = true; - this.forceUpdate(); - } - - /* ---------------------------- Component lifecycle methods ---------------------------- */ - - /** - * @private - * This method updates scrollLeft/scrollTop in state for the following conditions: - * 1) Empty content (0 rows or columns) - * 2) New scroll props overriding the current state - * 3) Cells-count or cells-size has changed, making previous scroll offsets invalid - */ - static getDerivedStateFromProps(nextProps, prevState) { - if ( - nextProps.cellCount === 0 && - (prevState.scrollLeft !== 0 || prevState.scrollTop !== 0) - ) { - return { - scrollLeft: 0, - scrollTop: 0, - scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED, - }; - } else if ( - nextProps.scrollLeft !== prevState.scrollLeft || - nextProps.scrollTop !== prevState.scrollTop - ) { - return { - scrollLeft: - nextProps.scrollLeft != null - ? nextProps.scrollLeft - : prevState.scrollLeft, - scrollTop: - nextProps.scrollTop != null - ? nextProps.scrollTop - : prevState.scrollTop, - scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED, - }; - } - - return null; - } - - componentDidMount() { - const {cellLayoutManager, scrollLeft, scrollToCell, scrollTop} = this.props; - - // If this component was first rendered server-side, scrollbar size will be undefined. - // In that event we need to remeasure. - if (!this._scrollbarSizeMeasured) { - this._scrollbarSize = getScrollbarSize(); - this._scrollbarSizeMeasured = true; - this.setState({}); - } - - if (scrollToCell >= 0) { - this._updateScrollPositionForScrollToCell(); - } else if (scrollLeft >= 0 || scrollTop >= 0) { - this._setScrollPosition({scrollLeft, scrollTop}); - } - - // Update onSectionRendered callback. - this._invokeOnSectionRenderedHelper(); - - const { - height: totalHeight, - width: totalWidth, - } = cellLayoutManager.getTotalSize(); - - // Initialize onScroll callback. - this._invokeOnScrollMemoizer({ - scrollLeft: scrollLeft || 0, - scrollTop: scrollTop || 0, - totalHeight, - totalWidth, - }); - } - - componentDidUpdate(prevProps, prevState) { - const {height, scrollToAlignment, scrollToCell, width} = this.props; - const {scrollLeft, scrollPositionChangeReason, scrollTop} = this.state; - - // Make sure requested changes to :scrollLeft or :scrollTop get applied. - // Assigning to scrollLeft/scrollTop tells the browser to interrupt any running scroll animations, - // And to discard any pending async changes to the scroll position that may have happened in the meantime (e.g. on a separate scrolling thread). - // So we only set these when we require an adjustment of the scroll position. - // See issue #2 for more information. - if ( - scrollPositionChangeReason === SCROLL_POSITION_CHANGE_REASONS.REQUESTED - ) { - if ( - scrollLeft >= 0 && - scrollLeft !== prevState.scrollLeft && - scrollLeft !== this._scrollingContainer.scrollLeft - ) { - this._scrollingContainer.scrollLeft = scrollLeft; - } - if ( - scrollTop >= 0 && - scrollTop !== prevState.scrollTop && - scrollTop !== this._scrollingContainer.scrollTop - ) { - this._scrollingContainer.scrollTop = scrollTop; - } - } - - // Update scroll offsets if the current :scrollToCell values requires it - if ( - height !== prevProps.height || - scrollToAlignment !== prevProps.scrollToAlignment || - scrollToCell !== prevProps.scrollToCell || - width !== prevProps.width - ) { - this._updateScrollPositionForScrollToCell(); - } - - // Update onRowsRendered callback if start/stop indices have changed - this._invokeOnSectionRenderedHelper(); - } - - componentWillUnmount() { - if (this._disablePointerEventsTimeoutId) { - clearTimeout(this._disablePointerEventsTimeoutId); - } - } - - render() { - const { - autoHeight, - cellCount, - cellLayoutManager, - className, - height, - horizontalOverscanSize, - id, - noContentRenderer, - style, - verticalOverscanSize, - width, - } = this.props; - - const {isScrolling, scrollLeft, scrollTop} = this.state; - - // Memoization reset - if ( - this._lastRenderedCellCount !== cellCount || - this._lastRenderedCellLayoutManager !== cellLayoutManager || - this._calculateSizeAndPositionDataOnNextUpdate - ) { - this._lastRenderedCellCount = cellCount; - this._lastRenderedCellLayoutManager = cellLayoutManager; - this._calculateSizeAndPositionDataOnNextUpdate = false; - - cellLayoutManager.calculateSizeAndPositionData(); - } - - const { - height: totalHeight, - width: totalWidth, - } = cellLayoutManager.getTotalSize(); - - // Safely expand the rendered area by the specified overscan amount - const left = Math.max(0, scrollLeft - horizontalOverscanSize); - const top = Math.max(0, scrollTop - verticalOverscanSize); - const right = Math.min( - totalWidth, - scrollLeft + width + horizontalOverscanSize, - ); - const bottom = Math.min( - totalHeight, - scrollTop + height + verticalOverscanSize, - ); - - const childrenToDisplay = - height > 0 && width > 0 - ? cellLayoutManager.cellRenderers({ - height: bottom - top, - isScrolling, - width: right - left, - x: left, - y: top, - }) - : []; - - const collectionStyle = { - boxSizing: 'border-box', - direction: 'ltr', - height: autoHeight ? 'auto' : height, - position: 'relative', - WebkitOverflowScrolling: 'touch', - width, - willChange: 'transform', - }; - - // Force browser to hide scrollbars when we know they aren't necessary. - // Otherwise once scrollbars appear they may not disappear again. - // For more info see issue #116 - const verticalScrollBarSize = - totalHeight > height ? this._scrollbarSize : 0; - const horizontalScrollBarSize = - totalWidth > width ? this._scrollbarSize : 0; - - // Also explicitly init styles to 'auto' if scrollbars are required. - // This works around an obscure edge case where external CSS styles have not yet been loaded, - // But an initial scroll index of offset is set as an external prop. - // Without this style, Grid would render the correct range of cells but would NOT update its internal offset. - // This was originally reported via clauderic/react-infinite-calendar/issues/23 - collectionStyle.overflowX = - totalWidth + verticalScrollBarSize <= width ? 'hidden' : 'auto'; - collectionStyle.overflowY = - totalHeight + horizontalScrollBarSize <= height ? 'hidden' : 'auto'; - - return ( -
- {cellCount > 0 && ( -
- {childrenToDisplay} -
- )} - {cellCount === 0 && noContentRenderer()} -
- ); - } - - /* ---------------------------- Helper methods ---------------------------- */ - - /** - * Sets an :isScrolling flag for a small window of time. - * This flag is used to disable pointer events on the scrollable portion of the Collection. - * This prevents jerky/stuttery mouse-wheel scrolling. - */ - _enablePointerEventsAfterDelay() { - if (this._disablePointerEventsTimeoutId) { - clearTimeout(this._disablePointerEventsTimeoutId); - } - - this._disablePointerEventsTimeoutId = setTimeout(() => { - const {isScrollingChange} = this.props; - - isScrollingChange(false); - - this._disablePointerEventsTimeoutId = null; - this.setState({ - isScrolling: false, - }); - }, IS_SCROLLING_TIMEOUT); - } - - _invokeOnSectionRenderedHelper = () => { - const {cellLayoutManager, onSectionRendered} = this.props; - - this._onSectionRenderedMemoizer({ - callback: onSectionRendered, - indices: { - indices: cellLayoutManager.getLastRenderedIndices(), - }, - }); - }; - - _invokeOnScrollMemoizer({scrollLeft, scrollTop, totalHeight, totalWidth}) { - this._onScrollMemoizer({ - callback: ({scrollLeft, scrollTop}) => { - const {height, onScroll, width} = this.props; - - onScroll({ - clientHeight: height, - clientWidth: width, - scrollHeight: totalHeight, - scrollLeft, - scrollTop, - scrollWidth: totalWidth, - }); - }, - indices: { - scrollLeft, - scrollTop, - }, - }); - } - - _setScrollingContainerRef = ref => { - this._scrollingContainer = ref; - }; - - _setScrollPosition({scrollLeft, scrollTop}) { - const newState = { - scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED, - }; - - if (scrollLeft >= 0) { - newState.scrollLeft = scrollLeft; - } - - if (scrollTop >= 0) { - newState.scrollTop = scrollTop; - } - - if ( - (scrollLeft >= 0 && scrollLeft !== this.state.scrollLeft) || - (scrollTop >= 0 && scrollTop !== this.state.scrollTop) - ) { - this.setState(newState); - } - } - - _updateScrollPositionForScrollToCell = () => { - const { - cellLayoutManager, - height, - scrollToAlignment, - scrollToCell, - width, - } = this.props; - const {scrollLeft, scrollTop} = this.state; - - if (scrollToCell >= 0) { - const scrollPosition = cellLayoutManager.getScrollPositionForCell({ - align: scrollToAlignment, - cellIndex: scrollToCell, - height, - scrollLeft, - scrollTop, - width, - }); - - if ( - scrollPosition.scrollLeft !== scrollLeft || - scrollPosition.scrollTop !== scrollTop - ) { - this._setScrollPosition(scrollPosition); - } - } - }; - - _onScroll = event => { - // In certain edge-cases React dispatches an onScroll event with an invalid target.scrollLeft / target.scrollTop. - // This invalid event can be detected by comparing event.target to this component's scrollable DOM element. - // See issue #404 for more information. - if (event.target !== this._scrollingContainer) { - return; - } - - // Prevent pointer events from interrupting a smooth scroll - this._enablePointerEventsAfterDelay(); - - // When this component is shrunk drastically, React dispatches a series of back-to-back scroll events, - // Gradually converging on a scrollTop that is within the bounds of the new, smaller height. - // This causes a series of rapid renders that is slow for long lists. - // We can avoid that by doing some simple bounds checking to ensure that scrollTop never exceeds the total height. - const {cellLayoutManager, height, isScrollingChange, width} = this.props; - const scrollbarSize = this._scrollbarSize; - const { - height: totalHeight, - width: totalWidth, - } = cellLayoutManager.getTotalSize(); - const scrollLeft = Math.max( - 0, - Math.min(totalWidth - width + scrollbarSize, event.target.scrollLeft), - ); - const scrollTop = Math.max( - 0, - Math.min(totalHeight - height + scrollbarSize, event.target.scrollTop), - ); - - // Certain devices (like Apple touchpad) rapid-fire duplicate events. - // Don't force a re-render if this is the case. - // The mouse may move faster then the animation frame does. - // Use requestAnimationFrame to avoid over-updating. - if ( - this.state.scrollLeft !== scrollLeft || - this.state.scrollTop !== scrollTop - ) { - // Browsers with cancelable scroll events (eg. Firefox) interrupt scrolling animations if scrollTop/scrollLeft is set. - // Other browsers (eg. Safari) don't scroll as well without the help under certain conditions (DOM or style changes during scrolling). - // All things considered, this seems to be the best current work around that I'm aware of. - // For more information see https://github.com/bvaughn/react-virtualized/pull/124 - const scrollPositionChangeReason = event.cancelable - ? SCROLL_POSITION_CHANGE_REASONS.OBSERVED - : SCROLL_POSITION_CHANGE_REASONS.REQUESTED; - - // Synchronously set :isScrolling the first time (since _setNextState will reschedule its animation frame each time it's called) - if (!this.state.isScrolling) { - isScrollingChange(true); - } - - this.setState({ - isScrolling: true, - scrollLeft, - scrollPositionChangeReason, - scrollTop, - }); - } - - this._invokeOnScrollMemoizer({ - scrollLeft, - scrollTop, - totalWidth, - totalHeight, - }); - }; -} - -polyfill(CollectionView); - -export default CollectionView; diff --git a/source/Collection/Section.jest.js b/source/Collection/Section.jest.js deleted file mode 100644 index c894af93e..000000000 --- a/source/Collection/Section.jest.js +++ /dev/null @@ -1,42 +0,0 @@ -import Section from './Section'; - -describe('Section', () => { - function helper({height = 100, width = 200, x = 0, y = 0} = {}) { - return new Section({ - height, - width, - x, - y, - }); - } - - it('should add a new cell index', () => { - const section = helper(); - expect(section.getCellIndices()).toEqual([]); - section.addCellIndex({index: 0}); - expect(section.getCellIndices()).toEqual([0]); - section.addCellIndex({index: 1}); - expect(section.getCellIndices()).toEqual([0, 1]); - }); - - it('should not add a duplicate cell index', () => { - const section = helper(); - section.addCellIndex({index: 0}); - section.addCellIndex({index: 1}); - section.addCellIndex({index: 0}); - section.addCellIndex({index: 1}); - section.addCellIndex({index: 2}); - expect(section.getCellIndices()).toEqual([0, 1, 2]); - }); - - it('should define a working toString() method for debugging', () => { - const section = helper({ - height: 100, - width: 200, - x: 25, - y: 50, - }); - - expect(section.toString()).toEqual('25,50 200x100'); - }); -}); diff --git a/source/Collection/Section.js b/source/Collection/Section.js deleted file mode 100644 index 6eb654b79..000000000 --- a/source/Collection/Section.js +++ /dev/null @@ -1,38 +0,0 @@ -/** @flow */ -import type {Index, SizeAndPositionInfo} from './types'; - -/** - * A section of the Window. - * Window Sections are used to group nearby cells. - * This enables us to more quickly determine which cells to display in a given region of the Window. - * Sections have a fixed size and contain 0 to many cells (tracked by their indices). - */ -export default class Section { - constructor({height, width, x, y}: SizeAndPositionInfo) { - this.height = height; - this.width = width; - this.x = x; - this.y = y; - - this._indexMap = {}; - this._indices = []; - } - - /** Add a cell to this section. */ - addCellIndex({index}: Index) { - if (!this._indexMap[index]) { - this._indexMap[index] = true; - this._indices.push(index); - } - } - - /** Get all cell indices that have been added to this section. */ - getCellIndices(): Array { - return this._indices; - } - - /** Intended for debugger/test purposes only */ - toString() { - return `${this.x},${this.y} ${this.width}x${this.height}`; - } -} diff --git a/source/Collection/SectionManager.jest.js b/source/Collection/SectionManager.jest.js deleted file mode 100644 index b08a0303a..000000000 --- a/source/Collection/SectionManager.jest.js +++ /dev/null @@ -1,78 +0,0 @@ -import SectionManager from './SectionManager'; -import {CELLS, SECTION_SIZE} from './TestData'; - -function initSectionManager() { - const sectionManager = new SectionManager(SECTION_SIZE); - CELLS.forEach((cellMetadatum, index) => { - sectionManager.registerCell({ - cellMetadatum, - index, - }); - }); - return sectionManager; -} - -function verifySections( - sectionManager, - sizeAndPosition, - expectedSizeAndPositionInfos, -) { - const sections = sectionManager.getSections(sizeAndPosition); - expect(sections.length).toEqual(expectedSizeAndPositionInfos.length); - expectedSizeAndPositionInfos.forEach(sizeAndPosition => { - const match = sections.find( - section => - section.x === sizeAndPosition.x && section.y === sizeAndPosition.y, - ); - expect(!!match).toEqual(true); - }); -} - -describe('SectionManager', () => { - it('creates the appropriate number of Sections', () => { - const sectionManager = initSectionManager(); - expect(sectionManager.getTotalSectionCount()).toEqual(6); - }); - - it('returns the proper Sections based on the specified area', () => { - const sectionManager = initSectionManager(); - verifySections(sectionManager, {x: 0, y: 0, width: 1, height: 1}, [ - {x: 0, y: 0}, - ]); - verifySections(sectionManager, {x: 1, y: 1, width: 1, height: 1}, [ - {x: 0, y: 0}, - ]); - verifySections(sectionManager, {x: 0, y: 0, width: 4, height: 4}, [ - {x: 0, y: 0}, - {x: 2, y: 0}, - {x: 0, y: 2}, - {x: 2, y: 2}, - ]); - verifySections(sectionManager, {x: 4, y: 0, width: 2, height: 3}, [ - {x: 4, y: 0}, - {x: 4, y: 2}, - ]); - }); - - it('assigns cells to the appropriate sections', () => { - const sectionManager = initSectionManager(); - expect( - sectionManager.getCellIndices({x: 0, y: 0, width: 2, height: 2}), - ).toEqual([0]); - expect( - sectionManager.getCellIndices({x: 2, y: 0, width: 2, height: 2}), - ).toEqual([1, 2, 3]); - expect( - sectionManager.getCellIndices({x: 4, y: 0, width: 2, height: 2}), - ).toEqual([6]); - expect( - sectionManager.getCellIndices({x: 0, y: 2, width: 2, height: 2}), - ).toEqual([4]); - expect( - sectionManager.getCellIndices({x: 2, y: 2, width: 2, height: 2}), - ).toEqual([3, 4, 5]); - expect( - sectionManager.getCellIndices({x: 4, y: 2, width: 2, height: 2}), - ).toEqual([7, 8, 9]); - }); -}); diff --git a/source/Collection/SectionManager.js b/source/Collection/SectionManager.js deleted file mode 100644 index 109c56801..000000000 --- a/source/Collection/SectionManager.js +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Window Sections are used to group nearby cells. - * This enables us to more quickly determine which cells to display in a given region of the Window. - * @flow - */ -import Section from './Section'; -import type {Index, SizeAndPositionInfo} from './types'; - -const SECTION_SIZE = 100; - -type RegisterCellParams = { - cellMetadatum: SizeAndPositionInfo, - index: number, -}; - -/** - * Contains 0 to many Sections. - * Grows (and adds Sections) dynamically as cells are registered. - * Automatically adds cells to the appropriate Section(s). - */ -export default class SectionManager { - constructor(sectionSize = SECTION_SIZE) { - this._sectionSize = sectionSize; - - this._cellMetadata = []; - this._sections = {}; - } - - /** - * Gets all cell indices contained in the specified region. - * A region may encompass 1 or more Sections. - */ - getCellIndices({height, width, x, y}: SizeAndPositionInfo): Array { - const indices = {}; - - this.getSections({height, width, x, y}).forEach(section => - section.getCellIndices().forEach(index => { - indices[index] = index; - }), - ); - - // Object keys are strings; this function returns numbers - return Object.keys(indices).map(index => indices[index]); - } - - /** Get size and position information for the cell specified. */ - getCellMetadata({index}: Index): SizeAndPositionInfo { - return this._cellMetadata[index]; - } - - /** Get all Sections overlapping the specified region. */ - getSections({height, width, x, y}: SizeAndPositionInfo): Array
{ - const sectionXStart = Math.floor(x / this._sectionSize); - const sectionXStop = Math.floor((x + width - 1) / this._sectionSize); - const sectionYStart = Math.floor(y / this._sectionSize); - const sectionYStop = Math.floor((y + height - 1) / this._sectionSize); - - const sections = []; - - for (let sectionX = sectionXStart; sectionX <= sectionXStop; sectionX++) { - for (let sectionY = sectionYStart; sectionY <= sectionYStop; sectionY++) { - const key = `${sectionX}.${sectionY}`; - - if (!this._sections[key]) { - this._sections[key] = new Section({ - height: this._sectionSize, - width: this._sectionSize, - x: sectionX * this._sectionSize, - y: sectionY * this._sectionSize, - }); - } - - sections.push(this._sections[key]); - } - } - - return sections; - } - - /** Total number of Sections based on the currently registered cells. */ - getTotalSectionCount() { - return Object.keys(this._sections).length; - } - - /** Intended for debugger/test purposes only */ - toString() { - return Object.keys(this._sections).map(index => - this._sections[index].toString(), - ); - } - - /** Adds a cell to the appropriate Sections and registers it metadata for later retrievable. */ - registerCell({cellMetadatum, index}: RegisterCellParams) { - this._cellMetadata[index] = cellMetadatum; - - this.getSections(cellMetadatum).forEach(section => - section.addCellIndex({index}), - ); - } -} diff --git a/source/Collection/TestData.js b/source/Collection/TestData.js deleted file mode 100644 index 7b77e4e94..000000000 --- a/source/Collection/TestData.js +++ /dev/null @@ -1,33 +0,0 @@ -/* - 0 1 2 3 4 5 - ┏━━━┯━━━┯━━━┓ -0┃0 0┊1 3┊6 6┃ -1┃0 0┊2 3┊6 6┃ - ┠┈┈┈┼┈┈┈┼┈┈┈┨ -2┃4 4┊4 3┊7 8┃ -3┃4 4┊4 5┊9 9┃ - ┗━━━┷━━━┷━━━┛ - -Sections to Cells map: - 0.0 [0] - 1.0 [1, 2, 3] - 2.0 [6] - 0.1 [4] - 1.1 [3, 4, 5] - 2.1 [7, 8, 9] -*/ - -export const CELLS = [ - {x: 0, y: 0, width: 2, height: 2}, - {x: 2, y: 0, width: 1, height: 1}, - {x: 2, y: 1, width: 1, height: 1}, - {x: 3, y: 0, width: 1, height: 3}, - {x: 0, y: 2, width: 3, height: 2}, - {x: 3, y: 3, width: 1, height: 1}, - {x: 4, y: 0, width: 2, height: 2}, - {x: 4, y: 2, width: 1, height: 1}, - {x: 5, y: 2, width: 1, height: 1}, - {x: 4, y: 3, width: 2, height: 1}, -]; - -export const SECTION_SIZE = 2; diff --git a/source/Collection/index.js b/source/Collection/index.js deleted file mode 100644 index e58329e5b..000000000 --- a/source/Collection/index.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @flow */ -import Collection from './Collection'; - -export default Collection; -export {Collection}; diff --git a/source/Collection/types.js b/source/Collection/types.js deleted file mode 100644 index 5e0e2eda2..000000000 --- a/source/Collection/types.js +++ /dev/null @@ -1,27 +0,0 @@ -/** @flow */ - -export type Index = { - index: number, -}; - -export type PositionInfo = { - x: number, - y: number, -}; - -export type ScrollPosition = { - scrollLeft: number, - scrollTop: number, -}; - -export type SizeAndPositionInfo = { - height: number, - width: number, - x: number, - y: number, -}; - -export type SizeInfo = { - height: number, - width: number, -}; diff --git a/source/Collection/utils/calculateSizeAndPositionData.jest.js b/source/Collection/utils/calculateSizeAndPositionData.jest.js deleted file mode 100644 index cb1dc0e1b..000000000 --- a/source/Collection/utils/calculateSizeAndPositionData.jest.js +++ /dev/null @@ -1,31 +0,0 @@ -import calculateSizeAndPositionData from './calculateSizeAndPositionData'; - -describe('calculateSizeAndPositionData', () => { - it('should query for size and position of each cell', () => { - const cellSizeAndPositionGetterCalls = []; - function cellSizeAndPositionGetter({index}) { - cellSizeAndPositionGetterCalls.push(index); - return { - x: index * 50, - y: 0, - width: 50, - height: 50, - }; - } - const {sectionManager} = calculateSizeAndPositionData({ - cellCount: 3, - cellSizeAndPositionGetter, - }); - expect(cellSizeAndPositionGetterCalls).toEqual([0, 1, 2]); - expect(sectionManager.getTotalSectionCount()).toEqual(2); - }); - - it('should throw an error if invalid metadata is returned for a cell', () => { - expect(() => - calculateSizeAndPositionData({ - cellCount: 3, - cellSizeAndPositionGetter: () => {}, - }), - ).toThrow(); - }); -}); diff --git a/source/Collection/utils/calculateSizeAndPositionData.js b/source/Collection/utils/calculateSizeAndPositionData.js deleted file mode 100644 index 904e759d9..000000000 --- a/source/Collection/utils/calculateSizeAndPositionData.js +++ /dev/null @@ -1,48 +0,0 @@ -import SectionManager from '../SectionManager'; - -export default function calculateSizeAndPositionData({ - cellCount, - cellSizeAndPositionGetter, - sectionSize, -}) { - const cellMetadata = []; - const sectionManager = new SectionManager(sectionSize); - let height = 0; - let width = 0; - - for (let index = 0; index < cellCount; index++) { - const cellMetadatum = cellSizeAndPositionGetter({index}); - - if ( - cellMetadatum.height == null || - isNaN(cellMetadatum.height) || - cellMetadatum.width == null || - isNaN(cellMetadatum.width) || - cellMetadatum.x == null || - isNaN(cellMetadatum.x) || - cellMetadatum.y == null || - isNaN(cellMetadatum.y) - ) { - throw Error( - `Invalid metadata returned for cell ${index}: - x:${cellMetadatum.x}, y:${cellMetadatum.y}, width:${cellMetadatum.width}, height:${cellMetadatum.height}`, - ); - } - - height = Math.max(height, cellMetadatum.y + cellMetadatum.height); - width = Math.max(width, cellMetadatum.x + cellMetadatum.width); - - cellMetadata[index] = cellMetadatum; - sectionManager.registerCell({ - cellMetadatum, - index, - }); - } - - return { - cellMetadata, - height, - sectionManager, - width, - }; -} diff --git a/source/ColumnSizer/ColumnSizer.example.css b/source/ColumnSizer/ColumnSizer.example.css deleted file mode 100644 index 8f4f0285b..000000000 --- a/source/ColumnSizer/ColumnSizer.example.css +++ /dev/null @@ -1,32 +0,0 @@ -.GridContainer { - margin-top: 15px; - border: 1px solid #e0e0e0; -} - -.cell, -.firstCell { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 0 .5em; - border-left: 1px solid #e0e0e0; -} -.firstCell { - border-left: none; -} - -.noCells { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 1em; - color: #bdbdbd; -} diff --git a/source/ColumnSizer/ColumnSizer.example.js b/source/ColumnSizer/ColumnSizer.example.js deleted file mode 100644 index bbfc7fc3e..000000000 --- a/source/ColumnSizer/ColumnSizer.example.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * @flow - */ -import * as React from 'react'; -import styles from './ColumnSizer.example.css'; -import AutoSizer from '../AutoSizer'; -import ColumnSizer from './ColumnSizer'; -import Grid from '../Grid'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import {LabeledInput, InputRow} from '../demo/LabeledInput'; - -export default class ColumnSizerExample extends React.PureComponent { - constructor(props) { - super(props); - - this.state = { - columnMaxWidth: 100, - columnMinWidth: 75, - columnCount: 10, - }; - - this._noColumnMaxWidthChange = this._noColumnMaxWidthChange.bind(this); - this._noColumnMinWidthChange = this._noColumnMinWidthChange.bind(this); - this._onColumnCountChange = this._onColumnCountChange.bind(this); - this._noContentRenderer = this._noContentRenderer.bind(this); - this._cellRenderer = this._cellRenderer.bind(this); - } - - render() { - const {columnMaxWidth, columnMinWidth, columnCount} = this.state; - - return ( - - - - - This component decorates a Grid and calculates the width - of its columns based on the current (Grid) width. - - - - - - - - -
- - {({width}) => ( - - {({adjustedWidth, columnWidth, registerChild}) => ( -
- -
- )} -
- )} -
-
-
- ); - } - - _noColumnMaxWidthChange(event) { - let columnMaxWidth = parseInt(event.target.value, 10); - - if (isNaN(columnMaxWidth)) { - columnMaxWidth = undefined; - } else { - columnMaxWidth = Math.min(1000, columnMaxWidth); - } - - this.setState({columnMaxWidth}); - } - - _noColumnMinWidthChange(event) { - let columnMinWidth = parseInt(event.target.value, 10); - - if (isNaN(columnMinWidth)) { - columnMinWidth = undefined; - } else { - columnMinWidth = Math.max(1, columnMinWidth); - } - - this.setState({columnMinWidth}); - } - - _onColumnCountChange(event) { - this.setState({columnCount: parseInt(event.target.value, 10) || 0}); - } - - _noContentRenderer() { - return
No cells
; - } - - _cellRenderer({columnIndex, key, rowIndex, style}) { - const className = columnIndex === 0 ? styles.firstCell : styles.cell; - - return ( -
- {`R:${rowIndex}, C:${columnIndex}`} -
- ); - } -} diff --git a/source/ColumnSizer/ColumnSizer.jest.js b/source/ColumnSizer/ColumnSizer.jest.js deleted file mode 100644 index 1ed0064fe..000000000 --- a/source/ColumnSizer/ColumnSizer.jest.js +++ /dev/null @@ -1,153 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import ColumnSizer from './ColumnSizer'; -import Grid from '../Grid'; - -describe('ColumnSizer', () => { - function getMarkup({ - columnMinWidth = undefined, - columnMaxWidth = undefined, - columnCount = 10, - width = 200, - } = {}) { - function cellRenderer({columnIndex, key, rowIndex, style}) { - return ( -
- {`row:${rowIndex}, column:${columnIndex}`} -
- ); - } - - return ( - - {({adjustedWidth, columnWidth, registerChild}) => ( -
- -
- {`adjustedWidth:${adjustedWidth} columnWidth:${columnWidth}`} -
-
- )} -
- ); - } - - it('should distribute column widths evenly if no min/max boundaries have been set', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.querySelector('.debug').textContent).toContain( - 'columnWidth:20', - ); - }); - - it('should respect :columnMaxWidth if specified', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnMaxWidth: 10, - }), - ), - ); - expect(rendered.querySelector('.debug').textContent).toContain( - 'columnWidth:10', - ); - }); - - it('should respect :columnMinWidth if specified', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnMinWidth: 30, - }), - ), - ); - expect(rendered.querySelector('.debug').textContent).toContain( - 'columnWidth:30', - ); - }); - - describe('recomputeGridSize', () => { - function helper(updatedProps, expectedTextContent) { - const renderedA = findDOMNode(render(getMarkup())); - expect(renderedA.querySelector('.debug').textContent).toContain( - 'columnWidth:20', - ); - - const renderedB = findDOMNode(render(getMarkup(updatedProps))); - expect(renderedB.querySelector('.debug').textContent).toContain( - expectedTextContent, - ); - } - - it('should recompute metadata sizes if :columnMinWidth changes', () => { - helper({columnMinWidth: 30}, 'columnWidth:30'); - }); - - it('should recompute metadata sizes if :columnMaxWidth changes', () => { - helper({columnMaxWidth: 15}, 'columnWidth:15'); - }); - - it('should recompute metadata sizes if :width changes', () => { - helper({width: 300}, 'columnWidth:30'); - }); - - it('should recompute metadata sizes if :columnCount changes', () => { - helper({columnCount: 2}, 'columnWidth:100'); - }); - }); - - it('should pass the :width as :adjustedWidth if columns require more than the :width to be displayed', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnMinWidth: 30, - }), - ), - ); - expect(rendered.querySelector('.debug').textContent).toContain( - 'adjustedWidth:200', - ); - }); - - it('should pass an :adjustedWidth if columns require less than the :width to be displayed', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnMaxWidth: 10, - }), - ), - ); - expect(rendered.querySelector('.debug').textContent).toContain( - 'adjustedWidth:100', - ); - }); - - it('should error if the registered child is not a Grid or a MultiGrid', () => { - spyOn(console, 'error'); - - expect(() => { - render( - - {({registerChild}) =>
} - , - ); - }).toThrow(); - }); -}); diff --git a/source/ColumnSizer/ColumnSizer.js b/source/ColumnSizer/ColumnSizer.js deleted file mode 100644 index 1f2e01cd9..000000000 --- a/source/ColumnSizer/ColumnSizer.js +++ /dev/null @@ -1,98 +0,0 @@ -/** @flow */ -import PropTypes from 'prop-types'; -import * as React from 'react'; - -/** - * High-order component that auto-calculates column-widths for `Grid` cells. - */ -export default class ColumnSizer extends React.PureComponent { - static propTypes = { - /** - * Function responsible for rendering a virtualized Grid. - * This function should implement the following signature: - * ({ adjustedWidth, getColumnWidth, registerChild }) => PropTypes.element - * - * The specified :getColumnWidth function should be passed to the Grid's :columnWidth property. - * The :registerChild should be passed to the Grid's :ref property. - * The :adjustedWidth property is optional; it reflects the lesser of the overall width or the width of all columns. - */ - children: PropTypes.func.isRequired, - - /** Optional maximum allowed column width */ - columnMaxWidth: PropTypes.number, - - /** Optional minimum allowed column width */ - columnMinWidth: PropTypes.number, - - /** Number of columns in Grid or Table child */ - columnCount: PropTypes.number.isRequired, - - /** Width of Grid or Table child */ - width: PropTypes.number.isRequired, - }; - - constructor(props, context) { - super(props, context); - - this._registerChild = this._registerChild.bind(this); - } - - componentDidUpdate(prevProps) { - const {columnMaxWidth, columnMinWidth, columnCount, width} = this.props; - - if ( - columnMaxWidth !== prevProps.columnMaxWidth || - columnMinWidth !== prevProps.columnMinWidth || - columnCount !== prevProps.columnCount || - width !== prevProps.width - ) { - if (this._registeredChild) { - this._registeredChild.recomputeGridSize(); - } - } - } - - render() { - const { - children, - columnMaxWidth, - columnMinWidth, - columnCount, - width, - } = this.props; - - const safeColumnMinWidth = columnMinWidth || 1; - - const safeColumnMaxWidth = columnMaxWidth - ? Math.min(columnMaxWidth, width) - : width; - - let columnWidth = width / columnCount; - columnWidth = Math.max(safeColumnMinWidth, columnWidth); - columnWidth = Math.min(safeColumnMaxWidth, columnWidth); - columnWidth = Math.floor(columnWidth); - - let adjustedWidth = Math.min(width, columnWidth * columnCount); - - return children({ - adjustedWidth, - columnWidth, - getColumnWidth: () => columnWidth, - registerChild: this._registerChild, - }); - } - - _registerChild(child) { - if (child && typeof child.recomputeGridSize !== 'function') { - throw Error( - 'Unexpected child type registered; only Grid/MultiGrid children are supported.', - ); - } - - this._registeredChild = child; - - if (this._registeredChild) { - this._registeredChild.recomputeGridSize(); - } - } -} diff --git a/source/ColumnSizer/index.js b/source/ColumnSizer/index.js deleted file mode 100644 index 45353594f..000000000 --- a/source/ColumnSizer/index.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @flow */ -import ColumnSizer from './ColumnSizer'; - -export default ColumnSizer; -export {ColumnSizer}; diff --git a/source/Grid/Grid.example.css b/source/Grid/Grid.example.css deleted file mode 100644 index e115c4435..000000000 --- a/source/Grid/Grid.example.css +++ /dev/null @@ -1,67 +0,0 @@ -.GridRow { - margin-top: 15px; - display: flex; - flex-direction: row; -} -.GridColumn { - display: flex; - flex-direction: column; - flex: 1 1 auto; -} -.LeftSideGridContainer { - flex: 0 0 50px; -} - -.BodyGrid { - width: 100%; - border: 1px solid #e0e0e0; -} - -.evenRow, -.oddRow { - border-bottom: 1px solid #e0e0e0; -} -.oddRow { - background-color: #fafafa; -} - -.cell, -.headerCell { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - padding: 0 .5em; -} -.cell { - border-right: 1px solid #e0e0e0; - border-bottom: 1px solid #e0e0e0; -} -.headerCell { - font-weight: bold; - border-right: 1px solid #e0e0e0; -} -.centeredCell { - align-items: center; - text-align: center; -} - -.letterCell { - font-size: 1.5em; - color: #fff; - text-align: center; -} - -.noCells { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 1em; - color: #bdbdbd; -} diff --git a/source/Grid/Grid.example.js b/source/Grid/Grid.example.js deleted file mode 100644 index 20935135a..000000000 --- a/source/Grid/Grid.example.js +++ /dev/null @@ -1,312 +0,0 @@ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import {LabeledInput, InputRow} from '../demo/LabeledInput'; -import AutoSizer from '../AutoSizer'; -import Grid from './Grid'; -import clsx from 'clsx'; -import styles from './Grid.example.css'; - -export default class GridExample extends React.PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - columnCount: 1000, - height: 300, - overscanColumnCount: 0, - overscanRowCount: 10, - rowHeight: 40, - rowCount: 1000, - scrollToColumn: undefined, - scrollToRow: undefined, - useDynamicRowHeight: false, - }; - - this._cellRenderer = this._cellRenderer.bind(this); - this._getColumnWidth = this._getColumnWidth.bind(this); - this._getRowClassName = this._getRowClassName.bind(this); - this._getRowHeight = this._getRowHeight.bind(this); - this._noContentRenderer = this._noContentRenderer.bind(this); - this._onColumnCountChange = this._onColumnCountChange.bind(this); - this._onRowCountChange = this._onRowCountChange.bind(this); - this._onScrollToColumnChange = this._onScrollToColumnChange.bind(this); - this._onScrollToRowChange = this._onScrollToRowChange.bind(this); - this._renderBodyCell = this._renderBodyCell.bind(this); - this._renderLeftSideCell = this._renderLeftSideCell.bind(this); - } - - render() { - const { - columnCount, - height, - overscanColumnCount, - overscanRowCount, - rowHeight, - rowCount, - scrollToColumn, - scrollToRow, - useDynamicRowHeight, - } = this.state; - - return ( - - - - - Renders tabular data with virtualization along the vertical and - horizontal axes. Row heights and column widths must be calculated - ahead of time and specified as a fixed size or returned by a getter - function. - - - - - - - - - - - - - this.setState({height: parseInt(event.target.value, 10) || 1}) - } - value={height} - /> - - this.setState({ - rowHeight: parseInt(event.target.value, 10) || 1, - }) - } - value={rowHeight} - /> - - this.setState({ - overscanColumnCount: parseInt(event.target.value, 10) || 0, - }) - } - value={overscanColumnCount} - /> - - this.setState({ - overscanRowCount: parseInt(event.target.value, 10) || 0, - }) - } - value={overscanRowCount} - /> - - - - {({width}) => ( - - )} - - - ); - } - - _cellRenderer({columnIndex, key, rowIndex, style}) { - if (columnIndex === 0) { - return this._renderLeftSideCell({columnIndex, key, rowIndex, style}); - } else { - return this._renderBodyCell({columnIndex, key, rowIndex, style}); - } - } - - _getColumnWidth({index}) { - switch (index) { - case 0: - return 50; - case 1: - return 100; - case 2: - return 300; - default: - return 80; - } - } - - _getDatum(index) { - const {list} = this.context; - - return list.get(index % list.size); - } - - _getRowClassName(row) { - return row % 2 === 0 ? styles.evenRow : styles.oddRow; - } - - _getRowHeight({index}) { - return this._getDatum(index).size; - } - - _noContentRenderer() { - return
No cells
; - } - - _renderBodyCell({columnIndex, key, rowIndex, style}) { - const rowClass = this._getRowClassName(rowIndex); - const datum = this._getDatum(rowIndex); - - let content; - - switch (columnIndex) { - case 1: - content = datum.name; - break; - case 2: - content = datum.random; - break; - default: - content = `r:${rowIndex}, c:${columnIndex}`; - break; - } - - const classNames = clsx(rowClass, styles.cell, { - [styles.centeredCell]: columnIndex > 2, - }); - - return ( -
- {content} -
- ); - } - - _renderLeftSideCell({key, rowIndex, style}) { - const datum = this._getDatum(rowIndex); - - const classNames = clsx(styles.cell, styles.letterCell); - - // Don't modify styles. - // These are frozen by React now (as of 16.0.0). - // Since Grid caches and re-uses them, they aren't safe to modify. - style = { - ...style, - backgroundColor: datum.color, - }; - - return ( -
- {datum.name.charAt(0)} -
- ); - } - - _updateUseDynamicRowHeights(value) { - this.setState({ - useDynamicRowHeight: value, - }); - } - - _onColumnCountChange(event) { - const columnCount = parseInt(event.target.value, 10) || 0; - - this.setState({columnCount}); - } - - _onRowCountChange(event) { - const rowCount = parseInt(event.target.value, 10) || 0; - - this.setState({rowCount}); - } - - _onScrollToColumnChange(event) { - const {columnCount} = this.state; - let scrollToColumn = Math.min( - columnCount - 1, - parseInt(event.target.value, 10), - ); - - if (isNaN(scrollToColumn)) { - scrollToColumn = undefined; - } - - this.setState({scrollToColumn}); - } - - _onScrollToRowChange(event) { - const {rowCount} = this.state; - let scrollToRow = Math.min(rowCount - 1, parseInt(event.target.value, 10)); - - if (isNaN(scrollToRow)) { - scrollToRow = undefined; - } - - this.setState({scrollToRow}); - } -} diff --git a/source/Grid/Grid.jest.js b/source/Grid/Grid.jest.js deleted file mode 100644 index e030b9f16..000000000 --- a/source/Grid/Grid.jest.js +++ /dev/null @@ -1,2760 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {Simulate} from 'react-dom/test-utils'; -import TestRenderer from 'react-test-renderer'; -import {render} from '../TestUtils'; -import Grid, {DEFAULT_SCROLLING_RESET_TIME_INTERVAL} from './Grid'; -import defaultCellRangeRenderer from './defaultCellRangeRenderer'; -import {CellMeasurer, CellMeasurerCache} from '../CellMeasurer'; -import { - SCROLL_DIRECTION_BACKWARD, - SCROLL_DIRECTION_FORWARD, -} from './defaultOverscanIndicesGetter'; -import {getMaxElementSize} from './utils/maxElementSize.js'; - -const DEFAULT_COLUMN_WIDTH = 50; -const DEFAULT_HEIGHT = 100; -const DEFAULT_ROW_HEIGHT = 20; -const DEFAULT_WIDTH = 200; -const NUM_ROWS = 100; -const NUM_COLUMNS = 50; - -function getScrollbarSize0() { - return 0; -} - -function getScrollbarSize20() { - return 20; -} - -describe('Grid', () => { - function defaultCellRenderer({columnIndex, key, rowIndex, style}) { - return ( -
- {`row:${rowIndex}, column:${columnIndex}`} -
- ); - } - - function simulateScroll({grid, scrollLeft = 0, scrollTop = 0}) { - const target = {scrollLeft, scrollTop}; - grid._scrollingContainer = target; // HACK to work around _onScroll target check - Simulate.scroll(findDOMNode(grid), {target}); - } - - function getMarkup(props = {}) { - return ( - - ); - } - - describe('number of rendered children', () => { - it('should render enough children to fill the available area', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.querySelectorAll('.gridItem').length).toEqual(20); // 5 rows x 4 columns - }); - - it('should not render more rows than available if the area is not filled', () => { - const rendered = findDOMNode(render(getMarkup({rowCount: 2}))); - expect(rendered.querySelectorAll('.gridItem').length).toEqual(8); // 2 rows x 4 columns - }); - - it('should not render more columns than available if the area is not filled', () => { - const rendered = findDOMNode(render(getMarkup({columnCount: 2}))); - expect(rendered.querySelectorAll('.gridItem').length).toEqual(10); // 5 rows x 2 columns - }); - - // Small performance tweak added in 5.5.6 - it('should not render/parent cells that are null or false', () => { - function cellRenderer({columnIndex, key, rowIndex, style}) { - if (columnIndex === 0) { - return null; - } else if (rowIndex === 0) { - return false; - } else { - return ( -
- {`row:${rowIndex}, column:${columnIndex}`} -
- ); - } - } - const rendered = findDOMNode( - render( - getMarkup({ - columnCount: 3, - overscanColumnCount: 0, - overscanRowCount: 0, - rowCount: 3, - cellRenderer, - }), - ), - ); - expect(rendered.querySelectorAll('.cell').length).toEqual(4); // [1,1], [1,2], [2,1], and [2,2] - expect(rendered.textContent).not.toContain('column:0'); - expect(rendered.textContent).not.toContain('row:0'); - }); - - it('should scroll to the last existing point when rows are removed', () => { - const grid = render( - getMarkup({ - rowCount: 15, - }), - ); - - simulateScroll({ - grid, - scrollTop: 200, - }); - - const updatedGrid = render( - getMarkup({ - rowCount: 10, - }), - ); - - expect(updatedGrid.state.scrollTop).toEqual(100); - }); - - it('should scroll to the last existing point when columns are removed', () => { - const grid = render( - getMarkup({ - columnCount: 12, - }), - ); - - simulateScroll({ - grid, - scrollLeft: 400, - }); - - const updatedGrid = render( - getMarkup({ - columnCount: 8, - }), - ); - - expect(updatedGrid.state.scrollLeft).toEqual(200); - }); - - it('should not scroll unseen rows are removed', () => { - render( - getMarkup({ - rowCount: 15, - }), - ); - const updatedGrid = render( - getMarkup({ - rowCount: 10, - }), - ); - - expect(updatedGrid.state.scrollTop).toEqual(0); - }); - - it('should not scroll when unseen columns are removed', () => { - render( - getMarkup({ - columnCount: 12, - }), - ); - const updatedGrid = render( - getMarkup({ - columnCount: 8, - }), - ); - - expect(updatedGrid.state.scrollLeft).toEqual(0); - }); - }); - - describe('shows and hides scrollbars based on rendered content', () => { - it('should set overflowX:hidden if columns fit within the available width and y-axis has no scrollbar', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnCount: 4, - getScrollbarSize: getScrollbarSize20, - rowCount: 5, - }), - ), - ); - expect(rendered.style.overflowX).toEqual('hidden'); - }); - - it('should set overflowX:hidden if columns and y-axis scrollbar fit within the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnCount: 4, - getScrollbarSize: getScrollbarSize20, - width: 200 + getScrollbarSize20(), - }), - ), - ); - expect(rendered.style.overflowX).toEqual('hidden'); - }); - - it('should leave overflowX:auto if columns require more than the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnCount: 4, - getScrollbarSize: getScrollbarSize20, - width: 200 - 1, - rowCount: 5, - }), - ), - ); - expect(rendered.style.overflowX).not.toEqual('hidden'); - }); - - it('should leave overflowX:auto if columns and y-axis scrollbar require more than the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnCount: 4, - getScrollbarSize: getScrollbarSize20, - width: 200 + getScrollbarSize20() - 1, - }), - ), - ); - expect(rendered.style.overflowX).not.toEqual('hidden'); - }); - - it('should set overflowY:hidden if rows fit within the available width and xaxis has no scrollbar', () => { - const rendered = findDOMNode( - render( - getMarkup({ - getScrollbarSize: getScrollbarSize20, - rowCount: 5, - columnCount: 4, - }), - ), - ); - expect(rendered.style.overflowY).toEqual('hidden'); - }); - - it('should set overflowY:hidden if rows and x-axis scrollbar fit within the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - getScrollbarSize: getScrollbarSize20, - rowCount: 5, - height: 100 + getScrollbarSize20(), - }), - ), - ); - expect(rendered.style.overflowY).toEqual('hidden'); - }); - - it('should leave overflowY:auto if rows require more than the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - getScrollbarSize: getScrollbarSize20, - rowCount: 5, - height: 100 - 1, - columnCount: 4, - }), - ), - ); - expect(rendered.style.overflowY).not.toEqual('hidden'); - }); - - it('should leave overflowY:auto if rows and x-axis scrollbar require more than the available width', () => { - const rendered = findDOMNode( - render( - getMarkup({ - getScrollbarSize: getScrollbarSize20, - rowCount: 5, - height: 100 + getScrollbarSize20() - 1, - }), - ), - ); - expect(rendered.style.overflowY).not.toEqual('hidden'); - }); - - it('should accept styles that overwrite calculated ones', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnCount: 1, - getScrollbarSize: getScrollbarSize20, - height: 1, - rowCount: 1, - style: { - overflowY: 'visible', - overflowX: 'visible', - }, - width: 1, - }), - ), - ); - expect(rendered.style.overflowY).toEqual('visible'); - expect(rendered.style.overflowX).toEqual('visible'); - }); - }); - - /** Tests scrolling via initial props */ - describe(':scrollToColumn and :scrollToRow', () => { - it('should scroll to the left', () => { - const grid = render(getMarkup({scrollToColumn: 0})); - expect(grid.state.scrollLeft).toEqual(0); - }); - - it('should scroll over to the middle', () => { - const grid = render(getMarkup({scrollToColumn: 24})); - // 50 columns * 50 item width = 2,500 total item width - // 4 columns can be visible at a time and :scrollLeft is initially 0, - // So the minimum amount of scrolling leaves the 25th item at the right (just scrolled into view). - expect(grid.state.scrollLeft).toEqual(1050); - }); - - it('should scroll to the far right', () => { - const grid = render(getMarkup({scrollToColumn: 49})); - // 50 columns * 50 item width = 2,500 total item width - // Target offset for the last item then is 2,500 - 200 - expect(grid.state.scrollLeft).toEqual(2300); - }); - - it('should scroll to the top', () => { - const grid = render(getMarkup({scrollToRow: 0})); - expect(grid.state.scrollTop).toEqual(0); - }); - - it('should scroll down to the middle', () => { - const grid = render(getMarkup({scrollToRow: 49})); - // 100 rows * 20 item height = 2,000 total item height - // 5 rows can be visible at a time and :scrollTop is initially 0, - // So the minimum amount of scrolling leaves the 50th item at the bottom (just scrolled into view). - expect(grid.state.scrollTop).toEqual(900); - }); - - it('should scroll to the bottom', () => { - const grid = render(getMarkup({scrollToRow: 99})); - // 100 rows * 20 item height = 2,000 total item height - // Target offset for the last item then is 2,000 - 100 - expect(grid.state.scrollTop).toEqual(1900); - }); - - it('should scroll to a row and column just added', () => { - let grid = render(getMarkup()); - expect(grid.state.scrollLeft).toEqual(0); - expect(grid.state.scrollTop).toEqual(0); - grid = render( - getMarkup({ - columnCount: NUM_COLUMNS + 1, - rowCount: NUM_ROWS + 1, - scrollToColumn: NUM_COLUMNS, - scrollToRow: NUM_ROWS, - }), - ); - expect(grid.state.scrollLeft).toEqual(2350); - expect(grid.state.scrollTop).toEqual(1920); - }); - - it('should scroll back to a newly-added cell without a change in prop', () => { - let grid = render( - getMarkup({ - columnCount: NUM_COLUMNS, - rowCount: NUM_ROWS, - scrollToColumn: NUM_COLUMNS, - scrollToRow: NUM_ROWS, - }), - ); - grid = render( - getMarkup({ - columnCount: NUM_COLUMNS + 1, - rowCount: NUM_ROWS + 1, - scrollToColumn: NUM_COLUMNS, - scrollToRow: NUM_ROWS, - }), - ); - expect(grid.state.scrollLeft).toEqual(2350); - expect(grid.state.scrollTop).toEqual(1920); - }); - - it('should scroll to the correct position for :scrollToAlignment "start"', () => { - const grid = render( - getMarkup({ - scrollToAlignment: 'start', - scrollToColumn: 24, - scrollToRow: 49, - }), - ); - // 50 columns * 50 item width = 2,500 total item width - // 100 rows * 20 item height = 2,000 total item height - // 4 columns and 5 rows can be visible at a time. - // The minimum amount of scrolling leaves the specified cell in the bottom/right corner (just scrolled into view). - // Since alignment is set to "start" we should scroll past this point until the cell is aligned top/left. - expect(grid.state.scrollLeft).toEqual(1200); - expect(grid.state.scrollTop).toEqual(980); - }); - - it('should scroll to the correct position for :scrollToAlignment "end"', () => { - render( - getMarkup({ - scrollToColumn: 99, - scrollToRow: 99, - }), - ); - const grid = render( - getMarkup({ - scrollToAlignment: 'end', - scrollToColumn: 24, - scrollToRow: 49, - }), - ); - // 50 columns * 50 item width = 2,500 total item width - // 100 rows * 20 item height = 2,000 total item height - // We first scroll past the specified cell and then back. - // The minimum amount of scrolling then should leave the specified cell in the top/left corner (just scrolled into view). - // Since alignment is set to "end" we should scroll past this point until the cell is aligned bottom/right. - expect(grid.state.scrollLeft).toEqual(1050); - expect(grid.state.scrollTop).toEqual(900); - }); - - it('should scroll to the correct position for :scrollToAlignment "center"', () => { - render( - getMarkup({ - scrollToColumn: 99, - scrollToRow: 99, - }), - ); - const grid = render( - getMarkup({ - scrollToAlignment: 'center', - scrollToColumn: 24, - scrollToRow: 49, - }), - ); - // 50 columns * 50 item width = 2,500 total item width - // Viewport width is 200 - // Column 24 starts at 1,200, center point at 1,225, so... - expect(grid.state.scrollLeft).toEqual(1125); - // 100 rows * 20 item height = 2,000 total item height - // Viewport height is 100 - // Row 49 starts at 980, center point at 990, so... - expect(grid.state.scrollTop).toEqual(940); - }); - - // Tests issue #691 - it('should set the correct :scrollLeft after height increases from 0', () => { - render.unmount(); - expect( - findDOMNode( - render( - getMarkup({ - height: 0, - scrollToColumn: 24, - }), - ), - ).scrollLeft || 0, - ).toEqual(0); - expect( - findDOMNode( - render( - getMarkup({ - height: 100, - scrollToColumn: 24, - }), - ), - ).scrollLeft, - ).toEqual(1050); - }); - - // Tests issue #691 - it('should set the correct :scrollTop after width increases from 0', () => { - render.unmount(); - expect( - findDOMNode( - render( - getMarkup({ - scrollToRow: 49, - width: 0, - }), - ), - ).scrollTop || 0, - ).toEqual(0); - expect( - findDOMNode( - render( - getMarkup({ - scrollToRow: 49, - width: 100, - }), - ), - ).scrollTop, - ).toEqual(900); - }); - - // Tests issue #218 - it('should set the correct :scrollTop after row and column counts increase from 0', () => { - const expectedScrollTop = - 100 * DEFAULT_ROW_HEIGHT - DEFAULT_HEIGHT + DEFAULT_ROW_HEIGHT; - render( - getMarkup({ - columnCount: 0, - rowCount: 150, - scrollToRow: 100, - }), - ); - expect( - findDOMNode( - render( - getMarkup({ - columnCount: 150, - rowCount: 150, - scrollToRow: 100, - }), - ), - ).scrollTop, - ).toEqual(expectedScrollTop); - }); - - it('should support scrollToCell() public method', () => { - const grid = render(getMarkup()); - expect(grid.state.scrollLeft).toEqual(0); - expect(grid.state.scrollTop).toEqual(0); - grid.scrollToCell({ - columnIndex: 24, - rowIndex: 49, - }); - // 50 columns * 50 item width = 2,500 total item width - // 4 columns can be visible at a time and :scrollLeft is initially 0, - // So the minimum amount of scrolling leaves the 25th item at the right (just scrolled into view). - expect(grid.state.scrollLeft).toEqual(1050); - // 100 rows * 20 item height = 2,000 total item height - // 5 rows can be visible at a time and :scrollTop is initially 0, - // So the minimum amount of scrolling leaves the 50th item at the bottom (just scrolled into view). - expect(grid.state.scrollTop).toEqual(900); - - // Change column without affecting row - grid.scrollToCell({ - columnIndex: 49, - }); - expect(grid.state.scrollLeft).toEqual(2300); - expect(grid.state.scrollTop).toEqual(900); - - // Change row without affecting column - grid.scrollToCell({ - rowIndex: 99, - }); - expect(grid.state.scrollLeft).toEqual(2300); - expect(grid.state.scrollTop).toEqual(1900); - }); - - it('should support scrollToPosition() public method', () => { - const grid = render(getMarkup()); - expect(grid.state.scrollLeft).toEqual(0); - expect(grid.state.scrollTop).toEqual(0); - - grid.scrollToPosition({ - scrollLeft: 50, - scrollTop: 100, - }); - expect(grid.state.scrollLeft).toEqual(50); - expect(grid.state.scrollTop).toEqual(100); - - // Change column without affecting row - grid.scrollToPosition({ - scrollLeft: 25, - }); - expect(grid.state.scrollLeft).toEqual(25); - expect(grid.state.scrollTop).toEqual(100); - - // Change row without affecting column - grid.scrollToPosition({ - scrollTop: 50, - }); - expect(grid.state.scrollLeft).toEqual(25); - expect(grid.state.scrollTop).toEqual(50); - }); - - it('should support handleScrollEvent() public method', () => { - const grid = render(getMarkup()); - expect(grid.state.scrollLeft).toEqual(0); - expect(grid.state.scrollTop).toEqual(0); - - grid.handleScrollEvent({ - scrollLeft: 50, - scrollTop: 100, - }); - expect(grid.state.isScrolling).toEqual(true); - expect(grid.state.scrollLeft).toEqual(50); - expect(grid.state.scrollTop).toEqual(100); - }); - - it('should support getOffsetForCell() public method', () => { - const grid = render(getMarkup()); - const {scrollLeft, scrollTop} = grid.getOffsetForCell({ - columnIndex: 24, - rowIndex: 49, - }); - // 50 columns * 50 item width = 2,500 total item width - // 4 columns can be visible at a time and :scrollLeft is initially 0, - // So the minimum amount of scrolling leaves the 25th item at the right (just scrolled into view). - expect(scrollLeft).toEqual(1050); - // 100 rows * 20 item height = 2,000 total item height - // 5 rows can be visible at a time and :scrollTop is initially 0, - // So the minimum amount of scrolling leaves the 50th item at the bottom (just scrolled into view). - expect(scrollTop).toEqual(900); - }); - - it('should support getTotalRowsHeight() public method', () => { - const grid = render(getMarkup()); - grid.recomputeGridSize(); - const totalHeight = grid.getTotalRowsHeight(); - // 100 rows * 20 item height = 2,000 total item height - expect(totalHeight).toEqual(2000); - }); - - it('should support getTotalColumnsWidth() public method', () => { - const grid = render(getMarkup()); - grid.recomputeGridSize(); - const totalWidth = grid.getTotalColumnsWidth(); - // 50 columns * 50 item width = 2,500 total item width - expect(totalWidth).toEqual(2500); - }); - - // See issue #565 - it('should update scroll position to account for changed cell sizes within a function prop wrapper', () => { - let rowHeight = 20; - - const props = { - height: 100, - rowCount: 100, - rowHeight: ({index}) => (index === 99 ? rowHeight : 20), - scrollToRow: 99, - }; - - const grid = render(getMarkup(props)); - const node = findDOMNode(grid); - - expect(node.scrollTop).toBe(1900); - - rowHeight = 40; - grid.recomputeGridSize({ - rowIndex: 99, - }); - - expect(node.scrollTop).toBe(1920); - }); - - it('should not restore scrollLeft when scrolling left and recomputeGridSize with columnIndex smaller than scrollToColumn', () => { - const props = { - columnWidth: 50, - columnCount: 100, - height: 100, - rowCount: 100, - rowHeight: 20, - scrollToColumn: 50, - scrollToRow: 50, - width: 100, - }; - const grid = render(getMarkup(props)); - - expect(grid.state.scrollLeft).toEqual(2450); - - simulateScroll({grid, scrollLeft: 2250}); - expect(grid.state.scrollLeft).toEqual(2250); - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - - grid.recomputeGridSize({columnIndex: 30}); - expect(grid.state.scrollLeft).toEqual(2250); - }); - - it('should not restore scrollTop when scrolling up and recomputeGridSize with rowIndex smaller than scrollToRow', () => { - const props = { - columnWidth: 50, - columnCount: 100, - height: 100, - rowCount: 100, - rowHeight: 20, - scrollToColumn: 50, - scrollToRow: 50, - width: 100, - }; - const grid = render(getMarkup(props)); - - expect(grid.state.scrollTop).toEqual(920); - - simulateScroll({grid, scrollTop: 720}); - expect(grid.state.scrollTop).toEqual(720); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - - grid.recomputeGridSize({rowIndex: 20}); - expect(grid.state.scrollTop).toEqual(720); - }); - - it('should restore scroll offset for column when row count increases from 0 (and vice versa)', () => { - const props = { - columnWidth: 50, - columnCount: 100, - height: 100, - rowCount: 100, - rowHeight: 20, - scrollToColumn: 50, - scrollToRow: 50, - width: 100, - }; - const grid = render(getMarkup(props)); - expect(grid.state.scrollLeft).toEqual(2450); - expect(grid.state.scrollTop).toEqual(920); - render( - getMarkup({ - ...props, - columnCount: 0, - }), - ); - expect(grid.state.scrollLeft).toEqual(0); - expect(grid.state.scrollTop).toEqual(0); - render(getMarkup(props)); - expect(grid.state.scrollLeft).toEqual(2450); - expect(grid.state.scrollTop).toEqual(920); - render( - getMarkup({ - ...props, - rowCount: 0, - }), - ); - expect(grid.state.scrollLeft).toEqual(0); - expect(grid.state.scrollTop).toEqual(0); - render(getMarkup(props)); - expect(grid.state.scrollLeft).toEqual(2450); - expect(grid.state.scrollTop).toEqual(920); - }); - - it('should take scrollbar size into account when aligning cells', () => { - const grid = render( - getMarkup({ - columnWidth: 50, - columnCount: 100, - getScrollbarSize: getScrollbarSize20, - height: 100, - rowCount: 100, - rowHeight: 20, - scrollToColumn: 50, - scrollToRow: 50, - width: 100, - }), - ); - - expect(grid.state.scrollLeft).toEqual(2450 + getScrollbarSize20()); - expect(grid.state.scrollTop).toEqual(920 + getScrollbarSize20()); - }); - }); - - describe('property updates', () => { - it('should update :scrollToColumn position when :columnWidth changes', () => { - let grid = findDOMNode(render(getMarkup({scrollToColumn: 25}))); - expect(grid.textContent).toContain('column:25'); - // Making columns taller pushes name off/beyond the scrolled area - grid = findDOMNode( - render(getMarkup({scrollToColumn: 25, columnWidth: 20})), - ); - expect(grid.textContent).toContain('column:25'); - }); - - it('should update :scrollToRow position when :rowHeight changes', () => { - let grid = findDOMNode(render(getMarkup({scrollToRow: 50}))); - expect(grid.textContent).toContain('row:50'); - // Making rows taller pushes name off/beyond the scrolled area - grid = findDOMNode(render(getMarkup({scrollToRow: 50, rowHeight: 20}))); - expect(grid.textContent).toContain('row:50'); - }); - - it('should update :scrollToColumn position when :width changes', () => { - let grid = findDOMNode(render(getMarkup({scrollToColumn: 25}))); - expect(grid.textContent).toContain('column:25'); - // Making the grid narrower leaves only room for 1 item - grid = findDOMNode(render(getMarkup({scrollToColumn: 25, width: 50}))); - expect(grid.textContent).toContain('column:25'); - }); - - it('should update :scrollToRow position when :height changes', () => { - let grid = findDOMNode(render(getMarkup({scrollToRow: 50}))); - expect(grid.textContent).toContain('row:50'); - // Making the grid shorter leaves only room for 1 item - grid = findDOMNode(render(getMarkup({scrollToRow: 50, height: 20}))); - expect(grid.textContent).toContain('row:50'); - }); - - it('should update :scrollToColumn position when :scrollToColumn changes', () => { - let grid = findDOMNode(render(getMarkup())); - expect(grid.textContent).not.toContain('column:25'); - grid = findDOMNode(render(getMarkup({scrollToColumn: 25}))); - expect(grid.textContent).toContain('column:25'); - }); - - it('should update :scrollToRow position when :scrollToRow changes', () => { - let grid = findDOMNode(render(getMarkup())); - expect(grid.textContent).not.toContain('row:50'); - grid = findDOMNode(render(getMarkup({scrollToRow: 50}))); - expect(grid.textContent).toContain('row:50'); - }); - - it('should update scroll position if size shrinks smaller than the current scroll', () => { - let grid = findDOMNode(render(getMarkup({scrollToColumn: 250}))); - grid = findDOMNode(render(getMarkup())); - grid = findDOMNode( - render(getMarkup({scrollToColumn: 250, columnCount: 10})), - ); - expect(grid.textContent).toContain('column:9'); - }); - - it('should update scroll position if size shrinks smaller than the current scroll', () => { - let grid = findDOMNode(render(getMarkup({scrollToRow: 500}))); - grid = findDOMNode(render(getMarkup())); - grid = findDOMNode(render(getMarkup({scrollToRow: 500, rowCount: 10}))); - expect(grid.textContent).toContain('row:9'); - }); - }); - - describe('noContentRenderer', () => { - it('should call :noContentRenderer if :columnCount is 0', () => { - let list = findDOMNode( - render( - getMarkup({ - noContentRenderer: () =>
No data
, - columnCount: 0, - }), - ), - ); - expect(list.textContent).toEqual('No data'); - }); - - it('should call :noContentRenderer if :rowCount is 0', () => { - let list = findDOMNode( - render( - getMarkup({ - noContentRenderer: () =>
No data
, - rowCount: 0, - }), - ), - ); - expect(list.textContent).toEqual('No data'); - }); - - // Sanity check for bvaughn/react-virtualized/pull/348 - it('should render an empty body if :rowCount or :columnCount changes to 0', () => { - function noContentRenderer() { - return
No data
; - } - - let list = findDOMNode( - render( - getMarkup({ - noContentRenderer, - }), - ), - ); - expect(list.textContent).not.toEqual('No data'); - - list = findDOMNode( - render( - getMarkup({ - noContentRenderer, - rowCount: 0, - }), - ), - ); - expect(list.textContent).toEqual('No data'); - - list = findDOMNode( - render( - getMarkup({ - noContentRenderer, - }), - ), - ); - expect(list.textContent).not.toEqual('No data'); - - list = findDOMNode( - render( - getMarkup({ - columnCount: 0, - noContentRenderer, - }), - ), - ); - expect(list.textContent).toEqual('No data'); - }); - - it('should render an empty body if :columnCount is 0 and there is no :noContentRenderer', () => { - let list = findDOMNode( - render( - getMarkup({ - columnCount: 0, - }), - ), - ); - expect(list.textContent).toEqual(''); - }); - - it('should render an empty body if :rowCount is 0 and there is no :noContentRenderer', () => { - let list = findDOMNode( - render( - getMarkup({ - rowCount: 0, - }), - ), - ); - expect(list.textContent).toEqual(''); - }); - - it('should render an empty body there is a :noContentRenderer but :height or :width are 0', () => { - let list = findDOMNode( - render( - getMarkup({ - height: 0, - noContentRenderer: () =>
No data
, - }), - ), - ); - expect(list.textContent).toEqual(''); - list = findDOMNode( - render( - getMarkup({ - noContentRenderer: () =>
No data
, - width: 0, - }), - ), - ); - expect(list.textContent).toEqual(''); - }); - }); - - describe('onSectionRendered', () => { - it('should call :onSectionRendered if at least one cell is rendered', () => { - let columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex; - render( - getMarkup({ - onSectionRendered: params => - ({ - columnStartIndex, - columnStopIndex, - rowStartIndex, - rowStopIndex, - } = params), - }), - ); - expect(columnStartIndex).toEqual(0); - expect(columnStopIndex).toEqual(3); - expect(rowStartIndex).toEqual(0); - expect(rowStopIndex).toEqual(4); - }); - - it('should not call :onSectionRendered unless the column or row start or stop indices have changed', () => { - let numCalls = 0; - let columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex; - const onSectionRendered = params => { - columnStartIndex = params.columnStartIndex; - columnStopIndex = params.columnStopIndex; - rowStartIndex = params.rowStartIndex; - rowStopIndex = params.rowStopIndex; - numCalls++; - }; - render(getMarkup({onSectionRendered})); - expect(numCalls).toEqual(1); - expect(columnStartIndex).toEqual(0); - expect(columnStopIndex).toEqual(3); - expect(rowStartIndex).toEqual(0); - expect(rowStopIndex).toEqual(4); - render(getMarkup({onSectionRendered})); - expect(numCalls).toEqual(1); - expect(columnStartIndex).toEqual(0); - expect(columnStopIndex).toEqual(3); - expect(rowStartIndex).toEqual(0); - expect(rowStopIndex).toEqual(4); - }); - - it('should call :onSectionRendered if the row or column start or stop indices have changed', () => { - let numCalls = 0; - let columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex; - const onSectionRendered = params => { - columnStartIndex = params.columnStartIndex; - columnStopIndex = params.columnStopIndex; - rowStartIndex = params.rowStartIndex; - rowStopIndex = params.rowStopIndex; - numCalls++; - }; - render(getMarkup({onSectionRendered})); - expect(columnStartIndex).toEqual(0); - expect(columnStopIndex).toEqual(3); - expect(rowStartIndex).toEqual(0); - expect(rowStopIndex).toEqual(4); - render( - getMarkup({ - height: 50, - onSectionRendered, - }), - ); - expect(numCalls).toEqual(2); - expect(columnStartIndex).toEqual(0); - expect(columnStopIndex).toEqual(3); - expect(rowStartIndex).toEqual(0); - expect(rowStopIndex).toEqual(2); - render( - getMarkup({ - height: 50, - onSectionRendered, - width: 100, - }), - ); - expect(numCalls).toEqual(3); - expect(columnStartIndex).toEqual(0); - expect(columnStopIndex).toEqual(1); - expect(rowStartIndex).toEqual(0); - expect(rowStopIndex).toEqual(2); - }); - - it('should not call :onSectionRendered if no cells are rendered', () => { - let numCalls = 0; - render( - getMarkup({ - height: 0, - onSectionRendered: () => numCalls++, - }), - ); - expect(numCalls).toEqual(0); - }); - }); - - describe(':scrollLeft and :scrollTop properties', () => { - it('should render correctly when an initial :scrollLeft and :scrollTop properties are specified', () => { - let columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex; - findDOMNode( - render( - getMarkup({ - onSectionRendered: params => - ({ - columnStartIndex, - columnStopIndex, - rowStartIndex, - rowStopIndex, - } = params), - scrollLeft: 250, - scrollTop: 100, - }), - ), - ); - expect(rowStartIndex).toEqual(5); - expect(rowStopIndex).toEqual(9); - expect(columnStartIndex).toEqual(5); - expect(columnStopIndex).toEqual(8); - }); - - it('should render correctly when :scrollLeft and :scrollTop properties are updated', () => { - let columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex; - - render( - getMarkup({ - onSectionRendered: params => - ({ - columnStartIndex, - columnStopIndex, - rowStartIndex, - rowStopIndex, - } = params), - }), - ); - expect(rowStartIndex).toEqual(0); - expect(rowStopIndex).toEqual(4); - expect(columnStartIndex).toEqual(0); - expect(columnStopIndex).toEqual(3); - - render( - getMarkup({ - onSectionRendered: params => - ({ - columnStartIndex, - columnStopIndex, - rowStartIndex, - rowStopIndex, - } = params), - scrollLeft: 250, - scrollTop: 100, - }), - ); - expect(rowStartIndex).toEqual(5); - expect(rowStopIndex).toEqual(9); - expect(columnStartIndex).toEqual(5); - expect(columnStopIndex).toEqual(8); - }); - }); - - describe('styles, classNames, ids, and roles', () => { - it('should use the expected global CSS classNames', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.className).toEqual('ReactVirtualized__Grid'); - }); - - it('should use a custom :className if specified', () => { - const rendered = findDOMNode(render(getMarkup({className: 'foo'}))); - expect(rendered.className).toContain('foo'); - }); - - it('should use a custom :id if specified', () => { - const rendered = findDOMNode(render(getMarkup({id: 'bar'}))); - expect(rendered.getAttribute('id')).toEqual('bar'); - }); - - it('should use a custom :style if specified', () => { - const style = {backgroundColor: 'red'}; - const rendered = findDOMNode(render(getMarkup({style}))); - expect(rendered.style.backgroundColor).toEqual('red'); - }); - - it('should use a custom :containerStyle if specified', () => { - const containerStyle = {backgroundColor: 'red'}; - const rendered = findDOMNode(render(getMarkup({containerStyle}))); - expect( - rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer') - .style.backgroundColor, - ).toEqual('red'); - }); - - it('should have the gridcell role', () => { - const containerStyle = {backgroundColor: 'red'}; - const rendered = findDOMNode(render(getMarkup({containerStyle}))); - expect(rendered.querySelectorAll('[role="gridcell"]').length).toEqual(20); - }); - }); - - describe('onScroll', () => { - it('should trigger callback when component is mounted', () => { - const onScrollCalls = []; - render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - scrollLeft: 50, - scrollTop: 100, - }), - ); - expect(onScrollCalls).toEqual([ - { - clientHeight: 100, - clientWidth: 200, - scrollHeight: 2000, - scrollLeft: 50, - scrollTop: 100, - scrollWidth: 2500, - }, - ]); - }); - - it('should trigger callback when component scrolls horizontally', () => { - const onScrollCalls = []; - const grid = render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - simulateScroll({ - grid, - scrollLeft: 100, - scrollTop: 0, - }); - expect(onScrollCalls.length).toEqual(2); - expect(onScrollCalls[1]).toEqual({ - clientHeight: 100, - clientWidth: 200, - scrollHeight: 2000, - scrollLeft: 100, - scrollTop: 0, - scrollWidth: 2500, - }); - }); - - it('should trigger callback when component scrolls vertically', () => { - const onScrollCalls = []; - const grid = render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - simulateScroll({ - grid, - scrollLeft: 0, - scrollTop: 100, - }); - expect(onScrollCalls.length).toEqual(2); - expect(onScrollCalls[1]).toEqual({ - clientHeight: 100, - clientWidth: 200, - scrollHeight: 2000, - scrollLeft: 0, - scrollTop: 100, - scrollWidth: 2500, - }); - }); - - it('should trigger callback with scrollLeft of 0 when total columns width is less than width', () => { - const onScrollCalls = []; - const grid = render( - getMarkup({ - columnCount: 1, - columnWidth: 50, - onScroll: params => onScrollCalls.push(params), - scrollLeft: 0, - scrollTop: 10, - width: 200, - }), - ); - simulateScroll({ - grid, - scrollLeft: 0, - scrollTop: 0, - }); - expect(onScrollCalls.length).toEqual(2); - expect(onScrollCalls[1]).toEqual({ - clientHeight: 100, - clientWidth: 200, - scrollHeight: 2000, - scrollLeft: 0, - scrollTop: 0, - scrollWidth: 50, - }); - }); - - it('should trigger callback with scrollTop of 0 when total rows height is less than height', () => { - const onScrollCalls = []; - const grid = render( - getMarkup({ - rowCount: 1, - rowHeight: 50, - onScroll: params => onScrollCalls.push(params), - scrollLeft: 0, - scrollTop: 10, - height: 200, - }), - ); - simulateScroll({ - grid, - scrollLeft: 0, - scrollTop: 0, - }); - expect(onScrollCalls.length).toEqual(2); - expect(onScrollCalls[1]).toEqual({ - clientHeight: 200, - clientWidth: 200, - scrollHeight: 50, - scrollLeft: 0, - scrollTop: 0, - scrollWidth: 2500, - }); - }); - - // Support use-cases like WindowScroller; enable them to stay in sync with scroll-to-cell changes. - it('should trigger when :scrollToColumn or :scrollToRow are changed via props', () => { - const onScrollCalls = []; - render(getMarkup()); - render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - scrollToColumn: 24, - scrollToRow: 49, - }), - ); - expect(onScrollCalls).toEqual([ - { - clientHeight: 100, - clientWidth: 200, - scrollHeight: 2000, - scrollLeft: 1050, - scrollTop: 900, - scrollWidth: 2500, - }, - ]); - }); - }); - - describe('overscanColumnCount & overscanRowCount', () => { - function createHelper() { - let columnOverscanStartIndex, - columnOverscanStopIndex, - columnStartIndex, - columnStopIndex, - rowOverscanStartIndex, - rowOverscanStopIndex, - rowStartIndex, - rowStopIndex; - - function onSectionRendered(params) { - columnOverscanStartIndex = params.columnOverscanStartIndex; - columnOverscanStopIndex = params.columnOverscanStopIndex; - columnStartIndex = params.columnStartIndex; - columnStopIndex = params.columnStopIndex; - rowOverscanStartIndex = params.rowOverscanStartIndex; - rowOverscanStopIndex = params.rowOverscanStopIndex; - rowStartIndex = params.rowStartIndex; - rowStopIndex = params.rowStopIndex; - } - - return { - columnOverscanStartIndex: () => columnOverscanStartIndex, - columnOverscanStopIndex: () => columnOverscanStopIndex, - columnStartIndex: () => columnStartIndex, - columnStopIndex: () => columnStopIndex, - onSectionRendered, - rowOverscanStartIndex: () => rowOverscanStartIndex, - rowOverscanStopIndex: () => rowOverscanStopIndex, - rowStartIndex: () => rowStartIndex, - rowStopIndex: () => rowStopIndex, - }; - } - - it('should not overscan if disabled', () => { - const helper = createHelper(); - render( - getMarkup({ - onSectionRendered: helper.onSectionRendered, - }), - ); - expect(helper.columnOverscanStartIndex()).toEqual( - helper.columnStartIndex(), - ); - expect(helper.columnOverscanStopIndex()).toEqual( - helper.columnStopIndex(), - ); - expect(helper.rowOverscanStartIndex()).toEqual(helper.rowStartIndex()); - expect(helper.rowOverscanStopIndex()).toEqual(helper.rowStopIndex()); - }); - - it('should overscan the specified amount', () => { - const helper = createHelper(); - render( - getMarkup({ - onSectionRendered: helper.onSectionRendered, - overscanColumnCount: 2, - overscanRowCount: 5, - scrollToColumn: 25, - scrollToRow: 50, - }), - ); - expect(helper.columnOverscanStartIndex()).toEqual(22); - expect(helper.columnOverscanStopIndex()).toEqual(27); - expect(helper.columnStartIndex()).toEqual(22); - expect(helper.columnStopIndex()).toEqual(25); - expect(helper.rowOverscanStartIndex()).toEqual(46); - expect(helper.rowOverscanStopIndex()).toEqual(55); - expect(helper.rowStartIndex()).toEqual(46); - expect(helper.rowStopIndex()).toEqual(50); - }); - - it('should not overscan beyond the bounds of the grid', () => { - const helper = createHelper(); - render( - getMarkup({ - onSectionRendered: helper.onSectionRendered, - columnCount: 6, - overscanColumnCount: 10, - overscanRowCount: 10, - rowCount: 5, - }), - ); - expect(helper.columnOverscanStartIndex()).toEqual(0); - expect(helper.columnOverscanStopIndex()).toEqual(5); - expect(helper.columnStartIndex()).toEqual(0); - expect(helper.columnStopIndex()).toEqual(3); - expect(helper.rowOverscanStartIndex()).toEqual(0); - expect(helper.rowOverscanStopIndex()).toEqual(4); - expect(helper.rowStartIndex()).toEqual(0); - expect(helper.rowStopIndex()).toEqual(4); - }); - - it('should set the correct scroll direction', () => { - // Do not pass in the initial state as props, otherwise the internal state is forbidden from updating itself - const grid = render(getMarkup()); - - // Simulate a scroll to set the initial internal state - simulateScroll({ - grid, - scrollLeft: 50, - scrollTop: 50, - }); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - - simulateScroll({ - grid, - scrollLeft: 0, - scrollTop: 0, - }); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - - simulateScroll({ - grid, - scrollLeft: 100, - scrollTop: 100, - }); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - }); - - it('should set the correct scroll direction when scroll position is updated from props', () => { - let grid = render( - getMarkup({ - scrollLeft: 50, - scrollTop: 50, - }), - ); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - - grid = render( - getMarkup({ - scrollLeft: 0, - scrollTop: 0, - }), - ); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - - grid = render( - getMarkup({ - scrollLeft: 100, - scrollTop: 100, - }), - ); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - }); - - it('should not reset scroll direction for one axis when scrolled in another', () => { - // Do not pass in the initial state as props, otherwise the internal state is forbidden from updating itself - const grid = render(getMarkup()); - - // Simulate a scroll to set the initial internal state - simulateScroll({ - grid, - scrollLeft: 0, - scrollTop: 5, - }); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - - simulateScroll({ - grid, - scrollLeft: 5, - scrollTop: 5, - }); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - - simulateScroll({ - grid, - scrollLeft: 5, - scrollTop: 0, - }); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_FORWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - - simulateScroll({ - grid, - scrollLeft: 0, - scrollTop: 0, - }); - - expect(grid.state.scrollDirectionHorizontal).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - expect(grid.state.scrollDirectionVertical).toEqual( - SCROLL_DIRECTION_BACKWARD, - ); - }); - - it('should overscan in the direction being scrolled', async done => { - const helper = createHelper(); - - let onSectionRenderedResolve; - - function onSectionRendered(params) { - helper.onSectionRendered(params); - - if (onSectionRenderedResolve) { - onSectionRenderedResolve(); - } - } - - const grid = render( - getMarkup({ - onSectionRendered, - overscanColumnCount: 2, - overscanRowCount: 5, - }), - ); - - // Wait until the onSectionRendered handler / debouncer has processed - let onSectionRenderedPromise = new Promise(resolve => { - onSectionRenderedResolve = resolve; - }); - - simulateScroll({ - grid, - scrollLeft: 200, - scrollTop: 200, - }); - - await onSectionRenderedPromise; - - // It should overscan in the direction being scrolled while scroll is in progress - expect(helper.columnOverscanStartIndex()).toEqual(4); - expect(helper.columnOverscanStopIndex()).toEqual(9); - expect(helper.columnStartIndex()).toEqual(4); - expect(helper.columnStopIndex()).toEqual(7); - expect(helper.rowOverscanStartIndex()).toEqual(10); - expect(helper.rowOverscanStopIndex()).toEqual(19); - expect(helper.rowStartIndex()).toEqual(10); - expect(helper.rowStopIndex()).toEqual(14); - - // Wait until the onSectionRendered handler / debouncer has processed - onSectionRenderedPromise = new Promise(resolve => { - onSectionRenderedResolve = resolve; - }); - - simulateScroll({ - grid, - scrollLeft: 100, - scrollTop: 100, - }); - - await onSectionRenderedPromise; - - // It reset overscan once scrolling has finished - expect(helper.columnOverscanStartIndex()).toEqual(0); - expect(helper.columnOverscanStopIndex()).toEqual(5); - expect(helper.columnStartIndex()).toEqual(2); - expect(helper.columnStopIndex()).toEqual(5); - expect(helper.rowOverscanStartIndex()).toEqual(0); - expect(helper.rowOverscanStopIndex()).toEqual(9); - expect(helper.rowStartIndex()).toEqual(5); - expect(helper.rowStopIndex()).toEqual(9); - - done(); - }); - }); - - describe('cellRangeRenderer', () => { - it('should use a custom :cellRangeRenderer if specified', () => { - let cellRangeRendererCalled = 0; - let cellRangeRendererParams; - const rendered = findDOMNode( - render( - getMarkup({ - cellRangeRenderer: params => { - cellRangeRendererParams = params; - cellRangeRendererCalled++; - - return [
Fake content
]; - }, - }), - ), - ); - expect(cellRangeRendererCalled).toEqual(1); - expect(cellRangeRendererParams.columnStartIndex).toEqual(0); - expect(cellRangeRendererParams.columnStopIndex).toEqual(3); - expect(cellRangeRendererParams.rowStartIndex).toEqual(0); - expect(cellRangeRendererParams.rowStopIndex).toEqual(4); - expect(rendered.textContent).toContain('Fake content'); - }); - }); - - describe('estimated row and column sizes', () => { - it('should not estimate sizes if actual sizes are numbers', () => { - const grid = render( - getMarkup({ - columnWidth: 100, - estimatedColumnSize: 150, - estimatedRowSize: 15, - rowHeight: 20, - }), - ); - expect(Grid._getEstimatedColumnSize(grid.props)).toEqual(100); - expect(Grid._getEstimatedRowSize(grid.props)).toEqual(20); - }); - - it('should estimate row and column sizes if actual sizes are functions', () => { - const grid = render( - getMarkup({ - columnWidth: () => 100, - estimatedColumnSize: 150, - estimatedRowSize: 15, - rowHeight: () => 20, - }), - ); - expect(Grid._getEstimatedColumnSize(grid.props)).toEqual(150); - expect(Grid._getEstimatedRowSize(grid.props)).toEqual(15); - }); - }); - - it('should pass the cellRenderer an :isScrolling flag when scrolling is in progress', async done => { - const cellRendererCalls = []; - function cellRenderer({columnIndex, isScrolling, key, rowIndex, style}) { - cellRendererCalls.push(isScrolling); - return defaultCellRenderer({columnIndex, key, rowIndex, style}); - } - const grid = render( - getMarkup({ - cellRenderer, - }), - ); - expect(cellRendererCalls[0]).toEqual(false); - cellRendererCalls.splice(0); - - // Give React time to process the queued setState() - await new Promise(resolve => setTimeout(resolve, 1)); - - simulateScroll({grid, scrollTop: 100}); - expect(cellRendererCalls[0]).toEqual(true); - - done(); - }); - - it('should pass the cellRenderer an :isScrolling flag based on props override', () => { - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(({key, style}) => ( -
- )); - render( - getMarkup({ - cellRenderer, - isScrolling: true, - }), - ); - expect(cellRenderer).toHaveBeenCalled(); - expect(cellRenderer.mock.calls[0][0].isScrolling).toBe(true); - cellRenderer.mockReset(); - render( - getMarkup({ - cellRenderer, - isScrolling: false, - width: DEFAULT_WIDTH + 1, - }), - ); - expect(cellRenderer).toHaveBeenCalled(); - expect(cellRenderer.mock.calls[0][0].isScrolling).toBe(false); - }); - - it('should pass the cellRenderer an :isVisible flag', () => { - const cellRendererCalls = []; - function cellRenderer(props) { - cellRendererCalls.push(props); - return defaultCellRenderer(props); - } - render( - getMarkup({ - cellRenderer, - height: DEFAULT_ROW_HEIGHT, - overscanColumnCount: 1, - overscanRowCount: 1, - width: DEFAULT_COLUMN_WIDTH, - }), - ); - cellRendererCalls.forEach(props => { - expect(props.isVisible).toEqual( - props.columnIndex === 0 && props.rowIndex === 0, - ); // Only the first cell is visible - }); - }); - - describe('cell caching', () => { - it('should not cache cells if the Grid is not scrolling', () => { - const cellRendererCalls = []; - function cellRenderer({columnIndex, key, rowIndex, style}) { - cellRendererCalls.push({columnIndex, rowIndex}); - return defaultCellRenderer({columnIndex, key, rowIndex, style}); - } - const props = { - cellRenderer, - columnWidth: 100, - height: 40, - rowHeight: 20, - scrollToRow: 0, - width: 100, - }; - - render( - getMarkup({ - ...props, - scrollToRow: 0, - }), - ); - expect(cellRendererCalls).toEqual([ - {columnIndex: 0, rowIndex: 0}, - {columnIndex: 0, rowIndex: 1}, - ]); - - cellRendererCalls.splice(0); - - render( - getMarkup({ - ...props, - scrollToRow: 1, - }), - ); - expect(cellRendererCalls).toEqual([ - {columnIndex: 0, rowIndex: 0}, - {columnIndex: 0, rowIndex: 1}, - ]); - }); - - it('should not cache cells if the offsets are not adjusted', () => { - const cellRendererCalls = []; - function cellRenderer({columnIndex, key, rowIndex, style}) { - cellRendererCalls.push({columnIndex, rowIndex}); - return defaultCellRenderer({columnIndex, key, rowIndex, style}); - } - const props = { - cellRenderer, - columnWidth: 100, - height: 40, - rowHeight: 20, - rowCount: 100000, - scrollToRow: 0, - width: 100, - }; - - render( - getMarkup({ - ...props, - scrollToRow: 0, - }), - ); - expect(cellRendererCalls).toEqual([ - {columnIndex: 0, rowIndex: 0}, - {columnIndex: 0, rowIndex: 1}, - ]); - - cellRendererCalls.splice(0); - - render( - getMarkup({ - ...props, - scrollToRow: 1, - }), - ); - expect(cellRendererCalls).toEqual([ - {columnIndex: 0, rowIndex: 0}, - {columnIndex: 0, rowIndex: 1}, - ]); - }); - - it('should cache a cell once it has been rendered while scrolling', () => { - const cellRendererCalls = []; - function cellRenderer({columnIndex, key, rowIndex, style}) { - cellRendererCalls.push({columnIndex, rowIndex}); - return defaultCellRenderer({columnIndex, key, rowIndex, style}); - } - const props = { - cellRenderer, - columnWidth: 100, - height: 40, - rowHeight: 20, - width: 100, - }; - - const grid = render( - getMarkup({ - ...props, - scrollToRow: 0, - }), - ); - expect(cellRendererCalls).toEqual([ - {columnIndex: 0, rowIndex: 0}, - {columnIndex: 0, rowIndex: 1}, - ]); - - simulateScroll({grid, scrollTop: 1}); - - cellRendererCalls.splice(0); - - // Rows 0-2 have already rendered but row 3 is not yet visible - // This means that only row 3 should be newly-created - // The others should come from the cache - render( - getMarkup({ - ...props, - scrollToRow: 3, - }), - ); - expect(cellRendererCalls).toEqual([{columnIndex: 0, rowIndex: 3}]); - }); - - it('should clear cache once :isScrolling is false', async done => { - const cellRendererCalls = []; - function cellRenderer({columnIndex, key, rowIndex, style}) { - cellRendererCalls.push({columnIndex, rowIndex}); - return defaultCellRenderer({columnIndex, key, rowIndex, style}); - } - const props = { - cellRenderer, - columnWidth: 100, - height: 40, - rowHeight: 20, - scrollToRow: 0, - width: 100, - }; - - const grid = render(getMarkup(props)); - expect(cellRendererCalls).toEqual([ - {columnIndex: 0, rowIndex: 0}, - {columnIndex: 0, rowIndex: 1}, - ]); - - simulateScroll({grid, scrollTop: 1}); - - // Allow scrolling timeout to complete so that cell cache is reset - await new Promise(resolve => - setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2), - ); - - cellRendererCalls.splice(0); - - render( - getMarkup({ - ...props, - scrollToRow: 1, - }), - ); - expect(cellRendererCalls.length).not.toEqual(0); - - done(); - }); - - it('should clear cache once :isScrolling via props is false', async () => { - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(params => ( -
- )); - - const props = { - autoHeight: true, - cellRenderer, - columnCount: 1, - isScrolling: true, - rowCount: 1, - }; - - render(getMarkup(props)); - render(getMarkup(props)); - expect(cellRenderer).toHaveBeenCalledTimes(1); // Due to cell cache - - const scrollingStyle = cellRenderer.mock.calls[0][0].style; - - cellRenderer.mockReset(); - - render( - getMarkup({ - ...props, - isScrolling: false, - }), - ); - - expect(cellRenderer.mock.calls[0][0].style).toBe(scrollingStyle); - expect(cellRenderer).toHaveBeenCalledTimes(1); // Reset cache - - cellRenderer.mockReset(); - - render( - getMarkup({ - ...props, - isScrolling: true, - }), - ); - - expect(cellRenderer.mock.calls[0][0].style).not.toBe(scrollingStyle); - expect(cellRenderer).toHaveBeenCalledTimes(1); // Only cached when scrolling - }); - - it('should clear cache if :recomputeGridSize is called', () => { - const cellRendererCalls = []; - function cellRenderer({columnIndex, key, rowIndex, style}) { - cellRendererCalls.push({columnIndex, rowIndex}); - return defaultCellRenderer({columnIndex, key, rowIndex, style}); - } - const props = { - cellRenderer, - columnWidth: 100, - height: 40, - rowHeight: 20, - scrollTop: 0, - width: 100, - }; - - const grid = render(getMarkup(props)); - expect(cellRendererCalls).toEqual([ - {columnIndex: 0, rowIndex: 0}, - {columnIndex: 0, rowIndex: 1}, - ]); - - simulateScroll({grid, scrollTop: 1}); - - cellRendererCalls.splice(0); - - grid.recomputeGridSize(); - - expect(cellRendererCalls.length).not.toEqual(0); - }); - - it('should not clear cache if :isScrollingOptOut is true', () => { - const cellRendererCalls = []; - function cellRenderer({columnIndex, key, rowIndex, style}) { - cellRendererCalls.push({columnIndex, rowIndex}); - return defaultCellRenderer({columnIndex, key, rowIndex, style}); - } - const props = { - cellRenderer, - columnWidth: 100, - height: 40, - rowHeight: 20, - scrollTop: 0, - width: 100, - isScrollingOptOut: true, - }; - - render(getMarkup(props)); - render(getMarkup(props)); - expect(cellRendererCalls).toEqual([ - {columnIndex: 0, rowIndex: 0}, - {columnIndex: 0, rowIndex: 1}, - ]); - - cellRendererCalls.splice(0); - - render( - getMarkup({ - ...props, - isScrolling: false, - }), - ); - - // Visible cells are cached - expect(cellRendererCalls.length).toEqual(0); - - render( - getMarkup({ - ...props, - isScrolling: true, - }), - ); - - // Only cleared non-visible cells - expect(cellRendererCalls.length).toEqual(0); - }); - - it('should not trigger render by _debounceScrollEndedCallback if process slow table', async () => { - const scrollingResetTimeInterval = 50; - let cellRangeRendererCalls = 0; - function cellRangeRenderer(props) { - const startTime = Date.now(); - while (Date.now() - startTime <= scrollingResetTimeInterval); // imitate very slow render - cellRangeRendererCalls++; - return defaultCellRangeRenderer(props); - } - const props = { - scrollingResetTimeInterval, - cellRangeRenderer, - }; - - const grid = render(getMarkup(props)); - render(getMarkup(props)); - expect(cellRangeRendererCalls).toEqual(1); - - for (let i = 1; i <= 5; i++) { - cellRangeRendererCalls = 0; - simulateScroll({grid, scrollTop: i}); - // small wait for maybe early _debounceScrollEndedCallback - await new Promise(resolve => - setTimeout(resolve, scrollingResetTimeInterval / 2), - ); - expect(cellRangeRendererCalls).toEqual(1); - } - - cellRangeRendererCalls = 0; - // wait for real _debounceScrollEndedCallback - await new Promise(resolve => - setTimeout(resolve, scrollingResetTimeInterval * 1.5), - ); - expect(cellRangeRendererCalls).toEqual(1); - }); - - it('should support a custom :scrollingResetTimeInterval prop', async done => { - const cellRendererCalls = []; - const scrollingResetTimeInterval = - DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2; - function cellRenderer({columnIndex, key, rowIndex, style}) { - cellRendererCalls.push({columnIndex, rowIndex}); - return defaultCellRenderer({columnIndex, key, rowIndex, style}); - } - const props = { - cellRenderer, - scrollingResetTimeInterval, - }; - - const grid = render(getMarkup(props)); - expect(cellRendererCalls.length > 0).toEqual(true); - - simulateScroll({grid, scrollTop: 1}); - - await new Promise(resolve => - setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL), - ); - - cellRendererCalls.splice(0); - render( - getMarkup({ - ...props, - className: 'foo', - }), - ); - expect(cellRendererCalls.length).toEqual(0); - - await new Promise(resolve => - setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2), - ); - - cellRendererCalls.splice(0); - render( - getMarkup({ - ...props, - className: 'bar', - }), - ); - expect(cellRendererCalls.length).not.toEqual(0); - - done(); - }); - }); - - describe('measureAllCells', () => { - it('should measure any unmeasured columns and rows', () => { - const grid = render( - getMarkup({ - columnCount: 10, - columnWidth: () => 100, - estimatedColumnSize: 150, - estimatedRowSize: 15, - height: 0, - rowCount: 10, - rowHeight: () => 20, - width: 0, - }), - ); - expect( - grid.state.instanceProps.columnSizeAndPositionManager.getTotalSize(), - ).toEqual(1500); - expect( - grid.state.instanceProps.rowSizeAndPositionManager.getTotalSize(), - ).toEqual(150); - grid.measureAllCells(); - expect( - grid.state.instanceProps.columnSizeAndPositionManager.getTotalSize(), - ).toEqual(1000); - expect( - grid.state.instanceProps.rowSizeAndPositionManager.getTotalSize(), - ).toEqual(200); - }); - }); - - describe('recomputeGridSize', () => { - it('should recompute cell sizes and other values when called', () => { - const columnIndices = []; - const rowIndices = []; - function columnWidth({index}) { - columnIndices.push(index); - return 10; - } - function rowHeight({index}) { - rowIndices.push(index); - return 10; - } - const props = { - columnCount: 50, - columnWidth, - height: 50, - rowHeight, - rowCount: 50, - width: 100, - }; - const component = render(getMarkup(props)); - - columnIndices.splice(0); - rowIndices.splice(0); - - component.recomputeGridSize(); - - // Only the rows required to fill the current viewport will be rendered - expect(columnIndices[0]).toEqual(0); - expect(columnIndices[columnIndices.length - 1]).toEqual(9); - expect(rowIndices[0]).toEqual(0); - expect(rowIndices[rowIndices.length - 1]).toEqual(4); - - columnIndices.splice(0); - rowIndices.splice(0); - - component.recomputeGridSize({ - columnIndex: 4, - rowIndex: 2, - }); - - // Only the rows required to fill the current viewport will be rendered - expect(columnIndices[0]).toEqual(4); - expect(columnIndices[columnIndices.length - 1]).toEqual(9); - expect(rowIndices[0]).toEqual(2); - expect(rowIndices[rowIndices.length - 1]).toEqual(4); - }); - }); - - describe('autoContainerWidth', () => { - it('should set the innerScrollContainer width to auto to better support single-column HOCs', () => { - const props = { - autoContainerWidth: true, - }; - const rendered = findDOMNode(render(getMarkup(props))); - expect( - rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer') - .style.width, - ).toEqual('auto'); - }); - - it('should set the innerScrollContainer width to :totalColumnsWidth unless :autoContainerWidth', () => { - const props = { - autoContainerWidth: false, - }; - const rendered = findDOMNode(render(getMarkup(props))); - expect( - rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer') - .style.width, - ).toEqual('2500px'); // 50 columns x 50px - }); - }); - - describe('autoHeight', () => { - it('should set the container height to auto to adjust to innerScrollContainer height', () => { - const props = { - autoHeight: true, - }; - const rendered = findDOMNode(render(getMarkup(props))); - expect(rendered.style.height).toEqual('auto'); - }); - - it('should have container height still affecting number of rows rendered', () => { - const props = { - height: 500, - autoHeight: true, - }; - const rendered = findDOMNode(render(getMarkup(props))); - expect(rendered.querySelectorAll('.gridItem').length).toEqual(100); // 25 rows x 4 columns - }); - - it('should have innerScrollContainer height to be equal number of rows * rowHeight', () => { - const props = { - autoHeight: true, - }; - const grid = render(getMarkup(props)); - const rendered = findDOMNode(grid); - expect( - rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer') - .style.height, - ).toEqual('2000px'); // 100 rows * 20px rowHeight - expect( - grid.state.instanceProps.rowSizeAndPositionManager.getTotalSize(), - ).toEqual(2000); - }); - }); - - describe('autoWidth', () => { - it('should set the container width to auto to adjust to innerScrollContainer width', () => { - const props = { - autoWidth: true, - }; - const rendered = findDOMNode(render(getMarkup(props))); - expect(rendered.style.width).toEqual('auto'); - }); - - it('should have container width still affecting number of columns rendered', () => { - const props = { - width: 500, - autoWidth: true, - }; - const rendered = findDOMNode(render(getMarkup(props))); - expect(rendered.querySelectorAll('.gridItem').length).toEqual(50); // 5 rows x 10 columns - }); - - it('should have innerScrollContainer width to be equal number of columns * columnWidth', () => { - const props = { - autoWidth: true, - }; - const grid = render(getMarkup(props)); - const rendered = findDOMNode(grid); - expect( - rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer') - .style.width, - ).toEqual('2500px'); // 50 columns * 50px columnWidth - expect( - grid.state.instanceProps.columnSizeAndPositionManager.getTotalSize(), - ).toEqual(2500); - }); - }); - - describe('tabIndex', () => { - it('should be focusable by default', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.tabIndex).toEqual(0); - }); - - it('should allow tabIndex to be overridden', () => { - const rendered = findDOMNode( - render( - getMarkup({ - tabIndex: -1, - }), - ), - ); - expect(rendered.tabIndex).toEqual(-1); - }); - }); - - describe('role', () => { - it('should have grid role by default', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.getAttribute('role')).toEqual('grid'); - }); - - it('should allow role to be overridden', () => { - const role = null; - const rendered = findDOMNode(render(getMarkup({role}))); - expect(rendered.getAttribute('role')).toEqual(role); - }); - }); - - describe('pure', () => { - it('should not re-render unless props have changed', () => { - let cellRendererCalled = false; - function cellRenderer({key, style}) { - cellRendererCalled = true; - return
; - } - const markup = getMarkup({cellRenderer}); - render(markup); - expect(cellRendererCalled).toEqual(true); - cellRendererCalled = false; - render(markup); - expect(cellRendererCalled).toEqual(false); - }); - - it('should not re-render grid components if they extend PureComponent', () => { - let componentUpdates = 0; - - class GridComponent extends React.PureComponent { - componentDidUpdate() { - componentUpdates++; - } - - render() { - const {columnIndex, rowIndex, style} = this.props; - return ( -
- {`row:${rowIndex}, column:${columnIndex}`} -
- ); - } - } - - function cellRenderer({columnIndex, key, rowIndex, style}) { - return ( - - ); - } - - const props = { - cellRenderer, - columnWidth: 100, - height: 40, - rowHeight: 20, - scrollTop: 0, - width: 100, - }; - - const grid = render(getMarkup(props)); - simulateScroll({grid, scrollToIndex: 1}); - - expect(componentUpdates).toEqual(0); - }); - - it('should clear all but the visible rows from the style cache once :isScrolling is false', async done => { - const props = { - columnWidth: 50, - height: 100, - overscanColumnCount: 0, - overscanRowCount: 0, - rowHeight: 50, - width: 100, - }; - - const grid = render(getMarkup(props)); - - expect(Object.keys(grid._styleCache).length).toBe(4); - - simulateScroll({grid, scrollTop: 50}); - - expect(Object.keys(grid._styleCache).length).toBe(6); - - // Allow scrolling timeout to complete so that cell cache is reset - await new Promise(resolve => - setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2), - ); - - expect(Object.keys(grid._styleCache).length).toBe(4); - - done(); - }); - - it('should clear style cache if :recomputeGridSize is called', () => { - const props = { - columnWidth: 50, - height: 100, - overscanColumnCount: 0, - overscanRowCount: 0, - rowHeight: 50, - width: 100, - }; - - const grid = render(getMarkup(props)); - - expect(Object.keys(grid._styleCache).length).toBe(4); - - render( - getMarkup({ - ...props, - scrollTop: 50, - }), - ); - - expect(Object.keys(grid._styleCache).length).toBe(6); - - grid.recomputeGridSize(); - - expect(Object.keys(grid._styleCache).length).toBe(4); - }); - - it('should clear style cache if cell sizes change', () => { - const cellRendererCalls = []; - function cellRenderer(params) { - cellRendererCalls.push(params); - return
; - } - - const props = { - cellRenderer, - columnWidth: 100, - height: 100, - overscanColumnCount: 0, - overscanRowCount: 0, - rowHeight: 100, - width: 100, - }; - - render(getMarkup(props)); - - expect(cellRendererCalls.length).toEqual(1); - expect(cellRendererCalls[0].style.width).toEqual(100); - - render( - getMarkup({ - ...props, - columnWidth: 50, - width: 50, - }), - ); - - expect(cellRendererCalls.length).toEqual(2); - expect(cellRendererCalls[1].style.width).toEqual(50); - }); - }); - - it('should not pull from the style cache while scrolling if there is an offset adjustment', () => { - let cellRendererCalls = []; - function cellRenderer(params) { - cellRendererCalls.push(params); - return
; - } - - const grid = render( - getMarkup({ - cellRenderer, - width: 100, - height: 100, - rowHeight: 100, - columnWidth: 100, - rowCount: (getMaxElementSize() * 2) / 100, // lots of offset - scrollTop: 2000, - }), - ); - - simulateScroll({ - grid, - scrollTop: 2100, - }); - - // cellRendererCalls[0] is the element at rowIndex 0 - // only two calls. Since the scrollTop is updated in getDerivedStateFromProps - const firstProps = cellRendererCalls[0]; - const secondProps = cellRendererCalls[1]; - - expect(cellRendererCalls.length).toEqual(2); - expect(firstProps.style).not.toBe(secondProps.style); - }); - - it('should only cache styles when a :deferredMeasurementCache is provided if the cell has already been measured', () => { - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - cache.set(0, 0, 100, 100); - cache.set(1, 1, 100, 100); - - const grid = render( - getMarkup({ - columnCount: 2, - deferredMeasurementCache: cache, - rowCount: 2, - }), - ); - - const keys = Object.keys(grid._styleCache); - - expect(keys).toEqual(['0-0', '1-1']); - }); - - describe('DEV warnings', () => { - it('should warn about cells that forget to include the :style property', () => { - spyOn(console, 'warn'); - - function cellRenderer(params) { - return
; - } - - render( - getMarkup({ - cellRenderer, - }), - ); - - expect(console.warn).toHaveBeenCalledWith( - 'Rendered cell should include style property for positioning.', - ); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - - it('should warn about CellMeasurer measured cells that forget to include the :style property', () => { - spyOn(console, 'warn'); - - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(params => ( - -
- - )); - - render( - getMarkup({ - cellRenderer, - columnCount: 1, - deferredMeasurementCache: cache, - rowCount: 1, - }), - ); - - expect(console.warn).toHaveBeenCalledWith( - 'Rendered cell should include style property for positioning.', - ); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - }); - - describe('deferredMeasurementCache', () => { - it('invalidateCellSizeAfterRender should invalidate cache and refresh displayed cells after mount', () => { - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - - let invalidateCellSizeAfterRender = true; - - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(params => { - // Don't get stuck in a loop - if (invalidateCellSizeAfterRender) { - invalidateCellSizeAfterRender = false; - - params.parent.invalidateCellSizeAfterRender({ - columnIndex: 1, - rowIndex: 0, - }); - } - return
; - }); - - const props = { - cellRenderer, - columnCount: 2, - deferredMeasurementCache: cache, - rowCount: 2, - }; - - render(getMarkup(props)); - - // 4 times for initial render + 4 once cellCache was cleared - expect(cellRenderer).toHaveBeenCalledTimes(8); - }); - - it('should invalidate cache and refresh displayed cells after update', () => { - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(params => ( -
- )); - - const props = { - cellRenderer, - columnCount: 2, - deferredMeasurementCache: cache, - rowCount: 2, - }; - - const grid = render(getMarkup(props)); - - expect(cellRenderer).toHaveBeenCalledTimes(4); - - let invalidateCellSizeAfterRender = false; - - cellRenderer.mockReset(); - cellRenderer.mockImplementation(params => { - // Don't get stuck in a loop - if (invalidateCellSizeAfterRender) { - invalidateCellSizeAfterRender = false; - params.parent.invalidateCellSizeAfterRender({ - columnIndex: 1, - rowIndex: 0, - }); - } - return
; - }); - - invalidateCellSizeAfterRender = true; - grid.recomputeGridSize(); - - // 4 times for initial render + 4 once cellCache was cleared - expect(cellRenderer).toHaveBeenCalledTimes(8); - }); - - it('should not cache cells until they have been measured by CellMeasurer', () => { - const cache = new CellMeasurerCache({ - fixedWidth: true, - }); - - // Fake measure cell 0,0 but not cell 0,1 - cache.set(0, 0, 100, 30); - - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(params => ( -
- )); - - const props = { - cellRenderer, - columnCount: 2, - deferredMeasurementCache: cache, - rowCount: 1, - }; - - // Trigger 2 renders - // The second render should re-use the style for cell 0,0 - // But should not re-use the style for cell 0,1 since it was not measured - const grid = render(getMarkup(props)); - grid.forceUpdate(); - - // 0,0 - 0,1 - 0,0 - 0,1 - expect(cellRenderer).toHaveBeenCalledTimes(4); - const style00A = cellRenderer.mock.calls[0][0].style; - const style01A = cellRenderer.mock.calls[1][0].style; - const style00B = cellRenderer.mock.calls[2][0].style; - const style01B = cellRenderer.mock.calls[3][0].style; - expect(style00A).toBe(style00B); - expect(style01A).not.toBe(style01B); - }); - }); - - describe('onScrollbarPresenceChange', () => { - it('should not trigger on-mount if scrollbars are hidden', () => { - const onScrollbarPresenceChange = jest.fn(); - - render( - getMarkup({ - columnCount: 1, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 1, - }), - ); - expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); - }); - - it('should trigger on-mount if scrollbars are visible', () => { - const onScrollbarPresenceChange = jest.fn(); - - render( - getMarkup({ - columnCount: 100, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 100, - }), - ); - expect(onScrollbarPresenceChange).toHaveBeenCalled(); - - const args = onScrollbarPresenceChange.mock.calls[0][0]; - expect(args.horizontal).toBe(true); - expect(args.size).toBe(getScrollbarSize20()); - expect(args.vertical).toBe(true); - }); - - it('should trigger on-update if scrollbar visibility has changed', () => { - const onScrollbarPresenceChange = jest.fn(); - render( - getMarkup({ - columnCount: 1, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 1, - }), - ); - expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); - - render( - getMarkup({ - columnCount: 100, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 100, - }), - ); - expect(onScrollbarPresenceChange).toHaveBeenCalled(); - - const args = onScrollbarPresenceChange.mock.calls[0][0]; - expect(args.horizontal).toBe(true); - expect(args.size).toBe(getScrollbarSize20()); - expect(args.vertical).toBe(true); - }); - - it('should not trigger on-update if scrollbar visibility does not change', () => { - const onScrollbarPresenceChange = jest.fn(); - render( - getMarkup({ - columnCount: 1, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 1, - }), - ); - expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); - render( - getMarkup({ - columnCount: 2, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 2, - }), - ); - expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); - }); - }); - - it('should not complain when using react-test-renderer', () => { - const instance = TestRenderer.create(getMarkup()).getInstance(); - expect(instance).toBeTruthy(); - }); -}); diff --git a/source/Grid/Grid.js b/source/Grid/Grid.js deleted file mode 100644 index e7caff40b..000000000 --- a/source/Grid/Grid.js +++ /dev/null @@ -1,1657 +0,0 @@ -/** @flow */ - -import type { - CellRenderer, - CellRangeRenderer, - CellPosition, - CellSize, - CellSizeGetter, - NoContentRenderer, - Scroll, - ScrollbarPresenceChange, - RenderedSection, - OverscanIndicesGetter, - Alignment, - CellCache, - StyleCache, -} from './types'; -import type {AnimationTimeoutId} from '../utils/requestAnimationTimeout'; - -import * as React from 'react'; -import clsx from 'clsx'; -import calculateSizeAndPositionDataAndUpdateScrollOffset from './utils/calculateSizeAndPositionDataAndUpdateScrollOffset'; -import ScalingCellSizeAndPositionManager from './utils/ScalingCellSizeAndPositionManager'; -import createCallbackMemoizer from '../utils/createCallbackMemoizer'; -import defaultOverscanIndicesGetter, { - SCROLL_DIRECTION_BACKWARD, - SCROLL_DIRECTION_FORWARD, -} from './defaultOverscanIndicesGetter'; -import updateScrollIndexHelper from './utils/updateScrollIndexHelper'; -import defaultCellRangeRenderer from './defaultCellRangeRenderer'; -import scrollbarSize from 'dom-helpers/scrollbarSize'; -import {polyfill} from 'react-lifecycles-compat'; -import { - requestAnimationTimeout, - cancelAnimationTimeout, -} from '../utils/requestAnimationTimeout'; - -/** - * Specifies the number of milliseconds during which to disable pointer events while a scroll is in progress. - * This improves performance and makes scrolling smoother. - */ -export const DEFAULT_SCROLLING_RESET_TIME_INTERVAL = 150; - -/** - * Controls whether the Grid updates the DOM element's scrollLeft/scrollTop based on the current state or just observes it. - * This prevents Grid from interrupting mouse-wheel animations (see issue #2). - */ -const SCROLL_POSITION_CHANGE_REASONS = { - OBSERVED: 'observed', - REQUESTED: 'requested', -}; - -const renderNull: NoContentRenderer = () => null; - -type ScrollPosition = { - scrollTop?: number, - scrollLeft?: number, -}; - -type Props = { - 'aria-label': string, - 'aria-readonly'?: boolean, - - /** - * Set the width of the inner scrollable container to 'auto'. - * This is useful for single-column Grids to ensure that the column doesn't extend below a vertical scrollbar. - */ - autoContainerWidth: boolean, - - /** - * Removes fixed height from the scrollingContainer so that the total height of rows can stretch the window. - * Intended for use with WindowScroller - */ - autoHeight: boolean, - - /** - * Removes fixed width from the scrollingContainer so that the total width of rows can stretch the window. - * Intended for use with WindowScroller - */ - autoWidth: boolean, - - /** Responsible for rendering a cell given an row and column index. */ - cellRenderer: CellRenderer, - - /** Responsible for rendering a group of cells given their index ranges. */ - cellRangeRenderer: CellRangeRenderer, - - /** Optional custom CSS class name to attach to root Grid element. */ - className?: string, - - /** Number of columns in grid. */ - columnCount: number, - - /** Either a fixed column width (number) or a function that returns the width of a column given its index. */ - columnWidth: CellSize, - - /** Unfiltered props for the Grid container. */ - containerProps?: Object, - - /** ARIA role for the cell-container. */ - containerRole: string, - - /** Optional inline style applied to inner cell-container */ - containerStyle: Object, - - /** - * If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache. - * A shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data. - */ - deferredMeasurementCache?: Object, - - /** - * Used to estimate the total width of a Grid before all of its columns have actually been measured. - * The estimated total width is adjusted as columns are rendered. - */ - estimatedColumnSize: number, - - /** - * Used to estimate the total height of a Grid before all of its rows have actually been measured. - * The estimated total height is adjusted as rows are rendered. - */ - estimatedRowSize: number, - - /** Exposed for testing purposes only. */ - getScrollbarSize: () => number, - - /** Height of Grid; this property determines the number of visible (vs virtualized) rows. */ - height: number, - - /** Optional custom id to attach to root Grid element. */ - id?: string, - - /** - * Override internal is-scrolling state tracking. - * This property is primarily intended for use with the WindowScroller component. - */ - isScrolling?: boolean, - - /** - * Opt-out of isScrolling param passed to cellRangeRenderer. - * To avoid the extra render when scroll stops. - */ - isScrollingOptOut: boolean, - - /** Optional renderer to be used in place of rows when either :rowCount or :columnCount is 0. */ - noContentRenderer: NoContentRenderer, - - /** - * Callback invoked whenever the scroll offset changes within the inner scrollable region. - * This callback can be used to sync scrolling between lists, tables, or grids. - */ - onScroll: (params: Scroll) => void, - - /** - * Called whenever a horizontal or vertical scrollbar is added or removed. - * This prop is not intended for end-user use; - * It is used by MultiGrid to support fixed-row/fixed-column scroll syncing. - */ - onScrollbarPresenceChange: (params: ScrollbarPresenceChange) => void, - - /** Callback invoked with information about the section of the Grid that was just rendered. */ - onSectionRendered: (params: RenderedSection) => void, - - /** - * Number of columns to render before/after the visible section of the grid. - * These columns can help for smoother scrolling on touch devices or browsers that send scroll events infrequently. - */ - overscanColumnCount: number, - - /** - * Calculates the number of cells to overscan before and after a specified range. - * This function ensures that overscanning doesn't exceed the available cells. - */ - overscanIndicesGetter: OverscanIndicesGetter, - - /** - * Number of rows to render above/below the visible section of the grid. - * These rows can help for smoother scrolling on touch devices or browsers that send scroll events infrequently. - */ - overscanRowCount: number, - - /** ARIA role for the grid element. */ - role: string, - - /** - * Either a fixed row height (number) or a function that returns the height of a row given its index. - * Should implement the following interface: ({ index: number }): number - */ - rowHeight: CellSize, - - /** Number of rows in grid. */ - rowCount: number, - - /** Wait this amount of time after the last scroll event before resetting Grid `pointer-events`. */ - scrollingResetTimeInterval: number, - - /** Horizontal offset. */ - scrollLeft?: number, - - /** - * Controls scroll-to-cell behavior of the Grid. - * The default ("auto") scrolls the least amount possible to ensure that the specified cell is fully visible. - * Use "start" to align cells to the top/left of the Grid and "end" to align bottom/right. - */ - scrollToAlignment: Alignment, - - /** Column index to ensure visible (by forcefully scrolling if necessary) */ - scrollToColumn: number, - - /** Vertical offset. */ - scrollTop?: number, - - /** Row index to ensure visible (by forcefully scrolling if necessary) */ - scrollToRow: number, - - /** Optional inline style */ - style: Object, - - /** Tab index for focus */ - tabIndex: ?number, - - /** Width of Grid; this property determines the number of visible (vs virtualized) columns. */ - width: number, - - /** Reference to DOM node */ - elementRef?: React.Ref, -}; - -type InstanceProps = { - prevColumnWidth: CellSize, - prevRowHeight: CellSize, - - prevColumnCount: number, - prevRowCount: number, - prevIsScrolling: boolean, - prevScrollToColumn: number, - prevScrollToRow: number, - - columnSizeAndPositionManager: ScalingCellSizeAndPositionManager, - rowSizeAndPositionManager: ScalingCellSizeAndPositionManager, - - scrollbarSize: number, - scrollbarSizeMeasured: boolean, -}; - -type State = { - instanceProps: InstanceProps, - isScrolling: boolean, - scrollDirectionHorizontal: -1 | 1, - scrollDirectionVertical: -1 | 1, - scrollLeft: number, - scrollTop: number, - scrollPositionChangeReason: 'observed' | 'requested' | null, - needToResetStyleCache: boolean, -}; - -/** - * Renders tabular data with virtualization along the vertical and horizontal axes. - * Row heights and column widths must be known ahead of time and specified as properties. - */ -class Grid extends React.PureComponent { - static defaultProps = { - 'aria-label': 'grid', - 'aria-readonly': true, - autoContainerWidth: false, - autoHeight: false, - autoWidth: false, - cellRangeRenderer: defaultCellRangeRenderer, - containerRole: 'row', - containerStyle: {}, - estimatedColumnSize: 100, - estimatedRowSize: 30, - getScrollbarSize: scrollbarSize, - noContentRenderer: renderNull, - onScroll: () => {}, - onScrollbarPresenceChange: () => {}, - onSectionRendered: () => {}, - overscanColumnCount: 0, - overscanIndicesGetter: defaultOverscanIndicesGetter, - overscanRowCount: 10, - role: 'grid', - scrollingResetTimeInterval: DEFAULT_SCROLLING_RESET_TIME_INTERVAL, - scrollToAlignment: 'auto', - scrollToColumn: -1, - scrollToRow: -1, - style: {}, - tabIndex: 0, - isScrollingOptOut: false, - }; - - // Invokes onSectionRendered callback only when start/stop row or column indices change - _onGridRenderedMemoizer = createCallbackMemoizer(); - _onScrollMemoizer = createCallbackMemoizer(false); - - _deferredInvalidateColumnIndex = null; - _deferredInvalidateRowIndex = null; - _recomputeScrollLeftFlag = false; - _recomputeScrollTopFlag = false; - - _horizontalScrollBarSize = 0; - _verticalScrollBarSize = 0; - _scrollbarPresenceChanged = false; - _scrollingContainer: Element; - - _childrenToDisplay: React.Element<*>[]; - - _columnStartIndex: number; - _columnStopIndex: number; - _rowStartIndex: number; - _rowStopIndex: number; - _renderedColumnStartIndex = 0; - _renderedColumnStopIndex = 0; - _renderedRowStartIndex = 0; - _renderedRowStopIndex = 0; - - _initialScrollTop: number; - _initialScrollLeft: number; - - _disablePointerEventsTimeoutId: ?AnimationTimeoutId; - - _styleCache: StyleCache = {}; - _cellCache: CellCache = {}; - - constructor(props: Props) { - super(props); - const columnSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ - cellCount: props.columnCount, - cellSizeGetter: params => Grid._wrapSizeGetter(props.columnWidth)(params), - estimatedCellSize: Grid._getEstimatedColumnSize(props), - }); - const rowSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ - cellCount: props.rowCount, - cellSizeGetter: params => Grid._wrapSizeGetter(props.rowHeight)(params), - estimatedCellSize: Grid._getEstimatedRowSize(props), - }); - - this.state = { - instanceProps: { - columnSizeAndPositionManager, - rowSizeAndPositionManager, - - prevColumnWidth: props.columnWidth, - prevRowHeight: props.rowHeight, - prevColumnCount: props.columnCount, - prevRowCount: props.rowCount, - prevIsScrolling: props.isScrolling === true, - prevScrollToColumn: props.scrollToColumn, - prevScrollToRow: props.scrollToRow, - - scrollbarSize: 0, - scrollbarSizeMeasured: false, - }, - isScrolling: false, - scrollDirectionHorizontal: SCROLL_DIRECTION_FORWARD, - scrollDirectionVertical: SCROLL_DIRECTION_FORWARD, - scrollLeft: 0, - scrollTop: 0, - scrollPositionChangeReason: null, - - needToResetStyleCache: false, - }; - - if (props.scrollToRow > 0) { - this._initialScrollTop = this._getCalculatedScrollTop(props, this.state); - } - if (props.scrollToColumn > 0) { - this._initialScrollLeft = this._getCalculatedScrollLeft( - props, - this.state, - ); - } - } - - /** - * Gets offsets for a given cell and alignment. - */ - getOffsetForCell({ - alignment = this.props.scrollToAlignment, - columnIndex = this.props.scrollToColumn, - rowIndex = this.props.scrollToRow, - }: { - alignment?: Alignment, - columnIndex?: number, - rowIndex?: number, - } = {}) { - const offsetProps = { - ...this.props, - scrollToAlignment: alignment, - scrollToColumn: columnIndex, - scrollToRow: rowIndex, - }; - - return { - scrollLeft: this._getCalculatedScrollLeft(offsetProps), - scrollTop: this._getCalculatedScrollTop(offsetProps), - }; - } - - /** - * Gets estimated total rows' height. - */ - getTotalRowsHeight() { - return this.state.instanceProps.rowSizeAndPositionManager.getTotalSize(); - } - - /** - * Gets estimated total columns' width. - */ - getTotalColumnsWidth() { - return this.state.instanceProps.columnSizeAndPositionManager.getTotalSize(); - } - - /** - * This method handles a scroll event originating from an external scroll control. - * It's an advanced method and should probably not be used unless you're implementing a custom scroll-bar solution. - */ - handleScrollEvent({ - scrollLeft: scrollLeftParam = 0, - scrollTop: scrollTopParam = 0, - }: ScrollPosition) { - // On iOS, we can arrive at negative offsets by swiping past the start. - // To prevent flicker here, we make playing in the negative offset zone cause nothing to happen. - if (scrollTopParam < 0) { - return; - } - - // Prevent pointer events from interrupting a smooth scroll - this._debounceScrollEnded(); - - const {autoHeight, autoWidth, height, width} = this.props; - const {instanceProps} = this.state; - - // When this component is shrunk drastically, React dispatches a series of back-to-back scroll events, - // Gradually converging on a scrollTop that is within the bounds of the new, smaller height. - // This causes a series of rapid renders that is slow for long lists. - // We can avoid that by doing some simple bounds checking to ensure that scroll offsets never exceed their bounds. - const scrollbarSize = instanceProps.scrollbarSize; - const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); - const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); - const scrollLeft = Math.min( - Math.max(0, totalColumnsWidth - width + scrollbarSize), - scrollLeftParam, - ); - const scrollTop = Math.min( - Math.max(0, totalRowsHeight - height + scrollbarSize), - scrollTopParam, - ); - - // Certain devices (like Apple touchpad) rapid-fire duplicate events. - // Don't force a re-render if this is the case. - // The mouse may move faster then the animation frame does. - // Use requestAnimationFrame to avoid over-updating. - if ( - this.state.scrollLeft !== scrollLeft || - this.state.scrollTop !== scrollTop - ) { - // Track scrolling direction so we can more efficiently overscan rows to reduce empty space around the edges while scrolling. - // Don't change direction for an axis unless scroll offset has changed. - const scrollDirectionHorizontal = - scrollLeft !== this.state.scrollLeft - ? scrollLeft > this.state.scrollLeft - ? SCROLL_DIRECTION_FORWARD - : SCROLL_DIRECTION_BACKWARD - : this.state.scrollDirectionHorizontal; - const scrollDirectionVertical = - scrollTop !== this.state.scrollTop - ? scrollTop > this.state.scrollTop - ? SCROLL_DIRECTION_FORWARD - : SCROLL_DIRECTION_BACKWARD - : this.state.scrollDirectionVertical; - - const newState: $Shape = { - isScrolling: true, - scrollDirectionHorizontal, - scrollDirectionVertical, - scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.OBSERVED, - }; - - if (!autoHeight) { - newState.scrollTop = scrollTop; - } - - if (!autoWidth) { - newState.scrollLeft = scrollLeft; - } - - newState.needToResetStyleCache = false; - this.setState(newState); - } - - this._invokeOnScrollMemoizer({ - scrollLeft, - scrollTop, - totalColumnsWidth, - totalRowsHeight, - }); - } - - /** - * Invalidate Grid size and recompute visible cells. - * This is a deferred wrapper for recomputeGridSize(). - * It sets a flag to be evaluated on cDM/cDU to avoid unnecessary renders. - * This method is intended for advanced use-cases like CellMeasurer. - */ - // @TODO (bvaughn) Add automated test coverage for this. - invalidateCellSizeAfterRender({columnIndex, rowIndex}: CellPosition) { - this._deferredInvalidateColumnIndex = - typeof this._deferredInvalidateColumnIndex === 'number' - ? Math.min(this._deferredInvalidateColumnIndex, columnIndex) - : columnIndex; - this._deferredInvalidateRowIndex = - typeof this._deferredInvalidateRowIndex === 'number' - ? Math.min(this._deferredInvalidateRowIndex, rowIndex) - : rowIndex; - } - - /** - * Pre-measure all columns and rows in a Grid. - * Typically cells are only measured as needed and estimated sizes are used for cells that have not yet been measured. - * This method ensures that the next call to getTotalSize() returns an exact size (as opposed to just an estimated one). - */ - measureAllCells() { - const {columnCount, rowCount} = this.props; - const {instanceProps} = this.state; - instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell( - columnCount - 1, - ); - instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell( - rowCount - 1, - ); - } - - /** - * Forced recompute of row heights and column widths. - * This function should be called if dynamic column or row sizes have changed but nothing else has. - * Since Grid only receives :columnCount and :rowCount it has no way of detecting when the underlying data changes. - */ - recomputeGridSize({columnIndex = 0, rowIndex = 0}: CellPosition = {}) { - const {scrollToColumn, scrollToRow} = this.props; - const {instanceProps} = this.state; - - instanceProps.columnSizeAndPositionManager.resetCell(columnIndex); - instanceProps.rowSizeAndPositionManager.resetCell(rowIndex); - - // Cell sizes may be determined by a function property. - // In this case the cDU handler can't know if they changed. - // Store this flag to let the next cDU pass know it needs to recompute the scroll offset. - this._recomputeScrollLeftFlag = - scrollToColumn >= 0 && - (this.state.scrollDirectionHorizontal === SCROLL_DIRECTION_FORWARD - ? columnIndex <= scrollToColumn - : columnIndex >= scrollToColumn); - this._recomputeScrollTopFlag = - scrollToRow >= 0 && - (this.state.scrollDirectionVertical === SCROLL_DIRECTION_FORWARD - ? rowIndex <= scrollToRow - : rowIndex >= scrollToRow); - - // Clear cell cache in case we are scrolling; - // Invalid row heights likely mean invalid cached content as well. - this._styleCache = {}; - this._cellCache = {}; - - this.forceUpdate(); - } - - /** - * Ensure column and row are visible. - */ - scrollToCell({columnIndex, rowIndex}: CellPosition) { - const {columnCount} = this.props; - - const props = this.props; - - // Don't adjust scroll offset for single-column grids (eg List, Table). - // This can cause a funky scroll offset because of the vertical scrollbar width. - if (columnCount > 1 && columnIndex !== undefined) { - this._updateScrollLeftForScrollToColumn({ - ...props, - scrollToColumn: columnIndex, - }); - } - - if (rowIndex !== undefined) { - this._updateScrollTopForScrollToRow({ - ...props, - scrollToRow: rowIndex, - }); - } - } - - componentDidMount() { - const { - getScrollbarSize, - height, - scrollLeft, - scrollToColumn, - scrollTop, - scrollToRow, - width, - } = this.props; - - const {instanceProps} = this.state; - - // Reset initial offsets to be ignored in browser - this._initialScrollTop = 0; - this._initialScrollLeft = 0; - - // If cell sizes have been invalidated (eg we are using CellMeasurer) then reset cached positions. - // We must do this at the start of the method as we may calculate and update scroll position below. - this._handleInvalidatedGridSize(); - - // If this component was first rendered server-side, scrollbar size will be undefined. - // In that event we need to remeasure. - if (!instanceProps.scrollbarSizeMeasured) { - this.setState(prevState => { - const stateUpdate = {...prevState, needToResetStyleCache: false}; - stateUpdate.instanceProps.scrollbarSize = getScrollbarSize(); - stateUpdate.instanceProps.scrollbarSizeMeasured = true; - return stateUpdate; - }); - } - - if ( - (typeof scrollLeft === 'number' && scrollLeft >= 0) || - (typeof scrollTop === 'number' && scrollTop >= 0) - ) { - const stateUpdate = Grid._getScrollToPositionStateUpdate({ - prevState: this.state, - scrollLeft, - scrollTop, - }); - if (stateUpdate) { - stateUpdate.needToResetStyleCache = false; - this.setState(stateUpdate); - } - } - - // refs don't work in `react-test-renderer` - if (this._scrollingContainer) { - // setting the ref's scrollLeft and scrollTop. - // Somehow in MultiGrid the main grid doesn't trigger a update on mount. - if (this._scrollingContainer.scrollLeft !== this.state.scrollLeft) { - this._scrollingContainer.scrollLeft = this.state.scrollLeft; - } - if (this._scrollingContainer.scrollTop !== this.state.scrollTop) { - this._scrollingContainer.scrollTop = this.state.scrollTop; - } - } - - // Don't update scroll offset if the size is 0; we don't render any cells in this case. - // Setting a state may cause us to later thing we've updated the offce when we haven't. - const sizeIsBiggerThanZero = height > 0 && width > 0; - if (scrollToColumn >= 0 && sizeIsBiggerThanZero) { - this._updateScrollLeftForScrollToColumn(); - } - if (scrollToRow >= 0 && sizeIsBiggerThanZero) { - this._updateScrollTopForScrollToRow(); - } - - // Update onRowsRendered callback - this._invokeOnGridRenderedHelper(); - - // Initialize onScroll callback - this._invokeOnScrollMemoizer({ - scrollLeft: scrollLeft || 0, - scrollTop: scrollTop || 0, - totalColumnsWidth: instanceProps.columnSizeAndPositionManager.getTotalSize(), - totalRowsHeight: instanceProps.rowSizeAndPositionManager.getTotalSize(), - }); - - this._maybeCallOnScrollbarPresenceChange(); - } - - /** - * @private - * This method updates scrollLeft/scrollTop in state for the following conditions: - * 1) New scroll-to-cell props have been set - */ - componentDidUpdate(prevProps: Props, prevState: State) { - const { - autoHeight, - autoWidth, - columnCount, - height, - rowCount, - scrollToAlignment, - scrollToColumn, - scrollToRow, - width, - } = this.props; - const { - scrollLeft, - scrollPositionChangeReason, - scrollTop, - instanceProps, - } = this.state; - // If cell sizes have been invalidated (eg we are using CellMeasurer) then reset cached positions. - // We must do this at the start of the method as we may calculate and update scroll position below. - this._handleInvalidatedGridSize(); - - // Handle edge case where column or row count has only just increased over 0. - // In this case we may have to restore a previously-specified scroll offset. - // For more info see bvaughn/react-virtualized/issues/218 - const columnOrRowCountJustIncreasedFromZero = - (columnCount > 0 && prevProps.columnCount === 0) || - (rowCount > 0 && prevProps.rowCount === 0); - - // Make sure requested changes to :scrollLeft or :scrollTop get applied. - // Assigning to scrollLeft/scrollTop tells the browser to interrupt any running scroll animations, - // And to discard any pending async changes to the scroll position that may have happened in the meantime (e.g. on a separate scrolling thread). - // So we only set these when we require an adjustment of the scroll position. - // See issue #2 for more information. - if ( - scrollPositionChangeReason === SCROLL_POSITION_CHANGE_REASONS.REQUESTED - ) { - // @TRICKY :autoHeight and :autoWidth properties instructs Grid to leave :scrollTop and :scrollLeft management to an external HOC (eg WindowScroller). - // In this case we should avoid checking scrollingContainer.scrollTop and scrollingContainer.scrollLeft since it forces layout/flow. - if ( - !autoWidth && - scrollLeft >= 0 && - (scrollLeft !== this._scrollingContainer.scrollLeft || - columnOrRowCountJustIncreasedFromZero) - ) { - this._scrollingContainer.scrollLeft = scrollLeft; - } - if ( - !autoHeight && - scrollTop >= 0 && - (scrollTop !== this._scrollingContainer.scrollTop || - columnOrRowCountJustIncreasedFromZero) - ) { - this._scrollingContainer.scrollTop = scrollTop; - } - } - - // Special case where the previous size was 0: - // In this case we don't show any windowed cells at all. - // So we should always recalculate offset afterwards. - const sizeJustIncreasedFromZero = - (prevProps.width === 0 || prevProps.height === 0) && - height > 0 && - width > 0; - - // Update scroll offsets if the current :scrollToColumn or :scrollToRow values requires it - // @TODO Do we also need this check or can the one in componentWillUpdate() suffice? - if (this._recomputeScrollLeftFlag) { - this._recomputeScrollLeftFlag = false; - this._updateScrollLeftForScrollToColumn(this.props); - } else { - updateScrollIndexHelper({ - cellSizeAndPositionManager: instanceProps.columnSizeAndPositionManager, - previousCellsCount: prevProps.columnCount, - previousCellSize: prevProps.columnWidth, - previousScrollToAlignment: prevProps.scrollToAlignment, - previousScrollToIndex: prevProps.scrollToColumn, - previousSize: prevProps.width, - scrollOffset: scrollLeft, - scrollToAlignment, - scrollToIndex: scrollToColumn, - size: width, - sizeJustIncreasedFromZero, - updateScrollIndexCallback: () => - this._updateScrollLeftForScrollToColumn(this.props), - }); - } - - if (this._recomputeScrollTopFlag) { - this._recomputeScrollTopFlag = false; - this._updateScrollTopForScrollToRow(this.props); - } else { - updateScrollIndexHelper({ - cellSizeAndPositionManager: instanceProps.rowSizeAndPositionManager, - previousCellsCount: prevProps.rowCount, - previousCellSize: prevProps.rowHeight, - previousScrollToAlignment: prevProps.scrollToAlignment, - previousScrollToIndex: prevProps.scrollToRow, - previousSize: prevProps.height, - scrollOffset: scrollTop, - scrollToAlignment, - scrollToIndex: scrollToRow, - size: height, - sizeJustIncreasedFromZero, - updateScrollIndexCallback: () => - this._updateScrollTopForScrollToRow(this.props), - }); - } - - // Update onRowsRendered callback if start/stop indices have changed - this._invokeOnGridRenderedHelper(); - - // Changes to :scrollLeft or :scrollTop should also notify :onScroll listeners - if ( - scrollLeft !== prevState.scrollLeft || - scrollTop !== prevState.scrollTop - ) { - const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); - const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); - - this._invokeOnScrollMemoizer({ - scrollLeft, - scrollTop, - totalColumnsWidth, - totalRowsHeight, - }); - } - - this._maybeCallOnScrollbarPresenceChange(); - } - - componentWillUnmount() { - if (this._disablePointerEventsTimeoutId) { - cancelAnimationTimeout(this._disablePointerEventsTimeoutId); - } - } - - /** - * This method updates scrollLeft/scrollTop in state for the following conditions: - * 1) Empty content (0 rows or columns) - * 2) New scroll props overriding the current state - * 3) Cells-count or cells-size has changed, making previous scroll offsets invalid - */ - static getDerivedStateFromProps( - nextProps: Props, - prevState: State, - ): $Shape { - const newState = {}; - - if ( - (nextProps.columnCount === 0 && prevState.scrollLeft !== 0) || - (nextProps.rowCount === 0 && prevState.scrollTop !== 0) - ) { - newState.scrollLeft = 0; - newState.scrollTop = 0; - - // only use scroll{Left,Top} from props if scrollTo{Column,Row} isn't specified - // scrollTo{Column,Row} should override scroll{Left,Top} - } else if ( - (nextProps.scrollLeft !== prevState.scrollLeft && - nextProps.scrollToColumn < 0) || - (nextProps.scrollTop !== prevState.scrollTop && nextProps.scrollToRow < 0) - ) { - Object.assign( - newState, - Grid._getScrollToPositionStateUpdate({ - prevState, - scrollLeft: nextProps.scrollLeft, - scrollTop: nextProps.scrollTop, - }), - ); - } - - let {instanceProps} = prevState; - - // Initially we should not clearStyleCache - newState.needToResetStyleCache = false; - if ( - nextProps.columnWidth !== instanceProps.prevColumnWidth || - nextProps.rowHeight !== instanceProps.prevRowHeight - ) { - // Reset cache. set it to {} in render - newState.needToResetStyleCache = true; - } - - instanceProps.columnSizeAndPositionManager.configure({ - cellCount: nextProps.columnCount, - estimatedCellSize: Grid._getEstimatedColumnSize(nextProps), - cellSizeGetter: Grid._wrapSizeGetter(nextProps.columnWidth), - }); - - instanceProps.rowSizeAndPositionManager.configure({ - cellCount: nextProps.rowCount, - estimatedCellSize: Grid._getEstimatedRowSize(nextProps), - cellSizeGetter: Grid._wrapSizeGetter(nextProps.rowHeight), - }); - - if ( - instanceProps.prevColumnCount === 0 || - instanceProps.prevRowCount === 0 - ) { - instanceProps.prevColumnCount = 0; - instanceProps.prevRowCount = 0; - } - - // If scrolling is controlled outside this component, clear cache when scrolling stops - if ( - nextProps.autoHeight && - nextProps.isScrolling === false && - instanceProps.prevIsScrolling === true - ) { - Object.assign(newState, { - isScrolling: false, - }); - } - - let maybeStateA; - let maybeStateB; - - calculateSizeAndPositionDataAndUpdateScrollOffset({ - cellCount: instanceProps.prevColumnCount, - cellSize: - typeof instanceProps.prevColumnWidth === 'number' - ? instanceProps.prevColumnWidth - : null, - computeMetadataCallback: () => - instanceProps.columnSizeAndPositionManager.resetCell(0), - computeMetadataCallbackProps: nextProps, - nextCellsCount: nextProps.columnCount, - nextCellSize: - typeof nextProps.columnWidth === 'number' - ? nextProps.columnWidth - : null, - nextScrollToIndex: nextProps.scrollToColumn, - scrollToIndex: instanceProps.prevScrollToColumn, - updateScrollOffsetForScrollToIndex: () => { - maybeStateA = Grid._getScrollLeftForScrollToColumnStateUpdate( - nextProps, - prevState, - ); - }, - }); - calculateSizeAndPositionDataAndUpdateScrollOffset({ - cellCount: instanceProps.prevRowCount, - cellSize: - typeof instanceProps.prevRowHeight === 'number' - ? instanceProps.prevRowHeight - : null, - computeMetadataCallback: () => - instanceProps.rowSizeAndPositionManager.resetCell(0), - computeMetadataCallbackProps: nextProps, - nextCellsCount: nextProps.rowCount, - nextCellSize: - typeof nextProps.rowHeight === 'number' ? nextProps.rowHeight : null, - nextScrollToIndex: nextProps.scrollToRow, - scrollToIndex: instanceProps.prevScrollToRow, - updateScrollOffsetForScrollToIndex: () => { - maybeStateB = Grid._getScrollTopForScrollToRowStateUpdate( - nextProps, - prevState, - ); - }, - }); - - instanceProps.prevColumnCount = nextProps.columnCount; - instanceProps.prevColumnWidth = nextProps.columnWidth; - instanceProps.prevIsScrolling = nextProps.isScrolling === true; - instanceProps.prevRowCount = nextProps.rowCount; - instanceProps.prevRowHeight = nextProps.rowHeight; - instanceProps.prevScrollToColumn = nextProps.scrollToColumn; - instanceProps.prevScrollToRow = nextProps.scrollToRow; - - // getting scrollBarSize (moved from componentWillMount) - instanceProps.scrollbarSize = nextProps.getScrollbarSize(); - if (instanceProps.scrollbarSize === undefined) { - instanceProps.scrollbarSizeMeasured = false; - instanceProps.scrollbarSize = 0; - } else { - instanceProps.scrollbarSizeMeasured = true; - } - - newState.instanceProps = instanceProps; - - return {...newState, ...maybeStateA, ...maybeStateB}; - } - - render() { - const { - autoContainerWidth, - autoHeight, - autoWidth, - className, - containerProps, - containerRole, - containerStyle, - height, - id, - noContentRenderer, - role, - style, - tabIndex, - width, - } = this.props; - const {instanceProps, needToResetStyleCache} = this.state; - - const isScrolling = this._isScrolling(); - - const gridStyle: Object = { - boxSizing: 'border-box', - direction: 'ltr', - height: autoHeight ? 'auto' : height, - position: 'relative', - width: autoWidth ? 'auto' : width, - WebkitOverflowScrolling: 'touch', - willChange: 'transform', - }; - - if (needToResetStyleCache) { - this._styleCache = {}; - } - - // calculate _styleCache here - // if state.isScrolling (not from _isScrolling) then reset - if (!this.state.isScrolling) { - this._resetStyleCache(); - } - - // calculate children to render here - this._calculateChildrenToRender(this.props, this.state); - - const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); - const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); - - // Force browser to hide scrollbars when we know they aren't necessary. - // Otherwise once scrollbars appear they may not disappear again. - // For more info see issue #116 - const verticalScrollBarSize = - totalRowsHeight > height ? instanceProps.scrollbarSize : 0; - const horizontalScrollBarSize = - totalColumnsWidth > width ? instanceProps.scrollbarSize : 0; - - if ( - horizontalScrollBarSize !== this._horizontalScrollBarSize || - verticalScrollBarSize !== this._verticalScrollBarSize - ) { - this._horizontalScrollBarSize = horizontalScrollBarSize; - this._verticalScrollBarSize = verticalScrollBarSize; - this._scrollbarPresenceChanged = true; - } - - // Also explicitly init styles to 'auto' if scrollbars are required. - // This works around an obscure edge case where external CSS styles have not yet been loaded, - // But an initial scroll index of offset is set as an external prop. - // Without this style, Grid would render the correct range of cells but would NOT update its internal offset. - // This was originally reported via clauderic/react-infinite-calendar/issues/23 - gridStyle.overflowX = - totalColumnsWidth + verticalScrollBarSize <= width ? 'hidden' : 'auto'; - gridStyle.overflowY = - totalRowsHeight + horizontalScrollBarSize <= height ? 'hidden' : 'auto'; - - const childrenToDisplay = this._childrenToDisplay; - - const showNoContentRenderer = - childrenToDisplay.length === 0 && height > 0 && width > 0; - - return ( -
- {childrenToDisplay.length > 0 && ( -
- {childrenToDisplay} -
- )} - {showNoContentRenderer && noContentRenderer()} -
- ); - } - - /* ---------------------------- Helper methods ---------------------------- */ - - _calculateChildrenToRender( - props: Props = this.props, - state: State = this.state, - ) { - const { - cellRenderer, - cellRangeRenderer, - columnCount, - deferredMeasurementCache, - height, - overscanColumnCount, - overscanIndicesGetter, - overscanRowCount, - rowCount, - width, - isScrollingOptOut, - } = props; - - const { - scrollDirectionHorizontal, - scrollDirectionVertical, - instanceProps, - } = state; - - const scrollTop = - this._initialScrollTop > 0 ? this._initialScrollTop : state.scrollTop; - const scrollLeft = - this._initialScrollLeft > 0 ? this._initialScrollLeft : state.scrollLeft; - - const isScrolling = this._isScrolling(props, state); - - this._childrenToDisplay = []; - - // Render only enough columns and rows to cover the visible area of the grid. - if (height > 0 && width > 0) { - const visibleColumnIndices = instanceProps.columnSizeAndPositionManager.getVisibleCellRange( - { - containerSize: width, - offset: scrollLeft, - }, - ); - const visibleRowIndices = instanceProps.rowSizeAndPositionManager.getVisibleCellRange( - { - containerSize: height, - offset: scrollTop, - }, - ); - - const horizontalOffsetAdjustment = instanceProps.columnSizeAndPositionManager.getOffsetAdjustment( - { - containerSize: width, - offset: scrollLeft, - }, - ); - const verticalOffsetAdjustment = instanceProps.rowSizeAndPositionManager.getOffsetAdjustment( - { - containerSize: height, - offset: scrollTop, - }, - ); - - // Store for _invokeOnGridRenderedHelper() - this._renderedColumnStartIndex = visibleColumnIndices.start; - this._renderedColumnStopIndex = visibleColumnIndices.stop; - this._renderedRowStartIndex = visibleRowIndices.start; - this._renderedRowStopIndex = visibleRowIndices.stop; - - const overscanColumnIndices = overscanIndicesGetter({ - direction: 'horizontal', - cellCount: columnCount, - overscanCellsCount: overscanColumnCount, - scrollDirection: scrollDirectionHorizontal, - startIndex: - typeof visibleColumnIndices.start === 'number' - ? visibleColumnIndices.start - : 0, - stopIndex: - typeof visibleColumnIndices.stop === 'number' - ? visibleColumnIndices.stop - : -1, - }); - - const overscanRowIndices = overscanIndicesGetter({ - direction: 'vertical', - cellCount: rowCount, - overscanCellsCount: overscanRowCount, - scrollDirection: scrollDirectionVertical, - startIndex: - typeof visibleRowIndices.start === 'number' - ? visibleRowIndices.start - : 0, - stopIndex: - typeof visibleRowIndices.stop === 'number' - ? visibleRowIndices.stop - : -1, - }); - - // Store for _invokeOnGridRenderedHelper() - let columnStartIndex = overscanColumnIndices.overscanStartIndex; - let columnStopIndex = overscanColumnIndices.overscanStopIndex; - let rowStartIndex = overscanRowIndices.overscanStartIndex; - let rowStopIndex = overscanRowIndices.overscanStopIndex; - - // Advanced use-cases (eg CellMeasurer) require batched measurements to determine accurate sizes. - if (deferredMeasurementCache) { - // If rows have a dynamic height, scan the rows we are about to render. - // If any have not yet been measured, then we need to render all columns initially, - // Because the height of the row is equal to the tallest cell within that row, - // (And so we can't know the height without measuring all column-cells first). - if (!deferredMeasurementCache.hasFixedHeight()) { - for ( - let rowIndex = rowStartIndex; - rowIndex <= rowStopIndex; - rowIndex++ - ) { - if (!deferredMeasurementCache.has(rowIndex, 0)) { - columnStartIndex = 0; - columnStopIndex = columnCount - 1; - break; - } - } - } - - // If columns have a dynamic width, scan the columns we are about to render. - // If any have not yet been measured, then we need to render all rows initially, - // Because the width of the column is equal to the widest cell within that column, - // (And so we can't know the width without measuring all row-cells first). - if (!deferredMeasurementCache.hasFixedWidth()) { - for ( - let columnIndex = columnStartIndex; - columnIndex <= columnStopIndex; - columnIndex++ - ) { - if (!deferredMeasurementCache.has(0, columnIndex)) { - rowStartIndex = 0; - rowStopIndex = rowCount - 1; - break; - } - } - } - } - - this._childrenToDisplay = cellRangeRenderer({ - cellCache: this._cellCache, - cellRenderer, - columnSizeAndPositionManager: - instanceProps.columnSizeAndPositionManager, - columnStartIndex, - columnStopIndex, - deferredMeasurementCache, - horizontalOffsetAdjustment, - isScrolling, - isScrollingOptOut, - parent: this, - rowSizeAndPositionManager: instanceProps.rowSizeAndPositionManager, - rowStartIndex, - rowStopIndex, - scrollLeft, - scrollTop, - styleCache: this._styleCache, - verticalOffsetAdjustment, - visibleColumnIndices, - visibleRowIndices, - }); - - // update the indices - this._columnStartIndex = columnStartIndex; - this._columnStopIndex = columnStopIndex; - this._rowStartIndex = rowStartIndex; - this._rowStopIndex = rowStopIndex; - } - } - - /** - * Sets an :isScrolling flag for a small window of time. - * This flag is used to disable pointer events on the scrollable portion of the Grid. - * This prevents jerky/stuttery mouse-wheel scrolling. - */ - _debounceScrollEnded() { - const {scrollingResetTimeInterval} = this.props; - - if (this._disablePointerEventsTimeoutId) { - cancelAnimationTimeout(this._disablePointerEventsTimeoutId); - } - - this._disablePointerEventsTimeoutId = requestAnimationTimeout( - this._debounceScrollEndedCallback, - scrollingResetTimeInterval, - ); - } - - _debounceScrollEndedCallback = () => { - this._disablePointerEventsTimeoutId = null; - // isScrolling is used to determine if we reset styleCache - this.setState({ - isScrolling: false, - needToResetStyleCache: false, - }); - }; - - static _getEstimatedColumnSize(props: Props) { - return typeof props.columnWidth === 'number' - ? props.columnWidth - : props.estimatedColumnSize; - } - - static _getEstimatedRowSize(props: Props) { - return typeof props.rowHeight === 'number' - ? props.rowHeight - : props.estimatedRowSize; - } - - /** - * Check for batched CellMeasurer size invalidations. - * This will occur the first time one or more previously unmeasured cells are rendered. - */ - _handleInvalidatedGridSize() { - if ( - typeof this._deferredInvalidateColumnIndex === 'number' && - typeof this._deferredInvalidateRowIndex === 'number' - ) { - const columnIndex = this._deferredInvalidateColumnIndex; - const rowIndex = this._deferredInvalidateRowIndex; - - this._deferredInvalidateColumnIndex = null; - this._deferredInvalidateRowIndex = null; - - this.recomputeGridSize({columnIndex, rowIndex}); - } - } - - _invokeOnGridRenderedHelper = () => { - const {onSectionRendered} = this.props; - - this._onGridRenderedMemoizer({ - callback: onSectionRendered, - indices: { - columnOverscanStartIndex: this._columnStartIndex, - columnOverscanStopIndex: this._columnStopIndex, - columnStartIndex: this._renderedColumnStartIndex, - columnStopIndex: this._renderedColumnStopIndex, - rowOverscanStartIndex: this._rowStartIndex, - rowOverscanStopIndex: this._rowStopIndex, - rowStartIndex: this._renderedRowStartIndex, - rowStopIndex: this._renderedRowStopIndex, - }, - }); - }; - - _invokeOnScrollMemoizer({ - scrollLeft, - scrollTop, - totalColumnsWidth, - totalRowsHeight, - }: { - scrollLeft: number, - scrollTop: number, - totalColumnsWidth: number, - totalRowsHeight: number, - }) { - this._onScrollMemoizer({ - callback: ({scrollLeft, scrollTop}) => { - const {height, onScroll, width} = this.props; - - onScroll({ - clientHeight: height, - clientWidth: width, - scrollHeight: totalRowsHeight, - scrollLeft, - scrollTop, - scrollWidth: totalColumnsWidth, - }); - }, - indices: { - scrollLeft, - scrollTop, - }, - }); - } - - _isScrolling(props: Props = this.props, state: State = this.state): boolean { - // If isScrolling is defined in props, use it to override the value in state - // This is a performance optimization for WindowScroller + Grid - return Object.hasOwnProperty.call(props, 'isScrolling') - ? Boolean(props.isScrolling) - : Boolean(state.isScrolling); - } - - _maybeCallOnScrollbarPresenceChange() { - if (this._scrollbarPresenceChanged) { - const {onScrollbarPresenceChange} = this.props; - - this._scrollbarPresenceChanged = false; - - onScrollbarPresenceChange({ - horizontal: this._horizontalScrollBarSize > 0, - size: this.state.instanceProps.scrollbarSize, - vertical: this._verticalScrollBarSize > 0, - }); - } - } - - _setScrollingContainerRef = (ref: Element) => { - this._scrollingContainer = ref; - - if (typeof this.props.elementRef === 'function') { - this.props.elementRef(ref); - } else if (typeof this.props.elementRef === 'object') { - this.props.elementRef.current = ref; - } - }; - - /** - * Get the updated state after scrolling to - * scrollLeft and scrollTop - */ - static _getScrollToPositionStateUpdate({ - prevState, - scrollLeft, - scrollTop, - }: { - prevState: State, - scrollLeft?: number, - scrollTop?: number, - }): $Shape { - const newState: Object = { - scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED, - }; - - if (typeof scrollLeft === 'number' && scrollLeft >= 0) { - newState.scrollDirectionHorizontal = - scrollLeft > prevState.scrollLeft - ? SCROLL_DIRECTION_FORWARD - : SCROLL_DIRECTION_BACKWARD; - newState.scrollLeft = scrollLeft; - } - - if (typeof scrollTop === 'number' && scrollTop >= 0) { - newState.scrollDirectionVertical = - scrollTop > prevState.scrollTop - ? SCROLL_DIRECTION_FORWARD - : SCROLL_DIRECTION_BACKWARD; - newState.scrollTop = scrollTop; - } - - if ( - (typeof scrollLeft === 'number' && - scrollLeft >= 0 && - scrollLeft !== prevState.scrollLeft) || - (typeof scrollTop === 'number' && - scrollTop >= 0 && - scrollTop !== prevState.scrollTop) - ) { - return newState; - } - return {}; - } - - /** - * Scroll to the specified offset(s). - * Useful for animating position changes. - */ - scrollToPosition({scrollLeft, scrollTop}: ScrollPosition) { - const stateUpdate = Grid._getScrollToPositionStateUpdate({ - prevState: this.state, - scrollLeft, - scrollTop, - }); - - if (stateUpdate) { - stateUpdate.needToResetStyleCache = false; - this.setState(stateUpdate); - } - } - - static _wrapSizeGetter(value: CellSize): CellSizeGetter { - return typeof value === 'function' ? value : () => (value: any); - } - - static _getCalculatedScrollLeft(nextProps: Props, prevState: State) { - const { - columnCount, - height, - scrollToAlignment, - scrollToColumn, - width, - } = nextProps; - const {scrollLeft, instanceProps} = prevState; - - if (columnCount > 0) { - const finalColumn = columnCount - 1; - const targetIndex = - scrollToColumn < 0 - ? finalColumn - : Math.min(finalColumn, scrollToColumn); - const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); - const scrollBarSize = - instanceProps.scrollbarSizeMeasured && totalRowsHeight > height - ? instanceProps.scrollbarSize - : 0; - - return instanceProps.columnSizeAndPositionManager.getUpdatedOffsetForIndex( - { - align: scrollToAlignment, - containerSize: width - scrollBarSize, - currentOffset: scrollLeft, - targetIndex, - }, - ); - } - return 0; - } - - _getCalculatedScrollLeft( - props: Props = this.props, - state: State = this.state, - ) { - return Grid._getCalculatedScrollLeft(props, state); - } - - static _getScrollLeftForScrollToColumnStateUpdate( - nextProps: Props, - prevState: State, - ): $Shape { - const {scrollLeft} = prevState; - const calculatedScrollLeft = Grid._getCalculatedScrollLeft( - nextProps, - prevState, - ); - - if ( - typeof calculatedScrollLeft === 'number' && - calculatedScrollLeft >= 0 && - scrollLeft !== calculatedScrollLeft - ) { - return Grid._getScrollToPositionStateUpdate({ - prevState, - scrollLeft: calculatedScrollLeft, - scrollTop: -1, - }); - } - return {}; - } - - _updateScrollLeftForScrollToColumn( - props: Props = this.props, - state: State = this.state, - ) { - const stateUpdate = Grid._getScrollLeftForScrollToColumnStateUpdate( - props, - state, - ); - if (stateUpdate) { - stateUpdate.needToResetStyleCache = false; - this.setState(stateUpdate); - } - } - - static _getCalculatedScrollTop(nextProps: Props, prevState: State) { - const {height, rowCount, scrollToAlignment, scrollToRow, width} = nextProps; - const {scrollTop, instanceProps} = prevState; - - if (rowCount > 0) { - const finalRow = rowCount - 1; - const targetIndex = - scrollToRow < 0 ? finalRow : Math.min(finalRow, scrollToRow); - const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); - const scrollBarSize = - instanceProps.scrollbarSizeMeasured && totalColumnsWidth > width - ? instanceProps.scrollbarSize - : 0; - - return instanceProps.rowSizeAndPositionManager.getUpdatedOffsetForIndex({ - align: scrollToAlignment, - containerSize: height - scrollBarSize, - currentOffset: scrollTop, - targetIndex, - }); - } - return 0; - } - - _getCalculatedScrollTop( - props: Props = this.props, - state: State = this.state, - ) { - return Grid._getCalculatedScrollTop(props, state); - } - - _resetStyleCache() { - const styleCache = this._styleCache; - const cellCache = this._cellCache; - const {isScrollingOptOut} = this.props; - - // Reset cell and style caches once scrolling stops. - // This makes Grid simpler to use (since cells commonly change). - // And it keeps the caches from growing too large. - // Performance is most sensitive when a user is scrolling. - // Don't clear visible cells from cellCache if isScrollingOptOut is specified. - // This keeps the cellCache to a resonable size. - this._cellCache = {}; - this._styleCache = {}; - - // Copy over the visible cell styles so avoid unnecessary re-render. - for ( - let rowIndex = this._rowStartIndex; - rowIndex <= this._rowStopIndex; - rowIndex++ - ) { - for ( - let columnIndex = this._columnStartIndex; - columnIndex <= this._columnStopIndex; - columnIndex++ - ) { - let key = `${rowIndex}-${columnIndex}`; - this._styleCache[key] = styleCache[key]; - - if (isScrollingOptOut) { - this._cellCache[key] = cellCache[key]; - } - } - } - } - - static _getScrollTopForScrollToRowStateUpdate( - nextProps: Props, - prevState: State, - ): $Shape { - const {scrollTop} = prevState; - const calculatedScrollTop = Grid._getCalculatedScrollTop( - nextProps, - prevState, - ); - - if ( - typeof calculatedScrollTop === 'number' && - calculatedScrollTop >= 0 && - scrollTop !== calculatedScrollTop - ) { - return Grid._getScrollToPositionStateUpdate({ - prevState, - scrollLeft: -1, - scrollTop: calculatedScrollTop, - }); - } - return {}; - } - - _updateScrollTopForScrollToRow( - props: Props = this.props, - state: State = this.state, - ) { - const stateUpdate = Grid._getScrollTopForScrollToRowStateUpdate( - props, - state, - ); - if (stateUpdate) { - stateUpdate.needToResetStyleCache = false; - this.setState(stateUpdate); - } - } - - _onScroll = (event: Event) => { - // In certain edge-cases React dispatches an onScroll event with an invalid target.scrollLeft / target.scrollTop. - // This invalid event can be detected by comparing event.target to this component's scrollable DOM element. - // See issue #404 for more information. - if (event.target === this._scrollingContainer) { - this.handleScrollEvent((event.target: any)); - } - }; -} - -polyfill(Grid); -export default Grid; diff --git a/source/Grid/Grid.ssr.js b/source/Grid/Grid.ssr.js deleted file mode 100644 index eed80cf15..000000000 --- a/source/Grid/Grid.ssr.js +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @flow - * @jest-environment node - */ - -import * as React from 'react'; -import * as ReactDOMServer from 'react-dom/server'; -import Grid from './Grid'; - -declare var test: any; -declare var expect: any; - -test('should render Grid with dom server', () => { - const rendered = ReactDOMServer.renderToString( - ( -
- {rowIndex + ':' + columnIndex} -
- )} - columnCount={1000} - columnWidth={20} - height={500} - rowCount={1000} - rowHeight={20} - width={500} - />, - ); - - expect(rendered).toContain('0:0'); - expect(rendered).toContain('24:24'); - expect(rendered).not.toContain('25:25'); -}); - -test('should support :scrollToColumn and :scrollToRow in server render', () => { - const rendered = ReactDOMServer.renderToString( - ( -
- {rowIndex + ':' + columnIndex} -
- )} - columnCount={1000} - columnWidth={20} - scrollToColumn={250} - height={500} - rowCount={1000} - rowHeight={20} - scrollToRow={250} - width={500} - />, - ); - - expect(rendered).toContain('250:250'); - expect(rendered).not.toContain('0:0'); -}); diff --git a/source/Grid/index.js b/source/Grid/index.js deleted file mode 100644 index 1507c9b0d..000000000 --- a/source/Grid/index.js +++ /dev/null @@ -1,18 +0,0 @@ -/** @flow */ - -export type { - NoContentRenderer, - Alignment, - CellPosition, - CellSize, - OverscanIndicesGetter, - RenderedSection, - CellRendererParams, - Scroll, -} from './types'; - -export {default} from './Grid'; -export {default as Grid} from './Grid'; -export {default as accessibilityOverscanIndicesGetter} from './accessibilityOverscanIndicesGetter'; -export {default as defaultCellRangeRenderer} from './defaultCellRangeRenderer'; -export {default as defaultOverscanIndicesGetter} from './defaultOverscanIndicesGetter'; diff --git a/source/Grid/types.js b/source/Grid/types.js deleted file mode 100644 index 190d48e59..000000000 --- a/source/Grid/types.js +++ /dev/null @@ -1,115 +0,0 @@ -// @flow - -import * as React from 'react'; -import ScalingCellSizeAndPositionManager from './utils/ScalingCellSizeAndPositionManager'; - -export type CellPosition = {columnIndex: number, rowIndex: number}; - -export type CellRendererParams = { - columnIndex: number, - isScrolling: boolean, - isVisible: boolean, - key: string, - parent: Object, - rowIndex: number, - style: Object, -}; - -export type CellRenderer = (props: CellRendererParams) => React.Element<*>; - -export type CellCache = {[key: string]: React.Element<*>}; -export type StyleCache = {[key: string]: Object}; - -export type CellRangeRendererParams = { - cellCache: CellCache, - cellRenderer: CellRenderer, - columnSizeAndPositionManager: ScalingCellSizeAndPositionManager, - columnStartIndex: number, - columnStopIndex: number, - deferredMeasurementCache?: Object, - horizontalOffsetAdjustment: number, - isScrolling: boolean, - isScrollingOptOut: boolean, - parent: Object, - rowSizeAndPositionManager: ScalingCellSizeAndPositionManager, - rowStartIndex: number, - rowStopIndex: number, - scrollLeft: number, - scrollTop: number, - styleCache: StyleCache, - verticalOffsetAdjustment: number, - visibleColumnIndices: Object, - visibleRowIndices: Object, -}; - -export type CellRangeRenderer = ( - params: CellRangeRendererParams, -) => React.Element<*>[]; - -export type CellSizeGetter = (params: {index: number}) => number; - -export type CellSize = CellSizeGetter | number; - -export type NoContentRenderer = () => React.Element<*> | null; - -export type Scroll = { - clientHeight: number, - clientWidth: number, - scrollHeight: number, - scrollLeft: number, - scrollTop: number, - scrollWidth: number, -}; - -export type ScrollbarPresenceChange = { - horizontal: boolean, - vertical: boolean, - size: number, -}; - -export type RenderedSection = { - columnOverscanStartIndex: number, - columnOverscanStopIndex: number, - columnStartIndex: number, - columnStopIndex: number, - rowOverscanStartIndex: number, - rowOverscanStopIndex: number, - rowStartIndex: number, - rowStopIndex: number, -}; - -export type OverscanIndicesGetterParams = { - // One of SCROLL_DIRECTION_HORIZONTAL or SCROLL_DIRECTION_VERTICAL - direction: 'horizontal' | 'vertical', - - // One of SCROLL_DIRECTION_BACKWARD or SCROLL_DIRECTION_FORWARD - scrollDirection: -1 | 1, - - // Number of rows or columns in the current axis - cellCount: number, - - // Maximum number of cells to over-render in either direction - overscanCellsCount: number, - - // Begin of range of visible cells - startIndex: number, - - // End of range of visible cells - stopIndex: number, -}; - -export type OverscanIndices = { - overscanStartIndex: number, - overscanStopIndex: number, -}; - -export type OverscanIndicesGetter = ( - params: OverscanIndicesGetterParams, -) => OverscanIndices; - -export type Alignment = 'auto' | 'end' | 'start' | 'center'; - -export type VisibleCellRange = { - start?: number, - stop?: number, -}; diff --git a/source/Grid/utils/CellSizeAndPositionManager.jest.js b/source/Grid/utils/CellSizeAndPositionManager.jest.js deleted file mode 100644 index 9c9cf5394..000000000 --- a/source/Grid/utils/CellSizeAndPositionManager.jest.js +++ /dev/null @@ -1,418 +0,0 @@ -import CellSizeAndPositionManager from './CellSizeAndPositionManager'; - -describe('CellSizeAndPositionManager', () => { - function getCellSizeAndPositionManager({ - cellCount = 100, - estimatedCellSize = 15, - } = {}) { - const cellSizeGetterCalls = []; - const cellSizeAndPositionManager = new CellSizeAndPositionManager({ - cellCount, - cellSizeGetter: ({index}) => { - cellSizeGetterCalls.push(index); - return 10; - }, - estimatedCellSize, - }); - - return { - cellSizeAndPositionManager, - cellSizeGetterCalls, - }; - } - - describe('configure', () => { - it('should update inner :cellCount and :estimatedCellSize', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(cellSizeAndPositionManager.getCellCount()).toEqual(100); - expect(cellSizeAndPositionManager.getEstimatedCellSize()).toEqual(15); - - cellSizeAndPositionManager.configure({ - cellCount: 20, - estimatedCellSize: 30, - }); - expect(cellSizeAndPositionManager.getCellCount()).toEqual(20); - expect(cellSizeAndPositionManager.getEstimatedCellSize()).toEqual(30); - }); - }); - - describe('findNearestCell', () => { - it('should error if given NaN', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(() => cellSizeAndPositionManager._findNearestCell(NaN)).toThrow(); - }); - - it('should gracefully handle offets outisde of bounds (to account for elastic scrolling)', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(cellSizeAndPositionManager._findNearestCell(-100)).toEqual(0); - expect(cellSizeAndPositionManager._findNearestCell(1234567890)).toEqual( - 99, - ); - }); - - it('should find the first cell', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(cellSizeAndPositionManager._findNearestCell(0)).toEqual(0); - expect(cellSizeAndPositionManager._findNearestCell(9)).toEqual(0); - }); - - it('should find the last cell', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(cellSizeAndPositionManager._findNearestCell(990)).toEqual(99); - expect(cellSizeAndPositionManager._findNearestCell(991)).toEqual(99); - }); - - it('should find the a cell that exactly matches a specified offset in the middle', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(cellSizeAndPositionManager._findNearestCell(100)).toEqual(10); - }); - - it('should find the cell closest to (but before) the specified offset in the middle', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(cellSizeAndPositionManager._findNearestCell(101)).toEqual(10); - }); - }); - - describe('getSizeAndPositionOfCell', () => { - it('should error if an invalid index is specified', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(() => - cellSizeAndPositionManager.getSizeAndPositionOfCell(-1), - ).toThrow(); - expect(() => - cellSizeAndPositionManager.getSizeAndPositionOfCell(100), - ).toThrow(); - }); - - it('should return the correct size and position information for the requested cell', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect( - cellSizeAndPositionManager.getSizeAndPositionOfCell(0).offset, - ).toEqual(0); - expect( - cellSizeAndPositionManager.getSizeAndPositionOfCell(0).size, - ).toEqual(10); - expect( - cellSizeAndPositionManager.getSizeAndPositionOfCell(1).offset, - ).toEqual(10); - expect( - cellSizeAndPositionManager.getSizeAndPositionOfCell(2).offset, - ).toEqual(20); - }); - - it('should only measure the necessary cells to return the information requested', () => { - const { - cellSizeAndPositionManager, - cellSizeGetterCalls, - } = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(0); - expect(cellSizeGetterCalls).toEqual([0]); - }); - - it('should just-in-time measure all cells up to the requested cell if no cells have yet been measured', () => { - const { - cellSizeAndPositionManager, - cellSizeGetterCalls, - } = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(5); - expect(cellSizeGetterCalls).toEqual([0, 1, 2, 3, 4, 5]); - }); - - it('should just-in-time measure cells up to the requested cell if some but not all cells have been measured', () => { - const { - cellSizeAndPositionManager, - cellSizeGetterCalls, - } = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(5); - cellSizeGetterCalls.splice(0); - cellSizeAndPositionManager.getSizeAndPositionOfCell(10); - expect(cellSizeGetterCalls).toEqual([6, 7, 8, 9, 10]); - }); - - it('should return cached size and position data if cell has already been measured', () => { - const { - cellSizeAndPositionManager, - cellSizeGetterCalls, - } = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(5); - cellSizeGetterCalls.splice(0); - cellSizeAndPositionManager.getSizeAndPositionOfCell(5); - expect(cellSizeGetterCalls).toEqual([]); - }); - }); - - describe('getSizeAndPositionOfLastMeasuredCell', () => { - it('should return an empty object if no cached cells are present', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect( - cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell(), - ).toEqual({ - offset: 0, - size: 0, - }); - }); - - it('should return size and position data for the highest/last measured cell', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(5); - expect( - cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell(), - ).toEqual({ - offset: 50, - size: 10, - }); - }); - }); - - describe('getTotalSize', () => { - it('should calculate total size based purely on :estimatedCellSize if no measurements have been done', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - expect(cellSizeAndPositionManager.getTotalSize()).toEqual(1500); - }); - - it('should calculate total size based on a mixture of actual cell sizes and :estimatedCellSize if some cells have been measured', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(49); - expect(cellSizeAndPositionManager.getTotalSize()).toEqual(1250); - }); - - it('should calculate total size based on the actual measured sizes if all cells have been measured', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(99); - expect(cellSizeAndPositionManager.getTotalSize()).toEqual(1000); - }); - }); - - describe('getUpdatedOffsetForIndex', () => { - function getUpdatedOffsetForIndexHelper({ - align = 'auto', - cellCount = 10, - cellSize = 10, - containerSize = 50, - currentOffset = 0, - estimatedCellSize = 15, - targetIndex = 0, - }) { - const cellSizeAndPositionManager = new CellSizeAndPositionManager({ - cellCount, - cellSizeGetter: () => cellSize, - estimatedCellSize, - }); - - return cellSizeAndPositionManager.getUpdatedOffsetForIndex({ - align, - containerSize, - currentOffset, - targetIndex, - }); - } - - it('should scroll to the beginning', () => { - expect( - getUpdatedOffsetForIndexHelper({ - currentOffset: 100, - targetIndex: 0, - }), - ).toEqual(0); - }); - - it('should scroll to the end', () => { - expect( - getUpdatedOffsetForIndexHelper({ - currentOffset: 0, - targetIndex: 9, - }), - ).toEqual(50); - }); - - it('should scroll forward to the middle', () => { - expect( - getUpdatedOffsetForIndexHelper({ - currentOffset: 0, - targetIndex: 6, - }), - ).toEqual(20); - }); - - it('should scroll backward to the middle', () => { - expect( - getUpdatedOffsetForIndexHelper({ - currentOffset: 50, - targetIndex: 2, - }), - ).toEqual(20); - }); - - it('should not scroll if an item is already visible', () => { - expect( - getUpdatedOffsetForIndexHelper({ - currentOffset: 20, - targetIndex: 3, - }), - ).toEqual(20); - }); - - it('should honor specified :align values', () => { - expect( - getUpdatedOffsetForIndexHelper({ - align: 'auto', - currentOffset: 0, - targetIndex: 5, - }), - ).toEqual(10); - expect( - getUpdatedOffsetForIndexHelper({ - align: 'start', - currentOffset: 0, - targetIndex: 5, - }), - ).toEqual(50); - expect( - getUpdatedOffsetForIndexHelper({ - align: 'auto', - currentOffset: 50, - targetIndex: 4, - }), - ).toEqual(40); - expect( - getUpdatedOffsetForIndexHelper({ - align: 'end', - currentOffset: 50, - targetIndex: 5, - }), - ).toEqual(10); - expect( - getUpdatedOffsetForIndexHelper({ - align: 'center', - currentOffset: 50, - targetIndex: 5, - }), - ).toEqual(30); - }); - - it('should not scroll past the safe bounds even if the specified :align requests it', () => { - expect( - getUpdatedOffsetForIndexHelper({ - align: 'end', - currentOffset: 50, - targetIndex: 0, - }), - ).toEqual(0); - expect( - getUpdatedOffsetForIndexHelper({ - align: 'center', - currentOffset: 50, - targetIndex: 1, - }), - ).toEqual(0); - expect( - getUpdatedOffsetForIndexHelper({ - align: 'start', - currentOffset: 0, - targetIndex: 9, - }), - ).toEqual(50); - - // TRICKY: We would expect this to be positioned at 50. - // But since the :estimatedCellSize is 15 and we only measure up to the 8th item, - // The helper assumes it can scroll farther than it actually can. - // Not sure if this edge case is worth "fixing" or just acknowledging... - expect( - getUpdatedOffsetForIndexHelper({ - align: 'center', - currentOffset: 0, - targetIndex: 8, - }), - ).toEqual(55); - }); - - it('should always return an offset of 0 when :containerSize is 0', () => { - expect( - getUpdatedOffsetForIndexHelper({ - containerSize: 0, - currentOffset: 50, - targetIndex: 2, - }), - ).toEqual(0); - }); - }); - - describe('getVisibleCellRange', () => { - it('should not return any indices if :cellCount is 0', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager({ - cellCount: 0, - }); - const {start, stop} = cellSizeAndPositionManager.getVisibleCellRange({ - containerSize: 50, - offset: 0, - }); - expect(start).toEqual(undefined); - expect(stop).toEqual(undefined); - }); - - it('should return a visible range of cells for the beginning of the list', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - const {start, stop} = cellSizeAndPositionManager.getVisibleCellRange({ - containerSize: 50, - offset: 0, - }); - expect(start).toEqual(0); - expect(stop).toEqual(4); - }); - - it('should return a visible range of cells for the middle of the list where some are partially visible', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - const {start, stop} = cellSizeAndPositionManager.getVisibleCellRange({ - containerSize: 50, - offset: 425, - }); - // 42 and 47 are partially visible - expect(start).toEqual(42); - expect(stop).toEqual(47); - }); - - it('should return a visible range of cells for the end of the list', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - const {start, stop} = cellSizeAndPositionManager.getVisibleCellRange({ - containerSize: 50, - offset: 950, - }); - expect(start).toEqual(95); - expect(stop).toEqual(99); - }); - }); - - describe('resetCell', () => { - it('should clear size and position metadata for the specified index and all cells after it', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(5); - cellSizeAndPositionManager.resetCell(3); - expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(2); - cellSizeAndPositionManager.resetCell(0); - expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1); - }); - - it('should not clear size and position metadata for cells before the specified index', () => { - const { - cellSizeAndPositionManager, - cellSizeGetterCalls, - } = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(5); - cellSizeGetterCalls.splice(0); - cellSizeAndPositionManager.resetCell(3); - cellSizeAndPositionManager.getSizeAndPositionOfCell(4); - expect(cellSizeGetterCalls).toEqual([3, 4]); - }); - - it('should not skip over any unmeasured or previously-cleared cells', () => { - const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); - cellSizeAndPositionManager.getSizeAndPositionOfCell(5); - cellSizeAndPositionManager.resetCell(2); - expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); - cellSizeAndPositionManager.resetCell(4); - expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); - cellSizeAndPositionManager.resetCell(0); - expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1); - }); - }); -}); diff --git a/source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js b/source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js deleted file mode 100644 index dd57cfeb8..000000000 --- a/source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js +++ /dev/null @@ -1,200 +0,0 @@ -import ScalingCellSizeAndPositionManager from './ScalingCellSizeAndPositionManager'; - -describe('ScalingCellSizeAndPositionManager', () => { - function init({ - cellCount = 10, - cellSize = 10, - estimatedCellSize = 10, - maxScrollSize = 50, - } = {}) { - const cellSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ - cellCount, - cellSizeGetter: () => cellSize, - estimatedCellSize, - maxScrollSize, - }); - - return cellSizeAndPositionManager; - } - - describe('_getOffsetPercentage', () => { - it('should return the correct offset fraction', () => { - var expectations = [ - {offset: 0, expectedOffsetPercentage: 0}, - {offset: 35, expectedOffsetPercentage: 0.5}, - {offset: 70, expectedOffsetPercentage: 1}, - ]; - const instance = init(); - expectations.forEach(expectation => { - expect( - instance._getOffsetPercentage({ - containerSize: 30, - offset: expectation.offset, - totalSize: 100, - }), - ).toBe(expectation.expectedOffsetPercentage); - }); - }); - }); - - describe('getOffsetAdjustment', () => { - it('should always return 0 as the adjustment for unscaled lists', () => { - const maxScrollSizes = [100, 150]; - maxScrollSizes.forEach(maxScrollSize => { - const instance = init({ - cellCount: 10, - maxScrollSize, - }); - const offsets = [0, 35, 70]; - offsets.forEach(offset => { - expect( - instance.getOffsetAdjustment({ - containerSize: 30, - offset, - }), - ).toBe(0); - }); - }); - }); - - it('should properly scale an offset at the beginning, middle, and end of the list', () => { - var offsetsAndExpectedAdjustements = [ - {offset: 0, expectedAdjustment: -0}, - {offset: 10, expectedAdjustment: -25}, - {offset: 20, expectedAdjustment: -50}, - ]; - const instance = init(); - offsetsAndExpectedAdjustements.forEach(offsetAndExpectedAdjustement => { - expect( - instance.getOffsetAdjustment({ - containerSize: 30, - offset: offsetAndExpectedAdjustement.offset, - }), - ).toBe(offsetAndExpectedAdjustement.expectedAdjustment); - }); - }); - }); - - describe('getTotalSize', () => { - it('should return :totalSize if it is not greater than :maxScrollSize', () => { - const maxScrollSizes = [500, 750]; - maxScrollSizes.forEach(maxScrollSize => { - const instance = init({ - cellCount: 50, - maxScrollSize, - }); - expect(instance.getTotalSize()).toEqual(500); - }); - }); - - it('should return :maxScrollSize if :totalSize is greater', () => { - const instance = init({ - cellCount: 100, - maxScrollSize: 100, - }); - expect(instance.getTotalSize()).toEqual(100); - }); - }); - - describe('getUpdatedOffsetForIndex', () => { - it('should scroll to a cell before the current range', () => { - const data = [ - {targetIndex: 0, expectedOffset: 0}, - {targetIndex: 1, expectedOffset: 3}, // (unsafe: 10) - {targetIndex: 2, expectedOffset: 6}, // (unsafe: 20) - ]; - const instance = init(); - data.forEach(datum => { - expect( - instance.getUpdatedOffsetForIndex({ - containerSize: 30, - currentOffset: 10, // (unsafe: 35) - targetIndex: datum.targetIndex, - }), - ).toBe(datum.expectedOffset); - }); - }); - - it('should scroll to a cell after the current range', () => { - const data = [ - {targetIndex: 7, expectedOffset: 14}, // (unsafe: 50) - {targetIndex: 9, expectedOffset: 20}, // (unsafe: 70) - ]; - const instance = init(); - data.forEach(datum => { - expect( - instance.getUpdatedOffsetForIndex({ - containerSize: 30, - currentOffset: 0, - targetIndex: datum.targetIndex, - }), - ).toBe(datum.expectedOffset); - }); - }); - - it('should not scroll to a cell already visible within the current range', () => { - const instance = init(); - expect( - instance.getUpdatedOffsetForIndex({ - containerSize: 30, - currentOffset: 10, // (unsafe: 35) - targetIndex: 4, - }), - ).toBe(10); - }); - }); - - describe('getVisibleCellRange', () => { - it('should correct identify the first set of cells', () => { - const instance = init(); - expect( - instance.getVisibleCellRange({ - containerSize: 30, - offset: 0, - }), - ).toEqual({ - start: 0, - stop: 2, - }); - }); - - it('should correct identify cells in the middle', () => { - const instance = init(); - expect( - instance.getVisibleCellRange({ - containerSize: 30, - offset: 2.85, // (unsafe: 10) - }), - ).toEqual({ - start: 1, - stop: 3, - }); - }); - - it('should correct identify partially visible cells', () => { - const instance = init(); - expect( - instance.getVisibleCellRange({ - containerSize: 30, - offset: 10, // (unsafe: 35) - }), - ).toEqual({ - start: 3, - stop: 6, - }); - }); - - it('should correct identify the last set of cells', () => { - const instance = init(); - expect( - instance.getVisibleCellRange({ - containerSize: 30, - offset: 20, - }), - ).toEqual({ - start: 7, - stop: 9, - }); - }); - }); -}); diff --git a/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js b/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js deleted file mode 100644 index ee9ce455c..000000000 --- a/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js +++ /dev/null @@ -1,77 +0,0 @@ -import calculateSizeAndPositionDataAndUpdateScrollOffset from './calculateSizeAndPositionDataAndUpdateScrollOffset'; - -describe('calculateSizeAndPositionDataAndUpdateScrollOffset', () => { - function helper({ - cellCount = 100, - cellSize = 10, - computeMetadataCallbackProps = {}, - nextCellsCount = 100, - nextCellSize = 10, - nextScrollToIndex, - scrollToIndex, - } = {}) { - const computeMetadataCallbackCalls = []; - const updateScrollOffsetForScrollToIndexCalls = []; - - calculateSizeAndPositionDataAndUpdateScrollOffset({ - cellCount, - cellSize, - computeMetadataCallback: params => - computeMetadataCallbackCalls.push(params), - computeMetadataCallbackProps, - nextCellsCount, - nextCellSize, - nextScrollToIndex, - scrollToIndex, - updateScrollOffsetForScrollToIndex: params => - updateScrollOffsetForScrollToIndexCalls.push(params), - }); - - return { - computeMetadataCallbackCalls, - updateScrollOffsetForScrollToIndexCalls, - }; - } - - it('should call :computeMetadataCallback if :cellCount has changed', () => { - const {computeMetadataCallbackCalls} = helper({ - cellCount: 100, - nextCellsCount: 200, - }); - expect(computeMetadataCallbackCalls.length).toEqual(1); - }); - - it('should call :computeMetadataCallback if numeric :cellSize has changed', () => { - const {computeMetadataCallbackCalls} = helper({ - cellSize: 10, - nextCellSize: 20, - }); - expect(computeMetadataCallbackCalls.length).toEqual(1); - }); - - it('should not call :computeMetadataCallback if :cellSize callback has changed', () => { - const {computeMetadataCallbackCalls} = helper({ - cellSize: () => {}, - nextCellSize: () => {}, - }); - expect(computeMetadataCallbackCalls.length).toEqual(0); - }); - - it('should not call :updateScrollOffsetForScrollToIndex if :scrollToIndex is not specified', () => { - const {updateScrollOffsetForScrollToIndexCalls} = helper(); - expect(updateScrollOffsetForScrollToIndexCalls.length).toEqual(0); - }); - - it('should not call :updateScrollOffsetForScrollToIndex if :scrollToIndex has also changed', () => { - const {updateScrollOffsetForScrollToIndexCalls} = helper({ - scrollToIndex: 10, - nextScrollToIndex: 20, - }); - expect(updateScrollOffsetForScrollToIndexCalls.length).toEqual(0); - }); - - it('should not call :computeMetadataCallback if the above conditions are not true', () => { - const {computeMetadataCallbackCalls} = helper(); - expect(computeMetadataCallbackCalls.length).toEqual(0); - }); -}); diff --git a/source/Grid/utils/maxElementSize.js b/source/Grid/utils/maxElementSize.js deleted file mode 100644 index 618c08dab..000000000 --- a/source/Grid/utils/maxElementSize.js +++ /dev/null @@ -1,17 +0,0 @@ -// @flow - -const DEFAULT_MAX_ELEMENT_SIZE = 1500000; -const CHROME_MAX_ELEMENT_SIZE = 1.67771e7; - -const isBrowser = () => typeof window !== 'undefined'; - -const isChrome = () => !!window.chrome; - -export const getMaxElementSize = (): number => { - if (isBrowser()) { - if (isChrome()) { - return CHROME_MAX_ELEMENT_SIZE; - } - } - return DEFAULT_MAX_ELEMENT_SIZE; -}; diff --git a/source/Grid/utils/updateScrollIndexHelper.jest.js b/source/Grid/utils/updateScrollIndexHelper.jest.js deleted file mode 100644 index 50f55ea75..000000000 --- a/source/Grid/utils/updateScrollIndexHelper.jest.js +++ /dev/null @@ -1,194 +0,0 @@ -import updateScrollIndexHelper from './updateScrollIndexHelper'; -import CellSizeAndPositionManager from './CellSizeAndPositionManager'; - -// Default cell sizes and offsets for use in shared tests -export function getCellSizeAndPositionManager({ - cellCount = CELL_SIZES.length, - estimatedCellSize = 10, -}) { - return new CellSizeAndPositionManager({ - cellCount, - cellSizeGetter: ({index}) => CELL_SIZES[index % CELL_SIZES.length], - estimatedCellSize, - }); -} - -const CELL_SIZES = [ - 10, // 0: 0..0 (min) - 20, // 1: 0..10 - 15, // 2: 0..30 - 10, // 3: 5..45 - 15, // 4: 20..55 - 30, // 5: 50..70 - 20, // 6: 70..100 - 10, // 7: 80..110 - 30, // 8: 110..110 (max) -]; - -describe('updateScrollIndexHelper', () => { - function helper({ - cellCount = undefined, - cellSizeAndPositionManager, - cellSize = 10, - previousCellsCount = undefined, - previousCellSize = 10, - previousScrollToAlignment = 'auto', - previousScrollToIndex, - previousSize = 50, - scrollOffset = 0, - scrollToAlignment = 'auto', - scrollToIndex, - size = 50, - } = {}) { - cellSizeAndPositionManager = - cellSizeAndPositionManager || getCellSizeAndPositionManager({cellCount}); - cellCount = - cellCount === undefined - ? cellSizeAndPositionManager.getCellCount() - : cellCount; - previousCellsCount = - previousCellsCount === undefined ? cellCount : previousCellsCount; - - let updateScrollIndexCallbackCalled = false; - - function updateScrollIndexCallback() { - updateScrollIndexCallbackCalled = true; - } - - updateScrollIndexHelper({ - cellCount, - cellSizeAndPositionManager, - cellSize, - previousCellsCount, - previousCellSize, - previousScrollToAlignment, - previousScrollToIndex, - previousSize, - scrollOffset, - scrollToAlignment, - scrollToIndex, - size, - updateScrollIndexCallback, - }); - - return updateScrollIndexCallbackCalled; - } - - it('should not call :updateScrollIndexCallback if there is no :scrollToIndex and size has not changed', () => { - expect(helper()).toEqual(false); - }); - - it('should not call :updateScrollIndexCallback if an invalid :scrollToIndex has been specified', () => { - expect( - helper({ - size: 100, - previousSize: 50, - scrollToIndex: -1, - }), - ).toEqual(false); - }); - - it('should call :updateScrollIndexCallback if there is a :scrollToIndex and :size has changed', () => { - expect( - helper({ - cellCount: 100, - size: 100, - previousSize: 50, - scrollToIndex: 10, - }), - ).toEqual(true); - }); - - it('should call :updateScrollIndexCallback if there is a :scrollToIndex and :cellSize has changed', () => { - expect( - helper({ - cellCount: 100, - cellSize: 15, - previousCellSize: 20, - scrollToIndex: 10, - }), - ).toEqual(true); - }); - - it('should call :updateScrollIndexCallback if previous :scrollToIndex has changed', () => { - expect( - helper({ - cellCount: 15, - previousScrollToIndex: 20, - scrollToIndex: 10, - }), - ).toEqual(true); - }); - - it('should call :updateScrollIndexCallback if :cellCount has been reduced past the current scroll offset', () => { - expect( - helper({ - previousCellsCount: 100, - scrollOffset: 510, - }), - ).toEqual(true); - }); - - it('should call :updateScrollIndexCallback if there is no :scrollToIndex but :size has been reduced', () => { - expect( - helper({ - previousSize: 100, - scrollOffset: 510, - size: 50, - }), - ).toEqual(true); - }); - - it('should not measure rows if :size or :cellCount have been reduced but only use already measured (or estimated) total size', () => { - const cellSizeAndPositionManager = { - getCellCount: () => CELL_SIZES.length, - getTotalSize: () => 560, - }; - - expect( - helper({ - cellSizeAndPositionManager, - previousSize: 100, - scrollOffset: 510, - size: 50, - }), - ).toEqual(false); - }); - - it('should not call :updateScrollIndexCallback if there is no :scrollToIndex but :cellCount has been increased', () => { - expect( - helper({ - cellCount: 100, - previousCellsCount: 50, - }), - ).toEqual(false); - }); - - it('should not call :updateScrollIndexCallback if there is no :scrollToIndex but :size has been increased', () => { - expect( - helper({ - previousSize: 50, - size: 100, - }), - ).toEqual(false); - }); - - it('should call :updateScrollIndexCallback if :scrollToAlignment has changed', () => { - expect( - helper({ - previousScrollToAlignment: 'start', - scrollToAlignment: 'end', - scrollToIndex: 5, - }), - ).toEqual(true); - }); - - it('should not call :updateScrollIndexCallback if :scrollToAlignment has changed but there is no :scrollToIndex', () => { - expect( - helper({ - previousScrollToAlignment: 'start', - scrollToAlignment: 'end', - }), - ).toEqual(false); - }); -}); diff --git a/source/Grid/utils/updateScrollIndexHelper.js b/source/Grid/utils/updateScrollIndexHelper.js deleted file mode 100644 index 95cc810fd..000000000 --- a/source/Grid/utils/updateScrollIndexHelper.js +++ /dev/null @@ -1,98 +0,0 @@ -// @flow - -import type {Alignment, CellSize} from '../types'; - -import ScalingCellSizeAndPositionManager from './ScalingCellSizeAndPositionManager.js'; - -/** - * Helper function that determines when to update scroll offsets to ensure that a scroll-to-index remains visible. - * This function also ensures that the scroll ofset isn't past the last column/row of cells. - */ - -type Params = { - // Width or height of cells for the current axis - cellSize?: CellSize, - - // Manages size and position metadata of cells - cellSizeAndPositionManager: ScalingCellSizeAndPositionManager, - - // Previous number of rows or columns - previousCellsCount: number, - - // Previous width or height of cells - previousCellSize: CellSize, - - previousScrollToAlignment: Alignment, - - // Previous scroll-to-index - previousScrollToIndex: number, - - // Previous width or height of the virtualized container - previousSize: number, - - // Current scrollLeft or scrollTop - scrollOffset: number, - - scrollToAlignment: Alignment, - - // Scroll-to-index - scrollToIndex: number, - - // Width or height of the virtualized container - size: number, - - sizeJustIncreasedFromZero: boolean, - - // Callback to invoke with an scroll-to-index value - updateScrollIndexCallback: (index: number) => void, -}; - -export default function updateScrollIndexHelper({ - cellSize, - cellSizeAndPositionManager, - previousCellsCount, - previousCellSize, - previousScrollToAlignment, - previousScrollToIndex, - previousSize, - scrollOffset, - scrollToAlignment, - scrollToIndex, - size, - sizeJustIncreasedFromZero, - updateScrollIndexCallback, -}: Params) { - const cellCount = cellSizeAndPositionManager.getCellCount(); - const hasScrollToIndex = scrollToIndex >= 0 && scrollToIndex < cellCount; - const sizeHasChanged = - size !== previousSize || - sizeJustIncreasedFromZero || - !previousCellSize || - (typeof cellSize === 'number' && cellSize !== previousCellSize); - - // If we have a new scroll target OR if height/row-height has changed, - // We should ensure that the scroll target is visible. - if ( - hasScrollToIndex && - (sizeHasChanged || - scrollToAlignment !== previousScrollToAlignment || - scrollToIndex !== previousScrollToIndex) - ) { - updateScrollIndexCallback(scrollToIndex); - - // If we don't have a selected item but list size or number of children have decreased, - // Make sure we aren't scrolled too far past the current content. - } else if ( - !hasScrollToIndex && - cellCount > 0 && - (size < previousSize || cellCount < previousCellsCount) - ) { - // We need to ensure that the current scroll offset is still within the collection's range. - // To do this, we don't need to measure everything; CellMeasurer would perform poorly. - // Just check to make sure we're still okay. - // Only adjust the scroll position if we've scrolled below the last set of rows. - if (scrollOffset > cellSizeAndPositionManager.getTotalSize() - size) { - updateScrollIndexCallback(cellCount - 1); - } - } -} diff --git a/source/InfiniteLoader/InfiniteLoader.example.css b/source/InfiniteLoader/InfiniteLoader.example.css deleted file mode 100644 index 963319350..000000000 --- a/source/InfiniteLoader/InfiniteLoader.example.css +++ /dev/null @@ -1,41 +0,0 @@ -.List { - width: 100%; - border: 1px solid #DDD; -} - -.row { - display: flex; - flex-direction: row; - align-items: center; - padding: 0 25px; - background-color: #fff; - border-bottom: 1px solid #e0e0e0; -} - -.placeholder { - display: inline-block; - height: 1em; - background-color: #DDD; -} - -.cacheButtonAndCountRow { - display: flex; - align-items: center; -} -.button { - flex: 0 0 auto; - background-color: #4db6ac; - color: #fff; - appearance: none; - border: none; - padding: .5em 1em; - border-radius: .35em; - font-size: 1em; -} -.cacheCountRow { - flex: 1 1 auto; - text-align: right; - color: #bdbdbd; - font-size: .75em; - font-weight: 100; -} diff --git a/source/InfiniteLoader/InfiniteLoader.example.js b/source/InfiniteLoader/InfiniteLoader.example.js deleted file mode 100644 index 902c68905..000000000 --- a/source/InfiniteLoader/InfiniteLoader.example.js +++ /dev/null @@ -1,173 +0,0 @@ -/** @flow */ -import * as React from 'react'; -import PropTypes from 'prop-types'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import Immutable from 'immutable'; -import AutoSizer from '../AutoSizer'; -import InfiniteLoader from './InfiniteLoader'; -import List from '../List'; -import styles from './InfiniteLoader.example.css'; - -const STATUS_LOADING = 1; -const STATUS_LOADED = 2; - -export default class InfiniteLoaderExample extends React.PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - }; - - constructor(props) { - super(props); - - this.state = { - loadedRowCount: 0, - loadedRowsMap: {}, - loadingRowCount: 0, - }; - - this._timeoutIdMap = {}; - - this._clearData = this._clearData.bind(this); - this._isRowLoaded = this._isRowLoaded.bind(this); - this._loadMoreRows = this._loadMoreRows.bind(this); - this._rowRenderer = this._rowRenderer.bind(this); - } - - componentWillUnmount() { - Object.keys(this._timeoutIdMap).forEach(timeoutId => { - clearTimeout(timeoutId); - }); - } - - render() { - const {list} = this.context; - const {loadedRowCount, loadingRowCount} = this.state; - - return ( - - - - - This component manages just-in-time data fetching to ensure that the - all visible rows have been loaded. It also uses a threshold to - determine how early to pre-fetch rows (before a user scrolls to them). - - - -
- - -
- {loadingRowCount} loading, {loadedRowCount} loaded -
-
-
- - - {({onRowsRendered, registerChild}) => ( - - {({width}) => ( - - )} - - )} - -
- ); - } - - _clearData() { - this.setState({ - loadedRowCount: 0, - loadedRowsMap: {}, - loadingRowCount: 0, - }); - } - - _isRowLoaded({index}) { - const {loadedRowsMap} = this.state; - return !!loadedRowsMap[index]; // STATUS_LOADING or STATUS_LOADED - } - - _loadMoreRows({startIndex, stopIndex}) { - const {loadedRowsMap, loadingRowCount} = this.state; - const increment = stopIndex - startIndex + 1; - - for (var i = startIndex; i <= stopIndex; i++) { - loadedRowsMap[i] = STATUS_LOADING; - } - - this.setState({ - loadingRowCount: loadingRowCount + increment, - }); - - const timeoutId = setTimeout(() => { - const {loadedRowCount, loadingRowCount} = this.state; - - delete this._timeoutIdMap[timeoutId]; - - for (var i = startIndex; i <= stopIndex; i++) { - loadedRowsMap[i] = STATUS_LOADED; - } - - this.setState({ - loadingRowCount: loadingRowCount - increment, - loadedRowCount: loadedRowCount + increment, - }); - - promiseResolver(); - }, 1000 + Math.round(Math.random() * 2000)); - - this._timeoutIdMap[timeoutId] = true; - - let promiseResolver; - - return new Promise(resolve => { - promiseResolver = resolve; - }); - } - - _rowRenderer({index, key, style}) { - const {list} = this.context; - const {loadedRowsMap} = this.state; - - const row = list.get(index); - let content; - - if (loadedRowsMap[index] === STATUS_LOADED) { - content = row.name; - } else { - content = ( -
- ); - } - - return ( -
- {content} -
- ); - } -} diff --git a/source/InfiniteLoader/InfiniteLoader.jest.js b/source/InfiniteLoader/InfiniteLoader.jest.js deleted file mode 100644 index 598680631..000000000 --- a/source/InfiniteLoader/InfiniteLoader.jest.js +++ /dev/null @@ -1,472 +0,0 @@ -import InfiniteLoader, { - forceUpdateReactVirtualizedComponent, - isRangeVisible, - scanForUnloadedRanges, -} from './InfiniteLoader'; -import * as React from 'react'; -import List from '../List'; -import {render} from '../TestUtils'; - -describe('InfiniteLoader', () => { - let innerOnRowsRendered; - let isRowLoadedCalls = []; - let isRowLoadedMap = {}; - let loadMoreRowsCalls = []; - let rowRendererCalls = []; - - beforeEach(() => { - isRowLoadedCalls = []; - isRowLoadedMap = {}; - loadMoreRowsCalls = []; - rowRendererCalls = []; - }); - - function defaultIsRowLoaded({index}) { - isRowLoadedCalls.push(index); - return !!isRowLoadedMap[index]; - } - - function defaultLoadMoreRows({startIndex, stopIndex}) { - loadMoreRowsCalls.push({startIndex, stopIndex}); - } - - function rowRenderer({index, key, style}) { - rowRendererCalls.push(index); - return
; - } - - function getMarkup({ - height = 100, - isRowLoaded = defaultIsRowLoaded, - loadMoreRows = defaultLoadMoreRows, - minimumBatchSize = 1, - rowHeight = 20, - rowCount = 100, - scrollToIndex, - threshold = 10, - width = 200, - } = {}) { - return ( - - {({onRowsRendered, registerChild}) => { - innerOnRowsRendered = onRowsRendered; - - return ( - - ); - }} - - ); - } - - it('should call :isRowLoaded for all rows within the threshold each time a range of rows are rendered', () => { - render(getMarkup()); - expect(isRowLoadedCalls).toEqual([ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - ]); - }); - - it('should call :isRowLoaded for all rows within the rowCount each time a range of rows are rendered', () => { - render(getMarkup({rowCount: 10})); - expect(isRowLoadedCalls).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - }); - - it('should call :loadMoreRows for unloaded rows within the threshold', () => { - render(getMarkup()); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 14}]); - }); - - it('should call :loadMoreRows for unloaded rows within the rowCount', () => { - render(getMarkup({rowCount: 10})); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 9}]); - }); - - it('should :forceUpdate once rows have loaded if :loadMoreRows returns a Promise', async done => { - let savedResolve; - function loadMoreRows() { - return new Promise(resolve => { - savedResolve = resolve; - }); - } - render(getMarkup({loadMoreRows})); - rowRendererCalls.splice(0); - await savedResolve(); - expect(rowRendererCalls.length > 0).toEqual(true); - done(); - }); - - it('should not :forceUpdate once rows have loaded rows are no longer visible', async done => { - let resolves = []; - function loadMoreRows() { - return new Promise(resolve => { - resolves.push(resolve); - }); - } - render(getMarkup({loadMoreRows})); - // Simulate a new range of rows being loaded - innerOnRowsRendered({startIndex: 100, stopIndex: 101}); - rowRendererCalls.splice(0); - await resolves[0](); // Resolve the first request only, not the simulated row-change - expect(rowRendererCalls.length).toEqual(0); - done(); - }); - - describe('minimumBatchSize', () => { - it('should respect the specified :minimumBatchSize when scrolling down', () => { - render( - getMarkup({ - minimumBatchSize: 10, - threshold: 0, - }), - ); - expect(loadMoreRowsCalls.length).toEqual(1); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 9}]); - }); - - it('should respect the specified :minimumBatchSize when scrolling up', () => { - render( - getMarkup({ - minimumBatchSize: 10, - scrollToIndex: 20, - threshold: 0, - }), - ); - loadMoreRowsCalls.splice(0); - render( - getMarkup({ - isRowLoaded: ({index}) => index >= 20, - minimumBatchSize: 10, - scrollToIndex: 15, - threshold: 0, - }), - ); - expect(loadMoreRowsCalls.length).toEqual(1); - expect(loadMoreRowsCalls).toEqual([{startIndex: 10, stopIndex: 19}]); - }); - - it('should not interfere with :threshold', () => { - render( - getMarkup({ - minimumBatchSize: 10, - threshold: 10, - }), - ); - expect(loadMoreRowsCalls.length).toEqual(1); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 14}]); - }); - - it('should respect the specified :minimumBatchSize if a user scrolls past the previous range', () => { - const isRowLoadedIndices = {}; - - function isRowLoaded({index}) { - if (!isRowLoadedIndices[index]) { - isRowLoadedIndices[index] = true; - - return false; - } else { - return true; - } - } - - render( - getMarkup({ - isRowLoaded, - minimumBatchSize: 10, - threshold: 0, - }), - ); - // Simulate a new range of rows being loaded - innerOnRowsRendered({startIndex: 5, stopIndex: 10}); - expect(loadMoreRowsCalls).toEqual([ - {startIndex: 0, stopIndex: 9}, - {startIndex: 10, stopIndex: 19}, - ]); - }); - - it('should not exceed ending boundaries if :minimumBatchSize is larger than needed', () => { - render( - getMarkup({ - minimumBatchSize: 10, - rowCount: 25, - threshold: 0, - }), - ); - // Simulate a new range of rows being loaded - innerOnRowsRendered({startIndex: 18, stopIndex: 22}); - expect(loadMoreRowsCalls).toEqual([ - {startIndex: 0, stopIndex: 9}, - {startIndex: 15, stopIndex: 24}, - ]); - }); - - it('should not exceed beginning boundaries if :minimumBatchSize is larger than needed', () => { - render( - getMarkup({ - minimumBatchSize: 10, - scrollToIndex: 15, - threshold: 0, - }), - ); - loadMoreRowsCalls.splice(0); - render( - getMarkup({ - isRowLoaded: ({index}) => index >= 6, - minimumBatchSize: 10, - scrollToIndex: 2, - threshold: 0, - }), - ); - expect(loadMoreRowsCalls.length).toEqual(1); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 5}]); - }); - }); - - // Verifies improved memoization; see bvaughn/react-virtualized/issues/345 - it('should memoize calls to :loadMoreRows (not calling unless unloaded ranges have changed)', () => { - render( - getMarkup({ - isRowLoaded: () => false, - minimumBatchSize: 20, - threshold: 0, - }), - ); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 19}]); - innerOnRowsRendered({startIndex: 0, stopIndex: 15}); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 19}]); - loadMoreRowsCalls.splice(0); - innerOnRowsRendered({startIndex: 0, stopIndex: 20}); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 20}]); - }); - - it('resetLoadMoreRowsCache should reset memoized state', () => { - const component = render( - getMarkup({ - isRowLoaded: () => false, - minimumBatchSize: 20, - threshold: 0, - }), - ); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 19}]); - innerOnRowsRendered({startIndex: 0, stopIndex: 15}); - loadMoreRowsCalls.splice(0); - expect(loadMoreRowsCalls).toEqual([]); - component.resetLoadMoreRowsCache(); - innerOnRowsRendered({startIndex: 0, stopIndex: 15}); - expect(loadMoreRowsCalls).toEqual([{startIndex: 0, stopIndex: 19}]); - }); - - it('resetLoadMoreRowsCache should call :loadMoreRows if :autoReload parameter is true', () => { - const component = render( - getMarkup({ - isRowLoaded: () => false, - minimumBatchSize: 1, - threshold: 0, - }), - ); - - // Simulate a new range of rows being loaded - loadMoreRowsCalls.splice(0); - innerOnRowsRendered({startIndex: 0, stopIndex: 10}); - component.resetLoadMoreRowsCache(true); - expect(loadMoreRowsCalls[loadMoreRowsCalls.length - 1]).toEqual({ - startIndex: 0, - stopIndex: 10, - }); - - // Simulate a new range of rows being loaded - loadMoreRowsCalls.splice(0); - innerOnRowsRendered({startIndex: 20, stopIndex: 30}); - expect(loadMoreRowsCalls[loadMoreRowsCalls.length - 1]).toEqual({ - startIndex: 20, - stopIndex: 30, - }); - - loadMoreRowsCalls.splice(0); - component.resetLoadMoreRowsCache(true); - expect(loadMoreRowsCalls[loadMoreRowsCalls.length - 1]).toEqual({ - startIndex: 20, - stopIndex: 30, - }); - }); -}); - -describe('scanForUnloadedRanges', () => { - function createIsRowLoaded(rows) { - return ({index}) => rows[index]; - } - - it('should return an empty array for a range of rows that have all been loaded', () => { - expect( - scanForUnloadedRanges({ - isRowLoaded: createIsRowLoaded([true, true, true]), - startIndex: 0, - stopIndex: 2, - }), - ).toEqual([]); - }); - - it('return a range of only 1 unloaded row', () => { - expect( - scanForUnloadedRanges({ - isRowLoaded: createIsRowLoaded([true, false, true]), - startIndex: 0, - stopIndex: 2, - }), - ).toEqual([{startIndex: 1, stopIndex: 1}]); - }); - - it('return a range of multiple unloaded rows', () => { - expect( - scanForUnloadedRanges({ - isRowLoaded: createIsRowLoaded([false, false, true]), - startIndex: 0, - stopIndex: 2, - }), - ).toEqual([{startIndex: 0, stopIndex: 1}]); - }); - - it('return multiple ranges of unloaded rows', () => { - expect( - scanForUnloadedRanges({ - isRowLoaded: createIsRowLoaded([ - true, - false, - false, - true, - false, - true, - false, - ]), - startIndex: 0, - stopIndex: 6, - }), - ).toEqual([ - {startIndex: 1, stopIndex: 2}, - {startIndex: 4, stopIndex: 4}, - {startIndex: 6, stopIndex: 6}, - ]); - }); -}); - -describe('isRangeVisible', () => { - it('first row(s) are visible', () => { - expect( - isRangeVisible({ - lastRenderedStartIndex: 10, - lastRenderedStopIndex: 20, - startIndex: 20, - stopIndex: 30, - }), - ).toEqual(true); - }); - - it('last row(s) are visible', () => { - expect( - isRangeVisible({ - lastRenderedStartIndex: 10, - lastRenderedStopIndex: 20, - startIndex: 0, - stopIndex: 10, - }), - ).toEqual(true); - }); - - it('all row(s) are visible', () => { - expect( - isRangeVisible({ - lastRenderedStartIndex: 10, - lastRenderedStopIndex: 20, - startIndex: 12, - stopIndex: 14, - }), - ).toEqual(true); - }); - - it('no row(s) are visible', () => { - expect( - isRangeVisible({ - lastRenderedStartIndex: 10, - lastRenderedStopIndex: 20, - startIndex: 0, - stopIndex: 9, - }), - ).toEqual(false); - - expect( - isRangeVisible({ - lastRenderedStartIndex: 10, - lastRenderedStopIndex: 20, - startIndex: 21, - stopIndex: 30, - }), - ).toEqual(false); - }); -}); - -describe('forceUpdateReactVirtualizedComponent', () => { - it('should call :recomputeGridSize if defined', () => { - const recomputeGridSize = jest.fn(); - class TestComponent extends React.Component { - recomputeGridSize = recomputeGridSize; - render() { - return
; - } - } - forceUpdateReactVirtualizedComponent(render(), 10); - expect(recomputeGridSize).toHaveBeenCalledTimes(1); - expect(recomputeGridSize).toHaveBeenCalledWith(10); - }); - - it('should called :recomputeRowHeights if defined', () => { - const recomputeRowHeights = jest.fn(); - class TestComponent extends React.Component { - recomputeRowHeights = recomputeRowHeights; - render() { - return
; - } - } - forceUpdateReactVirtualizedComponent(render(), 10); - expect(recomputeRowHeights).toHaveBeenCalledTimes(1); - expect(recomputeRowHeights).toHaveBeenCalledWith(10); - }); - - it('should call :forceUpdate otherwise', () => { - const forceUpdate = jest.fn(); - class TestComponent extends React.Component { - forceUpdate = forceUpdate; - render() { - return
; - } - } - forceUpdateReactVirtualizedComponent(render(), 10); - expect(forceUpdate).toHaveBeenCalledTimes(1); - }); -}); diff --git a/source/InfiniteLoader/InfiniteLoader.js b/source/InfiniteLoader/InfiniteLoader.js deleted file mode 100644 index b6e1bc85d..000000000 --- a/source/InfiniteLoader/InfiniteLoader.js +++ /dev/null @@ -1,274 +0,0 @@ -/** @flow */ -import * as React from 'react'; -import PropTypes from 'prop-types'; -import createCallbackMemoizer from '../utils/createCallbackMemoizer'; - -/** - * Higher-order component that manages lazy-loading for "infinite" data. - * This component decorates a virtual component and just-in-time prefetches rows as a user scrolls. - * It is intended as a convenience component; fork it if you'd like finer-grained control over data-loading. - */ -export default class InfiniteLoader extends React.PureComponent { - static propTypes = { - /** - * Function responsible for rendering a virtualized component. - * This function should implement the following signature: - * ({ onRowsRendered, registerChild }) => PropTypes.element - * - * The specified :onRowsRendered function should be passed through to the child's :onRowsRendered property. - * The :registerChild callback should be set as the virtualized component's :ref. - */ - children: PropTypes.func.isRequired, - - /** - * Function responsible for tracking the loaded state of each row. - * It should implement the following signature: ({ index: number }): boolean - */ - isRowLoaded: PropTypes.func.isRequired, - - /** - * Callback to be invoked when more rows must be loaded. - * It should implement the following signature: ({ startIndex, stopIndex }): Promise - * The returned Promise should be resolved once row data has finished loading. - * It will be used to determine when to refresh the list with the newly-loaded data. - * This callback may be called multiple times in reaction to a single scroll event. - */ - loadMoreRows: PropTypes.func.isRequired, - - /** - * Minimum number of rows to be loaded at a time. - * This property can be used to batch requests to reduce HTTP requests. - */ - minimumBatchSize: PropTypes.number.isRequired, - - /** - * Number of rows in list; can be arbitrary high number if actual number is unknown. - */ - rowCount: PropTypes.number.isRequired, - - /** - * Threshold at which to pre-fetch data. - * A threshold X means that data will start loading when a user scrolls within X rows. - * This value defaults to 15. - */ - threshold: PropTypes.number.isRequired, - }; - - static defaultProps = { - minimumBatchSize: 10, - rowCount: 0, - threshold: 15, - }; - - constructor(props, context) { - super(props, context); - - this._loadMoreRowsMemoizer = createCallbackMemoizer(); - - this._onRowsRendered = this._onRowsRendered.bind(this); - this._registerChild = this._registerChild.bind(this); - } - - resetLoadMoreRowsCache(autoReload) { - this._loadMoreRowsMemoizer = createCallbackMemoizer(); - - if (autoReload) { - this._doStuff(this._lastRenderedStartIndex, this._lastRenderedStopIndex); - } - } - - render() { - const {children} = this.props; - - return children({ - onRowsRendered: this._onRowsRendered, - registerChild: this._registerChild, - }); - } - - _loadUnloadedRanges(unloadedRanges) { - const {loadMoreRows} = this.props; - - unloadedRanges.forEach(unloadedRange => { - let promise = loadMoreRows(unloadedRange); - if (promise) { - promise.then(() => { - // Refresh the visible rows if any of them have just been loaded. - // Otherwise they will remain in their unloaded visual state. - if ( - isRangeVisible({ - lastRenderedStartIndex: this._lastRenderedStartIndex, - lastRenderedStopIndex: this._lastRenderedStopIndex, - startIndex: unloadedRange.startIndex, - stopIndex: unloadedRange.stopIndex, - }) - ) { - if (this._registeredChild) { - forceUpdateReactVirtualizedComponent( - this._registeredChild, - this._lastRenderedStartIndex, - ); - } - } - }); - } - }); - } - - _onRowsRendered({startIndex, stopIndex}) { - this._lastRenderedStartIndex = startIndex; - this._lastRenderedStopIndex = stopIndex; - - this._doStuff(startIndex, stopIndex); - } - - _doStuff(startIndex, stopIndex) { - const {isRowLoaded, minimumBatchSize, rowCount, threshold} = this.props; - - const unloadedRanges = scanForUnloadedRanges({ - isRowLoaded, - minimumBatchSize, - rowCount, - startIndex: Math.max(0, startIndex - threshold), - stopIndex: Math.min(rowCount - 1, stopIndex + threshold), - }); - - // For memoize comparison - const squashedUnloadedRanges = [].concat( - ...unloadedRanges.map(({startIndex, stopIndex}) => [ - startIndex, - stopIndex, - ]), - ); - - this._loadMoreRowsMemoizer({ - callback: () => { - this._loadUnloadedRanges(unloadedRanges); - }, - indices: {squashedUnloadedRanges}, - }); - } - - _registerChild(registeredChild) { - this._registeredChild = registeredChild; - } -} - -/** - * Determines if the specified start/stop range is visible based on the most recently rendered range. - */ -export function isRangeVisible({ - lastRenderedStartIndex, - lastRenderedStopIndex, - startIndex, - stopIndex, -}) { - return !( - startIndex > lastRenderedStopIndex || stopIndex < lastRenderedStartIndex - ); -} - -/** - * Returns all of the ranges within a larger range that contain unloaded rows. - */ -export function scanForUnloadedRanges({ - isRowLoaded, - minimumBatchSize, - rowCount, - startIndex, - stopIndex, -}) { - const unloadedRanges = []; - - let rangeStartIndex = null; - let rangeStopIndex = null; - - for (let index = startIndex; index <= stopIndex; index++) { - let loaded = isRowLoaded({index}); - - if (!loaded) { - rangeStopIndex = index; - if (rangeStartIndex === null) { - rangeStartIndex = index; - } - } else if (rangeStopIndex !== null) { - unloadedRanges.push({ - startIndex: rangeStartIndex, - stopIndex: rangeStopIndex, - }); - - rangeStartIndex = rangeStopIndex = null; - } - } - - // If :rangeStopIndex is not null it means we haven't ran out of unloaded rows. - // Scan forward to try filling our :minimumBatchSize. - if (rangeStopIndex !== null) { - const potentialStopIndex = Math.min( - Math.max(rangeStopIndex, rangeStartIndex + minimumBatchSize - 1), - rowCount - 1, - ); - - for (let index = rangeStopIndex + 1; index <= potentialStopIndex; index++) { - if (!isRowLoaded({index})) { - rangeStopIndex = index; - } else { - break; - } - } - - unloadedRanges.push({ - startIndex: rangeStartIndex, - stopIndex: rangeStopIndex, - }); - } - - // Check to see if our first range ended prematurely. - // In this case we should scan backwards to try filling our :minimumBatchSize. - if (unloadedRanges.length) { - const firstUnloadedRange = unloadedRanges[0]; - - while ( - firstUnloadedRange.stopIndex - firstUnloadedRange.startIndex + 1 < - minimumBatchSize && - firstUnloadedRange.startIndex > 0 - ) { - let index = firstUnloadedRange.startIndex - 1; - - if (!isRowLoaded({index})) { - firstUnloadedRange.startIndex = index; - } else { - break; - } - } - } - - return unloadedRanges; -} - -/** - * Since RV components use shallowCompare we need to force a render (even though props haven't changed). - * However InfiniteLoader may wrap a Grid or it may wrap a Table or List. - * In the first case the built-in React forceUpdate() method is sufficient to force a re-render, - * But in the latter cases we need to use the RV-specific forceUpdateGrid() method. - * Else the inner Grid will not be re-rendered and visuals may be stale. - * - * Additionally, while a Grid is scrolling the cells can be cached, - * So it's important to invalidate that cache by recalculating sizes - * before forcing a rerender. - */ -export function forceUpdateReactVirtualizedComponent( - component, - currentIndex = 0, -) { - const recomputeSize = - typeof component.recomputeGridSize === 'function' - ? component.recomputeGridSize - : component.recomputeRowHeights; - - if (recomputeSize) { - recomputeSize.call(component, currentIndex); - } else { - component.forceUpdate(); - } -} diff --git a/source/InfiniteLoader/index.js b/source/InfiniteLoader/index.js deleted file mode 100644 index 781d65d1e..000000000 --- a/source/InfiniteLoader/index.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @flow */ -import InfiniteLoader from './InfiniteLoader'; - -export default InfiniteLoader; -export {InfiniteLoader}; diff --git a/source/List/List.example.css b/source/List/List.example.css deleted file mode 100644 index d2fae7576..000000000 --- a/source/List/List.example.css +++ /dev/null @@ -1,67 +0,0 @@ -.List { - width: 100%; - border: 1px solid #DDD; - margin-top: 15px; -} - -.row { - height: 100%; - display: flex; - flex-direction: row; - align-items: center; - padding: 0 25px; - background-color: #fff; - border-bottom: 1px solid #e0e0e0; -} -.letter { - display: inline-block; - height: 40px; - width: 40px; - line-height: 40px; - text-align: center; - border-radius: 40px; - color: white; - font-size: 1.5em; - margin-right: 25px; -} -.name { - font-weight: bold; - margin-bottom: 2px; -} -.index { - color: #37474f; -} -.height { - flex: 1; - text-align: right; - color: #bdbdbd; - font-size: .75em; - font-weight: 100; -} - -.noRows { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - color: #bdbdbd; -} - -.checkboxLabel { - margin-left: .5rem; -} -.checkboxLabel:first-of-type { - margin-left: 0; -} -.checkbox { - margin-right: 5px; -} - -.isScrollingPlaceholder { - color: #DDD; - font-style: italic; -} diff --git a/source/List/List.example.js b/source/List/List.example.js deleted file mode 100644 index 8dd2bfff2..000000000 --- a/source/List/List.example.js +++ /dev/null @@ -1,255 +0,0 @@ -import clsx from 'clsx'; -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import styles from './List.example.css'; -import AutoSizer from '../AutoSizer'; -import List from './List'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import {LabeledInput, InputRow} from '../demo/LabeledInput'; - -export default class ListExample extends React.PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - listHeight: 300, - listRowHeight: 50, - overscanRowCount: 10, - rowCount: context.list.size, - scrollToIndex: undefined, - showScrollingPlaceholder: false, - useDynamicRowHeight: false, - }; - - this._getRowHeight = this._getRowHeight.bind(this); - this._noRowsRenderer = this._noRowsRenderer.bind(this); - this._onRowCountChange = this._onRowCountChange.bind(this); - this._onScrollToRowChange = this._onScrollToRowChange.bind(this); - this._rowRenderer = this._rowRenderer.bind(this); - } - - render() { - const { - listHeight, - listRowHeight, - overscanRowCount, - rowCount, - scrollToIndex, - showScrollingPlaceholder, - useDynamicRowHeight, - } = this.state; - - return ( - - - - - The list below is windowed (or "virtualized") meaning that only the - visible rows are rendered. Adjust its configurable properties below to - see how it reacts. - - - - - - - - - - - - - this.setState({ - listHeight: parseInt(event.target.value, 10) || 1, - }) - } - value={listHeight} - /> - - this.setState({ - listRowHeight: parseInt(event.target.value, 10) || 1, - }) - } - value={listRowHeight} - /> - - this.setState({ - overscanRowCount: parseInt(event.target.value, 10) || 0, - }) - } - value={overscanRowCount} - /> - - -
- - {({width}) => ( - - )} - -
-
- ); - } - - _getDatum(index) { - const {list} = this.context; - - return list.get(index % list.size); - } - - _getRowHeight({index}) { - return this._getDatum(index).size; - } - - _noRowsRenderer() { - return
No rows
; - } - - _onRowCountChange(event) { - const rowCount = parseInt(event.target.value, 10) || 0; - - this.setState({rowCount}); - } - - _onScrollToRowChange(event) { - const {rowCount} = this.state; - let scrollToIndex = Math.min( - rowCount - 1, - parseInt(event.target.value, 10), - ); - - if (isNaN(scrollToIndex)) { - scrollToIndex = undefined; - } - - this.setState({scrollToIndex}); - } - - _rowRenderer({index, isScrolling, key, style}) { - const {showScrollingPlaceholder, useDynamicRowHeight} = this.state; - - if (showScrollingPlaceholder && isScrolling) { - return ( -
- Scrolling... -
- ); - } - - const datum = this._getDatum(index); - - let additionalContent; - - if (useDynamicRowHeight) { - switch (datum.size) { - case 75: - additionalContent =
It is medium-sized.
; - break; - case 100: - additionalContent = ( -
- It is large-sized. -
- It has a 3rd row. -
- ); - break; - } - } - - return ( -
-
- {datum.name.charAt(0)} -
-
-
{datum.name}
-
This is row {index}
- {additionalContent} -
- {useDynamicRowHeight && ( - {datum.size}px - )} -
- ); - } -} diff --git a/source/List/List.jest.js b/source/List/List.jest.js deleted file mode 100644 index cbaa5729e..000000000 --- a/source/List/List.jest.js +++ /dev/null @@ -1,560 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import {Simulate} from 'react-dom/test-utils'; -import Immutable from 'immutable'; -import List from './List'; -import {defaultOverscanIndicesGetter} from '../Grid'; - -describe('List', () => { - const array = []; - for (var i = 0; i < 100; i++) { - array.push(`Name ${i}`); - } - const names = Immutable.fromJS(array); - - // Override default behavior of overscanning by at least 1 (for accessibility) - // Because it makes for simple tests below - function overscanIndicesGetter({startIndex, stopIndex}) { - return { - overscanStartIndex: startIndex, - overscanStopIndex: stopIndex, - }; - } - - function getMarkup(props = {}) { - function rowRenderer({index, key, style}) { - return ( -
- {names.get(index)} -
- ); - } - - return ( - - ); - } - - describe('number of rendered children', () => { - it('should render enough children to fill the view', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.querySelectorAll('.listItem').length).toEqual(10); - }); - - it('should not render more children than available if the list is not filled', () => { - const rendered = findDOMNode(render(getMarkup({rowCount: 5}))); - expect(rendered.querySelectorAll('.listItem').length).toEqual(5); - }); - }); - - describe('scrollToPosition', () => { - it('should scroll to the top', () => { - const instance = render( - getMarkup({ - rowHeight: 10, - }), - ); - instance.scrollToPosition(100); - const rendered = findDOMNode(instance); - expect(rendered.textContent).toContain('Name 10'); - expect(rendered.textContent).toContain('Name 19'); - }); - }); - - /** Tests scrolling via initial props */ - describe('scrollToIndex', () => { - it('should scroll to the top', () => { - const rendered = findDOMNode(render(getMarkup({scrollToIndex: 0}))); - expect(rendered.textContent).toContain('Name 0'); - }); - - it('should scroll down to the middle', () => { - const rendered = findDOMNode(render(getMarkup({scrollToIndex: 49}))); - // 100 items * 10 item height = 1,000 total item height - // 10 items can be visible at a time and :scrollTop is initially 0, - // So the minimum amount of scrolling leaves the 50th item at the bottom (just scrolled into view). - expect(rendered.textContent).toContain('Name 49'); - }); - - it('should scroll to the bottom', () => { - const rendered = findDOMNode(render(getMarkup({scrollToIndex: 99}))); - // 100 height - 10 header = 90 available scroll space. - // 100 items * 10 item height = 1,000 total item height - // Target height for the last item then is 1000 - 90 - expect(rendered.textContent).toContain('Name 99'); - }); - - it('should scroll to the correct position for :scrollToAlignment "start"', () => { - const rendered = findDOMNode( - render( - getMarkup({ - scrollToAlignment: 'start', - scrollToIndex: 49, - }), - ), - ); - // 100 items * 10 item height = 1,000 total item height; 10 items can be visible at a time. - expect(rendered.textContent).toContain('Name 49'); - expect(rendered.textContent).toContain('Name 58'); - }); - - it('should scroll to the correct position for :scrollToAlignment "end"', () => { - render( - getMarkup({ - scrollToIndex: 99, - }), - ); - const rendered = findDOMNode( - render( - getMarkup({ - scrollToAlignment: 'end', - scrollToIndex: 49, - }), - ), - ); - // 100 items * 10 item height = 1,000 total item height; 10 items can be visible at a time. - expect(rendered.textContent).toContain('Name 40'); - expect(rendered.textContent).toContain('Name 49'); - }); - - it('should scroll to the correct position for :scrollToAlignment "center"', () => { - render( - getMarkup({ - scrollToIndex: 99, - }), - ); - const rendered = findDOMNode( - render( - getMarkup({ - scrollToAlignment: 'center', - scrollToIndex: 49, - }), - ), - ); - // 100 items * 10 item height = 1,000 total item height; 11 items can be visible at a time (the first and last item are only partially visible) - expect(rendered.textContent).toContain('Name 44'); - expect(rendered.textContent).toContain('Name 54'); - }); - }); - - describe('property updates', () => { - it('should update :scrollToIndex position when :rowHeight changes', () => { - let rendered = findDOMNode(render(getMarkup({scrollToIndex: 50}))); - expect(rendered.textContent).toContain('Name 50'); - // Making rows taller pushes name off/beyond the scrolled area - rendered = findDOMNode( - render(getMarkup({scrollToIndex: 50, rowHeight: 20})), - ); - expect(rendered.textContent).toContain('Name 50'); - }); - - it('should update :scrollToIndex position when :height changes', () => { - let rendered = findDOMNode(render(getMarkup({scrollToIndex: 50}))); - expect(rendered.textContent).toContain('Name 50'); - // Making the list shorter leaves only room for 1 item - rendered = findDOMNode( - render(getMarkup({scrollToIndex: 50, height: 20})), - ); - expect(rendered.textContent).toContain('Name 50'); - }); - - it('should update :scrollToIndex position when :scrollToIndex changes', () => { - let rendered = findDOMNode(render(getMarkup())); - expect(rendered.textContent).not.toContain('Name 50'); - rendered = findDOMNode(render(getMarkup({scrollToIndex: 50}))); - expect(rendered.textContent).toContain('Name 50'); - }); - - it('should update scroll position if size shrinks smaller than the current scroll', () => { - findDOMNode(render(getMarkup({scrollToIndex: 500}))); - findDOMNode(render(getMarkup())); - const rendered = findDOMNode( - render(getMarkup({scrollToIndex: 500, rowCount: 10})), - ); - expect(rendered.textContent).toContain('Name 9'); - }); - }); - - describe('noRowsRenderer', () => { - it('should call :noRowsRenderer if :rowCount is 0', () => { - let rendered = findDOMNode( - render( - getMarkup({ - noRowsRenderer: () =>
No rows!
, - rowCount: 0, - }), - ), - ); - expect(rendered.textContent).toEqual('No rows!'); - }); - - it('should render an empty body if :rowCount is 0 and there is no :noRowsRenderer', () => { - let rendered = findDOMNode( - render( - getMarkup({ - rowCount: 0, - }), - ), - ); - expect(rendered.textContent).toEqual(''); - }); - }); - - describe('onRowsRendered', () => { - it('should call :onRowsRendered if at least one row is rendered', () => { - let startIndex, stopIndex; - render( - getMarkup({ - onRowsRendered: params => ({startIndex, stopIndex} = params), - }), - ); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(9); - }); - - it('should not call :onRowsRendered unless the start or stop indices have changed', () => { - let numCalls = 0; - let startIndex; - let stopIndex; - const onRowsRendered = params => { - startIndex = params.startIndex; - stopIndex = params.stopIndex; - numCalls++; - }; - findDOMNode(render(getMarkup({onRowsRendered}))); - expect(numCalls).toEqual(1); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(9); - findDOMNode(render(getMarkup({onRowsRendered}))); - expect(numCalls).toEqual(1); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(9); - }); - - it('should call :onRowsRendered if the start or stop indices have changed', () => { - let numCalls = 0; - let startIndex; - let stopIndex; - const onRowsRendered = params => { - startIndex = params.startIndex; - stopIndex = params.stopIndex; - numCalls++; - }; - findDOMNode(render(getMarkup({onRowsRendered}))); - expect(numCalls).toEqual(1); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(9); - findDOMNode( - render( - getMarkup({ - height: 50, - onRowsRendered, - }), - ), - ); - expect(numCalls).toEqual(2); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(4); - }); - - it('should not call :onRowsRendered if no rows are rendered', () => { - let startIndex, stopIndex; - render( - getMarkup({ - height: 0, - onRowsRendered: params => ({startIndex, stopIndex} = params), - }), - ); - expect(startIndex).toEqual(undefined); - expect(stopIndex).toEqual(undefined); - }); - }); - - describe(':scrollTop property', () => { - it('should render correctly when an initial :scrollTop property is specified', () => { - let startIndex, stopIndex; - render( - getMarkup({ - onRowsRendered: params => ({startIndex, stopIndex} = params), - scrollTop: 100, - }), - ); - expect(startIndex).toEqual(10); - expect(stopIndex).toEqual(19); - }); - - it('should render correctly when :scrollTop property is updated', () => { - let startIndex, stopIndex; - - findDOMNode( - render( - getMarkup({ - onRowsRendered: params => ({startIndex, stopIndex} = params), - }), - ), - ); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(9); - - findDOMNode( - render( - getMarkup({ - onRowsRendered: params => ({startIndex, stopIndex} = params), - scrollTop: 100, - }), - ), - ); - expect(startIndex).toEqual(10); - expect(stopIndex).toEqual(19); - }); - }); - - describe('styles, classNames, and ids', () => { - it('should use the expected global CSS classNames', () => { - const node = findDOMNode(render(getMarkup())); - expect(node.className).toContain('ReactVirtualized__List'); - }); - - it('should use a custom :className if specified', () => { - const node = findDOMNode(render(getMarkup({className: 'foo'}))); - expect(node.className).toContain('foo'); - }); - - it('should use a custom :id if specified', () => { - const node = findDOMNode(render(getMarkup({id: 'bar'}))); - expect(node.getAttribute('id')).toEqual('bar'); - }); - - it('should use a custom :style if specified', () => { - const style = {backgroundColor: 'red'}; - const rendered = findDOMNode(render(getMarkup({style}))); - expect(rendered.style.backgroundColor).toEqual('red'); - }); - - it('should set the width of a row to be 100% by default', () => { - const rendered = findDOMNode(render(getMarkup())); - const cell = rendered.querySelector('.listItem'); - expect(cell.style.width).toEqual('100%'); - }); - }); - - describe('overscanRowCount', () => { - it('should not overscan by default', () => { - const mock = jest.fn(); - mock.mockImplementation(overscanIndicesGetter); - - render( - getMarkup({ - overscanIndicesGetter: mock, - }), - ); - expect(mock.mock.calls[0][0].overscanCellsCount).toEqual(0); - expect(mock.mock.calls[1][0].overscanCellsCount).toEqual(0); - }); - - it('should overscan the specified amount', () => { - const mock = jest.fn(); - mock.mockImplementation(overscanIndicesGetter); - - render( - getMarkup({ - overscanIndicesGetter: mock, - overscanRowCount: 10, - }), - ); - expect(mock.mock.calls[0][0].overscanCellsCount).toEqual(0); - expect(mock.mock.calls[1][0].overscanCellsCount).toEqual(10); - }); - }); - - describe('onScroll', () => { - it('should trigger callback when component initially mounts', () => { - const onScrollCalls = []; - render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - expect(onScrollCalls).toEqual([ - { - clientHeight: 100, - scrollHeight: 1000, - scrollTop: 0, - }, - ]); - }); - - it('should trigger callback when component scrolls', () => { - const onScrollCalls = []; - const rendered = render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - const target = { - scrollLeft: 0, - scrollTop: 100, - }; - rendered.Grid._scrollingContainer = target; // HACK to work around _onScroll target check - Simulate.scroll(findDOMNode(rendered), {target}); - expect(onScrollCalls[onScrollCalls.length - 1]).toEqual({ - clientHeight: 100, - scrollHeight: 1000, - scrollTop: 100, - }); - }); - }); - - describe('measureAllRows', () => { - it('should measure any unmeasured rows', () => { - const rendered = render( - getMarkup({ - estimatedRowSize: 15, - height: 0, - rowCount: 10, - rowHeight: () => 20, - width: 0, - }), - ); - expect( - rendered.Grid.state.instanceProps.rowSizeAndPositionManager.getTotalSize(), - ).toEqual(150); - rendered.measureAllRows(); - expect( - rendered.Grid.state.instanceProps.rowSizeAndPositionManager.getTotalSize(), - ).toEqual(200); - }); - }); - - describe('recomputeRowHeights', () => { - it('should recompute row heights and other values when called', () => { - const indices = []; - const rowHeight = ({index}) => { - indices.push(index); - return 10; - }; - const component = render( - getMarkup({ - rowHeight, - rowCount: 50, - }), - ); - - indices.splice(0); - component.recomputeRowHeights(); - - // Only the rows required to fill the current viewport will be rendered - expect(indices[0]).toEqual(0); - expect(indices[indices.length - 1]).toEqual(9); - - indices.splice(0); - component.recomputeRowHeights(4); - - expect(indices[0]).toEqual(4); - expect(indices[indices.length - 1]).toEqual(9); - }); - }); - - describe('forceUpdateGrid', () => { - it('should refresh inner Grid content when called', () => { - let marker = 'a'; - function rowRenderer({index, key, style}) { - return ( -
- {index} - {marker} -
- ); - } - const component = render(getMarkup({rowRenderer})); - const node = findDOMNode(component); - expect(node.textContent).toContain('1a'); - marker = 'b'; - component.forceUpdateGrid(); - expect(node.textContent).toContain('1b'); - }); - }); - - describe('tabIndex', () => { - it('should be focusable by default', () => { - const rendered = findDOMNode(render(getMarkup())); - expect(rendered.tabIndex).toEqual(0); - }); - - it('should allow tabIndex to be overridden', () => { - const rendered = findDOMNode( - render( - getMarkup({ - tabIndex: -1, - }), - ), - ); - expect(rendered.tabIndex).toEqual(-1); - }); - }); - - it('should pass the cellRenderer an :isVisible flag', () => { - const rowRendererCalls = []; - function rowRenderer(props) { - rowRendererCalls.push(props); - return null; - } - findDOMNode( - render( - getMarkup({ - height: 50, - overscanIndicesGetter: defaultOverscanIndicesGetter, - overscanRowCount: 1, - rowHeight: 50, - rowRenderer, - }), - ), - ); - expect(rowRendererCalls[0].isVisible).toEqual(true); - expect(rowRendererCalls[1].isVisible).toEqual(false); - }); - - it('should relay the Grid :parent param to the :rowRenderer', () => { - const rowRenderer = jest.fn().mockReturnValue(null); - findDOMNode(render(getMarkup({rowRenderer}))); - expect(rowRenderer.mock.calls[0][0].parent).not.toBeUndefined(); - }); - - describe('pure', () => { - it('should not re-render unless props have changed', () => { - let rowRendererCalled = false; - function rowRenderer({index, key, style}) { - rowRendererCalled = true; - return ( -
- {index} -
- ); - } - const markup = getMarkup({rowRenderer}); - render(markup); - expect(rowRendererCalled).toEqual(true); - rowRendererCalled = false; - render(markup); - expect(rowRendererCalled).toEqual(false); - }); - }); - - it('should set the width of the single-column inner Grid to auto', () => { - const rendered = findDOMNode(render(getMarkup())); - expect( - rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer') - .style.width, - ).toEqual('auto'); - }); -}); diff --git a/source/List/List.js b/source/List/List.js deleted file mode 100644 index fd85ec691..000000000 --- a/source/List/List.js +++ /dev/null @@ -1,268 +0,0 @@ -/** @flow */ - -import type { - NoContentRenderer, - Alignment, - CellSize, - CellPosition, - OverscanIndicesGetter, - RenderedSection, - CellRendererParams, - Scroll as GridScroll, -} from '../Grid'; -import type {RowRenderer, RenderedRows, Scroll} from './types'; - -import Grid, {accessibilityOverscanIndicesGetter} from '../Grid'; -import * as React from 'react'; -import clsx from 'clsx'; - -/** - * It is inefficient to create and manage a large list of DOM elements within a scrolling container - * if only a few of those elements are visible. The primary purpose of this component is to improve - * performance by only rendering the DOM nodes that a user is able to see based on their current - * scroll position. - * - * This component renders a virtualized list of elements with either fixed or dynamic heights. - */ - -type Props = { - 'aria-label'?: string, - - /** - * Removes fixed height from the scrollingContainer so that the total height - * of rows can stretch the window. Intended for use with WindowScroller - */ - autoHeight: boolean, - - /** Optional CSS class name */ - className?: string, - - /** - * Used to estimate the total height of a List before all of its rows have actually been measured. - * The estimated total height is adjusted as rows are rendered. - */ - estimatedRowSize: number, - - /** Height constraint for list (determines how many actual rows are rendered) */ - height: number, - - /** Optional renderer to be used in place of rows when rowCount is 0 */ - noRowsRenderer: NoContentRenderer, - - /** Callback invoked with information about the slice of rows that were just rendered. */ - - onRowsRendered: (params: RenderedRows) => void, - - /** - * Callback invoked whenever the scroll offset changes within the inner scrollable region. - * This callback can be used to sync scrolling between lists, tables, or grids. - */ - onScroll: (params: Scroll) => void, - - /** See Grid#overscanIndicesGetter */ - overscanIndicesGetter: OverscanIndicesGetter, - - /** - * Number of rows to render above/below the visible bounds of the list. - * These rows can help for smoother scrolling on touch devices. - */ - overscanRowCount: number, - - /** Either a fixed row height (number) or a function that returns the height of a row given its index. */ - rowHeight: CellSize, - - /** Responsible for rendering a row given an index; ({ index: number }): node */ - rowRenderer: RowRenderer, - - /** Number of rows in list. */ - rowCount: number, - - /** See Grid#scrollToAlignment */ - scrollToAlignment: Alignment, - - /** Row index to ensure visible (by forcefully scrolling if necessary) */ - scrollToIndex: number, - - /** Vertical offset. */ - scrollTop?: number, - - /** Optional inline style */ - style: Object, - - /** Tab index for focus */ - tabIndex?: number, - - /** Width of list */ - width: number, -}; - -export default class List extends React.PureComponent { - static defaultProps = { - autoHeight: false, - estimatedRowSize: 30, - onScroll: () => {}, - noRowsRenderer: () => null, - onRowsRendered: () => {}, - overscanIndicesGetter: accessibilityOverscanIndicesGetter, - overscanRowCount: 10, - scrollToAlignment: 'auto', - scrollToIndex: -1, - style: {}, - }; - - Grid: ?React.ElementRef; - - forceUpdateGrid() { - if (this.Grid) { - this.Grid.forceUpdate(); - } - } - - /** See Grid#getOffsetForCell */ - getOffsetForRow({alignment, index}: {alignment: Alignment, index: number}) { - if (this.Grid) { - const {scrollTop} = this.Grid.getOffsetForCell({ - alignment, - rowIndex: index, - columnIndex: 0, - }); - - return scrollTop; - } - return 0; - } - - /** CellMeasurer compatibility */ - invalidateCellSizeAfterRender({columnIndex, rowIndex}: CellPosition) { - if (this.Grid) { - this.Grid.invalidateCellSizeAfterRender({ - rowIndex, - columnIndex, - }); - } - } - - /** See Grid#measureAllCells */ - measureAllRows() { - if (this.Grid) { - this.Grid.measureAllCells(); - } - } - - /** CellMeasurer compatibility */ - recomputeGridSize({columnIndex = 0, rowIndex = 0}: CellPosition = {}) { - if (this.Grid) { - this.Grid.recomputeGridSize({ - rowIndex, - columnIndex, - }); - } - } - - /** See Grid#recomputeGridSize */ - recomputeRowHeights(index: number = 0) { - if (this.Grid) { - this.Grid.recomputeGridSize({ - rowIndex: index, - columnIndex: 0, - }); - } - } - - /** See Grid#scrollToPosition */ - scrollToPosition(scrollTop: number = 0) { - if (this.Grid) { - this.Grid.scrollToPosition({scrollTop}); - } - } - - /** See Grid#scrollToCell */ - scrollToRow(index: number = 0) { - if (this.Grid) { - this.Grid.scrollToCell({ - columnIndex: 0, - rowIndex: index, - }); - } - } - - render() { - const {className, noRowsRenderer, scrollToIndex, width} = this.props; - - const classNames = clsx('ReactVirtualized__List', className); - - return ( - - ); - } - - _cellRenderer = ({ - parent, - rowIndex, - style, - isScrolling, - isVisible, - key, - }: CellRendererParams) => { - const {rowRenderer} = this.props; - - // TRICKY The style object is sometimes cached by Grid. - // This prevents new style objects from bypassing shallowCompare(). - // However as of React 16, style props are auto-frozen (at least in dev mode) - // Check to make sure we can still modify the style before proceeding. - // https://github.com/facebook/react/commit/977357765b44af8ff0cfea327866861073095c12#commitcomment-20648713 - const widthDescriptor = Object.getOwnPropertyDescriptor(style, 'width'); - if (widthDescriptor && widthDescriptor.writable) { - // By default, List cells should be 100% width. - // This prevents them from flowing under a scrollbar (if present). - style.width = '100%'; - } - - return rowRenderer({ - index: rowIndex, - style, - isScrolling, - isVisible, - key, - parent, - }); - }; - - _setRef = (ref: ?React.ElementRef) => { - this.Grid = ref; - }; - - _onScroll = ({clientHeight, scrollHeight, scrollTop}: GridScroll) => { - const {onScroll} = this.props; - - onScroll({clientHeight, scrollHeight, scrollTop}); - }; - - _onSectionRendered = ({ - rowOverscanStartIndex, - rowOverscanStopIndex, - rowStartIndex, - rowStopIndex, - }: RenderedSection) => { - const {onRowsRendered} = this.props; - - onRowsRendered({ - overscanStartIndex: rowOverscanStartIndex, - overscanStopIndex: rowOverscanStopIndex, - startIndex: rowStartIndex, - stopIndex: rowStopIndex, - }); - }; -} diff --git a/source/List/index.js b/source/List/index.js deleted file mode 100644 index e45ba29b9..000000000 --- a/source/List/index.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @flow */ - -export type {RowRendererParams} from './types'; - -export {default} from './List'; -export {default as List} from './List'; diff --git a/source/List/types.js b/source/List/types.js deleted file mode 100644 index 3ff004a44..000000000 --- a/source/List/types.js +++ /dev/null @@ -1,27 +0,0 @@ -// @flow - -import * as React from 'react'; - -export type RowRendererParams = { - index: number, - isScrolling: boolean, - isVisible: boolean, - key: string, - parent: Object, - style: Object, -}; - -export type RowRenderer = (params: RowRendererParams) => React.Element<*>; - -export type RenderedRows = { - overscanStartIndex: number, - overscanStopIndex: number, - startIndex: number, - stopIndex: number, -}; - -export type Scroll = { - clientHeight: number, - scrollHeight: number, - scrollTop: number, -}; diff --git a/source/Masonry/Masonry.example.css b/source/Masonry/Masonry.example.css deleted file mode 100644 index 118ac5dc3..000000000 --- a/source/Masonry/Masonry.example.css +++ /dev/null @@ -1,18 +0,0 @@ -.Cell { - display: flex; - flex-direction: column; - border-radius: .5rem; - padding: 0.5rem; - background-color: #f7f7f7; - word-break: break-all; -} - -.checkboxLabel { - margin-left: .5rem; -} -.checkboxLabel:first-of-type { - margin-left: 0; -} -.checkbox { - margin-right: 5px; -} \ No newline at end of file diff --git a/source/Masonry/Masonry.example.js b/source/Masonry/Masonry.example.js deleted file mode 100644 index c0b956c3f..000000000 --- a/source/Masonry/Masonry.example.js +++ /dev/null @@ -1,301 +0,0 @@ -/** @flow */ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import {LabeledInput, InputRow} from '../demo/LabeledInput'; -import {CellMeasurer, CellMeasurerCache} from '../CellMeasurer'; -import AutoSizer from '../AutoSizer'; -import WindowScroller from '../WindowScroller'; -import createCellPositioner from './createCellPositioner'; -import Masonry from './Masonry'; -import styles from './Masonry.example.css'; - -export default class GridExample extends React.PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - }; - - constructor(props, context) { - super(props, context); - - this._columnCount = 0; - - this._cache = new CellMeasurerCache({ - defaultHeight: 250, - defaultWidth: 200, - fixedWidth: true, - }); - - this.state = { - columnWidth: 200, - height: 300, - gutterSize: 10, - overscanByPixels: 0, - windowScrollerEnabled: false, - }; - - this._cellRenderer = this._cellRenderer.bind(this); - this._onResize = this._onResize.bind(this); - this._renderAutoSizer = this._renderAutoSizer.bind(this); - this._renderMasonry = this._renderMasonry.bind(this); - this._setMasonryRef = this._setMasonryRef.bind(this); - } - - render() { - const { - columnWidth, - height, - gutterSize, - overscanByPixels, - windowScrollerEnabled, - } = this.state; - - let child; - - if (windowScrollerEnabled) { - child = ( - - {this._renderAutoSizer} - - ); - } else { - child = this._renderAutoSizer({height}); - } - - return ( - - - - - Optimized for masonry layouts. Cells are j.i.t. measured and layed out - as a user scrolls. Sizes are cached so that resize/reflow is fast and - does not require re-measuring. - - - - - - - - - { - this.setState({ - height: parseInt(event.target.value, 10) || 300, - }); - }} - value={height} - /> - { - this._cache.clearAll(); - this.setState( - { - columnWidth: parseInt(event.target.value, 10) || 200, - }, - () => { - this._calculateColumnCount(); - this._resetCellPositioner(); - this._masonry.clearCellPositions(); - }, - ); - }} - value={columnWidth} - /> - { - this.setState( - { - gutterSize: parseInt(event.target.value, 10) || 10, - }, - () => { - this._calculateColumnCount(); - this._resetCellPositioner(); - this._masonry.recomputeCellPositions(); - }, - ); - }} - value={gutterSize} - /> - { - this.setState({ - overscanByPixels: parseInt(event.target.value, 10) || 0, - }); - }} - value={overscanByPixels} - /> - - - {child} - - ); - } - - _calculateColumnCount() { - const {columnWidth, gutterSize} = this.state; - - this._columnCount = Math.floor(this._width / (columnWidth + gutterSize)); - } - - _cellRenderer({index, key, parent, style}) { - const {list} = this.context; - const {columnWidth} = this.state; - - const datum = list.get(index % list.size); - - return ( - -
-
- {index} -
- {datum.random} -
-
- ); - } - - _initCellPositioner() { - if (typeof this._cellPositioner === 'undefined') { - const {columnWidth, gutterSize} = this.state; - - this._cellPositioner = createCellPositioner({ - cellMeasurerCache: this._cache, - columnCount: this._columnCount, - columnWidth, - spacer: gutterSize, - }); - } - } - - _onResize({width}) { - this._width = width; - - this._calculateColumnCount(); - this._resetCellPositioner(); - this._masonry.recomputeCellPositions(); - } - - _renderAutoSizer({height, scrollTop}) { - this._height = height; - this._scrollTop = scrollTop; - - const {overscanByPixels} = this.state; - - return ( - - {this._renderMasonry} - - ); - } - - _renderMasonry({width}) { - this._width = width; - - this._calculateColumnCount(); - this._initCellPositioner(); - - const {height, overscanByPixels, windowScrollerEnabled} = this.state; - - return ( - - ); - } - - // This is a bit of a hack to simulate newly loaded cells - _resetList = () => { - const ROW_HEIGHTS = [25, 50, 75, 100]; - - const {list} = this.context; - list.forEach(datum => { - datum.size = ROW_HEIGHTS[Math.floor(Math.random() * ROW_HEIGHTS.length)]; - }); - - this._cache.clearAll(); - this._resetCellPositioner(); - this._masonry.clearCellPositions(); - }; - - _resetCellPositioner() { - const {columnWidth, gutterSize} = this.state; - - this._cellPositioner.reset({ - columnCount: this._columnCount, - columnWidth, - spacer: gutterSize, - }); - } - - _setMasonryRef(ref) { - this._masonry = ref; - } -} diff --git a/source/Masonry/Masonry.jest.js b/source/Masonry/Masonry.jest.js deleted file mode 100644 index 23a00189b..000000000 --- a/source/Masonry/Masonry.jest.js +++ /dev/null @@ -1,472 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {Simulate} from 'react-dom/test-utils'; -import {render} from '../TestUtils'; -import createCellPositionerUtil from './createCellPositioner'; -import Masonry from './Masonry'; -import {CellMeasurer, CellMeasurerCache} from '../CellMeasurer'; - -const ALTERNATING_CELL_HEIGHTS = [100, 50, 100, 150]; -const CELL_SIZE_MULTIPLIER = 50; -const COLUMN_COUNT = 3; - -function assertVisibleCells(rendered, text) { - expect( - Array.from(rendered.querySelectorAll('.cell')) - .map(node => node.textContent) - .sort() - .join(','), - ).toEqual(text); -} - -function createCellMeasurerCache(props = {}) { - return new CellMeasurerCache({ - defaultHeight: CELL_SIZE_MULTIPLIER, - defaultWidth: CELL_SIZE_MULTIPLIER, - fixedWidth: true, - keyMapper: index => index, - ...props, - }); -} - -function createCellPositioner(cache) { - return createCellPositionerUtil({ - cellMeasurerCache: cache, - columnCount: COLUMN_COUNT, - columnWidth: CELL_SIZE_MULTIPLIER, - }); -} - -function createCellRenderer(cache, renderCallback) { - renderCallback = - typeof renderCallback === 'function' ? renderCallback : index => index; - - return function cellRenderer({index, isScrolling, key, parent, style}) { - const height = - ALTERNATING_CELL_HEIGHTS[index % ALTERNATING_CELL_HEIGHTS.length]; - const width = CELL_SIZE_MULTIPLIER; - - return ( - -
{ - if (ref) { - // Accounts for the fact that JSDom doesn't support measurements. - Object.defineProperty(ref, 'offsetHeight', { - configurable: true, - value: height, - }); - Object.defineProperty(ref, 'offsetWidth', { - configurable: true, - value: width, - }); - } - }} - style={{ - ...style, - minHeight: height, - minWidth: width, - }}> - {renderCallback(index, {index, isScrolling, key, parent, style})} -
-
- ); - }; -} - -function getMarkup(props = {}) { - const cellMeasurerCache = - props.cellMeasurerCache || createCellMeasurerCache(); - - return ( - - ); -} - -function simulateScroll(masonry, scrollTop = 0) { - const target = {scrollTop}; - masonry._scrollingContainer = target; // HACK to work around _onScroll target check - - const masonryNode = findDOMNode(masonry); - masonryNode.scrollTop = scrollTop; - Simulate.scroll(masonryNode); -} - -describe('Masonry', () => { - beforeEach(render.unmount); - - describe('layout and measuring', () => { - it('should measure only enough cells required for initial render', () => { - // avg cell size: CELL_SIZE_MULTIPLIER - // width: CELL_SIZE_MULTIPLIER * 3 - // height: CELL_SIZE_MULTIPLIER * 2 - // overcsan by: CELL_SIZE_MULTIPLIER - // Expected to measure 9 cells - const cellMeasurerCache = createCellMeasurerCache(); - render(getMarkup({cellMeasurerCache})); - for (let i = 0; i <= 8; i++) { - expect(cellMeasurerCache.has(i)).toBe(true); - } - expect(cellMeasurerCache.has(9)).toBe(false); - }); - - it('should not measure cells while scrolling until they are needed', () => { - // Expected to measure 9 cells - const cellMeasurerCache = createCellMeasurerCache(); - const renderCallback = jest.fn().mockImplementation(index => index); - const cellRenderer = createCellRenderer( - cellMeasurerCache, - renderCallback, - ); - const rendered = findDOMNode( - render(getMarkup({cellMeasurerCache, cellRenderer})), - ); - renderCallback.mockClear(); - // Scroll a little bit, but not so much to require re-measuring - simulateScroll(rendered, 51); - // Verify that render was only called enough times to fill view port (no extra for measuring) - expect(renderCallback).toHaveBeenCalledTimes(9); - }); - - it('should measure additional cells on scroll when it runs out of measured cells', () => { - const cellMeasurerCache = createCellMeasurerCache(); - const renderCallback = jest.fn().mockImplementation(index => index); - const cellRenderer = createCellRenderer( - cellMeasurerCache, - renderCallback, - ); - const rendered = findDOMNode( - render(getMarkup({cellRenderer, cellMeasurerCache})), - ); - expect(cellMeasurerCache.has(9)).toBe(false); - - renderCallback.mockClear(); - - simulateScroll(rendered, 101); - expect(cellMeasurerCache.has(9)).toBe(true); - expect(cellMeasurerCache.has(10)).toBe(false); - }); - - // Masonry used to do a render pass for only unmeasured cells, - // But this resulting in removing (and later re-adding) measured cells from the DOM, - // Which was bad for performance. See GitHub issue #875 - it('should not remove previously-measured cells when measuring new ones', () => { - const log = []; - - const cellMeasurerCache = createCellMeasurerCache(); - const renderCallback = index => { - log.push(index); - }; - const cellRenderer = createCellRenderer( - cellMeasurerCache, - renderCallback, - ); - - const rendered = findDOMNode( - render( - getMarkup({ - cellMeasurerCache, - cellRenderer, - }), - ), - ); - - // Expected to have rendered twice: - // 1st time to measure 9 cells (b'c of esimated size) - // 2nd time to render and position 9 cells (b'c of actual size) - expect(log).toHaveLength(18); - - log.splice(0); - - simulateScroll(rendered, 101); - - // Expected to have rendered twice: - // 1st time to measure additional cells (based on estimated size) - // 2nd time to render and position with new cells - // The 1st render should also have included the pre-measured cells, - // To prevent them from being removed, recreated, and re-added to the DOM. - expect(log).toHaveLength(18); - }); - - it('should only render enough cells to fill the viewport', () => { - const rendered = findDOMNode( - render( - getMarkup({ - overscanByPixels: 0, - }), - ), - ); - assertVisibleCells(rendered, '0,1,2,3,4,5'); - simulateScroll(rendered, 51); - assertVisibleCells(rendered, '0,2,3,4,5,6'); - simulateScroll(rendered, 101); - assertVisibleCells(rendered, '3,4,5,6,7,8'); - simulateScroll(rendered, 1001); - assertVisibleCells(rendered, '30,31,32,33,34,35'); - }); - - it('should only render enough cells to fill the viewport plus overscanByPixels', () => { - const rendered = findDOMNode( - render( - getMarkup({ - overscanByPixels: 100, - }), - ), - ); - assertVisibleCells(rendered, '0,1,10,11,2,3,4,5,6,7,8,9'); - simulateScroll(rendered, 51); - assertVisibleCells(rendered, '0,1,10,11,2,3,4,5,6,7,8,9'); - simulateScroll(rendered, 101); - assertVisibleCells(rendered, '0,1,10,11,2,3,4,5,6,7,8,9'); - simulateScroll(rendered, 1001); - assertVisibleCells(rendered, '26,27,28,29,30,31,32,33,34,35,36,37'); - }); - - it('should still render correctly when autoHeight is true (eg WindowScroller)', () => { - // Share instances between renders to avoid resetting state in ways we don't intend - const cellMeasurerCache = createCellMeasurerCache(); - const cellPositioner = createCellPositioner(cellMeasurerCache); - - let rendered = findDOMNode( - render( - getMarkup({ - autoHeight: true, - cellMeasurerCache, - cellPositioner, - }), - ), - ); - assertVisibleCells(rendered, '0,1,2,3,4,5,6,7,8'); - rendered = findDOMNode( - render( - getMarkup({ - autoHeight: true, - cellMeasurerCache, - cellPositioner, - scrollTop: 51, - }), - ), - ); - assertVisibleCells(rendered, '0,1,2,3,4,5,6,7,8'); - rendered = findDOMNode( - render( - getMarkup({ - autoHeight: true, - cellMeasurerCache, - cellPositioner, - scrollTop: 101, - }), - ), - ); - assertVisibleCells(rendered, '0,2,3,4,5,6,7,8,9'); - rendered = findDOMNode( - render( - getMarkup({ - autoHeight: true, - cellMeasurerCache, - cellPositioner, - scrollTop: 1001, - }), - ), - ); - assertVisibleCells(rendered, '27,29,30,31,32,33,34,35,36'); - }); - - it('should set right instead of left in a cell styles for rtl row direction', () => { - // Share instances between renders to avoid resetting state in ways we don't intend - const cellMeasurerCache = createCellMeasurerCache(); - const cellPositioner = createCellPositioner(cellMeasurerCache); - - let rendered = findDOMNode( - render( - getMarkup({ - cellMeasurerCache, - cellPositioner, - rowDirection: 'rtl', - }), - ), - ); - Array.from(rendered.querySelectorAll('.cell')).map(node => { - expect(node.style.right).toMatch(/px/); - }); - }); - - it('should consider scroll only of the container element and not of any ancestor element', () => { - const cellMeasurerCache = createCellMeasurerCache(); - const renderScrollableCell = index => ( -
-
{index}
-
- ); - const cellRenderer = createCellRenderer( - cellMeasurerCache, - renderScrollableCell, - ); - - const rendered = findDOMNode( - render( - getMarkup({ - overscanByPixels: 0, - cellMeasurerCache, - cellRenderer, - }), - ), - ); - assertVisibleCells(rendered, '0,1,2,3,4,5'); - const cellEl = rendered.querySelector('#scrollable-cell-1'); - Simulate.scroll(cellEl, {target: {scrollTop: 100}}); - assertVisibleCells(rendered, '0,1,2,3,4,5'); - }); - }); - - describe('recomputeCellPositions', () => { - it('should refresh all cell positions', () => { - // Share instances between renders to avoid resetting state in ways we don't intend - const cellMeasurerCache = createCellMeasurerCache(); - const cellPositioner = jest - .fn() - .mockImplementation(createCellPositioner(cellMeasurerCache)); - - let rendered = findDOMNode( - render( - getMarkup({ - cellMeasurerCache, - cellPositioner, - }), - ), - ); - assertVisibleCells(rendered, '0,1,2,3,4,5,6,7,8'); - - cellPositioner.mockImplementation(index => ({ - left: 0, - top: index * CELL_SIZE_MULTIPLIER, - })); - - const component = render( - getMarkup({ - cellMeasurerCache, - cellPositioner, - }), - ); - rendered = findDOMNode(component); - assertVisibleCells(rendered, '0,1,2,3,4,5,6,7,8'); - component.recomputeCellPositions(); - assertVisibleCells(rendered, '0,1,2,3,4'); - }); - - it('should not reset measurement cache', () => { - const cellMeasurerCache = createCellMeasurerCache(); - const component = render(getMarkup({cellMeasurerCache})); - const rendered = findDOMNode(component); - simulateScroll(rendered, 101); - expect(cellMeasurerCache.has(9)).toBe(true); - simulateScroll(rendered, 0); - component.recomputeCellPositions(); - for (let i = 0; i <= 9; i++) { - expect(cellMeasurerCache.has(i)).toBe(true); - } - }); - }); - - describe('isScrolling', () => { - it('should be true for cellRenderer while scrolling is in progress', () => { - const cellMeasurerCache = createCellMeasurerCache(); - const renderCallback = jest.fn().mockImplementation(index => index); - const cellRenderer = createCellRenderer( - cellMeasurerCache, - renderCallback, - ); - const rendered = findDOMNode( - render(getMarkup({cellMeasurerCache, cellRenderer})), - ); - renderCallback.mockClear(); - simulateScroll(rendered, 51); - expect(renderCallback.mock.calls[0][1].isScrolling).toEqual(true); - }); - - it('should be reset after a small debounce when scrolling stops', () => { - const cellMeasurerCache = createCellMeasurerCache(); - const renderCallback = jest.fn().mockImplementation(index => index); - const cellRenderer = createCellRenderer( - cellMeasurerCache, - renderCallback, - ); - const rendered = findDOMNode( - render(getMarkup({cellMeasurerCache, cellRenderer})), - ); - simulateScroll(rendered, 51); - renderCallback.mockClear(); - setTimeout(() => { - expect(renderCallback.mock.calls[0][1].isScrolling).toEqual(false); - }, 0); - }); - }); - - describe('callbacks', () => { - it('should call onCellsRendered when rendered cells change', () => { - const onCellsRendered = jest.fn(); - const rendered = findDOMNode(render(getMarkup({onCellsRendered}))); - expect(onCellsRendered.mock.calls).toEqual([ - [{startIndex: 0, stopIndex: 8}], - ]); - simulateScroll(rendered, 51); - expect(onCellsRendered.mock.calls).toEqual([ - [{startIndex: 0, stopIndex: 8}], - ]); - simulateScroll(rendered, 101); - expect(onCellsRendered.mock.calls).toEqual([ - [{startIndex: 0, stopIndex: 8}], - [{startIndex: 0, stopIndex: 9}], - ]); - }); - - it('should call onScroll when scroll position changes', () => { - const onScroll = jest.fn(); - const rendered = findDOMNode(render(getMarkup({onScroll}))); - expect(onScroll.mock.calls).toEqual([ - [{clientHeight: 100, scrollHeight: 16900, scrollTop: 0}], - ]); - simulateScroll(rendered, 51); - expect(onScroll.mock.calls).toEqual([ - [{clientHeight: 100, scrollHeight: 16900, scrollTop: 0}], - [{clientHeight: 100, scrollHeight: 16900, scrollTop: 51}], - ]); - simulateScroll(rendered, 0); - expect(onScroll.mock.calls).toEqual([ - [{clientHeight: 100, scrollHeight: 16900, scrollTop: 0}], - [{clientHeight: 100, scrollHeight: 16900, scrollTop: 51}], - [{clientHeight: 100, scrollHeight: 16900, scrollTop: 0}], - ]); - }); - }); - - describe('keyMapper', () => { - it('should pass the correct key to rendered cells', () => { - const keyMapper = jest.fn().mockImplementation(index => `key:${index}`); - const cellRenderer = jest - .fn() - .mockImplementation(({index, key, style}) => ( -
- {index} -
- )); - findDOMNode(render(getMarkup({cellRenderer, keyMapper}))); - expect(keyMapper).toHaveBeenCalled(); - expect(cellRenderer).toHaveBeenCalled(); - expect(cellRenderer.mock.calls[0][0].key).toEqual('key:0'); - }); - }); -}); diff --git a/source/Masonry/Masonry.js b/source/Masonry/Masonry.js deleted file mode 100644 index f5cf8657e..000000000 --- a/source/Masonry/Masonry.js +++ /dev/null @@ -1,490 +0,0 @@ -/** @flow */ -import clsx from 'clsx'; -import * as React from 'react'; -import {polyfill} from 'react-lifecycles-compat'; -import PositionCache from './PositionCache'; -import { - requestAnimationTimeout, - cancelAnimationTimeout, -} from '../utils/requestAnimationTimeout'; - -import type {AnimationTimeoutId} from '../utils/requestAnimationTimeout'; - -type Props = { - autoHeight: boolean, - cellCount: number, - cellMeasurerCache: CellMeasurerCache, - cellPositioner: Positioner, - cellRenderer: CellRenderer, - className: ?string, - height: number, - id: ?string, - keyMapper: KeyMapper, - onCellsRendered: ?OnCellsRenderedCallback, - onScroll: ?OnScrollCallback, - overscanByPixels: number, - role: string, - scrollingResetTimeInterval: number, - style: mixed, - tabIndex: number, - width: number, - rowDirection: string, - scrollTop?: number, -}; - -type State = { - isScrolling: boolean, - scrollTop: number, -}; - -const emptyObject = {}; - -/** - * Specifies the number of milliseconds during which to disable pointer events while a scroll is in progress. - * This improves performance and makes scrolling smoother. - */ -export const DEFAULT_SCROLLING_RESET_TIME_INTERVAL = 150; - -/** - * This component efficiently displays arbitrarily positioned cells using windowing techniques. - * Cell position is determined by an injected `cellPositioner` property. - * Windowing is vertical; this component does not support horizontal scrolling. - * - * Rendering occurs in two phases: - * 1) First pass uses estimated cell sizes (provided by the cache) to determine how many cells to measure in a batch. - * Batch size is chosen using a fast, naive layout algorithm that stacks images in order until the viewport has been filled. - * After measurement is complete (componentDidMount or componentDidUpdate) this component evaluates positioned cells - * in order to determine if another measurement pass is required (eg if actual cell sizes were less than estimated sizes). - * All measurements are permanently cached (keyed by `keyMapper`) for performance purposes. - * 2) Second pass uses the external `cellPositioner` to layout cells. - * At this time the positioner has access to cached size measurements for all cells. - * The positions it returns are cached by Masonry for fast access later. - * Phase one is repeated if the user scrolls beyond the current layout's bounds. - * If the layout is invalidated due to eg a resize, cached positions can be cleared using `recomputeCellPositions()`. - * - * Animation constraints: - * Simple animations are supported (eg translate/slide into place on initial reveal). - * More complex animations are not (eg flying from one position to another on resize). - * - * Layout constraints: - * This component supports multi-column layout. - * The height of each item may vary. - * The width of each item must not exceed the width of the column it is "in". - * The left position of all items within a column must align. - * (Items may not span multiple columns.) - */ -class Masonry extends React.PureComponent { - static defaultProps = { - autoHeight: false, - keyMapper: identity, - onCellsRendered: noop, - onScroll: noop, - overscanByPixels: 20, - role: 'grid', - scrollingResetTimeInterval: DEFAULT_SCROLLING_RESET_TIME_INTERVAL, - style: emptyObject, - tabIndex: 0, - rowDirection: 'ltr', - }; - - state = { - isScrolling: false, - scrollTop: 0, - }; - - _debounceResetIsScrollingId: AnimationTimeoutId; - _invalidateOnUpdateStartIndex: ?number = null; - _invalidateOnUpdateStopIndex: ?number = null; - _positionCache: PositionCache = new PositionCache(); - _startIndex: ?number = null; - _startIndexMemoized: ?number = null; - _stopIndex: ?number = null; - _stopIndexMemoized: ?number = null; - - clearCellPositions() { - this._positionCache = new PositionCache(); - this.forceUpdate(); - } - - // HACK This method signature was intended for Grid - invalidateCellSizeAfterRender({rowIndex: index}) { - if (this._invalidateOnUpdateStartIndex === null) { - this._invalidateOnUpdateStartIndex = index; - this._invalidateOnUpdateStopIndex = index; - } else { - this._invalidateOnUpdateStartIndex = Math.min( - this._invalidateOnUpdateStartIndex, - index, - ); - this._invalidateOnUpdateStopIndex = Math.max( - this._invalidateOnUpdateStopIndex, - index, - ); - } - } - - recomputeCellPositions() { - const stopIndex = this._positionCache.count - 1; - - this._positionCache = new PositionCache(); - this._populatePositionCache(0, stopIndex); - - this.forceUpdate(); - } - - static getDerivedStateFromProps( - nextProps: Props, - prevState: State, - ): $Shape { - if ( - nextProps.scrollTop !== undefined && - prevState.scrollTop !== nextProps.scrollTop - ) { - return { - isScrolling: true, - scrollTop: nextProps.scrollTop, - }; - } - - return null; - } - - componentDidMount() { - this._checkInvalidateOnUpdate(); - this._invokeOnScrollCallback(); - this._invokeOnCellsRenderedCallback(); - } - - componentDidUpdate(prevProps: Props, prevState: State) { - this._checkInvalidateOnUpdate(); - this._invokeOnScrollCallback(); - this._invokeOnCellsRenderedCallback(); - - if (this.props.scrollTop !== prevProps.scrollTop) { - this._debounceResetIsScrolling(); - } - } - - componentWillUnmount() { - if (this._debounceResetIsScrollingId) { - cancelAnimationTimeout(this._debounceResetIsScrollingId); - } - } - - render() { - const { - autoHeight, - cellCount, - cellMeasurerCache, - cellRenderer, - className, - height, - id, - keyMapper, - overscanByPixels, - role, - style, - tabIndex, - width, - rowDirection, - } = this.props; - - const {isScrolling, scrollTop} = this.state; - - const children = []; - - const estimateTotalHeight = this._getEstimatedTotalHeight(); - - const shortestColumnSize = this._positionCache.shortestColumnSize; - const measuredCellCount = this._positionCache.count; - - let startIndex = 0; - let stopIndex; - - this._positionCache.range( - Math.max(0, scrollTop - overscanByPixels), - height + overscanByPixels * 2, - (index: number, left: number, top: number) => { - if (typeof stopIndex === 'undefined') { - startIndex = index; - stopIndex = index; - } else { - startIndex = Math.min(startIndex, index); - stopIndex = Math.max(stopIndex, index); - } - - children.push( - cellRenderer({ - index, - isScrolling, - key: keyMapper(index), - parent: this, - style: { - height: cellMeasurerCache.getHeight(index), - [rowDirection === 'ltr' ? 'left' : 'right']: left, - position: 'absolute', - top, - width: cellMeasurerCache.getWidth(index), - }, - }), - ); - }, - ); - - // We need to measure additional cells for this layout - if ( - shortestColumnSize < scrollTop + height + overscanByPixels && - measuredCellCount < cellCount - ) { - const batchSize = Math.min( - cellCount - measuredCellCount, - Math.ceil( - (((scrollTop + height + overscanByPixels - shortestColumnSize) / - cellMeasurerCache.defaultHeight) * - width) / - cellMeasurerCache.defaultWidth, - ), - ); - - for ( - let index = measuredCellCount; - index < measuredCellCount + batchSize; - index++ - ) { - stopIndex = index; - - children.push( - cellRenderer({ - index: index, - isScrolling, - key: keyMapper(index), - parent: this, - style: { - width: cellMeasurerCache.getWidth(index), - }, - }), - ); - } - } - - this._startIndex = startIndex; - this._stopIndex = stopIndex; - - return ( -
-
- {children} -
-
- ); - } - - _checkInvalidateOnUpdate() { - if (typeof this._invalidateOnUpdateStartIndex === 'number') { - const startIndex = this._invalidateOnUpdateStartIndex; - const stopIndex = this._invalidateOnUpdateStopIndex; - - this._invalidateOnUpdateStartIndex = null; - this._invalidateOnUpdateStopIndex = null; - - // Query external layout logic for position of newly-measured cells - this._populatePositionCache(startIndex, stopIndex); - - this.forceUpdate(); - } - } - - _debounceResetIsScrolling() { - const {scrollingResetTimeInterval} = this.props; - - if (this._debounceResetIsScrollingId) { - cancelAnimationTimeout(this._debounceResetIsScrollingId); - } - - this._debounceResetIsScrollingId = requestAnimationTimeout( - this._debounceResetIsScrollingCallback, - scrollingResetTimeInterval, - ); - } - - _debounceResetIsScrollingCallback = () => { - this.setState({ - isScrolling: false, - }); - }; - - _getEstimatedTotalHeight() { - const {cellCount, cellMeasurerCache, width} = this.props; - - const estimatedColumnCount = Math.max( - 1, - Math.floor(width / cellMeasurerCache.defaultWidth), - ); - - return this._positionCache.estimateTotalHeight( - cellCount, - estimatedColumnCount, - cellMeasurerCache.defaultHeight, - ); - } - - _invokeOnScrollCallback() { - const {height, onScroll} = this.props; - const {scrollTop} = this.state; - - if (this._onScrollMemoized !== scrollTop) { - onScroll({ - clientHeight: height, - scrollHeight: this._getEstimatedTotalHeight(), - scrollTop, - }); - - this._onScrollMemoized = scrollTop; - } - } - - _invokeOnCellsRenderedCallback() { - if ( - this._startIndexMemoized !== this._startIndex || - this._stopIndexMemoized !== this._stopIndex - ) { - const {onCellsRendered} = this.props; - - onCellsRendered({ - startIndex: this._startIndex, - stopIndex: this._stopIndex, - }); - - this._startIndexMemoized = this._startIndex; - this._stopIndexMemoized = this._stopIndex; - } - } - - _populatePositionCache(startIndex: number, stopIndex: number) { - const {cellMeasurerCache, cellPositioner} = this.props; - - for (let index = startIndex; index <= stopIndex; index++) { - const {left, top} = cellPositioner(index); - - this._positionCache.setPosition( - index, - left, - top, - cellMeasurerCache.getHeight(index), - ); - } - } - - _setScrollingContainerRef = ref => { - this._scrollingContainer = ref; - }; - - _onScroll = event => { - const {height} = this.props; - - const eventScrollTop = event.currentTarget.scrollTop; - - // When this component is shrunk drastically, React dispatches a series of back-to-back scroll events, - // Gradually converging on a scrollTop that is within the bounds of the new, smaller height. - // This causes a series of rapid renders that is slow for long lists. - // We can avoid that by doing some simple bounds checking to ensure that scroll offsets never exceed their bounds. - const scrollTop = Math.min( - Math.max(0, this._getEstimatedTotalHeight() - height), - eventScrollTop, - ); - - // On iOS, we can arrive at negative offsets by swiping past the start or end. - // Avoid re-rendering in this case as it can cause problems; see #532 for more. - if (eventScrollTop !== scrollTop) { - return; - } - - // Prevent pointer events from interrupting a smooth scroll - this._debounceResetIsScrolling(); - - // Certain devices (like Apple touchpad) rapid-fire duplicate events. - // Don't force a re-render if this is the case. - // The mouse may move faster then the animation frame does. - // Use requestAnimationFrame to avoid over-updating. - if (this.state.scrollTop !== scrollTop) { - this.setState({ - isScrolling: true, - scrollTop, - }); - } - }; -} - -function identity(value) { - return value; -} - -function noop() {} - -type KeyMapper = (index: number) => mixed; - -export type CellMeasurerCache = { - defaultHeight: number, - defaultWidth: number, - getHeight: (index: number) => number, - getWidth: (index: number) => number, -}; - -type CellRenderer = (params: {| - index: number, - isScrolling: boolean, - key: mixed, - parent: mixed, - style: mixed, -|}) => mixed; - -type OnCellsRenderedCallback = (params: {| - startIndex: number, - stopIndex: number, -|}) => void; - -type OnScrollCallback = (params: {| - clientHeight: number, - scrollHeight: number, - scrollTop: number, -|}) => void; - -type Position = { - left: number, - top: number, -}; - -polyfill(Masonry); - -export default Masonry; - -export type Positioner = (index: number) => Position; diff --git a/source/Masonry/index.js b/source/Masonry/index.js deleted file mode 100644 index 609fc182e..000000000 --- a/source/Masonry/index.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @flow */ -import createCellPositioner from './createCellPositioner'; -import Masonry from './Masonry'; - -export default Masonry; -export {createCellPositioner, Masonry}; diff --git a/source/MultiGrid/MultiGrid.example.css b/source/MultiGrid/MultiGrid.example.css deleted file mode 100644 index 7a46f81f9..000000000 --- a/source/MultiGrid/MultiGrid.example.css +++ /dev/null @@ -1,7 +0,0 @@ -.Cell { - display: flex; - align-items: center; - justify-content: center; - border-bottom: 1px solid #eee; - border-right: 1px solid #eee; -} \ No newline at end of file diff --git a/source/MultiGrid/MultiGrid.example.js b/source/MultiGrid/MultiGrid.example.js deleted file mode 100644 index 0f49a3506..000000000 --- a/source/MultiGrid/MultiGrid.example.js +++ /dev/null @@ -1,142 +0,0 @@ -/** @flow */ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import {LabeledInput, InputRow} from '../demo/LabeledInput'; -import AutoSizer from '../AutoSizer'; -import MultiGrid from './MultiGrid'; -import styles from './MultiGrid.example.css'; - -const STYLE = { - border: '1px solid #ddd', -}; -const STYLE_BOTTOM_LEFT_GRID = { - borderRight: '2px solid #aaa', - backgroundColor: '#f7f7f7', -}; -const STYLE_TOP_LEFT_GRID = { - borderBottom: '2px solid #aaa', - borderRight: '2px solid #aaa', - fontWeight: 'bold', -}; -const STYLE_TOP_RIGHT_GRID = { - borderBottom: '2px solid #aaa', - fontWeight: 'bold', -}; - -export default class MultiGridExample extends React.PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - fixedColumnCount: 2, - fixedRowCount: 1, - scrollToColumn: 0, - scrollToRow: 0, - }; - - this._cellRenderer = this._cellRenderer.bind(this); - this._onFixedColumnCountChange = this._createEventHandler( - 'fixedColumnCount', - ); - this._onFixedRowCountChange = this._createEventHandler('fixedRowCount'); - this._onScrollToColumnChange = this._createEventHandler('scrollToColumn'); - this._onScrollToRowChange = this._createEventHandler('scrollToRow'); - } - - render() { - return ( - - - - - This component stitches together several grids to provide a fixed - column/row interface. - - - - {this._createLabeledInput( - 'fixedColumnCount', - this._onFixedColumnCountChange, - )} - {this._createLabeledInput( - 'fixedRowCount', - this._onFixedRowCountChange, - )} - {this._createLabeledInput( - 'scrollToColumn', - this._onScrollToColumnChange, - )} - {this._createLabeledInput('scrollToRow', this._onScrollToRowChange)} - - - - {({width}) => ( - - )} - - - ); - } - - _cellRenderer({columnIndex, key, rowIndex, style}) { - return ( -
- {columnIndex}, {rowIndex} -
- ); - } - - _createEventHandler(property) { - return event => { - const value = parseInt(event.target.value, 10) || 0; - - this.setState({ - [property]: value, - }); - }; - } - - _createLabeledInput(property, eventHandler) { - const value = this.state[property]; - - return ( - - ); - } -} diff --git a/source/MultiGrid/MultiGrid.jest.js b/source/MultiGrid/MultiGrid.jest.js deleted file mode 100644 index 06d352556..000000000 --- a/source/MultiGrid/MultiGrid.jest.js +++ /dev/null @@ -1,715 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import MultiGrid from './MultiGrid'; -import {CellMeasurerCache} from '../CellMeasurer'; - -// These tests only focus on what MultiGrid does specifically. -// The inner Grid component is tested in depth elsewhere. -describe('MultiGrid', () => { - function defaultCellRenderer({columnIndex, key, rowIndex, style}) { - return ( -
- {`row:${rowIndex}, column:${columnIndex}`} -
- ); - } - - function getMarkup(props = {}) { - return ( - - ); - } - - describe('fixed columns and rows', () => { - it('should render 4 Grids when configured for fixed columns and rows', () => { - const rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 1, - fixedRowCount: 1, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - expect(grids.length).toEqual(4); - const [topLeft, topRight, bottomLeft, bottomRight] = grids; - expect(topLeft.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(topLeft.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(topRight.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(topRight.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(bottomLeft.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(bottomLeft.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(bottomRight.style.getPropertyValue('overflow-x')).toEqual('auto'); - expect(bottomRight.style.getPropertyValue('overflow-y')).toEqual('auto'); - }); - - it('should render 2 Grids when configured for fixed columns only', () => { - const rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 1, - fixedRowCount: 0, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - expect(grids.length).toEqual(2); - const [bottomLeft, bottomRight] = grids; - expect(bottomLeft.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(bottomLeft.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(bottomRight.style.getPropertyValue('overflow-x')).toEqual('auto'); - expect(bottomRight.style.getPropertyValue('overflow-y')).toEqual('auto'); - }); - - it('should render 2 Grids when configured for fixed rows only', () => { - const rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 0, - fixedRowCount: 1, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - expect(grids.length).toEqual(2); - const [topRight, bottomRight] = grids; - expect(topRight.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(topRight.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(bottomRight.style.getPropertyValue('overflow-x')).toEqual('auto'); - expect(bottomRight.style.getPropertyValue('overflow-y')).toEqual('auto'); - }); - - it('should render 1 Grid when configured for neither fixed columns and rows', () => { - const rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 0, - fixedRowCount: 0, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - expect(grids.length).toEqual(1); - const [bottomRight] = grids; - expect(bottomRight.style.getPropertyValue('overflow-x')).toEqual('auto'); - expect(bottomRight.style.getPropertyValue('overflow-y')).toEqual('auto'); - }); - - it('should adjust the number of Grids when fixed column or row counts change', () => { - let rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 2, - fixedRowCount: 1, - }), - ), - ); - expect( - rendered.querySelectorAll('.ReactVirtualized__Grid').length, - ).toEqual(4); - rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 0, - fixedRowCount: 0, - }), - ), - ); - expect( - rendered.querySelectorAll('.ReactVirtualized__Grid').length, - ).toEqual(1); - rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 0, - fixedRowCount: 2, - }), - ), - ); - expect( - rendered.querySelectorAll('.ReactVirtualized__Grid').length, - ).toEqual(2); - }); - - it('should allow scrolling of fixed Grids when configured for fixed columns and rows with scroll interaction', () => { - const rendered = findDOMNode( - render( - getMarkup({ - enableFixedColumnScroll: true, - enableFixedRowScroll: true, - fixedColumnCount: 1, - fixedRowCount: 1, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - expect(grids.length).toEqual(4); - const [topLeft, topRight, bottomLeft, bottomRight] = grids; - expect(topLeft.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(topLeft.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(topRight.style.getPropertyValue('overflow-x')).toEqual('auto'); - expect(topRight.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(bottomLeft.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(bottomLeft.style.getPropertyValue('overflow-y')).toEqual('auto'); - expect(bottomRight.style.getPropertyValue('overflow-x')).toEqual('auto'); - expect(bottomRight.style.getPropertyValue('overflow-y')).toEqual('auto'); - }); - }); - - describe('hideTopRightGridScrollbar, hideBottomLeftGridScrollbar should hide the scrollbars', () => { - function getScrollbarSize20() { - return 20; - } - it('should add scroll wrappers to hide scroll bar when configured for fixed columns and rows with scroll interaction', () => { - const rendered = findDOMNode( - render( - getMarkup({ - enableFixedColumnScroll: true, - enableFixedRowScroll: true, - fixedColumnCount: 1, - fixedRowCount: 1, - hideTopRightGridScrollbar: true, - hideBottomLeftGridScrollbar: true, - getScrollbarSize: getScrollbarSize20, - }), - ), - ); - let wrappers = rendered.querySelectorAll('.TopRightGrid_ScrollWrapper'); - expect(wrappers.length).toEqual(1); - const [topRightWrapper] = wrappers; - wrappers = rendered.querySelectorAll('.BottomLeftGrid_ScrollWrapper'); - expect(wrappers.length).toEqual(1); - const [bottomLeftWrapper] = wrappers; - - expect(topRightWrapper.style.getPropertyValue('overflow-x')).toEqual( - 'hidden', - ); - expect(topRightWrapper.style.getPropertyValue('overflow-y')).toEqual( - 'hidden', - ); - expect(bottomLeftWrapper.style.getPropertyValue('overflow-x')).toEqual( - 'hidden', - ); - expect(bottomLeftWrapper.style.getPropertyValue('overflow-y')).toEqual( - 'hidden', - ); - - expect(topRightWrapper.style.getPropertyValue('height')).toEqual('20px'); - expect(bottomLeftWrapper.style.getPropertyValue('height')).toEqual( - '280px', - ); - expect(topRightWrapper.style.getPropertyValue('width')).toEqual('350px'); - expect(bottomLeftWrapper.style.getPropertyValue('width')).toEqual('50px'); - - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - expect(grids.length).toEqual(4); - const [topLeft, topRight, bottomLeft, bottomRight] = grids; - expect(topLeft.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(topLeft.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(topRight.style.getPropertyValue('overflow-x')).toEqual('auto'); - expect(topRight.style.getPropertyValue('overflow-y')).toEqual('hidden'); - expect(topRight.style.getPropertyValue('height')).toEqual('40px'); - expect(bottomLeft.style.getPropertyValue('overflow-x')).toEqual('hidden'); - expect(bottomLeft.style.getPropertyValue('overflow-y')).toEqual('auto'); - expect(bottomLeft.style.getPropertyValue('width')).toEqual('70px'); - expect(bottomRight.style.getPropertyValue('overflow-x')).toEqual('auto'); - expect(bottomRight.style.getPropertyValue('overflow-y')).toEqual('auto'); - }); - }); - - describe('#recomputeGridSize', () => { - it('should clear calculated cached styles in recomputeGridSize', () => { - let fixedRowHeight = 75; - let fixedColumnWidth = 100; - - function variableRowHeight({index}) { - if (index === 0) { - return fixedRowHeight; - } - return 20; - } - function variableColumnWidth({index}) { - if (index === 0) { - return fixedColumnWidth; - } - return 50; - } - - let multiGrid; - let rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 1, - fixedRowCount: 1, - rowHeight: variableRowHeight, - columnWidth: variableColumnWidth, - ref: ref => { - multiGrid = ref; - }, - }), - ), - ); - - let grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - expect(grids.length).toEqual(4); - let [topLeft, topRight, bottomLeft, bottomRight] = grids; - expect(topLeft.style.getPropertyValue('height')).toEqual('75px'); - expect(topRight.style.getPropertyValue('height')).toEqual('75px'); - expect(bottomLeft.style.getPropertyValue('height')).toEqual('225px'); - expect(bottomRight.style.getPropertyValue('height')).toEqual('225px'); - - expect(topLeft.style.getPropertyValue('width')).toEqual('100px'); - expect(topRight.style.getPropertyValue('width')).toEqual('300px'); - expect(bottomLeft.style.getPropertyValue('width')).toEqual('100px'); - expect(bottomRight.style.getPropertyValue('width')).toEqual('300px'); - - expect(multiGrid._topGridHeight).toEqual(75); - expect(multiGrid._leftGridWidth).toEqual(100); - - fixedRowHeight = 125; - fixedColumnWidth = 75; - multiGrid.recomputeGridSize(); - expect(multiGrid._topGridHeight).toEqual(125); - expect(multiGrid._leftGridWidth).toEqual(75); - - multiGrid.forceUpdate(); - - let gridsAfter = rendered.querySelectorAll('.ReactVirtualized__Grid'); - expect(gridsAfter.length).toEqual(4); - let [ - topLeftAfter, - topRightAfter, - bottomLeftAfter, - bottomRightAfter, - ] = gridsAfter; - expect(topLeftAfter.style.getPropertyValue('height')).toEqual('125px'); - expect(topRightAfter.style.getPropertyValue('height')).toEqual('125px'); - expect(bottomLeftAfter.style.getPropertyValue('height')).toEqual('175px'); - expect(bottomRightAfter.style.getPropertyValue('height')).toEqual( - '175px', - ); - - expect(topLeftAfter.style.getPropertyValue('width')).toEqual('75px'); - expect(topRightAfter.style.getPropertyValue('width')).toEqual('325px'); - expect(bottomLeftAfter.style.getPropertyValue('width')).toEqual('75px'); - expect(bottomRightAfter.style.getPropertyValue('width')).toEqual('325px'); - }); - }); - - describe('scrollToColumn and scrollToRow', () => { - it('should adjust :scrollLeft for the main Grid when scrollToColumn is used', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnWidth: 50, - fixedColumnCount: 2, - scrollToAlignment: 'start', - scrollToColumn: 19, - }), - ), - ); - // Bottom-right Grid is the last Grid - const grid = rendered.querySelectorAll('.ReactVirtualized__Grid')[3]; - // 20th column, less 2 for the fixed-column Grid, 50px column width - expect(grid.scrollLeft).toEqual(850); - }); - - it('should adjust :scrollTop for the main Grid when scrollToRow is used', () => { - const rendered = findDOMNode( - render( - getMarkup({ - fixedRowCount: 1, - rowHeight: 50, - scrollToAlignment: 'start', - scrollToRow: 19, - }), - ), - ); - // Bottom-right Grid is the last Grid - const grid = rendered.querySelectorAll('.ReactVirtualized__Grid')[3]; - // 20th row, less 1 for the fixed-row Grid, 50px row width - expect(grid.scrollTop).toEqual(900); - }); - }); - - describe('#forceUpdateGrids', () => { - it('should call forceUpdate() on inner Grids', () => { - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(({key}) =>
); - - const rendered = render( - getMarkup({ - cellRenderer, - columnCount: 2, - fixedColumnCount: 1, - fixedRowCount: 1, - rowCount: 2, - }), - ); - - expect(cellRenderer.mock.calls).toHaveLength(4); - - cellRenderer.mockReset(); - rendered.forceUpdateGrids(); - - expect(cellRenderer.mock.calls).toHaveLength(4); - }); - }); - - describe('#invalidateCellSizeAfterRender', () => { - it('should call invalidateCellSizeAfterRender() on inner Grids', () => { - const cellRenderer = jest.fn(); - cellRenderer.mockImplementation(({key}) =>
); - - const rendered = render( - getMarkup({ - cellRenderer, - columnCount: 2, - fixedColumnCount: 1, - fixedRowCount: 1, - rowCount: 2, - }), - ); - - cellRenderer.mockReset(); - rendered.invalidateCellSizeAfterRender({ - columnIndex: 0, - rowIndex: 0, - }); - - rendered.forceUpdate(); - - expect(cellRenderer.mock.calls).toHaveLength(4); - }); - - it('should specify itself as the :parent for CellMeasurer rendered cells', () => { - // HACK For some reason, using Jest mock broke here - let savedParent; - function cellRenderer({key, parent}) { - savedParent = parent; - return
; - } - - const rendered = render( - getMarkup({ - cellRenderer, - columnCount: 2, - fixedColumnCount: 1, - fixedRowCount: 1, - rowCount: 2, - }), - ); - - expect(savedParent).toBe(rendered); - }); - }); - - describe('styles', () => { - it('should support custom style for the outer MultiGrid wrapper element', () => { - const rendered = findDOMNode( - render( - getMarkup({ - style: {backgroundColor: 'black'}, - }), - ), - ); - expect(rendered.style.backgroundColor).toEqual('black'); - }); - - it('should support custom styles for each Grid', () => { - const rendered = findDOMNode( - render( - getMarkup({ - fixedColumnCount: 2, - fixedRowCount: 1, - styleBottomLeftGrid: {backgroundColor: 'green'}, - styleBottomRightGrid: {backgroundColor: 'red'}, - styleTopLeftGrid: {backgroundColor: 'blue'}, - styleTopRightGrid: {backgroundColor: 'purple'}, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - const topLeftGrid = grids[0]; - const topRightGrid = grids[1]; - const bottomLeftGrid = grids[2]; - const bottomRightGrid = grids[3]; - expect(topLeftGrid.style.backgroundColor).toEqual('blue'); - expect(topRightGrid.style.backgroundColor).toEqual('purple'); - expect(bottomLeftGrid.style.backgroundColor).toEqual('green'); - expect(bottomRightGrid.style.backgroundColor).toEqual('red'); - }); - }); - describe('scrollTop and scrollLeft', () => { - it('should adjust :scrollLeft for top-right and main grids when scrollLeft is used', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnWidth: 50, - fixedColumnCount: 2, - scrollLeft: 850, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - const topRightGrid = grids[1]; - const bottomRightGrid = grids[3]; - expect(topRightGrid.scrollLeft).toEqual(850); - expect(bottomRightGrid.scrollLeft).toEqual(850); - }); - - it('should adjust :scrollTop for bottom-left and main grids when scrollTop is used', () => { - const rendered = findDOMNode( - render( - getMarkup({ - columnWidth: 50, - fixedColumnCount: 2, - scrollTop: 500, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - const bottomLeftGrid = grids[2]; - const bottomRightGrid = grids[3]; - expect(bottomLeftGrid.scrollTop).toEqual(500); - expect(bottomRightGrid.scrollTop).toEqual(500); - }); - - it('should adjust :scrollTop and :scrollLeft when scrollTop and scrollLeft change', () => { - render(getMarkup()); - const rendered = findDOMNode( - render( - getMarkup({ - scrollTop: 750, - scrollLeft: 900, - }), - ), - ); - const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); - const topRightGrid = grids[1]; - const bottomLeftGrid = grids[2]; - const bottomRightGrid = grids[3]; - expect(topRightGrid.scrollLeft).toEqual(900); - expect(bottomRightGrid.scrollLeft).toEqual(900); - expect(bottomLeftGrid.scrollTop).toEqual(750); - expect(bottomRightGrid.scrollTop).toEqual(750); - }); - - it('should not crash when decreasing :rowCount', () => { - render(getMarkup()); - const updated = render( - getMarkup({ - rowCount: 2, - }), - ); - expect(updated.props.rowCount).toEqual(2); - }); - - it('should not crash when decreasing :columnCount', () => { - render(getMarkup()); - const updated = render( - getMarkup({ - columnCount: 3, - }), - ); - expect(updated.props.columnCount).toEqual(3); - }); - }); - - describe('deferredMeasurementCache', () => { - function getDeferredMeasurementCache() { - const deferredMeasurementCache = new CellMeasurerCache({ - fixedHeight: true, - fixedWidth: true, - }); - - deferredMeasurementCache._columnIndices = {}; - deferredMeasurementCache._rowIndices = {}; - deferredMeasurementCache.has = (rowIndex, columnIndex) => { - deferredMeasurementCache._columnIndices[columnIndex] = columnIndex; - deferredMeasurementCache._rowIndices[rowIndex] = rowIndex; - return true; - }; - - return deferredMeasurementCache; - } - - it('should wrap top-right and bottom-right deferredMeasurementCache if fixedColumnCount is > 0', () => { - const deferredMeasurementCache = getDeferredMeasurementCache(); - render( - getMarkup({ - deferredMeasurementCache: deferredMeasurementCache, - columnCount: 3, - fixedColumnCount: 1, - fixedRowCount: 0, - rowCount: 1, - }), - ); - - expect(Object.keys(deferredMeasurementCache._columnIndices)).toEqual([ - '0', - '1', - '2', - ]); - }); - - it('should not wrap top-right and bottom-right deferredMeasurementCache if fixedColumnCount is 0', () => { - const deferredMeasurementCache = getDeferredMeasurementCache(); - render( - getMarkup({ - deferredMeasurementCache: deferredMeasurementCache, - columnCount: 2, - fixedColumnCount: 0, - fixedRowCount: 0, - rowCount: 1, - }), - ); - - expect(Object.keys(deferredMeasurementCache._columnIndices)).toEqual([ - '0', - '1', - ]); - }); - - it('should wrap bottom-left and bottom-right deferredMeasurementCache if fixedRowCount is > 0', () => { - const deferredMeasurementCache = getDeferredMeasurementCache(); - render( - getMarkup({ - deferredMeasurementCache: deferredMeasurementCache, - columnCount: 1, - fixedColumnCount: 0, - fixedRowCount: 1, - rowCount: 3, - }), - ); - - expect(Object.keys(deferredMeasurementCache._rowIndices)).toEqual([ - '0', - '1', - '2', - ]); - }); - - it('should not wrap bottom-left and bottom-right deferredMeasurementCache if fixedRowCount is 0', () => { - const deferredMeasurementCache = getDeferredMeasurementCache(); - render( - getMarkup({ - deferredMeasurementCache: deferredMeasurementCache, - columnCount: 1, - fixedColumnCount: 0, - fixedRowCount: 0, - rowCount: 2, - }), - ); - - expect(Object.keys(deferredMeasurementCache._rowIndices)).toEqual([ - '0', - '1', - ]); - }); - }); - - describe('onScrollbarPresenceChange', () => { - function getScrollbarSize20() { - return 20; - } - - it('should not trigger on-mount if scrollbars are hidden', () => { - const onScrollbarPresenceChange = jest.fn(); - - render( - getMarkup({ - columnCount: 1, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 1, - }), - ); - expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); - }); - - it('should trigger on-mount if scrollbars are visible', () => { - const onScrollbarPresenceChange = jest.fn(); - - render( - getMarkup({ - columnCount: 100, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 100, - }), - ); - expect(onScrollbarPresenceChange).toHaveBeenCalled(); - - const args = onScrollbarPresenceChange.mock.calls[0][0]; - expect(args.horizontal).toBe(true); - expect(args.size).toBe(getScrollbarSize20()); - expect(args.vertical).toBe(true); - }); - - it('should trigger on-update if scrollbar visibility has changed', () => { - const onScrollbarPresenceChange = jest.fn(); - render( - getMarkup({ - columnCount: 1, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 1, - }), - ); - expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); - - render( - getMarkup({ - columnCount: 100, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 100, - }), - ); - expect(onScrollbarPresenceChange).toHaveBeenCalled(); - - const args = onScrollbarPresenceChange.mock.calls[0][0]; - expect(args.horizontal).toBe(true); - expect(args.size).toBe(getScrollbarSize20()); - expect(args.vertical).toBe(true); - }); - - it('should not trigger on-update if scrollbar visibility does not change', () => { - const onScrollbarPresenceChange = jest.fn(); - render( - getMarkup({ - columnCount: 1, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 1, - }), - ); - expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); - render( - getMarkup({ - columnCount: 2, - getScrollbarSize: getScrollbarSize20, - onScrollbarPresenceChange, - rowCount: 2, - }), - ); - expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/source/MultiGrid/MultiGrid.js b/source/MultiGrid/MultiGrid.js deleted file mode 100644 index 4a1eb8e99..000000000 --- a/source/MultiGrid/MultiGrid.js +++ /dev/null @@ -1,820 +0,0 @@ -/** @flow */ -import PropTypes from 'prop-types'; -import * as React from 'react'; -import {polyfill} from 'react-lifecycles-compat'; -import CellMeasurerCacheDecorator from './CellMeasurerCacheDecorator'; -import Grid from '../Grid'; - -const SCROLLBAR_SIZE_BUFFER = 20; - -/** - * Renders 1, 2, or 4 Grids depending on configuration. - * A main (body) Grid will always be rendered. - * Optionally, 1-2 Grids for sticky header rows will also be rendered. - * If no sticky columns, only 1 sticky header Grid will be rendered. - * If sticky columns, 2 sticky header Grids will be rendered. - */ -class MultiGrid extends React.PureComponent { - static propTypes = { - classNameBottomLeftGrid: PropTypes.string.isRequired, - classNameBottomRightGrid: PropTypes.string.isRequired, - classNameTopLeftGrid: PropTypes.string.isRequired, - classNameTopRightGrid: PropTypes.string.isRequired, - enableFixedColumnScroll: PropTypes.bool.isRequired, - enableFixedRowScroll: PropTypes.bool.isRequired, - fixedColumnCount: PropTypes.number.isRequired, - fixedRowCount: PropTypes.number.isRequired, - onScrollbarPresenceChange: PropTypes.func, - style: PropTypes.object.isRequired, - styleBottomLeftGrid: PropTypes.object.isRequired, - styleBottomRightGrid: PropTypes.object.isRequired, - styleTopLeftGrid: PropTypes.object.isRequired, - styleTopRightGrid: PropTypes.object.isRequired, - hideTopRightGridScrollbar: PropTypes.bool, - hideBottomLeftGridScrollbar: PropTypes.bool, - }; - - static defaultProps = { - classNameBottomLeftGrid: '', - classNameBottomRightGrid: '', - classNameTopLeftGrid: '', - classNameTopRightGrid: '', - enableFixedColumnScroll: false, - enableFixedRowScroll: false, - fixedColumnCount: 0, - fixedRowCount: 0, - scrollToColumn: -1, - scrollToRow: -1, - style: {}, - styleBottomLeftGrid: {}, - styleBottomRightGrid: {}, - styleTopLeftGrid: {}, - styleTopRightGrid: {}, - hideTopRightGridScrollbar: false, - hideBottomLeftGridScrollbar: false, - }; - - state = { - scrollLeft: 0, - scrollTop: 0, - scrollbarSize: 0, - showHorizontalScrollbar: false, - showVerticalScrollbar: false, - }; - - _deferredInvalidateColumnIndex = null; - _deferredInvalidateRowIndex = null; - - constructor(props, context) { - super(props, context); - - const {deferredMeasurementCache, fixedColumnCount, fixedRowCount} = props; - - this._maybeCalculateCachedStyles(true); - - if (deferredMeasurementCache) { - this._deferredMeasurementCacheBottomLeftGrid = - fixedRowCount > 0 - ? new CellMeasurerCacheDecorator({ - cellMeasurerCache: deferredMeasurementCache, - columnIndexOffset: 0, - rowIndexOffset: fixedRowCount, - }) - : deferredMeasurementCache; - - this._deferredMeasurementCacheBottomRightGrid = - fixedColumnCount > 0 || fixedRowCount > 0 - ? new CellMeasurerCacheDecorator({ - cellMeasurerCache: deferredMeasurementCache, - columnIndexOffset: fixedColumnCount, - rowIndexOffset: fixedRowCount, - }) - : deferredMeasurementCache; - - this._deferredMeasurementCacheTopRightGrid = - fixedColumnCount > 0 - ? new CellMeasurerCacheDecorator({ - cellMeasurerCache: deferredMeasurementCache, - columnIndexOffset: fixedColumnCount, - rowIndexOffset: 0, - }) - : deferredMeasurementCache; - } - } - - forceUpdateGrids() { - this._bottomLeftGrid && this._bottomLeftGrid.forceUpdate(); - this._bottomRightGrid && this._bottomRightGrid.forceUpdate(); - this._topLeftGrid && this._topLeftGrid.forceUpdate(); - this._topRightGrid && this._topRightGrid.forceUpdate(); - } - - /** See Grid#invalidateCellSizeAfterRender */ - invalidateCellSizeAfterRender({columnIndex = 0, rowIndex = 0} = {}) { - this._deferredInvalidateColumnIndex = - typeof this._deferredInvalidateColumnIndex === 'number' - ? Math.min(this._deferredInvalidateColumnIndex, columnIndex) - : columnIndex; - this._deferredInvalidateRowIndex = - typeof this._deferredInvalidateRowIndex === 'number' - ? Math.min(this._deferredInvalidateRowIndex, rowIndex) - : rowIndex; - } - - /** See Grid#measureAllCells */ - measureAllCells() { - this._bottomLeftGrid && this._bottomLeftGrid.measureAllCells(); - this._bottomRightGrid && this._bottomRightGrid.measureAllCells(); - this._topLeftGrid && this._topLeftGrid.measureAllCells(); - this._topRightGrid && this._topRightGrid.measureAllCells(); - } - - /** See Grid#recomputeGridSize */ - recomputeGridSize({columnIndex = 0, rowIndex = 0} = {}) { - const {fixedColumnCount, fixedRowCount} = this.props; - - const adjustedColumnIndex = Math.max(0, columnIndex - fixedColumnCount); - const adjustedRowIndex = Math.max(0, rowIndex - fixedRowCount); - - this._bottomLeftGrid && - this._bottomLeftGrid.recomputeGridSize({ - columnIndex, - rowIndex: adjustedRowIndex, - }); - this._bottomRightGrid && - this._bottomRightGrid.recomputeGridSize({ - columnIndex: adjustedColumnIndex, - rowIndex: adjustedRowIndex, - }); - this._topLeftGrid && - this._topLeftGrid.recomputeGridSize({ - columnIndex, - rowIndex, - }); - this._topRightGrid && - this._topRightGrid.recomputeGridSize({ - columnIndex: adjustedColumnIndex, - rowIndex, - }); - - this._leftGridWidth = null; - this._topGridHeight = null; - this._maybeCalculateCachedStyles(true); - } - - static getDerivedStateFromProps(nextProps, prevState) { - if ( - nextProps.scrollLeft !== prevState.scrollLeft || - nextProps.scrollTop !== prevState.scrollTop - ) { - return { - scrollLeft: - nextProps.scrollLeft != null && nextProps.scrollLeft >= 0 - ? nextProps.scrollLeft - : prevState.scrollLeft, - scrollTop: - nextProps.scrollTop != null && nextProps.scrollTop >= 0 - ? nextProps.scrollTop - : prevState.scrollTop, - }; - } - - return null; - } - - componentDidMount() { - const {scrollLeft, scrollTop} = this.props; - - if (scrollLeft > 0 || scrollTop > 0) { - const newState = {}; - - if (scrollLeft > 0) { - newState.scrollLeft = scrollLeft; - } - - if (scrollTop > 0) { - newState.scrollTop = scrollTop; - } - - this.setState(newState); - } - this._handleInvalidatedGridSize(); - } - - componentDidUpdate() { - this._handleInvalidatedGridSize(); - } - - render() { - const { - onScroll, - onSectionRendered, - onScrollbarPresenceChange, // eslint-disable-line no-unused-vars - scrollLeft: scrollLeftProp, // eslint-disable-line no-unused-vars - scrollToColumn, - scrollTop: scrollTopProp, // eslint-disable-line no-unused-vars - scrollToRow, - ...rest - } = this.props; - - this._prepareForRender(); - - // Don't render any of our Grids if there are no cells. - // This mirrors what Grid does, - // And prevents us from recording inaccurage measurements when used with CellMeasurer. - if (this.props.width === 0 || this.props.height === 0) { - return null; - } - - // scrollTop and scrollLeft props are explicitly filtered out and ignored - - const {scrollLeft, scrollTop} = this.state; - - return ( -
-
- {this._renderTopLeftGrid(rest)} - {this._renderTopRightGrid({ - ...rest, - onScroll, - scrollLeft, - })} -
-
- {this._renderBottomLeftGrid({ - ...rest, - onScroll, - scrollTop, - })} - {this._renderBottomRightGrid({ - ...rest, - onScroll, - onSectionRendered, - scrollLeft, - scrollToColumn, - scrollToRow, - scrollTop, - })} -
-
- ); - } - - _bottomLeftGridRef = ref => { - this._bottomLeftGrid = ref; - }; - - _bottomRightGridRef = ref => { - this._bottomRightGrid = ref; - }; - - _cellRendererBottomLeftGrid = ({rowIndex, ...rest}) => { - const {cellRenderer, fixedRowCount, rowCount} = this.props; - - if (rowIndex === rowCount - fixedRowCount) { - return ( -
- ); - } else { - return cellRenderer({ - ...rest, - parent: this, - rowIndex: rowIndex + fixedRowCount, - }); - } - }; - - _cellRendererBottomRightGrid = ({columnIndex, rowIndex, ...rest}) => { - const {cellRenderer, fixedColumnCount, fixedRowCount} = this.props; - - return cellRenderer({ - ...rest, - columnIndex: columnIndex + fixedColumnCount, - parent: this, - rowIndex: rowIndex + fixedRowCount, - }); - }; - - _cellRendererTopRightGrid = ({columnIndex, ...rest}) => { - const {cellRenderer, columnCount, fixedColumnCount} = this.props; - - if (columnIndex === columnCount - fixedColumnCount) { - return ( -
- ); - } else { - return cellRenderer({ - ...rest, - columnIndex: columnIndex + fixedColumnCount, - parent: this, - }); - } - }; - - _columnWidthRightGrid = ({index}) => { - const {columnCount, fixedColumnCount, columnWidth} = this.props; - const {scrollbarSize, showHorizontalScrollbar} = this.state; - - // An extra cell is added to the count - // This gives the smaller Grid extra room for offset, - // In case the main (bottom right) Grid has a scrollbar - // If no scrollbar, the extra space is overflow:hidden anyway - if (showHorizontalScrollbar && index === columnCount - fixedColumnCount) { - return scrollbarSize; - } - - return typeof columnWidth === 'function' - ? columnWidth({index: index + fixedColumnCount}) - : columnWidth; - }; - - _getBottomGridHeight(props) { - const {height} = props; - - let topGridHeight = this._getTopGridHeight(props); - - return height - topGridHeight; - } - - _getLeftGridWidth(props) { - const {fixedColumnCount, columnWidth} = props; - - if (this._leftGridWidth == null) { - if (typeof columnWidth === 'function') { - let leftGridWidth = 0; - - for (let index = 0; index < fixedColumnCount; index++) { - leftGridWidth += columnWidth({index}); - } - - this._leftGridWidth = leftGridWidth; - } else { - this._leftGridWidth = columnWidth * fixedColumnCount; - } - } - - return this._leftGridWidth; - } - - _getRightGridWidth(props) { - const {width} = props; - - let leftGridWidth = this._getLeftGridWidth(props); - - return width - leftGridWidth; - } - - _getTopGridHeight(props) { - const {fixedRowCount, rowHeight} = props; - - if (this._topGridHeight == null) { - if (typeof rowHeight === 'function') { - let topGridHeight = 0; - - for (let index = 0; index < fixedRowCount; index++) { - topGridHeight += rowHeight({index}); - } - - this._topGridHeight = topGridHeight; - } else { - this._topGridHeight = rowHeight * fixedRowCount; - } - } - - return this._topGridHeight; - } - - _handleInvalidatedGridSize() { - if (typeof this._deferredInvalidateColumnIndex === 'number') { - const columnIndex = this._deferredInvalidateColumnIndex; - const rowIndex = this._deferredInvalidateRowIndex; - - this._deferredInvalidateColumnIndex = null; - this._deferredInvalidateRowIndex = null; - - this.recomputeGridSize({ - columnIndex, - rowIndex, - }); - this.forceUpdate(); - } - } - - /** - * Avoid recreating inline styles each render; this bypasses Grid's shallowCompare. - * This method recalculates styles only when specific props change. - */ - _maybeCalculateCachedStyles(resetAll) { - const { - columnWidth, - enableFixedColumnScroll, - enableFixedRowScroll, - height, - fixedColumnCount, - fixedRowCount, - rowHeight, - style, - styleBottomLeftGrid, - styleBottomRightGrid, - styleTopLeftGrid, - styleTopRightGrid, - width, - } = this.props; - - const sizeChange = - resetAll || - height !== this._lastRenderedHeight || - width !== this._lastRenderedWidth; - const leftSizeChange = - resetAll || - columnWidth !== this._lastRenderedColumnWidth || - fixedColumnCount !== this._lastRenderedFixedColumnCount; - const topSizeChange = - resetAll || - fixedRowCount !== this._lastRenderedFixedRowCount || - rowHeight !== this._lastRenderedRowHeight; - - if (resetAll || sizeChange || style !== this._lastRenderedStyle) { - this._containerOuterStyle = { - height, - overflow: 'visible', // Let :focus outline show through - width, - ...style, - }; - } - - if (resetAll || sizeChange || topSizeChange) { - this._containerTopStyle = { - height: this._getTopGridHeight(this.props), - position: 'relative', - width, - }; - - this._containerBottomStyle = { - height: height - this._getTopGridHeight(this.props), - overflow: 'visible', // Let :focus outline show through - position: 'relative', - width, - }; - } - - if ( - resetAll || - styleBottomLeftGrid !== this._lastRenderedStyleBottomLeftGrid - ) { - this._bottomLeftGridStyle = { - left: 0, - overflowX: 'hidden', - overflowY: enableFixedColumnScroll ? 'auto' : 'hidden', - position: 'absolute', - ...styleBottomLeftGrid, - }; - } - - if ( - resetAll || - leftSizeChange || - styleBottomRightGrid !== this._lastRenderedStyleBottomRightGrid - ) { - this._bottomRightGridStyle = { - left: this._getLeftGridWidth(this.props), - position: 'absolute', - ...styleBottomRightGrid, - }; - } - - if (resetAll || styleTopLeftGrid !== this._lastRenderedStyleTopLeftGrid) { - this._topLeftGridStyle = { - left: 0, - overflowX: 'hidden', - overflowY: 'hidden', - position: 'absolute', - top: 0, - ...styleTopLeftGrid, - }; - } - - if ( - resetAll || - leftSizeChange || - styleTopRightGrid !== this._lastRenderedStyleTopRightGrid - ) { - this._topRightGridStyle = { - left: this._getLeftGridWidth(this.props), - overflowX: enableFixedRowScroll ? 'auto' : 'hidden', - overflowY: 'hidden', - position: 'absolute', - top: 0, - ...styleTopRightGrid, - }; - } - - this._lastRenderedColumnWidth = columnWidth; - this._lastRenderedFixedColumnCount = fixedColumnCount; - this._lastRenderedFixedRowCount = fixedRowCount; - this._lastRenderedHeight = height; - this._lastRenderedRowHeight = rowHeight; - this._lastRenderedStyle = style; - this._lastRenderedStyleBottomLeftGrid = styleBottomLeftGrid; - this._lastRenderedStyleBottomRightGrid = styleBottomRightGrid; - this._lastRenderedStyleTopLeftGrid = styleTopLeftGrid; - this._lastRenderedStyleTopRightGrid = styleTopRightGrid; - this._lastRenderedWidth = width; - } - - _prepareForRender() { - if ( - this._lastRenderedColumnWidth !== this.props.columnWidth || - this._lastRenderedFixedColumnCount !== this.props.fixedColumnCount - ) { - this._leftGridWidth = null; - } - - if ( - this._lastRenderedFixedRowCount !== this.props.fixedRowCount || - this._lastRenderedRowHeight !== this.props.rowHeight - ) { - this._topGridHeight = null; - } - - this._maybeCalculateCachedStyles(); - - this._lastRenderedColumnWidth = this.props.columnWidth; - this._lastRenderedFixedColumnCount = this.props.fixedColumnCount; - this._lastRenderedFixedRowCount = this.props.fixedRowCount; - this._lastRenderedRowHeight = this.props.rowHeight; - } - - _onScroll = scrollInfo => { - const {scrollLeft, scrollTop} = scrollInfo; - this.setState({ - scrollLeft, - scrollTop, - }); - const onScroll = this.props.onScroll; - if (onScroll) { - onScroll(scrollInfo); - } - }; - - _onScrollbarPresenceChange = ({horizontal, size, vertical}) => { - const {showHorizontalScrollbar, showVerticalScrollbar} = this.state; - - if ( - horizontal !== showHorizontalScrollbar || - vertical !== showVerticalScrollbar - ) { - this.setState({ - scrollbarSize: size, - showHorizontalScrollbar: horizontal, - showVerticalScrollbar: vertical, - }); - - const {onScrollbarPresenceChange} = this.props; - if (typeof onScrollbarPresenceChange === 'function') { - onScrollbarPresenceChange({ - horizontal, - size, - vertical, - }); - } - } - }; - - _onScrollLeft = scrollInfo => { - const {scrollLeft} = scrollInfo; - this._onScroll({ - scrollLeft, - scrollTop: this.state.scrollTop, - }); - }; - - _onScrollTop = scrollInfo => { - const {scrollTop} = scrollInfo; - this._onScroll({ - scrollTop, - scrollLeft: this.state.scrollLeft, - }); - }; - - _renderBottomLeftGrid(props) { - const { - enableFixedColumnScroll, - fixedColumnCount, - fixedRowCount, - rowCount, - hideBottomLeftGridScrollbar, - } = props; - const {showVerticalScrollbar} = this.state; - - if (!fixedColumnCount) { - return null; - } - - const additionalRowCount = showVerticalScrollbar ? 1 : 0, - height = this._getBottomGridHeight(props), - width = this._getLeftGridWidth(props), - scrollbarSize = this.state.showVerticalScrollbar - ? this.state.scrollbarSize - : 0, - gridWidth = hideBottomLeftGridScrollbar ? width + scrollbarSize : width; - - const bottomLeftGrid = ( - - ); - - if (hideBottomLeftGridScrollbar) { - return ( -
- {bottomLeftGrid} -
- ); - } - return bottomLeftGrid; - } - - _renderBottomRightGrid(props) { - const { - columnCount, - fixedColumnCount, - fixedRowCount, - rowCount, - scrollToColumn, - scrollToRow, - } = props; - - return ( - - ); - } - - _renderTopLeftGrid(props) { - const {fixedColumnCount, fixedRowCount} = props; - - if (!fixedColumnCount || !fixedRowCount) { - return null; - } - - return ( - - ); - } - - _renderTopRightGrid(props) { - const { - columnCount, - enableFixedRowScroll, - fixedColumnCount, - fixedRowCount, - scrollLeft, - hideTopRightGridScrollbar, - } = props; - const {showHorizontalScrollbar, scrollbarSize} = this.state; - - if (!fixedRowCount) { - return null; - } - - const additionalColumnCount = showHorizontalScrollbar ? 1 : 0, - height = this._getTopGridHeight(props), - width = this._getRightGridWidth(props), - additionalHeight = showHorizontalScrollbar ? scrollbarSize : 0; - - let gridHeight = height, - style = this._topRightGridStyle; - - if (hideTopRightGridScrollbar) { - gridHeight = height + additionalHeight; - style = { - ...this._topRightGridStyle, - left: 0, - }; - } - - const topRightGrid = ( - - ); - - if (hideTopRightGridScrollbar) { - return ( -
- {topRightGrid} -
- ); - } - return topRightGrid; - } - - _rowHeightBottomGrid = ({index}) => { - const {fixedRowCount, rowCount, rowHeight} = this.props; - const {scrollbarSize, showVerticalScrollbar} = this.state; - - // An extra cell is added to the count - // This gives the smaller Grid extra room for offset, - // In case the main (bottom right) Grid has a scrollbar - // If no scrollbar, the extra space is overflow:hidden anyway - if (showVerticalScrollbar && index === rowCount - fixedRowCount) { - return scrollbarSize; - } - - return typeof rowHeight === 'function' - ? rowHeight({index: index + fixedRowCount}) - : rowHeight; - }; - - _topLeftGridRef = ref => { - this._topLeftGrid = ref; - }; - - _topRightGridRef = ref => { - this._topRightGrid = ref; - }; -} - -polyfill(MultiGrid); - -export default MultiGrid; diff --git a/source/MultiGrid/index.js b/source/MultiGrid/index.js deleted file mode 100644 index 0e20bb3b5..000000000 --- a/source/MultiGrid/index.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @flow */ -import MultiGrid from './MultiGrid'; - -export default MultiGrid; -export {MultiGrid}; diff --git a/source/ScrollSync/ScrollSync.example.css b/source/ScrollSync/ScrollSync.example.css deleted file mode 100644 index 5df1eb068..000000000 --- a/source/ScrollSync/ScrollSync.example.css +++ /dev/null @@ -1,48 +0,0 @@ -.GridRow { - position: relative; - display: flex; - flex-direction: row; -} -.GridColumn { - display: flex; - flex-direction: column; - flex: 1 1 auto; -} -.LeftSideGridContainer { - flex: 0 0 75px; - z-index: 10; -} - -.LeftSideGrid { - overflow: hidden !important; -} -.HeaderGrid { - width: 100%; - overflow: hidden !important; -} -.BodyGrid { - width: 100%; -} - -.evenRow { -} -.oddRow { - background-color: rgba(0, 0, 0, .1); -} - -.cell, -.headerCell, -.leftCell { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - text-align: center; - padding: 0 .5em; -} -.headerCell, -.leftCell { - font-weight: bold; -} diff --git a/source/ScrollSync/ScrollSync.example.js b/source/ScrollSync/ScrollSync.example.js deleted file mode 100644 index f24c872c3..000000000 --- a/source/ScrollSync/ScrollSync.example.js +++ /dev/null @@ -1,271 +0,0 @@ -/** @flow */ -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import AutoSizer from '../AutoSizer'; -import Grid from '../Grid'; -import ScrollSync from './ScrollSync'; -import clsx from 'clsx'; -import styles from './ScrollSync.example.css'; -import scrollbarSize from 'dom-helpers/scrollbarSize'; - -const LEFT_COLOR_FROM = hexToRgb('#471061'); -const LEFT_COLOR_TO = hexToRgb('#BC3959'); -const TOP_COLOR_FROM = hexToRgb('#000000'); -const TOP_COLOR_TO = hexToRgb('#333333'); - -export default class GridExample extends React.PureComponent { - constructor(props, context) { - super(props, context); - - this.state = { - columnWidth: 75, - columnCount: 50, - height: 300, - overscanColumnCount: 0, - overscanRowCount: 5, - rowHeight: 40, - rowCount: 100, - }; - - this._renderBodyCell = this._renderBodyCell.bind(this); - this._renderHeaderCell = this._renderHeaderCell.bind(this); - this._renderLeftSideCell = this._renderLeftSideCell.bind(this); - } - - render() { - const { - columnCount, - columnWidth, - height, - overscanColumnCount, - overscanRowCount, - rowHeight, - rowCount, - } = this.state; - - return ( - - - - - High order component that simplifies the process of synchronizing - scrolling between two or more virtualized components. - - - - This example shows two Grids and one List{' '} - configured to mimic a spreadsheet with a fixed header and first - column. It also shows how a scroll callback can be used to control UI - properties such as background color. - - - - {({ - clientHeight, - clientWidth, - onScroll, - scrollHeight, - scrollLeft, - scrollTop, - scrollWidth, - }) => { - const x = scrollLeft / (scrollWidth - clientWidth); - const y = scrollTop / (scrollHeight - clientHeight); - - const leftBackgroundColor = mixColors( - LEFT_COLOR_FROM, - LEFT_COLOR_TO, - y, - ); - const leftColor = '#ffffff'; - const topBackgroundColor = mixColors( - TOP_COLOR_FROM, - TOP_COLOR_TO, - x, - ); - const topColor = '#ffffff'; - const middleBackgroundColor = mixColors( - leftBackgroundColor, - topBackgroundColor, - 0.5, - ); - const middleColor = '#ffffff'; - - return ( -
-
- -
-
- -
-
- - {({width}) => ( -
-
- -
-
- -
-
- )} -
-
-
- ); - }} -
-
- ); - } - - _renderBodyCell({columnIndex, key, rowIndex, style}) { - if (columnIndex < 1) { - return; - } - - return this._renderLeftSideCell({columnIndex, key, rowIndex, style}); - } - - _renderHeaderCell({columnIndex, key, rowIndex, style}) { - if (columnIndex < 1) { - return; - } - - return this._renderLeftHeaderCell({columnIndex, key, rowIndex, style}); - } - - _renderLeftHeaderCell({columnIndex, key, style}) { - return ( -
- {`C${columnIndex}`} -
- ); - } - - _renderLeftSideCell({columnIndex, key, rowIndex, style}) { - const rowClass = - rowIndex % 2 === 0 - ? columnIndex % 2 === 0 - ? styles.evenRow - : styles.oddRow - : columnIndex % 2 !== 0 - ? styles.evenRow - : styles.oddRow; - const classNames = clsx(rowClass, styles.cell); - - return ( -
- {`R${rowIndex}, C${columnIndex}`} -
- ); - } -} - -function hexToRgb(hex) { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result - ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } - : null; -} - -/** - * Ported from sass implementation in C - * https://github.com/sass/libsass/blob/0e6b4a2850092356aa3ece07c6b249f0221caced/functions.cpp#L209 - */ -function mixColors(color1, color2, amount) { - const weight1 = amount; - const weight2 = 1 - amount; - - const r = Math.round(weight1 * color1.r + weight2 * color2.r); - const g = Math.round(weight1 * color1.g + weight2 * color2.g); - const b = Math.round(weight1 * color1.b + weight2 * color2.b); - - return {r, g, b}; -} diff --git a/source/ScrollSync/ScrollSync.jest.js b/source/ScrollSync/ScrollSync.jest.js deleted file mode 100644 index f987a2f10..000000000 --- a/source/ScrollSync/ScrollSync.jest.js +++ /dev/null @@ -1,82 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import ScrollSync from './ScrollSync'; - -function ChildComponent({ - clientHeight, - clientWidth, - scrollHeight, - scrollLeft, - scrollTop, - scrollWidth, -}) { - return ( -
- {`clientHeight:${clientHeight}`} - {`clientWidth:${clientWidth}`} - {`scrollHeight:${scrollHeight}`} - {`scrollLeft:${scrollLeft}`} - {`scrollTop:${scrollTop}`} - {`scrollWidth:${scrollWidth}`} -
- ); -} - -describe('ScrollSync', () => { - it('should pass through an initial value of 0 for :scrollLeft and :scrollTop', () => { - const component = render( - - {({ - clientHeight, - clientWidth, - scrollHeight, - scrollLeft, - scrollTop, - scrollWidth, - }) => ( - - )} - , - ); - expect(findDOMNode(component).textContent).toContain('clientHeight:0'); - expect(findDOMNode(component).textContent).toContain('clientWidth:0'); - expect(findDOMNode(component).textContent).toContain('scrollHeight:0'); - expect(findDOMNode(component).textContent).toContain('scrollLeft:0'); - expect(findDOMNode(component).textContent).toContain('scrollTop:0'); - expect(findDOMNode(component).textContent).toContain('scrollWidth:0'); - }); - - it('should update :scrollLeft and :scrollTop when :onScroll is called', () => { - let onScroll; - const component = render( - - {params => { - onScroll = params.onScroll; - return ; - }} - , - ); - onScroll({ - clientHeight: 400, - clientWidth: 200, - scrollHeight: 1000, - scrollLeft: 50, - scrollTop: 100, - scrollWidth: 500, - }); - expect(findDOMNode(component).textContent).toContain('clientHeight:400'); - expect(findDOMNode(component).textContent).toContain('clientWidth:200'); - expect(findDOMNode(component).textContent).toContain('scrollHeight:1000'); - expect(findDOMNode(component).textContent).toContain('scrollLeft:50'); - expect(findDOMNode(component).textContent).toContain('scrollTop:100'); - expect(findDOMNode(component).textContent).toContain('scrollWidth:500'); - }); -}); diff --git a/source/ScrollSync/ScrollSync.js b/source/ScrollSync/ScrollSync.js deleted file mode 100644 index 93e030699..000000000 --- a/source/ScrollSync/ScrollSync.js +++ /dev/null @@ -1,71 +0,0 @@ -import PropTypes from 'prop-types'; -import * as React from 'react'; - -/** - * HOC that simplifies the process of synchronizing scrolling between two or more virtualized components. - */ -export default class ScrollSync extends React.PureComponent { - static propTypes = { - /** - * Function responsible for rendering 2 or more virtualized components. - * This function should implement the following signature: - * ({ onScroll, scrollLeft, scrollTop }) => PropTypes.element - */ - children: PropTypes.func.isRequired, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - clientHeight: 0, - clientWidth: 0, - scrollHeight: 0, - scrollLeft: 0, - scrollTop: 0, - scrollWidth: 0, - }; - - this._onScroll = this._onScroll.bind(this); - } - - render() { - const {children} = this.props; - const { - clientHeight, - clientWidth, - scrollHeight, - scrollLeft, - scrollTop, - scrollWidth, - } = this.state; - - return children({ - clientHeight, - clientWidth, - onScroll: this._onScroll, - scrollHeight, - scrollLeft, - scrollTop, - scrollWidth, - }); - } - - _onScroll({ - clientHeight, - clientWidth, - scrollHeight, - scrollLeft, - scrollTop, - scrollWidth, - }) { - this.setState({ - clientHeight, - clientWidth, - scrollHeight, - scrollLeft, - scrollTop, - scrollWidth, - }); - } -} diff --git a/source/ScrollSync/index.js b/source/ScrollSync/index.js deleted file mode 100644 index 02cf7039c..000000000 --- a/source/ScrollSync/index.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @flow */ -import ScrollSync from './ScrollSync'; - -export default ScrollSync; -export {ScrollSync}; diff --git a/source/Table/Column.jest.js b/source/Table/Column.jest.js deleted file mode 100644 index f448c6922..000000000 --- a/source/Table/Column.jest.js +++ /dev/null @@ -1,112 +0,0 @@ -import * as React from 'react'; -import Immutable from 'immutable'; -import defaultCellDataGetter from './defaultCellDataGetter'; -import defaultCellRenderer from './defaultCellRenderer'; -import defaultHeaderRenderer from './defaultHeaderRenderer'; - -describe('Column', () => { - const rowData = Immutable.Map({ - foo: 'Foo', - bar: 1, - }); - - describe('defaultCellDataGetter', () => { - it('should return a value for specified attributes', () => { - expect( - defaultCellDataGetter({ - dataKey: 'foo', - rowData, - }), - ).toEqual('Foo'); - expect( - defaultCellDataGetter({ - dataKey: 'bar', - rowData, - }), - ).toEqual(1); - }); - - it('should return undefined for missing attributes', () => { - expect( - defaultCellDataGetter({ - dataKey: 'baz', - rowData, - }), - ).toEqual(undefined); - }); - }); - - describe('defaultCellRenderer', () => { - it('should render a value for specified attributes', () => { - expect( - defaultCellRenderer({ - cellData: 'Foo', - dataKey: 'foo', - rowData, - rowIndex: 0, - }), - ).toEqual('Foo'); - expect( - defaultCellRenderer({ - cellData: 1, - dataKey: 'bar', - rowData, - rowIndex: 0, - }), - ).toEqual('1'); - }); - - it('should render empty string for null or missing attributes', () => { - expect( - defaultCellRenderer({ - cellData: null, - dataKey: 'baz', - rowData, - rowIndex: 0, - }), - ).toEqual(''); - expect( - defaultCellRenderer({ - cellData: undefined, - dataKey: 'baz', - rowData, - rowIndex: 0, - }), - ).toEqual(''); - }); - }); - - describe('defaultHeaderRenderer', () => { - it('should render a value for specified attributes', () => { - expect( - defaultHeaderRenderer({ - dataKey: 'foo', - label: 'squirrel', - })[0].props.children, - ).toEqual('squirrel'); - - const label =
Rabbit
; - expect( - defaultHeaderRenderer({ - dataKey: 'bar', - label: label, - })[0].props.children, - ).toEqual(label); - }); - - it('should render empty string for null or missing attributes', () => { - expect( - defaultHeaderRenderer({ - dataKey: 'foo', - label: null, - })[0].props.children, - ).toBeNull(); - expect( - defaultHeaderRenderer({ - dataKey: 'bar', - label: undefined, - })[0].props.children, - ).toBeUndefined(); - }); - }); -}); diff --git a/source/Table/Column.js b/source/Table/Column.js deleted file mode 100644 index 6c39d122a..000000000 --- a/source/Table/Column.js +++ /dev/null @@ -1,93 +0,0 @@ -/** @flow */ -import PropTypes from 'prop-types'; -import * as React from 'react'; -import defaultHeaderRenderer from './defaultHeaderRenderer'; -import defaultCellRenderer from './defaultCellRenderer'; -import defaultCellDataGetter from './defaultCellDataGetter'; -import SortDirection from './SortDirection'; - -/** - * Describes the header and cell contents of a table column. - */ -export default class Column extends React.Component { - static propTypes = { - /** Optional aria-label value to set on the column header */ - 'aria-label': PropTypes.string, - - /** - * Callback responsible for returning a cell's data, given its :dataKey - * ({ columnData: any, dataKey: string, rowData: any }): any - */ - cellDataGetter: PropTypes.func, - - /** - * Callback responsible for rendering a cell's contents. - * ({ cellData: any, columnData: any, dataKey: string, rowData: any, rowIndex: number }): node - */ - cellRenderer: PropTypes.func, - - /** Optional CSS class to apply to cell */ - className: PropTypes.string, - - /** Optional additional data passed to this column's :cellDataGetter */ - columnData: PropTypes.object, - - /** Uniquely identifies the row-data attribute corresponding to this cell */ - dataKey: PropTypes.any.isRequired, - - /** Optional direction to be used when clicked the first time */ - defaultSortDirection: PropTypes.oneOf([ - SortDirection.ASC, - SortDirection.DESC, - ]), - - /** If sort is enabled for the table at large, disable it for this column */ - disableSort: PropTypes.bool, - - /** Flex grow style; defaults to 0 */ - flexGrow: PropTypes.number, - - /** Flex shrink style; defaults to 1 */ - flexShrink: PropTypes.number, - - /** Optional CSS class to apply to this column's header */ - headerClassName: PropTypes.string, - - /** - * Optional callback responsible for rendering a column header contents. - * ({ columnData: object, dataKey: string, disableSort: boolean, label: node, sortBy: string, sortDirection: string }): PropTypes.node - */ - headerRenderer: PropTypes.func.isRequired, - - /** Optional inline style to apply to this column's header */ - headerStyle: PropTypes.object, - - /** Optional id to set on the column header */ - id: PropTypes.string, - - /** Header label for this column */ - label: PropTypes.node, - - /** Maximum width of column; this property will only be used if :flexGrow is > 0. */ - maxWidth: PropTypes.number, - - /** Minimum width of column. */ - minWidth: PropTypes.number, - - /** Optional inline style to apply to cell */ - style: PropTypes.object, - - /** Flex basis (width) for this column; This value can grow or shrink based on :flexGrow and :flexShrink properties. */ - width: PropTypes.number.isRequired, - }; - - static defaultProps = { - cellDataGetter: defaultCellDataGetter, - cellRenderer: defaultCellRenderer, - defaultSortDirection: SortDirection.ASC, - flexGrow: 0, - flexShrink: 1, - headerRenderer: defaultHeaderRenderer, - style: {}, - }; -} diff --git a/source/Table/SortIndicator.js b/source/Table/SortIndicator.js deleted file mode 100644 index fa0cd351c..000000000 --- a/source/Table/SortIndicator.js +++ /dev/null @@ -1,31 +0,0 @@ -import clsx from 'clsx'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import SortDirection from './SortDirection'; - -/** - * Displayed beside a header to indicate that a Table is currently sorted by this column. - */ -export default function SortIndicator({sortDirection}) { - const classNames = clsx('ReactVirtualized__Table__sortableHeaderIcon', { - 'ReactVirtualized__Table__sortableHeaderIcon--ASC': - sortDirection === SortDirection.ASC, - 'ReactVirtualized__Table__sortableHeaderIcon--DESC': - sortDirection === SortDirection.DESC, - }); - - return ( - - {sortDirection === SortDirection.ASC ? ( - - ) : ( - - )} - - - ); -} - -SortIndicator.propTypes = { - sortDirection: PropTypes.oneOf([SortDirection.ASC, SortDirection.DESC]), -}; diff --git a/source/Table/Table.example.css b/source/Table/Table.example.css deleted file mode 100644 index 444e553ab..000000000 --- a/source/Table/Table.example.css +++ /dev/null @@ -1,40 +0,0 @@ -.Table { - width: 100%; - margin-top: 15px; -} -.headerRow, -.evenRow, -.oddRow { - border-bottom: 1px solid #e0e0e0; -} -.oddRow { - background-color: #fafafa; -} -.headerColumn { - text-transform: none; -} -.exampleColumn { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.checkboxLabel { - margin-left: .5rem; -} -.checkboxLabel:first-of-type { - margin-left: 0; -} - -.noRows { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - font-size: 1em; - color: #bdbdbd; -} diff --git a/source/Table/Table.example.js b/source/Table/Table.example.js deleted file mode 100644 index e5f83475e..000000000 --- a/source/Table/Table.example.js +++ /dev/null @@ -1,315 +0,0 @@ -/** @flow */ -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import {LabeledInput, InputRow} from '../demo/LabeledInput'; -import AutoSizer from '../AutoSizer'; -import Column from './Column'; -import Table from './Table'; -import SortDirection from './SortDirection'; -import SortIndicator from './SortIndicator'; -import styles from './Table.example.css'; - -export default class TableExample extends React.PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired, - }; - - constructor(props, context) { - super(props, context); - - const sortBy = 'index'; - const sortDirection = SortDirection.ASC; - const sortedList = this._sortList({sortBy, sortDirection}); - - this.state = { - disableHeader: false, - headerHeight: 30, - height: 270, - hideIndexRow: false, - overscanRowCount: 10, - rowHeight: 40, - rowCount: 1000, - scrollToIndex: undefined, - sortBy, - sortDirection, - sortedList, - useDynamicRowHeight: false, - }; - - this._getRowHeight = this._getRowHeight.bind(this); - this._headerRenderer = this._headerRenderer.bind(this); - this._noRowsRenderer = this._noRowsRenderer.bind(this); - this._onRowCountChange = this._onRowCountChange.bind(this); - this._onScrollToRowChange = this._onScrollToRowChange.bind(this); - this._rowClassName = this._rowClassName.bind(this); - this._sort = this._sort.bind(this); - } - - render() { - const { - disableHeader, - headerHeight, - height, - hideIndexRow, - overscanRowCount, - rowHeight, - rowCount, - scrollToIndex, - sortBy, - sortDirection, - sortedList, - useDynamicRowHeight, - } = this.state; - - const rowGetter = ({index}) => this._getDatum(sortedList, index); - - return ( - - - - - The table layout below is created with flexboxes. This allows it to - have a fixed header and scrollable body content. It also makes use of{' '} - Grid for windowing table content so that large lists are - rendered efficiently. Adjust its configurable properties below to see - how it reacts. - - - - - - - - - - - - - - - this.setState({height: parseInt(event.target.value, 10) || 1}) - } - value={height} - /> - - this.setState({ - rowHeight: parseInt(event.target.value, 10) || 1, - }) - } - value={rowHeight} - /> - - this.setState({ - headerHeight: parseInt(event.target.value, 10) || 1, - }) - } - value={headerHeight} - /> - - this.setState({ - overscanRowCount: parseInt(event.target.value, 10) || 0, - }) - } - value={overscanRowCount} - /> - - -
- - {({width}) => ( - - {!hideIndexRow && ( - rowData.index} - dataKey="index" - disableSort={!this._isSortEnabled()} - width={60} - /> - )} - - cellData} - flexGrow={1} - /> -
- )} -
-
-
- ); - } - - _getDatum(list, index) { - return list.get(index % list.size); - } - - _getRowHeight({index}) { - const {list} = this.context; - - return this._getDatum(list, index).size; - } - - _headerRenderer({dataKey, sortBy, sortDirection}) { - return ( -
- Full Name - {sortBy === dataKey && } -
- ); - } - - _isSortEnabled() { - const {list} = this.context; - const {rowCount} = this.state; - - return rowCount <= list.size; - } - - _noRowsRenderer() { - return
No rows
; - } - - _onRowCountChange(event) { - const rowCount = parseInt(event.target.value, 10) || 0; - - this.setState({rowCount}); - } - - _onScrollToRowChange(event) { - const {rowCount} = this.state; - let scrollToIndex = Math.min( - rowCount - 1, - parseInt(event.target.value, 10), - ); - - if (isNaN(scrollToIndex)) { - scrollToIndex = undefined; - } - - this.setState({scrollToIndex}); - } - - _rowClassName({index}) { - if (index < 0) { - return styles.headerRow; - } else { - return index % 2 === 0 ? styles.evenRow : styles.oddRow; - } - } - - _sort({sortBy, sortDirection}) { - const sortedList = this._sortList({sortBy, sortDirection}); - - this.setState({sortBy, sortDirection, sortedList}); - } - - _sortList({sortBy, sortDirection}) { - const {list} = this.context; - - return list - .sortBy(item => item[sortBy]) - .update(list => - sortDirection === SortDirection.DESC ? list.reverse() : list, - ); - } - - _updateUseDynamicRowHeight(value) { - this.setState({ - useDynamicRowHeight: value, - }); - } -} diff --git a/source/Table/Table.jest.js b/source/Table/Table.jest.js deleted file mode 100644 index 544759fdc..000000000 --- a/source/Table/Table.jest.js +++ /dev/null @@ -1,1566 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import {Simulate} from 'react-dom/test-utils'; -import Immutable from 'immutable'; -import Column from './Column'; -import Table from './Table'; -import SortDirection from './SortDirection'; - -describe('Table', () => { - const array = []; - for (var i = 0; i < 100; i++) { - array.push({ - id: i, - name: `Name ${i}`, - email: `user-${i}@treasure-data.com`, - }); - } - const list = Immutable.fromJS(array); - - // Works with an Immutable List of Maps - function immutableRowGetter({index}) { - return list.get(index); - } - - // Works with an Array of Objects - function vanillaRowGetter({index}) { - return array[index]; - } - - // Override default behavior of overscanning by at least 1 (for accessibility) - // Because it makes for simple tests below - function overscanIndicesGetter({startIndex, stopIndex}) { - return { - overscanStartIndex: startIndex, - overscanStopIndex: stopIndex, - }; - } - - function getMarkup({ - cellDataGetter, - cellRenderer, - columnData = {data: 123}, - columnID, - columnStyle, - columnHeaderStyle, - disableSort = false, - headerRenderer, - maxWidth, - minWidth, - defaultSortDirection, - label, - ...flexTableProps - } = {}) { - return ( - - - - {false} - {true} - {null} - {undefined} -
- ); - } - - beforeEach(() => jest.resetModules()); - - describe('children', () => { - it('should accept Column children', () => { - const children = []; - const result = Table.propTypes.children({children}, 'children', 'Table'); - expect(result instanceof Error).toEqual(false); - }); - - it('should accept subclasses of Column as children', () => { - class AnotherColumn extends Column {} - - const children = []; - const result = Table.propTypes.children({children}, 'children', 'Table'); - expect(result instanceof Error).toEqual(false); - }); - - it('should not accept non-Column children', () => { - const children = [
]; - const result = Table.propTypes.children({children}, 'children', 'Table'); - expect(result instanceof Error).toEqual(true); - }); - - it('should accept falsy children to allow easier dynamic showing/hiding of columns', () => { - const children = [false, , null]; - const result = Table.propTypes.children({children}, 'children', 'Table'); - expect(result instanceof Error).toEqual(false); - }); - }); - - describe('height', () => { - it('should subtract header row height from the inner Grid height if headers are enabled', () => { - const rendered = findDOMNode( - render( - getMarkup({ - headerHeight: 10, - overscanRowCount: 0, - rowHeight: 20, - height: 50, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - expect(rows.length).toEqual(2); - }); - - it('should not subtract header row height from the inner Grid height if headers are disabled', () => { - const rendered = findDOMNode( - render( - getMarkup({ - disableHeader: true, - headerHeight: 10, - overscanRowCount: 0, - rowHeight: 20, - height: 50, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - expect(rows.length).toEqual(3); - }); - }); - - describe('initial rendering', () => { - // Ensure that both Immutable Lists of Maps and Arrays of Objects are supported - const useImmutable = [true, false]; - useImmutable.forEach(useImmutable => { - it('should render the correct number of rows', () => { - const rendered = findDOMNode( - render( - getMarkup({ - rowGetter: useImmutable ? immutableRowGetter : vanillaRowGetter, - }), - ), - ); - // 100px height should fit 1 header (20px) and 8 rows (10px each) - - expect( - rendered.querySelectorAll('.ReactVirtualized__Table__headerRow') - .length, - ).toEqual(1); - expect( - rendered.querySelectorAll('.ReactVirtualized__Table__row').length, - ).toEqual(8); - }); - - it('should render the expected headers', () => { - const rendered = findDOMNode( - render( - getMarkup({ - rowGetter: useImmutable ? immutableRowGetter : vanillaRowGetter, - }), - ), - ); - const columns = rendered.querySelectorAll( - '.ReactVirtualized__Table__headerColumn', - ); - expect(columns.length).toEqual(2); - expect(columns[0].textContent).toEqual('Name'); - expect(columns[1].textContent).toEqual('Email'); - }); - - it('should render the expected rows and columns', () => { - const rendered = findDOMNode( - render( - getMarkup({ - rowGetter: useImmutable ? immutableRowGetter : vanillaRowGetter, - headerHeight: 10, - rowHeight: 20, - height: 50, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - expect(rows.length).toEqual(2); - Array.from(rows).forEach((row, index) => { - let rowData = list.get(index); - let columns = row.querySelectorAll( - '.ReactVirtualized__Table__rowColumn', - ); - expect(columns.length).toEqual(2); - expect(columns[0].textContent).toEqual(rowData.get('name')); - expect(columns[1].textContent).toEqual(rowData.get('email')); - }); - }); - }); - - it('should support a :rowHeight function', () => { - const rowHeight = ({index}) => 10 + index * 10; - const rendered = findDOMNode( - render( - getMarkup({ - rowHeight, - rowCount: 3, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - Array.from(rows).forEach((row, index) => { - expect(Number.parseInt(row.style.height, 10)).toEqual( - rowHeight({index}), - ); - }); - }); - - it('should support :minWidth and :maxWidth values for a column', () => { - const rendered = findDOMNode( - render( - getMarkup({ - maxWidth: 75, - minWidth: 25, - rowCount: 1, - }), - ), - ); - const columns = rendered.querySelectorAll( - '.ReactVirtualized__Table__rowColumn', - ); - const emailColumn = columns[1]; - expect(Number.parseInt(emailColumn.style.maxWidth, 10)).toEqual(75); - expect(Number.parseInt(emailColumn.style.minWidth, 10)).toEqual(25); - }); - }); - - describe('measureAllRows', () => { - it('should measure any unmeasured rows', () => { - const rendered = render( - getMarkup({ - estimatedRowSize: 15, - height: 0, - rowCount: 10, - rowHeight: () => 20, - width: 0, - }), - ); - expect( - rendered.Grid.state.instanceProps.rowSizeAndPositionManager.getTotalSize(), - ).toEqual(150); - rendered.measureAllRows(); - expect( - rendered.Grid.state.instanceProps.rowSizeAndPositionManager.getTotalSize(), - ).toEqual(200); - }); - }); - - describe('recomputeRowHeights', () => { - it('should recompute row heights and other values when called', () => { - const indices = []; - const rowHeight = ({index}) => { - indices.push(index); - return 10; - }; - const component = render( - getMarkup({ - rowHeight, - rowCount: 50, - }), - ); - - indices.splice(0); - component.recomputeRowHeights(); - - // Only the rows required to fill the current viewport will be rendered - expect(indices[0]).toEqual(0); - expect(indices[indices.length - 1]).toEqual(7); - - indices.splice(0); - component.recomputeRowHeights(4); - - expect(indices[0]).toEqual(4); - expect(indices[indices.length - 1]).toEqual(7); - }); - }); - - describe('forceUpdateGrid', () => { - it('should refresh inner Grid content when called', () => { - let marker = 'a'; - function cellRenderer({rowIndex}) { - return `${rowIndex}${marker}`; - } - const component = render(getMarkup({cellRenderer})); - const node = findDOMNode(component); - expect(node.textContent).toContain('1a'); - marker = 'b'; - component.forceUpdateGrid(); - expect(node.textContent).toContain('1b'); - }); - }); - - describe('custom getter functions', () => { - it('should use a custom cellDataGetter if specified', () => { - const rendered = findDOMNode( - render( - getMarkup({ - cellDataGetter: ({dataKey, rowData}) => - `Custom ${dataKey} for row ${rowData.get('id')}`, - }), - ), - ); - const nameColumns = rendered.querySelectorAll( - '.ReactVirtualized__Table__rowColumn:first-of-type', - ); - Array.from(nameColumns).forEach((nameColumn, index) => { - expect(nameColumn.textContent).toEqual(`Custom name for row ${index}`); - }); - }); - - it('should use a custom cellRenderer if specified', () => { - const rendered = findDOMNode( - render( - getMarkup({ - cellRenderer: ({cellData}) => `Custom ${cellData}`, - }), - ), - ); - const nameColumns = rendered.querySelectorAll( - '.ReactVirtualized__Table__rowColumn:first-of-type', - ); - Array.from(nameColumns).forEach((nameColumn, index) => { - let rowData = list.get(index); - expect(nameColumn.textContent).toEqual(`Custom ${rowData.get('name')}`); - }); - }); - - it('should set the rendered cell content as the cell :title if it is a string', () => { - const rendered = findDOMNode( - render( - getMarkup({ - cellRenderer: () => 'Custom', - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__rowColumn:first-of-type', - ); - expect(nameColumn.getAttribute('title')).toContain('Custom'); - }); - - it('should not set a cell :title if the rendered cell content is not a string', () => { - const rendered = findDOMNode( - render( - getMarkup({ - cellRenderer: () =>
Custom
, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__rowColumn:first-of-type', - ); - expect(nameColumn.getAttribute('title')).toEqual(null); - }); - - it('should set the rendered header label as header :title if it is a string', () => { - const rendered = findDOMNode( - render( - getMarkup({ - label: 'Custom', - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerTruncatedText:first-of-type', - ); - expect(nameColumn.getAttribute('title')).toContain('Custom'); - }); - - it('should not set a header :title if the rendered header label is not a string', () => { - const rendered = findDOMNode( - render( - getMarkup({ - label:
Custom
, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerTruncatedText:first-of-type', - ); - expect(nameColumn.getAttribute('title')).toEqual(null); - }); - }); - - describe('sorting', () => { - it('should not render sort indicators if no sort function is provided', () => { - const rendered = findDOMNode(render(getMarkup())); - const nameColumn = rendered.querySelectorAll( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - expect(nameColumn.className || '').not.toContain( - 'ReactVirtualized__Table__sortableHeaderColumn', - ); - }); - - it('should not render sort indicators for non-sortable columns', () => { - const rendered = findDOMNode( - render( - getMarkup({ - disableSort: true, - sort: () => {}, - }), - ), - ); - const nameColumn = rendered.querySelectorAll( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - expect(nameColumn.className || '').not.toContain( - 'ReactVirtualized__Table__sortableHeaderColumn', - ); - expect( - rendered.querySelectorAll( - '.ReactVirtualized__Table__sortableHeaderColumn', - ).length, - ).toEqual(1); // Email only - }); - - it('should render sortable column headers as sortable', () => { - const rendered = findDOMNode( - render( - getMarkup({ - sort: () => {}, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - expect(nameColumn.className).toContain( - 'ReactVirtualized__Table__sortableHeaderColumn', - ); - expect( - rendered.querySelectorAll( - '.ReactVirtualized__Table__sortableHeaderColumn', - ).length, - ).toEqual(2); // Email and Name - }); - - it('should render the correct sort indicator by the current sort-by column', () => { - const sortDirections = [SortDirection.ASC, SortDirection.DESC]; - sortDirections.forEach(sortDirection => { - const rendered = findDOMNode( - render( - getMarkup({ - sort: () => {}, - sortBy: 'name', - sortDirection, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - expect( - nameColumn.querySelector( - '.ReactVirtualized__Table__sortableHeaderIcon', - ), - ).not.toEqual(null); - expect( - nameColumn.querySelector( - `.ReactVirtualized__Table__sortableHeaderIcon--${sortDirection}`, - ), - ).not.toEqual(null); - }); - }); - - it('should call sort with the correct arguments when the current sort-by column header is clicked', () => { - const sortDirections = [SortDirection.ASC, SortDirection.DESC]; - sortDirections.forEach(sortDirection => { - const sortCalls = []; - const rendered = findDOMNode( - render( - getMarkup({ - sort: ({sortBy, sortDirection}) => - sortCalls.push({sortBy, sortDirection}), - sortBy: 'name', - sortDirection, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - Simulate.click(nameColumn); - expect(sortCalls.length).toEqual(1); - - const {sortBy, sortDirection: newSortDirection} = sortCalls[0]; - const expectedSortDirection = - sortDirection === SortDirection.ASC - ? SortDirection.DESC - : SortDirection.ASC; - expect(sortBy).toEqual('name'); - expect(newSortDirection).toEqual(expectedSortDirection); - }); - }); - - it('should call sort with the correct arguments when a new sort-by column header is clicked', () => { - const sortCalls = []; - const rendered = findDOMNode( - render( - getMarkup({ - sort: ({sortBy, sortDirection}) => - sortCalls.push({sortBy, sortDirection}), - sortBy: 'email', - sortDirection: SortDirection.ASC, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - Simulate.click(nameColumn); - expect(sortCalls.length).toEqual(1); - - const {sortBy, sortDirection} = sortCalls[0]; - expect(sortBy).toEqual('name'); - expect(sortDirection).toEqual(SortDirection.ASC); - }); - - it('should call sort when a column header is activated via ENTER or SPACE key', () => { - const sortCalls = []; - const rendered = findDOMNode( - render( - getMarkup({ - sort: ({sortBy, sortDirection}) => - sortCalls.push({sortBy, sortDirection}), - sortBy: 'name', - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - expect(sortCalls.length).toEqual(0); - Simulate.keyDown(nameColumn, {key: ' '}); - expect(sortCalls.length).toEqual(1); - Simulate.keyDown(nameColumn, {key: 'Enter'}); - expect(sortCalls.length).toEqual(2); - Simulate.keyDown(nameColumn, {key: 'F'}); - expect(sortCalls.length).toEqual(2); - }); - - it('should honor the default sort order on first click of the column', () => { - const sortDirections = [SortDirection.ASC, SortDirection.DESC]; - sortDirections.forEach(sortDirection => { - const sortCalls = []; - const rendered = findDOMNode( - render( - getMarkup({ - sort: ({sortBy, sortDirection}) => - sortCalls.push({sortBy, sortDirection}), - defaultSortDirection: sortDirection, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - Simulate.click(nameColumn); - expect(sortCalls.length).toEqual(1); - - const {sortBy, sortDirection: newSortDirection} = sortCalls[0]; - expect(sortBy).toEqual('name'); - expect(newSortDirection).toEqual(sortDirection); - }); - }); - }); - - describe('headerRowRenderer', () => { - it('should render a custom header row if one is provided', () => { - const headerRowRenderer = jest.fn().mockReturnValue(
foo bar
); - const rendered = findDOMNode( - render( - getMarkup({ - headerHeight: 33, - headerRowRenderer, - rowClassName: 'someRowClass', - }), - ), - ); - expect(rendered.textContent).toContain('foo bar'); - expect(headerRowRenderer).toHaveBeenCalled(); - const params = headerRowRenderer.mock.calls[0][0]; - expect(params.className).toContain('someRowClass'); - expect(params.columns).toHaveLength(2); - expect(params.style.height).toBe(33); - }); - }); - - describe('headerRenderer', () => { - it('should render a custom header if one is provided', () => { - const columnData = {foo: 'foo', bar: 'bar'}; - const headerRendererCalls = []; - const rendered = findDOMNode( - render( - getMarkup({ - columnData, - headerRenderer: params => { - headerRendererCalls.push(params); - return 'custom header'; - }, - sortBy: 'name', - sortDirection: SortDirection.ASC, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - expect(nameColumn.textContent).toContain('custom header'); - expect(headerRendererCalls.length).toBeTruthy(); - - const headerRendererCall = headerRendererCalls[0]; - expect(headerRendererCall.columnData).toEqual(columnData); - expect(headerRendererCall.dataKey).toEqual('name'); - expect(headerRendererCall.disableSort).toEqual(false); - expect(headerRendererCall.label).toEqual('Name'); - expect(headerRendererCall.sortBy).toEqual('name'); - expect(headerRendererCall.sortDirection).toEqual(SortDirection.ASC); - }); - - it('should honor sort for custom headers', () => { - const sortCalls = []; - const rendered = findDOMNode( - render( - getMarkup({ - headerRenderer: () => 'custom header', - sort: ({sortBy, sortDirection}) => - sortCalls.push([sortBy, sortDirection]), - sortBy: 'name', - sortDirection: SortDirection.ASC, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - Simulate.click(nameColumn); - - expect(sortCalls.length).toEqual(1); - const sortCall = sortCalls[0]; - expect(sortCall[0]).toEqual('name'); - expect(sortCall[1]).toEqual(SortDirection.DESC); - }); - - it('should honor :onHeaderClick for custom header', () => { - const columnData = {foo: 'foo', bar: 'bar'}; - const onHeaderClick = jest.fn(); - const rendered = findDOMNode( - render( - getMarkup({ - columnData, - headerRenderer: () => 'custom header', - onHeaderClick, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - Simulate.click(nameColumn); - - expect(onHeaderClick).toHaveBeenCalledTimes(1); - const params = onHeaderClick.mock.calls[0][0]; - expect(params.dataKey).toEqual('name'); - expect(params.columnData).toEqual(columnData); - expect(params.event.type).toEqual('click'); - }); - }); - - describe('noRowsRenderer', () => { - it('should call :noRowsRenderer if :rowCount is 0', () => { - const rendered = render( - getMarkup({ - noRowsRenderer: () =>
No rows!
, - rowCount: 0, - }), - ); - const bodyDOMNode = findDOMNode(rendered.Grid); - expect(bodyDOMNode.textContent).toEqual('No rows!'); - }); - - it('should render an empty body if :rowCount is 0 and there is no :noRowsRenderer', () => { - const rendered = render( - getMarkup({ - rowCount: 0, - }), - ); - const bodyDOMNode = findDOMNode(rendered.Grid); - expect(bodyDOMNode.textContent).toEqual(''); - }); - }); - - describe('onColumnClick', () => { - it('should call :onColumnClick with the correct arguments when a column is clicked', () => { - const onColumnClick = jest.fn(); - const rendered = findDOMNode( - render( - getMarkup({ - onColumnClick, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__rowColumn:first-of-type', - ); - - Simulate.click(nameColumn); - - expect(onColumnClick).toHaveBeenCalledTimes(1); - const params = onColumnClick.mock.calls[0][0]; - expect(params.dataKey).toEqual('name'); - expect(params.columnData.data).toEqual(123); - expect(params.event.type).toEqual('click'); - }); - }); - - describe('onHeaderClick', () => { - it('should call :onHeaderClick with the correct arguments when a column header is clicked and sorting is disabled', () => { - const onHeaderClick = jest.fn(); - const rendered = findDOMNode( - render( - getMarkup({ - disableSort: true, - onHeaderClick, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - Simulate.click(nameColumn); - - expect(onHeaderClick).toHaveBeenCalledTimes(1); - const params = onHeaderClick.mock.calls[0][0]; - expect(params.dataKey).toEqual('name'); - expect(params.columnData.data).toEqual(123); - expect(params.event.type).toEqual('click'); - }); - - it('should call :onHeaderClick with the correct arguments when a column header is clicked and sorting is enabled', () => { - const onHeaderClick = jest.fn(); - const rendered = findDOMNode( - render( - getMarkup({ - disableSort: false, - onHeaderClick, - }), - ), - ); - const nameColumn = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn:first-of-type', - ); - - Simulate.click(nameColumn); - - expect(onHeaderClick).toHaveBeenCalledTimes(1); - const params = onHeaderClick.mock.calls[0][0]; - expect(params.dataKey).toEqual('name'); - expect(params.columnData.data).toEqual(123); - expect(params.event.type).toEqual('click'); - }); - }); - - describe('onRowClick', () => { - it('should call :onRowClick with the correct :rowIndex when a row is clicked', () => { - const onRowClick = jest.fn(); - const rendered = findDOMNode( - render( - getMarkup({ - onRowClick, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - Simulate.click(rows[0]); - Simulate.click(rows[3]); - expect(onRowClick).toHaveBeenCalledTimes(2); - expect(onRowClick.mock.calls.map(call => call[0].index)).toEqual([0, 3]); - }); - }); - - describe('onRowDoubleClick', () => { - it('should call :onRowDoubleClick with the correct :rowIndex when a row is clicked', () => { - const onRowDoubleClick = jest.fn(); - const rendered = findDOMNode( - render( - getMarkup({ - onRowDoubleClick, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - Simulate.doubleClick(rows[0]); - Simulate.doubleClick(rows[3]); - expect(onRowDoubleClick).toHaveBeenCalledTimes(2); - expect(onRowDoubleClick.mock.calls.map(call => call[0].index)).toEqual([ - 0, - 3, - ]); - }); - }); - - describe('onRowRightClick', () => { - it('should call :onRowRightClick with the correct :rowIndex when a row is right-clicked', () => { - const onRowRightClick = jest.fn(); - const rendered = findDOMNode( - render( - getMarkup({ - onRowRightClick, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - Simulate.contextMenu(rows[0]); - Simulate.contextMenu(rows[3]); - expect(onRowRightClick).toHaveBeenCalledTimes(2); - expect(onRowRightClick.mock.calls.map(call => call[0].index)).toEqual([ - 0, - 3, - ]); - }); - }); - - describe('onRowMouseOver/Out', () => { - it('should call :onRowMouseOver and :onRowMouseOut with the correct :rowIndex when the mouse is moved over rows', () => { - let onRowMouseOver = jest.fn(); - let onRowMouseOut = jest.fn(); - const rendered = findDOMNode( - render( - getMarkup({ - onRowMouseOver, - onRowMouseOut, - }), - ), - ); - - const simulateMouseOver = (from, to) => { - Simulate.mouseOut(from, {relatedTarget: to}); - Simulate.mouseOver(to, {relatedTarget: from}); - }; - - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - - simulateMouseOver(rows[0], rows[1]); - simulateMouseOver(rows[1], rows[2]); - simulateMouseOver(rows[2], rows[3]); - - expect(onRowMouseOver).toHaveBeenCalled(); - expect(onRowMouseOut).toHaveBeenCalled(); - expect(onRowMouseOver.mock.calls.map(call => call[0].index)).toEqual([ - 1, - 2, - 3, - ]); - expect(onRowMouseOut.mock.calls.map(call => call[0].index)).toEqual([ - 0, - 1, - 2, - ]); - }); - }); - - describe('rowClassName', () => { - it('should render a static classname given :rowClassName as a string', () => { - const staticClassName = 'staticClass'; - const rendered = findDOMNode( - render( - getMarkup({ - rowClassName: staticClassName, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - Array.from(rows).forEach(row => { - expect(row.className).toContain(staticClassName); - }); - }); - - it('should render dynamic classname given :rowClassName as a function', () => { - const rendered = findDOMNode( - render( - getMarkup({ - rowClassName: ({index}) => (index % 2 === 0 ? 'even' : 'odd'), - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - Array.from(rows).forEach((row, index) => { - if (index % 2 === 0) { - expect(row.className).toContain('even'); - expect(row.className).not.toContain('odd'); - } else { - expect(row.className).toContain('odd'); - expect(row.className).not.toContain('even'); - } - }); - }); - }); - - describe('onRowsRendered', () => { - it('should call :onRowsRendered at least one row is rendered', () => { - let startIndex, stopIndex; - render( - getMarkup({ - onRowsRendered: params => ({startIndex, stopIndex} = params), - }), - ); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(7); - }); - - it('should not call :onRowsRendered unless the start or stop indices have changed', () => { - let numCalls = 0; - let startIndex; - let stopIndex; - const onRowsRendered = params => { - startIndex = params.startIndex; - stopIndex = params.stopIndex; - numCalls++; - }; - render(getMarkup({onRowsRendered})); - expect(numCalls).toEqual(1); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(7); - render(getMarkup({onRowsRendered})); - expect(numCalls).toEqual(1); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(7); - }); - - it('should call :onRowsRendered if the start or stop indices have changed', () => { - let numCalls = 0; - let startIndex; - let stopIndex; - const onRowsRendered = params => { - startIndex = params.startIndex; - stopIndex = params.stopIndex; - numCalls++; - }; - render(getMarkup({onRowsRendered})); - expect(numCalls).toEqual(1); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(7); - render( - getMarkup({ - height: 50, - onRowsRendered, - }), - ); - expect(numCalls).toEqual(2); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(2); - }); - - it('should not call :onRowsRendered if no rows are rendered', () => { - let startIndex, stopIndex; - render( - getMarkup({ - height: 0, - onRowsRendered: params => ({startIndex, stopIndex} = params), - }), - ); - expect(startIndex).toEqual(undefined); - expect(stopIndex).toEqual(undefined); - }); - }); - - describe(':scrollTop property', () => { - it('should render correctly when an initial :scrollTop property is specified', () => { - let startIndex, stopIndex; - render( - getMarkup({ - onRowsRendered: params => ({startIndex, stopIndex} = params), - scrollTop: 80, - }), - ); - expect(startIndex).toEqual(8); - expect(stopIndex).toEqual(15); - }); - - it('should render correctly when :scrollTop property is updated', () => { - let startIndex, stopIndex; - - render( - getMarkup({ - onRowsRendered: params => ({startIndex, stopIndex} = params), - }), - ); - expect(startIndex).toEqual(0); - expect(stopIndex).toEqual(7); - - render( - getMarkup({ - onRowsRendered: params => ({startIndex, stopIndex} = params), - scrollTop: 80, - }), - ); - expect(startIndex).toEqual(8); - expect(stopIndex).toEqual(15); - }); - }); - - describe('styles, classNames, and ids', () => { - it('should use the expected global CSS classNames', () => { - const node = findDOMNode( - render( - getMarkup({ - sort: () => {}, - sortBy: 'name', - sortDirection: SortDirection.ASC, - }), - ), - ); - expect(node.className).toEqual('ReactVirtualized__Table'); - expect( - node.querySelector('.ReactVirtualized__Table__headerRow'), - ).toBeTruthy(); - expect( - node.querySelector('.ReactVirtualized__Table__rowColumn'), - ).toBeTruthy(); - expect( - node.querySelector('.ReactVirtualized__Table__headerColumn'), - ).toBeTruthy(); - expect(node.querySelector('.ReactVirtualized__Table__row')).toBeTruthy(); - expect( - node.querySelector('.ReactVirtualized__Table__sortableHeaderColumn'), - ).toBeTruthy(); - expect( - node.querySelector('.ReactVirtualized__Table__sortableHeaderIcon'), - ).toBeTruthy(); - }); - - it('should use a custom :className if specified', () => { - const node = findDOMNode( - render( - getMarkup({ - className: 'foo', - headerClassName: 'bar', - rowClassName: 'baz', - }), - ), - ); - expect(node.className).toContain('foo'); - expect(node.querySelectorAll('.bar').length).toEqual(2); - expect(node.querySelectorAll('.baz').length).toEqual(9); - }); - - it('should use a custom :id if specified', () => { - const node = findDOMNode(render(getMarkup({id: 'bar'}))); - expect(node.getAttribute('id')).toEqual('bar'); - }); - - it('should not set :id on the inner Grid', () => { - const node = findDOMNode(render(getMarkup({id: 'bar'}))); - const grid = node.querySelector('.ReactVirtualized__Grid'); - expect(grid.getAttribute('id')).not.toEqual('bar'); - }); - - it('should use custom :styles if specified', () => { - const columnStyle = {backgroundColor: 'red', overflow: 'visible'}; - const headerStyle = {backgroundColor: 'blue'}; - const columnHeaderStyle = {color: 'yellow'}; - const rowStyle = {backgroundColor: 'green'}; - const style = {backgroundColor: 'orange'}; - const node = findDOMNode( - render( - getMarkup({ - columnStyle, - headerStyle, - columnHeaderStyle, - rowStyle, - style, - }), - ), - ); - expect( - node.querySelector('.ReactVirtualized__Table__rowColumn').style - .backgroundColor, - ).toEqual('red'); - expect( - node.querySelector('.ReactVirtualized__Table__rowColumn').style - .overflow, - ).toEqual('visible'); - expect( - node.querySelector('.ReactVirtualized__Table__headerColumn').style - .backgroundColor, - ).toEqual('blue'); - expect( - node.querySelector('.ReactVirtualized__Table__headerColumn').style - .color, - ).toEqual('yellow'); - expect( - node.querySelector('.ReactVirtualized__Table__row').style - .backgroundColor, - ).toEqual('green'); - expect(node.style.backgroundColor).toEqual('orange'); - }); - - it('should render dynamic style given :rowStyle as a function', () => { - const rendered = findDOMNode( - render( - getMarkup({ - rowStyle: ({index}) => - index % 2 === 0 - ? {backgroundColor: 'red'} - : {backgroundColor: 'green'}, - }), - ), - ); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - Array.from(rows).forEach((row, index) => { - if (index % 2 === 0) { - expect(row.style.backgroundColor).toEqual('red'); - } else { - expect(row.style.backgroundColor).toEqual('green'); - } - }); - }); - - it('should pass :gridClassName and :gridStyle to the inner Grid', () => { - const rendered = findDOMNode( - render( - getMarkup({ - gridClassName: 'foo', - gridStyle: {backgroundColor: 'red'}, - }), - ), - ); - const grid = rendered.querySelector('.ReactVirtualized__Grid'); - expect(grid.className).toContain('foo'); - expect(grid.style.backgroundColor).toEqual('red'); - }); - }); - - describe('overscanRowCount', () => { - it('should not overscan by default', () => { - const mock = jest.fn(); - mock.mockImplementation(overscanIndicesGetter); - - render( - getMarkup({ - overscanIndicesGetter: mock, - }), - ); - expect(mock.mock.calls[0][0].overscanCellsCount).toEqual(0); - expect(mock.mock.calls[1][0].overscanCellsCount).toEqual(0); - }); - - it('should overscan the specified amount', () => { - const mock = jest.fn(); - mock.mockImplementation(overscanIndicesGetter); - - render( - getMarkup({ - overscanIndicesGetter: mock, - overscanRowCount: 10, - }), - ); - expect(mock.mock.calls[0][0].overscanCellsCount).toEqual(0); - expect(mock.mock.calls[1][0].overscanCellsCount).toEqual(10); - }); - }); - - describe('onScroll', () => { - it('should trigger callback when component initially mounts', () => { - const onScrollCalls = []; - render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - expect(onScrollCalls).toEqual([ - { - clientHeight: 80, - scrollHeight: 1000, - scrollTop: 0, - }, - ]); - }); - - it('should trigger callback when component scrolls', () => { - const onScrollCalls = []; - const rendered = render( - getMarkup({ - onScroll: params => onScrollCalls.push(params), - }), - ); - const target = { - scrollLeft: 0, - scrollTop: 100, - }; - rendered.Grid._scrollingContainer = target; // HACK to work around _onScroll target check - Simulate.scroll(findDOMNode(rendered.Grid), {target}); - expect(onScrollCalls.length).toEqual(2); - expect(onScrollCalls[1]).toEqual({ - clientHeight: 80, - scrollHeight: 1000, - scrollTop: 100, - }); - }); - }); - - describe('a11y properties', () => { - it('should set aria role on the table', () => { - const node = findDOMNode(render(getMarkup())); - expect(node.getAttribute('role')).toEqual('grid'); - }); - - it('should set aria col/row count on the table', () => { - const node = findDOMNode(render(getMarkup())); - expect(node.getAttribute('aria-colcount')).toEqual('2'); - expect(node.getAttribute('aria-rowcount')).toEqual(`${list.size}`); - }); - - it('should pass down aria labels on the table', () => { - const node = findDOMNode( - render( - getMarkup({ - 'aria-label': 'my-table-label', - 'aria-labelledby': 'my-table-label-id', - }), - ), - ); - expect(node.getAttribute('aria-label')).toEqual('my-table-label'); - expect(node.getAttribute('aria-labelledby')).toEqual('my-table-label-id'); - }); - - it('should set aria role on the header row', () => { - const rendered = findDOMNode(render(getMarkup())); - const row = rendered.querySelector('.ReactVirtualized__Table__headerRow'); - expect(row.getAttribute('role')).toEqual('row'); - }); - - it('should set appropriate aria role on the grid', () => { - const rendered = findDOMNode(render(getMarkup())); - const grid = rendered.querySelector('.ReactVirtualized__Table__Grid'); - expect(grid.getAttribute('role')).toEqual('rowgroup'); - }); - - it('should set aria role on a row', () => { - const rendered = findDOMNode(render(getMarkup())); - const row = rendered.querySelector('.ReactVirtualized__Table__row'); - expect(row.getAttribute('role')).toEqual('row'); - }); - - it('should set aria rowindex on a row', () => { - const rendered = findDOMNode(render(getMarkup())); - const rows = rendered.querySelectorAll('.ReactVirtualized__Table__row'); - expect(rows[0].getAttribute('aria-rowindex')).toEqual('1'); - expect(rows[1].getAttribute('aria-rowindex')).toEqual('2'); - }); - - it('should set aria role on a cell', () => { - const rendered = findDOMNode(render(getMarkup())); - const cell = rendered.querySelector( - '.ReactVirtualized__Table__rowColumn', - ); - expect(cell.getAttribute('role')).toEqual('gridcell'); - }); - - it('should set aria colindex on a cell', () => { - const rendered = findDOMNode(render(getMarkup())); - const cells = rendered.querySelectorAll( - '.ReactVirtualized__Table__rowColumn', - ); - expect(cells[0].getAttribute('aria-colindex')).toEqual('1'); - expect(cells[1].getAttribute('aria-colindex')).toEqual('2'); - }); - - it('should set aria-describedby on a cell when the column has an id', () => { - const columnID = 'column-header-test'; - const rendered = findDOMNode( - render( - getMarkup({ - columnID, - }), - ), - ); - const cell = rendered.querySelector( - '.ReactVirtualized__Table__rowColumn', - ); - expect(cell.getAttribute('aria-describedby')).toEqual(columnID); - }); - - it('should attach a11y properties to a row if :onRowClick is specified', () => { - const rendered = findDOMNode( - render( - getMarkup({ - onRowClick: () => {}, - }), - ), - ); - const row = rendered.querySelector('.ReactVirtualized__Table__row'); - expect(row.getAttribute('aria-label')).toEqual('row'); - expect(row.tabIndex).toEqual(0); - }); - - it('should not attach a11y properties to a row if no :onRowClick is specified', () => { - const rendered = findDOMNode( - render( - getMarkup({ - onRowClick: null, - }), - ), - ); - const row = rendered.querySelector('.ReactVirtualized__Table__row'); - expect(row.getAttribute('aria-label')).toEqual(null); - expect(row.tabIndex).toEqual(-1); - }); - - it('should set aria role on a header column', () => { - const rendered = findDOMNode(render(getMarkup())); - const header = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn', - ); - expect(header.getAttribute('role')).toEqual('columnheader'); - }); - - it('should set aria-sort ascending on a header column if the column is sorted ascending', () => { - const rendered = findDOMNode( - render( - getMarkup({ - sortBy: 'name', - sortDirection: SortDirection.ASC, - }), - ), - ); - const header = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn', - ); - expect(header.getAttribute('aria-sort')).toEqual('ascending'); - }); - - it('should set aria-sort descending on a header column if the column is sorted descending', () => { - const rendered = findDOMNode( - render( - getMarkup({ - sortBy: 'name', - sortDirection: SortDirection.DESC, - }), - ), - ); - const header = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn', - ); - expect(header.getAttribute('aria-sort')).toEqual('descending'); - }); - - it('should set aria-sort to "none" if the column is sortable but not the current sort', () => { - const rendered = findDOMNode( - render(getMarkup({disableSort: true, sort: jest.fn()})), - ); - const headers = rendered.querySelectorAll( - '.ReactVirtualized__Table__headerColumn', - ); - // the first column is not sortable - expect(headers[0].getAttribute('aria-sort')).toBe(null); - // the second column is sortable - expect(headers[1].getAttribute('aria-sort')).toEqual('none'); - }); - - it('should set id on a header column when the column has an id', () => { - const columnID = 'column-header-test'; - const rendered = findDOMNode( - render( - getMarkup({ - columnID, - }), - ), - ); - const header = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn', - ); - expect(header.getAttribute('id')).toEqual(columnID); - }); - - it('should attach a11y properties to a header column if sort is enabled', () => { - const rendered = findDOMNode( - render( - getMarkup({ - disableSort: false, - sort: () => {}, - }), - ), - ); - const row = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn', - ); - expect(row.getAttribute('aria-label')).toEqual('Name'); - expect(row.tabIndex).toEqual(0); - }); - - it('should not attach a11y properties to a header column if sort is not enabled', () => { - const rendered = findDOMNode( - render( - getMarkup({ - disableSort: true, - }), - ), - ); - const row = rendered.querySelector( - '.ReactVirtualized__Table__headerColumn', - ); - expect(row.getAttribute('aria-label')).toEqual(null); - expect(row.tabIndex).toEqual(-1); - }); - }); - - describe('tabIndex', () => { - it('should be focusable by default', () => { - const rendered = findDOMNode(render(getMarkup())); - expect( - rendered.querySelector('.ReactVirtualized__Grid').tabIndex, - ).toEqual(0); - }); - - it('should allow tabIndex to be overridden', () => { - const rendered = findDOMNode( - render( - getMarkup({ - tabIndex: -1, - }), - ), - ); - expect( - rendered.querySelector('.ReactVirtualized__Grid').tabIndex, - ).toEqual(-1); - }); - }); - - describe('pure', () => { - it('should not re-render unless props have changed', () => { - let headerRendererCalled = false; - let cellRendererCalled = false; - function headerRenderer() { - headerRendererCalled = true; - return 'foo'; - } - function cellRenderer() { - cellRendererCalled = true; - return 'foo'; - } - const markup = getMarkup({ - headerRenderer, - cellRenderer, - }); - render(markup); - expect(headerRendererCalled).toEqual(true); - expect(cellRendererCalled).toEqual(true); - headerRendererCalled = false; - cellRendererCalled = false; - render(markup); - expect(headerRendererCalled).toEqual(false); - expect(cellRendererCalled).toEqual(false); - }); - - it('should re-render both the Table and the inner Grid whenever an external property changes', () => { - let headerRendererCalled = false; - let cellRendererCalled = false; - function headerRenderer() { - headerRendererCalled = true; - return 'foo'; - } - function cellRenderer() { - cellRendererCalled = true; - return 'foo'; - } - const initialProperties = { - autoHeight: false, - cellRenderer, - estimatedRowSize: 15, - headerRenderer, - overscanRowCount: 1, - rowHeight: 15, - rowCount: 20, - scrollToAlignment: 'auto', - scrollTop: 0, - sortBy: 'name', - sortDirection: SortDirection.ASC, - tabIndex: null, - }; - const changedProperties = { - autoHeight: true, - estimatedRowSize: 10, - overscanRowCount: 0, - rowHeight: 10, - rowCount: 10, - scrollToAlignment: 'center', - scrollTop: 1, - sortBy: 'email', - sortDirection: SortDirection.DESC, - tabIndex: 1, - }; - Object.entries(changedProperties).forEach(([key, value]) => { - render.unmount(); // Reset - render(getMarkup(initialProperties)); - headerRendererCalled = true; - cellRendererCalled = false; - render( - getMarkup({ - ...initialProperties, - [key]: value, - }), - ); - expect(headerRendererCalled).toEqual(true); - expect(cellRendererCalled).toEqual(true); - }); - }); - }); - - it('should set the width of the single-column inner Grid to auto', () => { - const rendered = findDOMNode(render(getMarkup())); - expect( - rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer') - .style.width, - ).toEqual('auto'); - }); - - it('should relay the Grid :parent param to the Column :cellRenderer', () => { - const cellRenderer = jest.fn().mockReturnValue(null); - findDOMNode(render(getMarkup({cellRenderer}))); - expect(cellRenderer.mock.calls[0][0].parent).not.toBeUndefined(); - }); -}); diff --git a/source/Table/Table.js b/source/Table/Table.js deleted file mode 100644 index 9f87b7a52..000000000 --- a/source/Table/Table.js +++ /dev/null @@ -1,747 +0,0 @@ -/** @flow */ - -import type {CellPosition} from '../Grid'; - -import clsx from 'clsx'; -import Column from './Column'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import Grid, {accessibilityOverscanIndicesGetter} from '../Grid'; - -import defaultRowRenderer from './defaultRowRenderer'; -import defaultHeaderRowRenderer from './defaultHeaderRowRenderer'; -import SortDirection from './SortDirection'; - -/** - * Table component with fixed headers and virtualized rows for improved performance with large data sets. - * This component expects explicit width, height, and padding parameters. - */ -export default class Table extends React.PureComponent { - static propTypes = { - /** This is just set on the grid top element. */ - 'aria-label': PropTypes.string, - - /** This is just set on the grid top element. */ - 'aria-labelledby': PropTypes.string, - - /** - * Removes fixed height from the scrollingContainer so that the total height - * of rows can stretch the window. Intended for use with WindowScroller - */ - autoHeight: PropTypes.bool, - - /** One or more Columns describing the data displayed in this row */ - children: props => { - const children = React.Children.toArray(props.children); - for (let i = 0; i < children.length; i++) { - const childType = children[i].type; - if (childType !== Column && !(childType.prototype instanceof Column)) { - return new Error('Table only accepts children of type Column'); - } - } - }, - - /** Optional CSS class name */ - className: PropTypes.string, - - /** Disable rendering the header at all */ - disableHeader: PropTypes.bool, - - /** - * Used to estimate the total height of a Table before all of its rows have actually been measured. - * The estimated total height is adjusted as rows are rendered. - */ - estimatedRowSize: PropTypes.number.isRequired, - - /** Optional custom CSS class name to attach to inner Grid element. */ - gridClassName: PropTypes.string, - - /** Optional inline style to attach to inner Grid element. */ - gridStyle: PropTypes.object, - - /** Optional CSS class to apply to all column headers */ - headerClassName: PropTypes.string, - - /** Fixed height of header row */ - headerHeight: PropTypes.number.isRequired, - - /** - * Responsible for rendering a table row given an array of columns: - * Should implement the following interface: ({ - * className: string, - * columns: any[], - * style: any - * }): PropTypes.node - */ - headerRowRenderer: PropTypes.func, - - /** Optional custom inline style to attach to table header columns. */ - headerStyle: PropTypes.object, - - /** Fixed/available height for out DOM element */ - height: PropTypes.number.isRequired, - - /** Optional id */ - id: PropTypes.string, - - /** Optional renderer to be used in place of table body rows when rowCount is 0 */ - noRowsRenderer: PropTypes.func, - - /** - * Optional callback when a column is clicked. - * ({ columnData: any, dataKey: string }): void - */ - onColumnClick: PropTypes.func, - - /** - * Optional callback when a column's header is clicked. - * ({ columnData: any, dataKey: string }): void - */ - onHeaderClick: PropTypes.func, - - /** - * Callback invoked when a user clicks on a table row. - * ({ index: number }): void - */ - onRowClick: PropTypes.func, - - /** - * Callback invoked when a user double-clicks on a table row. - * ({ index: number }): void - */ - onRowDoubleClick: PropTypes.func, - - /** - * Callback invoked when the mouse leaves a table row. - * ({ index: number }): void - */ - onRowMouseOut: PropTypes.func, - - /** - * Callback invoked when a user moves the mouse over a table row. - * ({ index: number }): void - */ - onRowMouseOver: PropTypes.func, - - /** - * Callback invoked when a user right-clicks on a table row. - * ({ index: number }): void - */ - onRowRightClick: PropTypes.func, - - /** - * Callback invoked with information about the slice of rows that were just rendered. - * ({ startIndex, stopIndex }): void - */ - onRowsRendered: PropTypes.func, - - /** - * Callback invoked whenever the scroll offset changes within the inner scrollable region. - * This callback can be used to sync scrolling between lists, tables, or grids. - * ({ clientHeight, scrollHeight, scrollTop }): void - */ - onScroll: PropTypes.func.isRequired, - - /** See Grid#overscanIndicesGetter */ - overscanIndicesGetter: PropTypes.func.isRequired, - - /** - * Number of rows to render above/below the visible bounds of the list. - * These rows can help for smoother scrolling on touch devices. - */ - overscanRowCount: PropTypes.number.isRequired, - - /** - * Optional CSS class to apply to all table rows (including the header row). - * This property can be a CSS class name (string) or a function that returns a class name. - * If a function is provided its signature should be: ({ index: number }): string - */ - rowClassName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), - - /** - * Callback responsible for returning a data row given an index. - * ({ index: number }): any - */ - rowGetter: PropTypes.func.isRequired, - - /** - * Either a fixed row height (number) or a function that returns the height of a row given its index. - * ({ index: number }): number - */ - rowHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.func]) - .isRequired, - - /** Number of rows in table. */ - rowCount: PropTypes.number.isRequired, - - /** - * Responsible for rendering a table row given an array of columns: - * Should implement the following interface: ({ - * className: string, - * columns: Array, - * index: number, - * isScrolling: boolean, - * onRowClick: ?Function, - * onRowDoubleClick: ?Function, - * onRowMouseOver: ?Function, - * onRowMouseOut: ?Function, - * rowData: any, - * style: any - * }): PropTypes.node - */ - rowRenderer: PropTypes.func, - - /** Optional custom inline style to attach to table rows. */ - rowStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.func]) - .isRequired, - - /** See Grid#scrollToAlignment */ - scrollToAlignment: PropTypes.oneOf(['auto', 'end', 'start', 'center']) - .isRequired, - - /** Row index to ensure visible (by forcefully scrolling if necessary) */ - scrollToIndex: PropTypes.number.isRequired, - - /** Vertical offset. */ - scrollTop: PropTypes.number, - - /** - * Sort function to be called if a sortable header is clicked. - * Should implement the following interface: ({ - * defaultSortDirection: 'ASC' | 'DESC', - * event: MouseEvent, - * sortBy: string, - * sortDirection: SortDirection - * }): void - */ - sort: PropTypes.func, - - /** Table data is currently sorted by this :dataKey (if it is sorted at all) */ - sortBy: PropTypes.string, - - /** Table data is currently sorted in this direction (if it is sorted at all) */ - sortDirection: PropTypes.oneOf([SortDirection.ASC, SortDirection.DESC]), - - /** Optional inline style */ - style: PropTypes.object, - - /** Tab index for focus */ - tabIndex: PropTypes.number, - - /** Width of list */ - width: PropTypes.number.isRequired, - }; - - static defaultProps = { - disableHeader: false, - estimatedRowSize: 30, - headerHeight: 0, - headerStyle: {}, - noRowsRenderer: () => null, - onRowsRendered: () => null, - onScroll: () => null, - overscanIndicesGetter: accessibilityOverscanIndicesGetter, - overscanRowCount: 10, - rowRenderer: defaultRowRenderer, - headerRowRenderer: defaultHeaderRowRenderer, - rowStyle: {}, - scrollToAlignment: 'auto', - scrollToIndex: -1, - style: {}, - }; - - constructor(props) { - super(props); - - this.state = { - scrollbarWidth: 0, - }; - - this._createColumn = this._createColumn.bind(this); - this._createRow = this._createRow.bind(this); - this._onScroll = this._onScroll.bind(this); - this._onSectionRendered = this._onSectionRendered.bind(this); - this._setRef = this._setRef.bind(this); - this._setGridElementRef = this._setGridElementRef.bind(this); - } - - forceUpdateGrid() { - if (this.Grid) { - this.Grid.forceUpdate(); - } - } - - /** See Grid#getOffsetForCell */ - getOffsetForRow({alignment, index}) { - if (this.Grid) { - const {scrollTop} = this.Grid.getOffsetForCell({ - alignment, - rowIndex: index, - }); - - return scrollTop; - } - return 0; - } - - /** CellMeasurer compatibility */ - invalidateCellSizeAfterRender({columnIndex, rowIndex}: CellPosition) { - if (this.Grid) { - this.Grid.invalidateCellSizeAfterRender({ - rowIndex, - columnIndex, - }); - } - } - - /** See Grid#measureAllCells */ - measureAllRows() { - if (this.Grid) { - this.Grid.measureAllCells(); - } - } - - /** CellMeasurer compatibility */ - recomputeGridSize({columnIndex = 0, rowIndex = 0}: CellPosition = {}) { - if (this.Grid) { - this.Grid.recomputeGridSize({ - rowIndex, - columnIndex, - }); - } - } - - /** See Grid#recomputeGridSize */ - recomputeRowHeights(index = 0) { - if (this.Grid) { - this.Grid.recomputeGridSize({ - rowIndex: index, - }); - } - } - - /** See Grid#scrollToPosition */ - scrollToPosition(scrollTop = 0) { - if (this.Grid) { - this.Grid.scrollToPosition({scrollTop}); - } - } - - /** See Grid#scrollToCell */ - scrollToRow(index = 0) { - if (this.Grid) { - this.Grid.scrollToCell({ - columnIndex: 0, - rowIndex: index, - }); - } - } - - getScrollbarWidth() { - if (this.GridElement) { - const Grid = this.GridElement; - const clientWidth = Grid.clientWidth || 0; - const offsetWidth = Grid.offsetWidth || 0; - return offsetWidth - clientWidth; - } - - return 0; - } - - componentDidMount() { - this._setScrollbarWidth(); - } - - componentDidUpdate() { - this._setScrollbarWidth(); - } - - render() { - const { - children, - className, - disableHeader, - gridClassName, - gridStyle, - headerHeight, - headerRowRenderer, - height, - id, - noRowsRenderer, - rowClassName, - rowStyle, - scrollToIndex, - style, - width, - } = this.props; - const {scrollbarWidth} = this.state; - - const availableRowsHeight = disableHeader ? height : height - headerHeight; - - const rowClass = - typeof rowClassName === 'function' - ? rowClassName({index: -1}) - : rowClassName; - const rowStyleObject = - typeof rowStyle === 'function' ? rowStyle({index: -1}) : rowStyle; - - // Precompute and cache column styles before rendering rows and columns to speed things up - this._cachedColumnStyles = []; - React.Children.toArray(children).forEach((column, index) => { - const flexStyles = this._getFlexStyleForColumn( - column, - column.props.style || Column.defaultProps.style, - ); - - this._cachedColumnStyles[index] = { - overflow: 'hidden', - ...flexStyles, - }; - }); - - // Note that we specify :rowCount, :scrollbarWidth, :sortBy, and :sortDirection as properties on Grid even though these have nothing to do with Grid. - // This is done because Grid is a pure component and won't update unless its properties or state has changed. - // Any property that should trigger a re-render of Grid then is specified here to avoid a stale display. - return ( -
- {!disableHeader && - headerRowRenderer({ - className: clsx('ReactVirtualized__Table__headerRow', rowClass), - columns: this._getHeaderColumns(), - style: { - height: headerHeight, - overflow: 'hidden', - paddingRight: scrollbarWidth, - width: width, - ...rowStyleObject, - }, - })} - - -
- ); - } - - _createColumn({column, columnIndex, isScrolling, parent, rowData, rowIndex}) { - const {onColumnClick} = this.props; - const { - cellDataGetter, - cellRenderer, - className, - columnData, - dataKey, - id, - } = column.props; - - const cellData = cellDataGetter({columnData, dataKey, rowData}); - const renderedCell = cellRenderer({ - cellData, - columnData, - columnIndex, - dataKey, - isScrolling, - parent, - rowData, - rowIndex, - }); - - const onClick = event => { - onColumnClick && onColumnClick({columnData, dataKey, event}); - }; - - const style = this._cachedColumnStyles[columnIndex]; - - const title = typeof renderedCell === 'string' ? renderedCell : null; - - // Avoid using object-spread syntax with multiple objects here, - // Since it results in an extra method call to 'babel-runtime/helpers/extends' - // See PR https://github.com/bvaughn/react-virtualized/pull/942 - return ( -
- {renderedCell} -
- ); - } - - _createHeader({column, index}) { - const { - headerClassName, - headerStyle, - onHeaderClick, - sort, - sortBy, - sortDirection, - } = this.props; - const { - columnData, - dataKey, - defaultSortDirection, - disableSort, - headerRenderer, - id, - label, - } = column.props; - const sortEnabled = !disableSort && sort; - - const classNames = clsx( - 'ReactVirtualized__Table__headerColumn', - headerClassName, - column.props.headerClassName, - { - ReactVirtualized__Table__sortableHeaderColumn: sortEnabled, - }, - ); - const style = this._getFlexStyleForColumn(column, { - ...headerStyle, - ...column.props.headerStyle, - }); - - const renderedHeader = headerRenderer({ - columnData, - dataKey, - disableSort, - label, - sortBy, - sortDirection, - }); - - let headerOnClick, - headerOnKeyDown, - headerTabIndex, - headerAriaSort, - headerAriaLabel; - - if (sortEnabled || onHeaderClick) { - // If this is a sortable header, clicking it should update the table data's sorting. - const isFirstTimeSort = sortBy !== dataKey; - - // If this is the firstTime sort of this column, use the column default sort order. - // Otherwise, invert the direction of the sort. - const newSortDirection = isFirstTimeSort - ? defaultSortDirection - : sortDirection === SortDirection.DESC - ? SortDirection.ASC - : SortDirection.DESC; - - const onClick = event => { - sortEnabled && - sort({ - defaultSortDirection, - event, - sortBy: dataKey, - sortDirection: newSortDirection, - }); - onHeaderClick && onHeaderClick({columnData, dataKey, event}); - }; - - const onKeyDown = event => { - if (event.key === 'Enter' || event.key === ' ') { - onClick(event); - } - }; - - headerAriaLabel = column.props['aria-label'] || label || dataKey; - headerAriaSort = 'none'; - headerTabIndex = 0; - headerOnClick = onClick; - headerOnKeyDown = onKeyDown; - } - - if (sortBy === dataKey) { - headerAriaSort = - sortDirection === SortDirection.ASC ? 'ascending' : 'descending'; - } - - // Avoid using object-spread syntax with multiple objects here, - // Since it results in an extra method call to 'babel-runtime/helpers/extends' - // See PR https://github.com/bvaughn/react-virtualized/pull/942 - return ( -
- {renderedHeader} -
- ); - } - - _createRow({rowIndex: index, isScrolling, key, parent, style}) { - const { - children, - onRowClick, - onRowDoubleClick, - onRowRightClick, - onRowMouseOver, - onRowMouseOut, - rowClassName, - rowGetter, - rowRenderer, - rowStyle, - } = this.props; - - const {scrollbarWidth} = this.state; - - const rowClass = - typeof rowClassName === 'function' ? rowClassName({index}) : rowClassName; - const rowStyleObject = - typeof rowStyle === 'function' ? rowStyle({index}) : rowStyle; - const rowData = rowGetter({index}); - - const columns = React.Children.toArray(children).map( - (column, columnIndex) => - this._createColumn({ - column, - columnIndex, - isScrolling, - parent, - rowData, - rowIndex: index, - scrollbarWidth, - }), - ); - - const className = clsx('ReactVirtualized__Table__row', rowClass); - const flattenedStyle = { - ...style, - height: this._getRowHeight(index), - overflow: 'hidden', - paddingRight: scrollbarWidth, - ...rowStyleObject, - }; - - return rowRenderer({ - className, - columns, - index, - isScrolling, - key, - onRowClick, - onRowDoubleClick, - onRowRightClick, - onRowMouseOver, - onRowMouseOut, - rowData, - style: flattenedStyle, - }); - } - - /** - * Determines the flex-shrink, flex-grow, and width values for a cell (header or column). - */ - _getFlexStyleForColumn(column, customStyle = {}) { - const flexValue = `${column.props.flexGrow} ${column.props.flexShrink} ${column.props.width}px`; - - const style = { - ...customStyle, - flex: flexValue, - msFlex: flexValue, - WebkitFlex: flexValue, - }; - - if (column.props.maxWidth) { - style.maxWidth = column.props.maxWidth; - } - - if (column.props.minWidth) { - style.minWidth = column.props.minWidth; - } - - return style; - } - - _getHeaderColumns() { - const {children, disableHeader} = this.props; - const items = disableHeader ? [] : React.Children.toArray(children); - - return items.map((column, index) => this._createHeader({column, index})); - } - - _getRowHeight(rowIndex) { - const {rowHeight} = this.props; - - return typeof rowHeight === 'function' - ? rowHeight({index: rowIndex}) - : rowHeight; - } - - _onScroll({clientHeight, scrollHeight, scrollTop}) { - const {onScroll} = this.props; - - onScroll({clientHeight, scrollHeight, scrollTop}); - } - - _onSectionRendered({ - rowOverscanStartIndex, - rowOverscanStopIndex, - rowStartIndex, - rowStopIndex, - }) { - const {onRowsRendered} = this.props; - - onRowsRendered({ - overscanStartIndex: rowOverscanStartIndex, - overscanStopIndex: rowOverscanStopIndex, - startIndex: rowStartIndex, - stopIndex: rowStopIndex, - }); - } - - _setRef(ref) { - this.Grid = ref; - } - - _setGridElementRef(ref) { - this.GridElement = ref; - } - - _setScrollbarWidth() { - const scrollbarWidth = this.getScrollbarWidth(); - - this.setState({scrollbarWidth}); - } -} diff --git a/source/Table/createMultiSort.jest.js b/source/Table/createMultiSort.jest.js deleted file mode 100644 index 9a7f14660..000000000 --- a/source/Table/createMultiSort.jest.js +++ /dev/null @@ -1,200 +0,0 @@ -import createMultiSort from './createMultiSort'; - -describe('createMultiSort', () => { - function simulate( - sort, - dataKey, - eventModifier = '', - defaultSortDirection = 'ASC', - ) { - sort({ - defaultSortDirection, - event: { - ctrlKey: eventModifier === 'control', - metaKey: eventModifier === 'meta', - shiftKey: eventModifier === 'shift', - }, - sortBy: dataKey, - }); - } - - it('errors if the user did not specify a sort callback', () => { - expect(createMultiSort).toThrow(); - }); - - it('sets the correct default values', () => { - const multiSort = createMultiSort(jest.fn(), { - defaultSortBy: ['a', 'b'], - defaultSortDirection: { - a: 'ASC', - b: 'DESC', - }, - }); - expect(multiSort.sortBy).toEqual(['a', 'b']); - expect(multiSort.sortDirection.a).toBe('ASC'); - expect(multiSort.sortDirection.b).toBe('DESC'); - }); - - it('sets the correct default sparse values', () => { - const multiSort = createMultiSort(jest.fn(), { - defaultSortBy: ['a', 'b'], - }); - expect(multiSort.sortBy).toEqual(['a', 'b']); - expect(multiSort.sortDirection.a).toBe('ASC'); - expect(multiSort.sortDirection.b).toBe('ASC'); - }); - - describe('on click', () => { - it('sets the correct default value for a field', () => { - const multiSort = createMultiSort(jest.fn()); - - simulate(multiSort.sort, 'a'); - expect(multiSort.sortBy).toEqual(['a']); - expect(multiSort.sortDirection.a).toBe('ASC'); - - simulate(multiSort.sort, 'b', '', 'DESC'); - expect(multiSort.sortBy).toEqual(['b']); - expect(multiSort.sortDirection.b).toBe('DESC'); - }); - - it('toggles a field value', () => { - const multiSort = createMultiSort(jest.fn()); - - simulate(multiSort.sort, 'a'); - expect(multiSort.sortBy).toEqual(['a']); - expect(multiSort.sortDirection.a).toBe('ASC'); - - simulate(multiSort.sort, 'a'); - expect(multiSort.sortBy).toEqual(['a']); - expect(multiSort.sortDirection.a).toBe('DESC'); - - simulate(multiSort.sort, 'b', '', 'DESC'); - expect(multiSort.sortBy).toEqual(['b']); - expect(multiSort.sortDirection.b).toBe('DESC'); - - simulate(multiSort.sort, 'b', '', 'DESC'); - expect(multiSort.sortBy).toEqual(['b']); - expect(multiSort.sortDirection.b).toBe('ASC'); - }); - - it('resets sort-by fields', () => { - const multiSort = createMultiSort(jest.fn(), { - defaultSortBy: ['a', 'b'], - }); - expect(multiSort.sortBy).toEqual(['a', 'b']); - - simulate(multiSort.sort, 'a'); - expect(multiSort.sortBy).toEqual(['a']); - }); - - it('resets sort-direction fields', () => { - const multiSort = createMultiSort(jest.fn(), { - defaultSortBy: ['a', 'b'], - defaultSortDirection: { - a: 'DESC', - b: 'ASC', - }, - }); - expect(multiSort.sortBy).toEqual(['a', 'b']); - expect(multiSort.sortDirection.a).toEqual('DESC'); - expect(multiSort.sortDirection.b).toEqual('ASC'); - - simulate(multiSort.sort, 'a'); - expect(multiSort.sortBy).toEqual(['a']); - expect(multiSort.sortDirection.a).toEqual('ASC'); - expect(multiSort.sortDirection.b).toEqual(undefined); - - simulate(multiSort.sort, 'b'); - expect(multiSort.sortBy).toEqual(['b']); - expect(multiSort.sortDirection.a).toEqual(undefined); - expect(multiSort.sortDirection.b).toEqual('ASC'); - }); - }); - - describe('on shift click', () => { - it('appends a field to the sort by list', () => { - const multiSort = createMultiSort(jest.fn()); - - simulate(multiSort.sort, 'a'); - expect(multiSort.sortBy).toEqual(['a']); - expect(multiSort.sortDirection.a).toBe('ASC'); - - simulate(multiSort.sort, 'b', 'shift'); - expect(multiSort.sortBy).toEqual(['a', 'b']); - expect(multiSort.sortDirection.a).toBe('ASC'); - expect(multiSort.sortDirection.b).toBe('ASC'); - }); - - it('toggles an appended field value', () => { - const multiSort = createMultiSort(jest.fn()); - - simulate(multiSort.sort, 'a'); - expect(multiSort.sortBy).toEqual(['a']); - expect(multiSort.sortDirection.a).toBe('ASC'); - - simulate(multiSort.sort, 'b', 'shift'); - expect(multiSort.sortBy).toEqual(['a', 'b']); - expect(multiSort.sortDirection.a).toBe('ASC'); - expect(multiSort.sortDirection.b).toBe('ASC'); - - simulate(multiSort.sort, 'a', 'shift'); - expect(multiSort.sortBy).toEqual(['a', 'b']); - expect(multiSort.sortDirection.a).toBe('DESC'); - expect(multiSort.sortDirection.b).toBe('ASC'); - - simulate(multiSort.sort, 'a', 'shift'); - expect(multiSort.sortBy).toEqual(['a', 'b']); - expect(multiSort.sortDirection.a).toBe('ASC'); - expect(multiSort.sortDirection.b).toBe('ASC'); - }); - - it('able to shift+click more than once', () => { - const multiSort = createMultiSort(jest.fn()); - - simulate(multiSort.sort, 'a'); - expect(multiSort.sortBy).toEqual(['a']); - expect(multiSort.sortDirection.a).toBe('ASC'); - - simulate(multiSort.sort, 'b', 'shift'); - expect(multiSort.sortBy).toEqual(['a', 'b']); - expect(multiSort.sortDirection.a).toBe('ASC'); - expect(multiSort.sortDirection.b).toBe('ASC'); - - simulate(multiSort.sort, 'b'); - expect(multiSort.sortBy).toEqual(['b']); - expect(multiSort.sortDirection.b).toBe('DESC'); - - simulate(multiSort.sort, 'a', 'shift'); - expect(multiSort.sortBy).toEqual(['b', 'a']); - expect(multiSort.sortDirection.a).toBe('ASC'); - expect(multiSort.sortDirection.b).toBe('DESC'); - }); - }); - - ['control', 'meta'].forEach(modifier => { - describe(`${modifier} click`, () => { - it('removes a field from the sort by list', () => { - const multiSort = createMultiSort(jest.fn(), { - defaultSortBy: ['a', 'b'], - }); - expect(multiSort.sortBy).toEqual(['a', 'b']); - - simulate(multiSort.sort, 'a', modifier); - expect(multiSort.sortBy).toEqual(['b']); - - simulate(multiSort.sort, 'b', modifier); - expect(multiSort.sortBy).toEqual([]); - }); - - it('ignores fields not in the list on control click', () => { - const multiSort = createMultiSort(jest.fn(), { - defaultSortBy: ['a', 'b'], - }); - expect(multiSort.sortBy).toEqual(['a', 'b']); - - simulate(multiSort.sort, 'c', modifier); - expect(multiSort.sortBy).toEqual(['a', 'b']); - }); - }); - }); -}); diff --git a/source/Table/createMultiSort.js b/source/Table/createMultiSort.js deleted file mode 100644 index 689557f7b..000000000 --- a/source/Table/createMultiSort.js +++ /dev/null @@ -1,109 +0,0 @@ -/** @flow */ - -type SortDirection = 'ASC' | 'DESC'; - -type SortParams = { - defaultSortDirection: SortDirection, - event: MouseEvent, - sortBy: string, -}; - -type SortDirectionMap = {[string]: SortDirection}; - -type MultiSortOptions = { - defaultSortBy: ?Array, - defaultSortDirection: ?SortDirectionMap, -}; - -type MultiSortReturn = { - /** - * Sort property to be passed to the `Table` component. - * This function updates `sortBy` and `sortDirection` values. - */ - sort: (params: SortParams) => void, - - /** - * Specifies the fields currently responsible for sorting data, - * In order of importance. - */ - sortBy: Array, - - /** - * Specifies the direction a specific field is being sorted in. - */ - sortDirection: SortDirectionMap, -}; - -export default function createMultiSort( - sortCallback: Function, - {defaultSortBy, defaultSortDirection = {}}: MultiSortOptions = {}, -): MultiSortReturn { - if (!sortCallback) { - throw Error(`Required parameter "sortCallback" not specified`); - } - - const sortBy = defaultSortBy || []; - const sortDirection = {}; - - sortBy.forEach(dataKey => { - sortDirection[dataKey] = - defaultSortDirection[dataKey] !== undefined - ? defaultSortDirection[dataKey] - : 'ASC'; - }); - - function sort({ - defaultSortDirection, - event, - sortBy: dataKey, - }: SortParams): void { - if (event.shiftKey) { - // Shift + click appends a column to existing criteria - if (sortDirection[dataKey] !== undefined) { - sortDirection[dataKey] = - sortDirection[dataKey] === 'ASC' ? 'DESC' : 'ASC'; - } else { - sortDirection[dataKey] = defaultSortDirection; - sortBy.push(dataKey); - } - } else if (event.ctrlKey || event.metaKey) { - // Control + click removes column from sort (if pressent) - const index = sortBy.indexOf(dataKey); - if (index >= 0) { - sortBy.splice(index, 1); - delete sortDirection[dataKey]; - } - } else { - // Clear sortBy array of all non-selected keys - sortBy.length = 0; - sortBy.push(dataKey); - - // Clear sortDirection object of all non-selected keys - const sortDirectionKeys = Object.keys(sortDirection); - sortDirectionKeys.forEach(key => { - if (key !== dataKey) delete sortDirection[key]; - }); - - // If key is already selected, reverse sort direction. - // Else, set sort direction to default direction. - if (sortDirection[dataKey] !== undefined) { - sortDirection[dataKey] = - sortDirection[dataKey] === 'ASC' ? 'DESC' : 'ASC'; - } else { - sortDirection[dataKey] = defaultSortDirection; - } - } - - // Notify application code - sortCallback({ - sortBy, - sortDirection, - }); - } - - return { - sort, - sortBy, - sortDirection, - }; -} diff --git a/source/Table/defaultHeaderRenderer.js b/source/Table/defaultHeaderRenderer.js deleted file mode 100644 index 5ae186955..000000000 --- a/source/Table/defaultHeaderRenderer.js +++ /dev/null @@ -1,32 +0,0 @@ -/** @flow */ -import * as React from 'react'; -import SortIndicator from './SortIndicator'; -import type {HeaderRendererParams} from './types'; - -/** - * Default table header renderer. - */ -export default function defaultHeaderRenderer({ - dataKey, - label, - sortBy, - sortDirection, -}: HeaderRendererParams) { - const showSortIndicator = sortBy === dataKey; - const children = [ - - {label} - , - ]; - - if (showSortIndicator) { - children.push( - , - ); - } - - return children; -} diff --git a/source/Table/defaultHeaderRowRenderer.js b/source/Table/defaultHeaderRowRenderer.js deleted file mode 100644 index a44d82cbc..000000000 --- a/source/Table/defaultHeaderRowRenderer.js +++ /dev/null @@ -1,15 +0,0 @@ -/** @flow */ -import * as React from 'react'; -import type {HeaderRowRendererParams} from './types'; - -export default function defaultHeaderRowRenderer({ - className, - columns, - style, -}: HeaderRowRendererParams) { - return ( -
- {columns} -
- ); -} diff --git a/source/Table/defaultRowRenderer.js b/source/Table/defaultRowRenderer.js deleted file mode 100644 index 836c0ff39..000000000 --- a/source/Table/defaultRowRenderer.js +++ /dev/null @@ -1,62 +0,0 @@ -/** @flow */ -import * as React from 'react'; -import type {RowRendererParams} from './types'; - -/** - * Default row renderer for Table. - */ -export default function defaultRowRenderer({ - className, - columns, - index, - key, - onRowClick, - onRowDoubleClick, - onRowMouseOut, - onRowMouseOver, - onRowRightClick, - rowData, - style, -}: RowRendererParams) { - const a11yProps = {'aria-rowindex': index + 1}; - - if ( - onRowClick || - onRowDoubleClick || - onRowMouseOut || - onRowMouseOver || - onRowRightClick - ) { - a11yProps['aria-label'] = 'row'; - a11yProps.tabIndex = 0; - - if (onRowClick) { - a11yProps.onClick = event => onRowClick({event, index, rowData}); - } - if (onRowDoubleClick) { - a11yProps.onDoubleClick = event => - onRowDoubleClick({event, index, rowData}); - } - if (onRowMouseOut) { - a11yProps.onMouseOut = event => onRowMouseOut({event, index, rowData}); - } - if (onRowMouseOver) { - a11yProps.onMouseOver = event => onRowMouseOver({event, index, rowData}); - } - if (onRowRightClick) { - a11yProps.onContextMenu = event => - onRowRightClick({event, index, rowData}); - } - } - - return ( -
- {columns} -
- ); -} diff --git a/source/Table/index.js b/source/Table/index.js deleted file mode 100644 index c4a5dc920..000000000 --- a/source/Table/index.js +++ /dev/null @@ -1,25 +0,0 @@ -/* @flow */ -import createMultiSort from './createMultiSort'; -import defaultCellDataGetter from './defaultCellDataGetter'; -import defaultCellRenderer from './defaultCellRenderer'; -import defaultHeaderRowRenderer from './defaultHeaderRowRenderer.js'; -import defaultHeaderRenderer from './defaultHeaderRenderer'; -import defaultRowRenderer from './defaultRowRenderer'; -import Column from './Column'; -import SortDirection from './SortDirection'; -import SortIndicator from './SortIndicator'; -import Table from './Table'; - -export default Table; -export { - createMultiSort, - defaultCellDataGetter, - defaultCellRenderer, - defaultHeaderRowRenderer, - defaultHeaderRenderer, - defaultRowRenderer, - Column, - SortDirection, - SortIndicator, - Table, -}; diff --git a/source/Table/types.js b/source/Table/types.js deleted file mode 100644 index 5e4a5c907..000000000 --- a/source/Table/types.js +++ /dev/null @@ -1,43 +0,0 @@ -/** @flow */ -export type CellDataGetterParams = { - columnData: ?any, - dataKey: string, - rowData: any, -}; - -export type CellRendererParams = { - cellData: ?any, - columnData: ?any, - dataKey: string, - rowData: any, - rowIndex: number, -}; - -export type HeaderRowRendererParams = { - className: string, - columns: Array, - style: any, -}; - -export type HeaderRendererParams = { - columnData: ?any, - dataKey: string, - disableSort: ?boolean, - label: ?any, - sortBy: ?string, - sortDirection: ?string, -}; - -export type RowRendererParams = { - className: string, - columns: Array, - index: number, - isScrolling: boolean, - onRowClick: ?Function, - onRowDoubleClick: ?Function, - onRowMouseOver: ?Function, - onRowMouseOut: ?Function, - rowData: any, - style: any, - key: string, -}; diff --git a/source/TestUtils.js b/source/TestUtils.js deleted file mode 100644 index a258531e4..000000000 --- a/source/TestUtils.js +++ /dev/null @@ -1,33 +0,0 @@ -import * as ReactDOM from 'react-dom'; - -/** - * Helper method for testing components that may use Portal and thus require cleanup. - * This helper method renders components to a transient node that is destroyed after the test completes. - * Note that rendering twice within the same test method will update the same element (rather than recreate it). - */ -export function render(markup) { - if (!render._mountNode) { - render._mountNode = document.createElement('div'); - - // Unless we attach the mount-node to body, getBoundingClientRect() won't work - document.body.appendChild(render._mountNode); - - afterEach(render.unmount); - } - - return ReactDOM.render(markup, render._mountNode); -} - -/** - * The render() method auto-unmounts components after each test has completed. - * Use this method manually to test the componentWillUnmount() lifecycle method. - */ -render.unmount = function() { - if (render._mountNode) { - ReactDOM.unmountComponentAtNode(render._mountNode); - - document.body.removeChild(render._mountNode); - - render._mountNode = null; - } -}; diff --git a/source/WindowScroller/WindowScroller.e2e.js b/source/WindowScroller/WindowScroller.e2e.js deleted file mode 100644 index 767ba2e05..000000000 --- a/source/WindowScroller/WindowScroller.e2e.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @jest-environment jest-environment-puppeteer - */ - -const bootstrap = async () => { - const page = await global.browser.newPage(); - const scripts = [ - './node_modules/react/umd/react.development.js', - './node_modules/react-dom/umd/react-dom.development.js', - './dist/umd/react-virtualized.js', - ]; - - for (const path of scripts) { - await page.addScriptTag({path}); - } - - return page; -}; - -const renderWindowScroller = ({scrollElement}) => { - const {render} = window.ReactDOM; - const {createElement} = window.React; - const {WindowScroller} = window.ReactVirtualized; - - const container = document.createElement('div'); - container.id = 'container'; - container.style.margin = '100px'; - container.style.padding = '50px'; - document.body.appendChild(container); - document.body.style.margin = 0; - - if (scrollElement === 'container') { - container.style.width = '100%'; - container.style.height = '100%'; - container.style.overflow = 'auto'; - } - - render( - createElement( - WindowScroller, - { - scrollElement: scrollElement === 'container' ? container : window, - onScroll: window.scrollFn, - onResize: window.resizeFn, - }, - () => createElement('div', {style: {width: 2000, height: 3000}}), - ), - container, - ); -}; - -const delay = time => new Promise(resolve => setTimeout(resolve, time)); - -test('save position after resize and then scroll in window', async () => { - const page = await bootstrap(); - const scrollFn = jest.fn(); - const resizeFn = jest.fn(); - await page.exposeFunction('scrollFn', scrollFn); - await page.exposeFunction('resizeFn', resizeFn); - - await page.setViewport({width: 400, height: 600}); - await page.evaluate(renderWindowScroller, {scrollElement: 'window'}); - - // scroll more than viewport - await page.evaluate(() => window.scrollTo(610, 830)); - await delay(100); - // resize a bit container/window - await page.setViewport({width: 300, height: 500}); - await delay(100); - // scroll again - await page.evaluate(() => window.scrollTo(620, 840)); - await delay(100); - - await page.close(); - - expect(scrollFn.mock.calls).toEqual([ - [{scrollLeft: 610 - 150, scrollTop: 830 - 150}], - [{scrollLeft: 620 - 150, scrollTop: 840 - 150}], - ]); - expect(resizeFn.mock.calls).toEqual([[{width: 300, height: 500}]]); -}); - -test('save position after resize and then scroll in container', async () => { - const page = await bootstrap(); - const scrollFn = jest.fn(); - const resizeFn = jest.fn(); - await page.exposeFunction('scrollFn', scrollFn); - await page.exposeFunction('resizeFn', resizeFn); - - await page.setViewport({width: 400, height: 600}); - await page.evaluate(renderWindowScroller, {scrollElement: 'container'}); - - // scroll more than viewport - await page.$eval('#container', el => el.scrollTo(610, 830)); - await delay(100); - // resize a bit container/window - await page.setViewport({width: 300, height: 500}); - await delay(100); - // scroll again - await page.$eval('#container', el => el.scrollTo(620, 840)); - await delay(100); - - await page.close(); - - expect(scrollFn.mock.calls).toEqual([ - [{scrollLeft: 610 - 50, scrollTop: 830 - 50}], - [{scrollLeft: 620 - 50, scrollTop: 840 - 50}], - ]); - expect(resizeFn.mock.calls).toEqual([ - [{width: 500, height: 700}], - [{width: 400, height: 600}], - ]); -}); - -test('react on container resize without window changing', async () => { - const page = await bootstrap(); - const resizeFn = jest.fn(); - await page.exposeFunction('resizeFn', resizeFn); - - await page.evaluate(() => { - const {render} = window.ReactDOM; - const {createElement} = window.React; - const {WindowScroller} = window.ReactVirtualized; - - const wrapper = document.createElement('div'); - wrapper.id = 'wrapper'; - Object.assign(wrapper.style, { - width: '1000px', - height: '800px', - display: 'flex', - }); - const container = document.createElement('div'); - Object.assign(container.style, { - flex: '1', - }); - wrapper.appendChild(container); - document.body.style.margin = 0; - document.body.appendChild(wrapper); - - render( - createElement( - WindowScroller, - {scrollElement: container, onResize: window.resizeFn}, - () => null, - ), - container, - ); - }); - - await delay(100); - - await page.$eval('#wrapper', el => { - el.style.width = '500px'; - el.style.height = '700px'; - }); - - await delay(100); - - await page.close(); - - expect(resizeFn.mock.calls).toEqual([ - [{width: 1000, height: 800}], - [{width: 500, height: 700}], - ]); -}); diff --git a/source/WindowScroller/WindowScroller.example.css b/source/WindowScroller/WindowScroller.example.css deleted file mode 100644 index 517b6e3c3..000000000 --- a/source/WindowScroller/WindowScroller.example.css +++ /dev/null @@ -1,29 +0,0 @@ -.WindowScrollerWrapper { - flex: 1 1 auto; -} - -.List { - border: 1px solid #e0e0e0; -} - -.row { - display: flex; - flex-direction: row; - align-items: center; - padding: 0 25px; - background-color: #fff; - border-bottom: 1px solid #e0e0e0; -} -.rowScrolling::after { - content: ': scrolling'; - font-size: 0.65rem; - color: #aaa; -} - -.checkboxLabel { - display: flex; - align-items: center; -} -.checkbox { - margin-right: 5px; -} diff --git a/source/WindowScroller/WindowScroller.example.js b/source/WindowScroller/WindowScroller.example.js deleted file mode 100644 index e8831531e..000000000 --- a/source/WindowScroller/WindowScroller.example.js +++ /dev/null @@ -1,177 +0,0 @@ -// @flow - -import clsx from 'clsx'; -import Immutable from 'immutable'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { - ContentBox, - ContentBoxHeader, - ContentBoxParagraph, -} from '../demo/ContentBox'; -import {LabeledInput, InputRow} from '../demo/LabeledInput'; -import WindowScroller from './WindowScroller'; -import List from '../List'; -import AutoSizer from '../AutoSizer'; -import styles from './WindowScroller.example.css'; - -type State = { - scrollToIndex: number, - showHeaderText: boolean, -}; - -export default class WindowScrollerExample extends React.PureComponent< - {}, - State, -> { - static contextTypes = { - customElement: PropTypes.any, - isScrollingCustomElement: PropTypes.bool.isRequired, - list: PropTypes.instanceOf(Immutable.List).isRequired, - setScrollingCustomElement: PropTypes.func, - }; - - state = { - scrollToIndex: -1, - showHeaderText: true, - }; - - _windowScroller: ?WindowScroller; - - render() { - const {customElement, isScrollingCustomElement, list} = this.context; - const {scrollToIndex, showHeaderText} = this.state; - - return ( - - - - {showHeaderText && ( - - This component decorates List, Table, or - any other component and manages the window scroll to scroll through - the list - - )} - - {showHeaderText && ( - - - - )} - - - - - - - - - - {({height, isScrolling, registerChild, onChildScroll, scrollTop}) => ( -
- - {({width}) => ( -
- { - window.listEl = el; - }} - autoHeight - className={styles.List} - height={height} - isScrolling={isScrolling} - onScroll={onChildScroll} - overscanRowCount={2} - rowCount={list.size} - rowHeight={30} - rowRenderer={this._rowRenderer} - scrollToIndex={scrollToIndex} - scrollTop={scrollTop} - width={width} - /> -
- )} -
-
- )} -
-
- ); - } - - _hideHeader = () => { - const {showHeaderText} = this.state; - - this.setState( - { - showHeaderText: !showHeaderText, - }, - () => { - if (this._windowScroller) { - this._windowScroller.updatePosition(); - } - }, - ); - }; - - _rowRenderer = ({index, isScrolling, isVisible, key, style}) => { - const {list} = this.context; - const row = list.get(index); - const className = clsx(styles.row, { - [styles.rowScrolling]: isScrolling, - isVisible: isVisible, - }); - - return ( -
- {row.name} -
- ); - }; - - _setRef = windowScroller => { - this._windowScroller = windowScroller; - }; - - _onCheckboxChange = event => { - this.context.setScrollingCustomElement(event.target.checked); - }; - - _onScrollToRowChange = event => { - const {list} = this.context; - let scrollToIndex = Math.min( - list.size - 1, - parseInt(event.target.value, 10), - ); - - if (isNaN(scrollToIndex)) { - scrollToIndex = undefined; - } - - setTimeout(() => { - this.setState({scrollToIndex}); - }, 0); - }; -} diff --git a/source/WindowScroller/WindowScroller.header-resize.e2e.js b/source/WindowScroller/WindowScroller.header-resize.e2e.js deleted file mode 100644 index 23531309a..000000000 --- a/source/WindowScroller/WindowScroller.header-resize.e2e.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * @jest-environment jest-environment-puppeteer - */ - -const bootstrap = async () => { - const page = await global.browser.newPage(); - const scripts = [ - './node_modules/react/umd/react.development.js', - './node_modules/react-dom/umd/react-dom.development.js', - './dist/umd/react-virtualized.js', - ]; - - for (const path of scripts) { - await page.addScriptTag({path}); - } - - return page; -}; - -const renderWindowScroller = updateScrollTopOnUpdatePosition => { - const {render} = window.ReactDOM; - const {createElement, useState, useEffect} = window.React; - const {WindowScroller} = window.ReactVirtualized; - - const container = document.createElement('div'); - container.id = 'container'; - document.body.appendChild(container); - document.body.style.margin = 0; - - function Header({height}) { - return createElement('div', {style: {height, backgroundColor: 'red'}}); - } - - function App() { - const [height, setHeight] = useState(100); - window.setHeaderHeight = setHeight; - useEffect(() => () => (window.setHeaderHeight = null)); - - return createElement( - 'div', - {}, - createElement(Header, {height}), - createElement( - WindowScroller, - { - updateScrollTopOnUpdatePosition, - ref: windowScroller => { - window.windowScroller = windowScroller; - }, - onScroll: window.scrollFn, - onResize: window.resizeFn, - }, - ({width, scrollTop}) => { - console.log({scrollTop}); - window.windowScrollerScrollTop = scrollTop; - return createElement('div', { - style: { - width, - height: 3000, - backgroundColor: 'yellow', - }, - }); - }, - ), - ); - } - - render( - createElement( - 'div', - {'data-test-id': 'main-container'}, - createElement(App, {}), - ), - container, - ); -}; - -jest.setTimeout(1200000); - -const delay = time => new Promise(resolve => setTimeout(resolve, time)); - -test('will react to header height updates if notified through updatePosition', async () => { - const page = await bootstrap(); - const scrollFn = jest.fn(); - const resizeFn = jest.fn(); - await page.exposeFunction('scrollFn', scrollFn); - await page.exposeFunction('resizeFn', resizeFn); - - await page.setViewport({width: 400, height: 600}); - await page.evaluate(renderWindowScroller, true); - - const el = await page.$('[data-test-id="main-container"]'); - expect(el).not.toBeNull(); - - await page.evaluate(() => window.scrollTo(0, 200)); - await delay(500); - - { - const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); - expect(scrollTop).toEqual(100); - } - await delay(500); - - // Update the header height - await page.evaluate(() => { - console.log('change header height'); - window.setHeaderHeight(200); - }); - await delay(500); - - await page.evaluate(() => { - console.log('update position'); - window.windowScroller.updatePosition(); - }); - await delay(500); - - // Despite header updates, we'd expect the scrollTop to be the same. - { - const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); - expect(scrollTop).toEqual(100); - } -}); - -test('will NOT react to header height updates if notified through updatePosition if `updateScrollTopOnUpdatePosition` is false', async () => { - const page = await bootstrap(); - const scrollFn = jest.fn(); - const resizeFn = jest.fn(); - await page.exposeFunction('scrollFn', scrollFn); - await page.exposeFunction('resizeFn', resizeFn); - - await page.setViewport({width: 400, height: 600}); - await page.evaluate(renderWindowScroller, false); - - const el = await page.$('[data-test-id="main-container"]'); - expect(el).not.toBeNull(); - - await page.evaluate(() => window.scrollTo(0, 200)); - await delay(500); - - { - const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); - expect(scrollTop).toEqual(100); - } - await delay(500); - - // Update the header height - await page.evaluate(() => { - console.log('change header height'); - window.setHeaderHeight(200); - }); - await delay(500); - - await page.evaluate(() => { - console.log('update position'); - window.windowScroller.updatePosition(); - }); - await delay(500); - - // Despite header updates, we'd expect the scrollTop to be the same. - // As the fix is off, this will fail. - const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); - expect(() => { - expect(scrollTop).toEqual(100); - }).toThrow(); -}); - -test('will properly process scroll events after header height updates', async () => { - const page = await bootstrap(); - const scrollFn = jest.fn(); - const resizeFn = jest.fn(); - await page.exposeFunction('scrollFn', scrollFn); - await page.exposeFunction('resizeFn', resizeFn); - - await page.setViewport({width: 400, height: 600}); - await page.evaluate(renderWindowScroller, true); - - const el = await page.$('[data-test-id="main-container"]'); - expect(el).not.toBeNull(); - - await page.evaluate(() => window.scrollTo(0, 200)); - await delay(500); - - { - const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); - expect(scrollTop).toEqual(100); - } - await delay(500); - - // Update the header height - await page.evaluate(() => { - window.setHeaderHeight(200); - }); - await delay(500); - - await page.evaluate(() => { - window.windowScroller.updatePosition(); - }); - await delay(500); - // This is only 50px under the first position - await page.evaluate(() => window.scrollTo(0, 350)); - - { - const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); - expect(scrollTop).toEqual(150); - } -}); diff --git a/source/WindowScroller/WindowScroller.jest.js b/source/WindowScroller/WindowScroller.jest.js deleted file mode 100644 index e3c54cf93..000000000 --- a/source/WindowScroller/WindowScroller.jest.js +++ /dev/null @@ -1,476 +0,0 @@ -import * as React from 'react'; -import {findDOMNode} from 'react-dom'; -import {render} from '../TestUtils'; -import WindowScroller, {IS_SCROLLING_TIMEOUT} from './WindowScroller'; - -function mockGetBoundingClientRectForHeader({ - documentOffset = 0, - height, - width, -}) { - // Mock the WindowScroller element and window separately - // The only way to mock the former (before its created) is globally - Element.prototype.getBoundingClientRect = jest.fn(() => ({ - top: height, - left: width, - })); - document.documentElement.getBoundingClientRect = jest.fn(() => ({ - top: documentOffset, - left: documentOffset, - })); -} - -function getMarkup({headerElements, documentOffset, renderFn, ...props} = {}) { - const windowScroller = ( - - {params =>
{renderFn && renderFn(params)}
} -
- ); - - // JSDome doesn't implement a working getBoundingClientRect() - // But WindowScroller requires it - mockGetBoundingClientRectForHeader({ - documentOffset, - height: headerElements ? headerElements.props.style.height : 0, - width: headerElements ? headerElements.props.style.width : 0, - }); - - if (headerElements) { - return ( -
- {headerElements} - {windowScroller} -
- ); - } else { - return windowScroller; - } -} - -function simulateWindowScroll({scrollX = 0, scrollY = 0}) { - document.body.style.height = '10000px'; - window.scrollX = scrollX; - window.scrollY = scrollY; - document.dispatchEvent(new window.Event('scroll', {bubbles: true})); - document.body.style.height = ''; -} - -function simulateWindowResize({height = 0, width = 0}) { - window.innerHeight = height; - window.innerWidth = width; - document.dispatchEvent(new window.Event('resize', {bubbles: true})); -} - -describe('WindowScroller', () => { - // Set default window height and scroll position between tests - beforeEach(() => { - window.scrollY = 0; - window.scrollX = 0; - window.innerHeight = 500; - window.innerWidth = 500; - }); - - // Starts updating scrollTop only when the top position is reached - it('should have correct top and left properties to be defined on :_positionFromTop and :_positionFromLeft', () => { - const component = render(getMarkup()); - const rendered = findDOMNode(component); - const {top, left} = rendered.getBoundingClientRect(); - expect(component._positionFromTop).toEqual(top); - expect(component._positionFromLeft).toEqual(left); - }); - - it('should allow passing child element with registerChild of children function param', () => { - const scrollElement = document.createElement('div'); - scrollElement.scrollTop = 100; - scrollElement.scrollLeft = 150; - scrollElement.getBoundingClientRect = () => ({ - top: 200, - left: 250, - }); - const child = document.createElement('div'); - child.getBoundingClientRect = () => ({ - top: 300, - left: 350, - }); - const renderFn = jest.fn(); - const component = render(getMarkup({scrollElement, renderFn})); - renderFn.mock.calls[0][0].registerChild(child); - expect(component._positionFromTop).toEqual(300 + 100 - 200); - expect(component._positionFromLeft).toEqual(350 + 150 - 250); - }); - - it('should warn on passing non-element or not null', () => { - const warnFn = jest.spyOn(console, 'warn'); - const renderFn = jest.fn(); - - render(getMarkup({renderFn})); - - renderFn.mock.calls[0][0].registerChild(1); - renderFn.mock.calls[0][0].registerChild(document.createElement('div')); - renderFn.mock.calls[0][0].registerChild(null); - - expect(warnFn).toHaveBeenCalledTimes(1); - warnFn.mockRestore(); - }); - - // Test edge-case reported in bvaughn/react-virtualized/pull/346 - it('should have correct top and left properties to be defined on :_positionFromTop and :_positionFromLeft if documentElement is scrolled', () => { - render.unmount(); - - // Simulate scrolled documentElement - const component = render( - getMarkup({ - documentOffset: -100, - }), - ); - const rendered = findDOMNode(component); - const {top, left} = rendered.getBoundingClientRect(); - expect(component._positionFromTop).toEqual(top + 100); - expect(component._positionFromLeft).toEqual(left + 100); - // Reset override - delete document.documentElement.getBoundingClientRect; - }); - - it('inherits the window height and passes it to child component', () => { - const renderFn = jest.fn(); - const component = render(getMarkup({renderFn})); - - expect(component.state.height).toEqual(window.innerHeight); - expect(component.state.height).toEqual(500); - expect(renderFn).lastCalledWith( - expect.objectContaining({ - height: 500, - }), - ); - }); - - it('should restore pointerEvents on body after IS_SCROLLING_TIMEOUT', async () => { - render(getMarkup()); - document.body.style.pointerEvents = 'all'; - simulateWindowScroll({scrollY: 5000}); - expect(document.body.style.pointerEvents).toEqual('none'); - await new Promise(resolve => - setTimeout(resolve, IS_SCROLLING_TIMEOUT + 100), - ); - expect(document.body.style.pointerEvents).toEqual('all'); - }); - - it('should restore pointerEvents on body after unmount', () => { - render(getMarkup()); - document.body.style.pointerEvents = 'all'; - simulateWindowScroll({scrollY: 5000}); - expect(document.body.style.pointerEvents).toEqual('none'); - render.unmount(); - expect(document.body.style.pointerEvents).toEqual('all'); - }); - - describe('onScroll', () => { - it('should trigger callback when window scrolls', async () => { - const onScroll = jest.fn(); - render(getMarkup({onScroll})); - - simulateWindowScroll({scrollY: 5000}); - - // Allow scrolling timeout to complete so that the component computes state - await new Promise(resolve => setTimeout(resolve, 150)); - - expect(onScroll).toHaveBeenCalledWith({ - scrollLeft: 0, - scrollTop: 5000, - }); - - simulateWindowScroll({ - scrollX: 2500, - scrollY: 5000, - }); - - // Allow scrolling timeout to complete so that the component computes state - await new Promise(resolve => setTimeout(resolve, 150)); - - expect(onScroll).toHaveBeenCalledWith({ - scrollLeft: 2500, - scrollTop: 5000, - }); - }); - - it('should update :scrollTop when window is scrolled', async () => { - const renderFn = jest.fn(); - const component = render(getMarkup({renderFn})); - - // Initial load of the component should have 0 scrollTop - expect(renderFn).lastCalledWith( - expect.objectContaining({ - scrollTop: 0, - }), - ); - - simulateWindowScroll({scrollY: 5000}); - - // Allow scrolling timeout to complete so that the component computes state - await new Promise(resolve => setTimeout(resolve, 150)); - - const componentScrollTop = window.scrollY - component._positionFromTop; - expect(component.state.scrollTop).toEqual(componentScrollTop); - expect(renderFn).lastCalledWith( - expect.objectContaining({ - scrollTop: componentScrollTop, - }), - ); - }); - - it('should specify :isScrolling when scrolling and reset after scrolling', async () => { - const renderFn = jest.fn(); - render(getMarkup({renderFn})); - - simulateWindowScroll({scrollY: 5000}); - - expect(renderFn).lastCalledWith( - expect.objectContaining({ - isScrolling: true, - }), - ); - - await new Promise(resolve => setTimeout(resolve, 250)); - - expect(renderFn).lastCalledWith( - expect.objectContaining({ - isScrolling: false, - }), - ); - }); - - it('should support a custom :scrollingResetTimeInterval prop', async () => { - const renderFn = jest.fn(); - render( - getMarkup({ - scrollingResetTimeInterval: 500, - renderFn, - }), - ); - - expect(renderFn).lastCalledWith( - expect.objectContaining({ - isScrolling: false, - }), - ); - - simulateWindowScroll({scrollY: 5000}); - - expect(renderFn).lastCalledWith( - expect.objectContaining({ - isScrolling: true, - }), - ); - - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(renderFn).lastCalledWith( - expect.objectContaining({ - isScrolling: true, - }), - ); - - await new Promise(resolve => setTimeout(resolve, 100)); - - expect(renderFn).lastCalledWith( - expect.objectContaining({ - isScrolling: true, - }), - ); - - await new Promise(resolve => setTimeout(resolve, 400)); - - expect(renderFn).lastCalledWith( - expect.objectContaining({ - isScrolling: false, - }), - ); - }); - }); - - describe('onResize', () => { - it('should trigger callback on init and when window resizes', () => { - const resizeFn = jest.fn(); - render(getMarkup({onResize: resizeFn})); - - simulateWindowResize({height: 1000, width: 1024}); - - expect(resizeFn).toHaveBeenCalledTimes(1); - expect(resizeFn).lastCalledWith({height: 1000, width: 1024}); - }); - - it('should update height when window resizes', () => { - const renderFn = jest.fn(); - const component = render(getMarkup({renderFn})); - - // Initial load of the component should have the same window height = 500 - expect(component.state.height).toEqual(window.innerHeight); - expect(component.state.height).toEqual(500); - expect(renderFn).lastCalledWith( - expect.objectContaining({ - height: 500, - }), - ); - - simulateWindowResize({height: 1000}); - - expect(component.state.height).toEqual(window.innerHeight); - expect(component.state.height).toEqual(1000); - expect(renderFn).lastCalledWith( - expect.objectContaining({ - height: 1000, - }), - ); - }); - }); - - describe('updatePosition', () => { - it('should calculate the initial offset from the top of the page when mounted', () => { - let windowScroller; - - render( - getMarkup({ - headerElements:
, - ref: ref => { - windowScroller = ref; - }, - }), - ); - - expect(windowScroller._positionFromTop).toBe(100); - }); - - it('should recalculate the offset from the top when the window resizes', () => { - let windowScroller; - - render( - getMarkup({ - headerElements: