Repository: bvaughn/react-virtualized Branch: master Commit: c737715486f7 Files: 227 Total size: 1.0 MB Directory structure: gitextract_s_hdht1l/ ├── .babelrc.js ├── .circleci/ │ └── config.yml ├── .eslintignore ├── .eslintrc ├── .flowconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.md │ │ ├── feature-request.md │ │ └── question.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ └── stale.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .watchmanconfig ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── codemods/ │ ├── 6-to-7/ │ │ └── rename-properties.js │ └── 7-to-8/ │ └── rename-components.js ├── docs/ │ ├── ArrowKeyStepper.md │ ├── AutoSizer.md │ ├── CellMeasurer.md │ ├── Collection.md │ ├── Column.md │ ├── ColumnSizer.md │ ├── Grid.md │ ├── InfiniteLoader.md │ ├── List.md │ ├── Masonry.md │ ├── MultiGrid.md │ ├── README.md │ ├── ScrollSync.md │ ├── SortDirection.md │ ├── Table.md │ ├── WindowScroller.md │ ├── creatingAnInfiniteLoadingList.md │ ├── customizingStyles.md │ ├── multiColumnSortTable.md │ ├── overscanUsage.md │ ├── reverseList.md │ ├── tableWithNaturalSort.md │ ├── upgrades/ │ │ └── Version8.md │ └── usingAutoSizer.md ├── index.html ├── jest-puppeteer.config.js ├── jest.config.js ├── package.json ├── playground/ │ ├── chat-no-resize.html │ ├── chat-no-resize.js │ ├── chat.html │ ├── chat.js │ ├── grid-test.html │ ├── grid-test.js │ ├── grid.html │ ├── grid.js │ ├── helper.js │ ├── hover.html │ ├── hover.js │ ├── render-counters.html │ ├── render-counters.js │ ├── scroll-sync.html │ ├── scroll-sync.js │ ├── table.html │ ├── table.js │ ├── tests.js │ ├── tree.html │ ├── tree.js │ └── utils.js ├── postcss.config.js ├── rollup.config.js ├── source/ │ ├── ArrowKeyStepper/ │ │ ├── ArrowKeyStepper.example.css │ │ ├── ArrowKeyStepper.example.js │ │ ├── ArrowKeyStepper.jest.js │ │ ├── ArrowKeyStepper.js │ │ ├── index.js │ │ └── types.js │ ├── AutoSizer/ │ │ ├── AutoSizer.example.css │ │ ├── AutoSizer.example.js │ │ ├── AutoSizer.jest.js │ │ ├── AutoSizer.js │ │ ├── AutoSizer.ssr.js │ │ └── index.js │ ├── CellMeasurer/ │ │ ├── CellMeasurer.DynamicHeightGrid.example.js │ │ ├── CellMeasurer.DynamicHeightList.example.js │ │ ├── CellMeasurer.DynamicHeightTableColumn.example.js │ │ ├── CellMeasurer.DynamicWidthGrid.example.js │ │ ├── CellMeasurer.DynamicWidthMultiGrid.example.js │ │ ├── CellMeasurer.example.css │ │ ├── CellMeasurer.example.js │ │ ├── CellMeasurer.jest.js │ │ ├── CellMeasurer.js │ │ ├── CellMeasurerCache.jest.js │ │ ├── CellMeasurerCache.js │ │ ├── index.js │ │ └── types.js │ ├── Collection/ │ │ ├── Collection.example.css │ │ ├── Collection.example.js │ │ ├── Collection.jest.js │ │ ├── Collection.js │ │ ├── CollectionView.js │ │ ├── Section.jest.js │ │ ├── Section.js │ │ ├── SectionManager.jest.js │ │ ├── SectionManager.js │ │ ├── TestData.js │ │ ├── index.js │ │ ├── types.js │ │ └── utils/ │ │ ├── calculateSizeAndPositionData.jest.js │ │ └── calculateSizeAndPositionData.js │ ├── ColumnSizer/ │ │ ├── ColumnSizer.example.css │ │ ├── ColumnSizer.example.js │ │ ├── ColumnSizer.jest.js │ │ ├── ColumnSizer.js │ │ └── index.js │ ├── Grid/ │ │ ├── Grid.example.css │ │ ├── Grid.example.js │ │ ├── Grid.jest.js │ │ ├── Grid.js │ │ ├── Grid.ssr.js │ │ ├── accessibilityOverscanIndicesGetter.jest.js │ │ ├── accessibilityOverscanIndicesGetter.js │ │ ├── defaultCellRangeRenderer.js │ │ ├── defaultOverscanIndicesGetter.jest.js │ │ ├── defaultOverscanIndicesGetter.js │ │ ├── index.js │ │ ├── types.js │ │ └── utils/ │ │ ├── CellSizeAndPositionManager.jest.js │ │ ├── CellSizeAndPositionManager.js │ │ ├── ScalingCellSizeAndPositionManager.jest.js │ │ ├── ScalingCellSizeAndPositionManager.js │ │ ├── calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js │ │ ├── calculateSizeAndPositionDataAndUpdateScrollOffset.js │ │ ├── maxElementSize.js │ │ ├── updateScrollIndexHelper.jest.js │ │ └── updateScrollIndexHelper.js │ ├── InfiniteLoader/ │ │ ├── InfiniteLoader.example.css │ │ ├── InfiniteLoader.example.js │ │ ├── InfiniteLoader.jest.js │ │ ├── InfiniteLoader.js │ │ └── index.js │ ├── List/ │ │ ├── List.example.css │ │ ├── List.example.js │ │ ├── List.jest.js │ │ ├── List.js │ │ ├── index.js │ │ └── types.js │ ├── Masonry/ │ │ ├── Masonry.example.css │ │ ├── Masonry.example.js │ │ ├── Masonry.jest.js │ │ ├── Masonry.js │ │ ├── PositionCache.js │ │ ├── createCellPositioner.js │ │ └── index.js │ ├── MultiGrid/ │ │ ├── CellMeasurerCacheDecorator.js │ │ ├── MultiGrid.example.css │ │ ├── MultiGrid.example.js │ │ ├── MultiGrid.jest.js │ │ ├── MultiGrid.js │ │ └── index.js │ ├── ScrollSync/ │ │ ├── ScrollSync.example.css │ │ ├── ScrollSync.example.js │ │ ├── ScrollSync.jest.js │ │ ├── ScrollSync.js │ │ └── index.js │ ├── Table/ │ │ ├── Column.jest.js │ │ ├── Column.js │ │ ├── SortDirection.js │ │ ├── SortIndicator.js │ │ ├── Table.example.css │ │ ├── Table.example.js │ │ ├── Table.jest.js │ │ ├── Table.js │ │ ├── createMultiSort.jest.js │ │ ├── createMultiSort.js │ │ ├── defaultCellDataGetter.js │ │ ├── defaultCellRenderer.js │ │ ├── defaultHeaderRenderer.js │ │ ├── defaultHeaderRowRenderer.js │ │ ├── defaultRowRenderer.js │ │ ├── index.js │ │ └── types.js │ ├── TestUtils.js │ ├── WindowScroller/ │ │ ├── WindowScroller.e2e.js │ │ ├── WindowScroller.example.css │ │ ├── WindowScroller.example.js │ │ ├── WindowScroller.header-resize.e2e.js │ │ ├── WindowScroller.jest.js │ │ ├── WindowScroller.js │ │ ├── WindowScroller.ssr.js │ │ ├── index.js │ │ └── utils/ │ │ ├── dimensions.js │ │ └── onScroll.js │ ├── demo/ │ │ ├── Application.css │ │ ├── Application.js │ │ ├── ComponentLink.css │ │ ├── ComponentLink.js │ │ ├── ContentBox.css │ │ ├── ContentBox.js │ │ ├── Icon.css │ │ ├── Icon.js │ │ ├── LabeledInput.css │ │ ├── LabeledInput.js │ │ ├── NavLink.css │ │ ├── NavLink.js │ │ ├── Wizard/ │ │ │ ├── Generator.js │ │ │ ├── Wizard.css │ │ │ ├── Wizard.js │ │ │ └── index.js │ │ ├── index.js │ │ └── utils.js │ ├── index.js │ ├── jest-setup.js │ ├── utils/ │ │ ├── TestHelper.js │ │ ├── animationFrame.js │ │ ├── createCallbackMemoizer.jest.js │ │ ├── createCallbackMemoizer.js │ │ ├── getUpdatedOffsetForIndex.jest.js │ │ ├── getUpdatedOffsetForIndex.js │ │ ├── initCellMetadata.js │ │ └── requestAnimationTimeout.js │ └── vendor/ │ ├── binarySearchBounds.js │ ├── detectElementResize.js │ └── intervalTree.js ├── webpack.config.demo.js └── webpack.config.dev.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc.js ================================================ 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'], }; } ================================================ FILE: .circleci/config.yml ================================================ 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 ================================================ FILE: .eslintignore ================================================ node_modules/* # Website dist build/* # NPM dist dist/* # Vendor files source/vendor/* ================================================ FILE: .eslintrc ================================================ { "parser": "babel-eslint", "plugins": ["react"], "extends": ["fbjs", "eslint:recommended", "plugin:prettier/recommended"], "rules": { "no-console": 0 } } ================================================ FILE: .flowconfig ================================================ [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 ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.md ================================================ --- name: Bug Report about: Bugs, missing documentation, or unexpected behavior 🤔. --- ## Bug Report Please include either a failing unit test or a simple repro. You can start by forking this Code Sandbox: https://codesandbox.io/s/03qpzq1p9p?module=%2FExample.js ### What is the current behavior? If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem. Your bug will get fixed much faster if we can run your code and it doesn't have dependencies other than React and react-virtualized. Paste the link to your [Code Sandbox](https://codesandbox.io/s/03qpzq1p9p?module=%2FExample.js) below: ### What is the expected behavior? ### Which versions of React and react-virtualized, and which browser / OS are affected by this issue? Did this work in previous versions of react-virtualized? | | | |-------------------|----------| | Browser | | | OS | | | React | | | React DOM | | | react-virtualized | | ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.md ================================================ --- name: Feature request about: Ideas and suggestions --- ## Feature Request Provide as much information as possible about your requested feature. Here are a few questions you may consider answering: * What's your use case? (Tell me about your application and what problem you're trying to solve.) * What interface do you have in mind? (What new properties or methods do you think might be helpful?) * Can you point to similar functionality with any existing libraries or components? (Working demos can be helpful.) ================================================ FILE: .github/ISSUE_TEMPLATE/question.md ================================================ --- name: Questions about: Questions about react-virtualized --- ## Asking a Question? Please don't file GitHub issues to ask questions! Instead use: * Stack Overflow: http://stackoverflow.com/questions/tagged/react-virtualized ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ Thanks for contributing to react-virtualized! **Before submitting a pull request,** please complete the following checklist: - [ ] The existing test suites (`npm 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`). Here is a short checklist of additional things to keep in mind before submitting: - Please make sure your pull request description makes it very clear what you're trying to accomplish. If it's a bug fix, please also provide a failing test case (if possible). In either case, please add additional unit test coverage for your changes. :) - Be sure you have notifications setup so that you'll see my code review responses. (I may ask you to make some adjustments before merging.) ================================================ FILE: .github/workflows/stale.yml ================================================ name: Close stale issues and PRs on: schedule: - cron: '0 8 * * *' jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v9.0.0 with: days-before-stale: 28 days-before-close: 0 exempt-issue-labels: do-not-close exempt-pr-labels: do-not-close ================================================ FILE: .gitignore ================================================ .DS_Store build coverage dist node_modules npm-debug.log styles.css .vscode .idea ================================================ FILE: .nvmrc ================================================ v10.16.3 ================================================ FILE: .prettierrc ================================================ { "bracketSpacing": false, "jsxBracketSameLine": true, "printWidth": 80, "singleQuote": true, "trailingComma": "all" } ================================================ FILE: .watchmanconfig ================================================ ================================================ FILE: CHANGELOG.md ================================================ ## Changelog ##### 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)) ##### 9.22.5 - React 19 support added; special thanks to ([artur-ptaszek-mck](https://github.com/artur-ptaszek-mck) and ([adubrouski](https://github.com/adubrouski) ##### 9.22.4 - README changes ##### 9.22.3 - Add Trusted Types support ([shhnjk](https://github.com/shhnjk) - [#1614](https://github.com/bvaughn/react-virtualized/pull/1614)) ##### 9.22.2 - CollectionView scrollTop/scrollLeft ([dawnmist](https://github.com/dawnmist) - [#1260](https://github.com/bvaughn/react-virtualized/pull/1260)) ##### 9.22.1 - Upgrade babel-plugin-flow-react-proptypes ([Hypnosphi](https://github.com/Hypnosphi) - [#1578](https://github.com/bvaughn/react-virtualized/pull/1578)) ##### 9.22.0 - Make AutoSizer a Component ([vzaidman](https://github.com/vzaidman) - [#1490](https://github.com/bvaughn/react-virtualized/pull/1490)) - Update masonry props and docs ([seanstrom](https://github.com/seanstrom) - [#1493](https://github.com/bvaughn/react-virtualized/pull/1493)) - CellMeasurer: add registerChild render prop ([Hypnosphi](https://github.com/Hypnosphi) - [#1477](https://github.com/bvaughn/react-virtualized/pull/1477)) - Fix Table class names in documentation ([jakemmarsh](https://github.com/jakemmarsh) - [#1471](https://github.com/bvaughn/react-virtualized/pull/1471)) - Fix style.css import in demo ([Dominic0512](https://github.com/Dominic0512) - [#1466](https://github.com/bvaughn/react-virtualized/pull/1466)) ##### 9.21.2 - 🎉 Update prettier ([wuweiweiwu](https://github.com/wuweiweiwu) - [#1455](https://github.com/bvaughn/react-virtualized/pull/1455)) - 🎉 Remove slack from documentation ([wuweiweiwu](https://github.com/wuweiweiwu) - [#1417](https://github.com/bvaughn/react-virtualized/pull/1417)) - 🐛 Fix masionry scroll handler target ([chinmay17](https://github.com/chinmay17) - [#1420](https://github.com/bvaughn/react-virtualized/pull/1420)) - 🎉 AutoSizer support for multi window ([mbman](https://github.com/mbman) - [#1421](https://github.com/bvaughn/react-virtualized/pull/1421)) - 🎉 Upgrade dom-helpers ([dominykas](https://github.com/dominykas) - [#1424](https://github.com/bvaughn/react-virtualized/pull/1424)) - 🎉 Remove react-vtree from docs ([Lodin](https://github.com/Lodin) - [#1415](https://github.com/bvaughn/react-virtualized/pull/1415)) - 🐛 Fix detection of chrome ([nifgraup](https://github.com/nifgraup) - [#1355](https://github.com/bvaughn/react-virtualized/pull/1355)) - 🐛 Fix hostname issues for images ([nickstew](https://github.com/nickstew) - [#1401](https://github.com/bvaughn/react-virtualized/pull/1401)) - 🐛 Fix aria attributes on Table ([eps1lon](https://github.com/eps1lon) - [#1380](https://github.com/bvaughn/react-virtualized/pull/1380)) ##### 9.21.1 - 🐛 Fix doc typos. ([tienpham94](https://github.com/tienpham94) - [#1268](https://github.com/bvaughn/react-virtualized/pull/1268)) - 🐛 Fix typos in changelog. ([misacorn](https://github.com/misacorn) - [#1267](https://github.com/bvaughn/react-virtualized/pull/1267)) - 🐛 Fix formatting in AutoSizer.md ([jacklee814](https://github.com/jacklee814) - [#1246](https://github.com/bvaughn/react-virtualized/pull/1246)) - 🐛 Fix usage of reduce in InfiniteLoader ([jedwards1211](https://github.com/jedwards1211) - [#1277](https://github.com/bvaughn/react-virtualized/pull/1277)) - 🎉 Migrate from classnames to clsx ([TrySound](https://github.com/TrySound) - [#1306](https://github.com/bvaughn/react-virtualized/pull/1306)) - 🐛 Ensure a string title for header ([oakfang](https://github.com/oakfang) - [#1321](https://github.com/bvaughn/react-virtualized/pull/1321)) - 🎉 Use sparse array for cell position caches ([trxcllnt](https://github.com/trxcllnt) - [#1312](https://github.com/bvaughn/react-virtualized/pull/1312)) - 🐛 Fix scrollToPosition ([jaycrypto](https://github.com/jaycrypto) - [#1288](https://github.com/bvaughn/react-virtualized/pull/1288)) ##### 9.21.0 - 🎉 Added new use case example for CellMeasurer. ([wuweiweiwu](https://github.com/wuweiweiwu) - [#1168](https://github.com/bvaughn/react-virtualized/pull/1168)) - 🎉 Added react-timeline-9000 as a related library. ([vasdee](https://github.com/vasdee) - [#1197](https://github.com/bvaughn/react-virtualized/pull/1197)) - 🐛 Fix CellMeasurer docs with correct import statement. ([skipjack](https://github.com/skipjack) - [#1187](https://github.com/bvaughn/react-virtualized/pull/1187)) - 🐛 Fix broken Slack badge. ([slieshke](https://github.com/slieshke) - [#1205](https://github.com/bvaughn/react-virtualized/pull/1205)) - 🐛 Fix type in CellMeasurer example. ([rloqvist](https://github.com/rloqvist) - [#1190](https://github.com/bvaughn/react-virtualized/pull/1190)) - 🐛 Fix Table aria attributes. ([jsomsanith](https://github.com/jsomsanith) - [#1208](https://github.com/bvaughn/react-virtualized/pull/1208)) - 🐛 Removed unused variable in Masonry example. ([ignocide](https://github.com/ignocide) - [#1218](https://github.com/bvaughn/react-virtualized/pull/1218)) - 🎉 Add onColumnClick to Table. ([grahamlyus](https://github.com/grahamlyus) - [#1207](https://github.com/bvaughn/react-virtualized/pull/1207)) - 🎉 Allow users to override default table row styles. ([emroussel](https://github.com/emroussel) - [#1175](https://github.com/bvaughn/react-virtualized/pull/1175)) ##### 9.20.1 - 🐛 Removing `sideEffects: false` from package.json. ([wuweiweiwu](https://github.com/wuweiweiwu) - [#1163](https://github.com/bvaughn/react-virtualized/pull/1163)) - 🐛 Prevent early `debounceScrollEndedCallback`. ([Gvozd](https://github.com/Gvozd) - [#1141](https://github.com/bvaughn/react-virtualized/pull/1141)) - 🐛 Fix `scrollToIndex` behavior in `InfiniteLoader`. ([mengdage](https://github.com/mengdage), [dcolens](https://github.com/dcolens) - [#1154](https://github.com/bvaughn/react-virtualized/pull/1154)) ##### 9.20.0 - 🎉 Code of Conduct and updated Issue/PR templates. ([aem](https://github.com/aem) - [#1052](https://github.com/bvaughn/react-virtualized/pull/1052)) - 🐛 Make scrollTo{Column,Row} take precedence over scroll{Left,Top}. ([wuweiweiwu](https://github.com/wuweiweiwu) - [#1130](https://github.com/bvaughn/react-virtualized/pull/1130)) - 🐛 No `setState(null)`. ([wuweiweiwu](https://github.com/wuweiweiwu) - [#1129](https://github.com/bvaughn/react-virtualized/pull/1129)) - 🎉 New `isScrollingOptOut` prop for `Grid` that prevents re-rendering on scroll-end. ([wuweiweiwu](https://github.com/wuweiweiwu) - [#1131](https://github.com/bvaughn/react-virtualized/pull/1131)) - 🐛 Updated npm badge link. ([SpainTrain](https://github.com/SpainTrain) - [#1146](https://github.com/bvaughn/react-virtualized/pull/1146)) ##### 9.19.1 - Updated [react-lifecycles-compat](https://github.com/reactjs/react-lifecycles-compat) to 3.0.4. ([pigcan](https://github.com/pigcan) - [#1114](https://github.com/bvaughn/react-virtualized/pull/1114)) ##### 9.19.0 - Replaced `componentWillMount`, `componentWillReceiveProps`, and `componentWillUpdate` with async-safe lifecycles in advance of React 16.x deprecation warnings. Added [react-lifecycles-compat](https://github.com/reactjs/react-lifecycles-compat) as a dependency, to ensure backwards compatibility. - Public flow interface for `CellMeasurer`. ([diogofcunha](https://github.com/diogofcunha) - [#1058](https://github.com/bvaughn/react-virtualized/pull/1058)) - Improved build by setting `sideEffects` to `false`. ([0xR](https://github.com/0xR) - [#1064](https://github.com/bvaughn/react-virtualized/pull/1064)) - 🐛 Fix flow type checks. ([RyanLiu0235](https://github.com/RyanLiu0235) - [#1066](https://github.com/bvaughn/react-virtualized/pull/1066)) - Rollup for UMD build. ([TrySound](https://github.com/TrySound) - [#994](https://github.com/bvaughn/react-virtualized/pull/994)) - Row direction support for `Masonry` ([bardiarastin](https://github.com/bardiarastin) - [#1071](https://github.com/bvaughn/react-virtualized/pull/1071)) - Add lint-staged and precommit hooks ([TrySound](https://github.com/TrySound) - [#1082](https://github.com/bvaughn/react-virtualized/pull/1082)) - Add `scrollToRow` and `scrollToColumn` support for ssr. ([microcood](https://github.com/microcood) - [#1072](https://github.com/bvaughn/react-virtualized/pull/1072)) - Add `getTotalRowsHeight` and `getTotalColumnsWidth` to `Grid`. ([nihgwu](https://github.com/nihgwu) - [#1022](https://github.com/bvaughn/react-virtualized/pull/1022)) - Allow top-right and bottom-left scrollbars in `MultiGrid` be hidden. ([RaviDasari](https://github.com/RaviDasari) - [#1040](https://github.com/bvaughn/react-virtualized/pull/1040)) - Documentation changes - Added `forceUpdateGrid` documentation for `MultiGrid`. ([kartikluke](https://github.com/kartikluke) - [#1079](https://github.com/bvaughn/react-virtualized/pull/1079)) - 🐛 Fixed typo in `Grid` docs. ([r-kot](https://github.com/r-kot) - [#1092](https://github.com/bvaughn/react-virtualized/pull/1092)) - 🐛 Fixed typo in `Collection` docs. ([skipjack](https://github.com/skipjack) - [#1050](https://github.com/bvaughn/react-virtualized/pull/1050)) - Added dynamically measured images example for `Masonry`. ([kirill-konshin](https://github.com/kirill-konshin) - [#1081](https://github.com/bvaughn/react-virtualized/pull/1081)) ##### 9.18.5 - 🐛 Revert changes > 9.18.0 ##### 9.18.0 - ✨ Add `onScrollbarPresenceChange` prop to `MultiGrid`. ##### 9.17.3 - 🐛 Fix `Grid` server-side rendering which was broken after natural scrolling tweak in Chrome. ([TrySound](https://github.com/TrySound) - [#970](https://github.com/bvaughn/react-virtualized/pull/970)) ##### 9.17.2 - ✨ Eliminate unnecessary renders for `CellMeasurer` and `Grid`. ([bvaughn](https://github.com/bvaughn) - [#969](https://github.com/bvaughn/react-virtualized/pull/969)) ##### 9.17.1 - 🐛 `CellMeasurer` works properly in iframes and popup windows. ([dfdeagle47](https://github.com/dfdeagle47) - [#968](https://github.com/bvaughn/react-virtualized/pull/968)) ##### 9.17.0 - More natural scrolling speeds for large lists in Chrome. ([TrySound](https://github.com/TrySound) - [#936](https://github.com/bvaughn/react-virtualized/pull/936)) - Support for multi-column sort added to `Table` component. Read more about this [here](https://github.com/bvaughn/react-virtualized/blob/master/docs/multiColumnSortTable.md). Special thanks to [CzBuCHi](https://github.com/CzBuCHi) for the initial proposal and iteration in PRs [#946](https://github.com/bvaughn/react-virtualized/pull/946) and [#957](https://github.com/bvaughn/react-virtualized/pull/957). ([bvaughn](https://github.com/bvaughn) - [#966](https://github.com/bvaughn/react-virtualized/pull/966)) - ✨ Improved `Table` performance for cases with large numbers of columns. ([gannunziata](https://github.com/gannunziata) - [#942](https://github.com/bvaughn/react-virtualized/pull/942)) - 🐛 Fixed potential initial render bug when using `CellMeasurer` with a `List`. ([OriR](https://github.com/OriR) - [#959](https://github.com/bvaughn/react-virtualized/pull/959)) - 🐛 `Masonry` component now renders at least one column to avoid an invalid, Infinity height layout issue. ([djeeg](https://github.com/djeeg) - [#961](https://github.com/bvaughn/react-virtualized/pull/961)) - 🎉 Optional `className` and `style` props added to `AutoSizer` component. ##### 9.16.1 - 🐛 Run server-side rendering tests under pure node environment and fix SSR in `WindowScroller` ([TrySound](https://github.com/TrySound) - [#953](https://github.com/bvaughn/react-virtualized/pull/953)) - 🎉 Warn on passing wrong value to registerChild in `WindowScroller` ([TrySound](https://github.com/TrySound) - [#949](https://github.com/bvaughn/react-virtualized/pull/949)) - 🐛 Fixed overrided merge `WindowScroller` scrollElement prop type ([TrySound](https://github.com/TrySound) - [#948](https://github.com/bvaughn/react-virtualized/pull/948)) - 🎉 `AutoSizer` (via the `detectElementResize` helper) now supports properly rendering into iframes and child windows ([ahutchings](https://github.com/ahutchings) - [#900](https://github.com/bvaughn/react-virtualized/pull/900)) ##### 9.16.0 - 🐛 Fixed window check for SSR in `detectElementResize` ([eqyiel](https://github.com/eqyiel) - [#945](https://github.com/bvaughn/react-virtualized/pull/945)) - 🎉 Allowed custom `WindowScroller` child with `registerChild` in children function ([TrySound](https://github.com/TrySound) - [#940](https://github.com/bvaughn/react-virtualized/pull/940) and [#947](https://github.com/bvaughn/react-virtualized/pull/947)) - 🐛 Fixed `WindowScroller` scrollElement prop type ([TrySound](https://github.com/TrySound) - [#939](https://github.com/bvaughn/react-virtualized/pull/939)) ##### 9.15.0 - 🎉 Detected `WindowScroller` container (not only window) resize similar to `AutoSizer` ([TrySound](https://github.com/TrySound) - [#918](https://github.com/bvaughn/react-virtualized/pull/918)) - 🐛 Prevented position breaking on `WindowScroller` container resize ([TrySound](https://github.com/TrySound) - [#920](https://github.com/bvaughn/react-virtualized/pull/920)) - 🎉 Published `AutoSizer` Flow types ([TrySound](https://github.com/TrySound) - [#934](https://github.com/bvaughn/react-virtualized/pull/934)) - 🎉 Published `WindowScroller` Flow types ([TrySound](https://github.com/TrySound) - [#915](https://github.com/bvaughn/react-virtualized/pull/915)) ##### 9.14.1 - 🐛 Fixed server-side rendering bug in `WindowScroller` with undefined `window` variable. ##### 9.14.0 - 🎉 Added `serverHeight` and `serverWidth` props to `WindowScroller` for better server-side rendering support. ##### 9.13.0 - 🎉 Added `headerStyle` support for `Table` columns ([@mucsi96](https://github.com/mucsi96) - [#877](https://github.com/bvaughn/react-virtualized/pull/877)) - 🐛 Fixed `Masonry` bug that caused cells to be unnecessarily destroyed and then recreated when new cells were measured - d561d9c ##### 9.12.0 - 🎉 Added `defaultWidth` and `defaultHeight` props to `AutoSizer` to better support server-side rendering. ##### 9.11.1 - 🐛 `Masonry` component now properly pre-renders as specified by `overscanByPixels` ##### 9.11.0 - 🐛 `List` and `Grid` scroll snapping / resetting bugfix #825 by @TrySound - 🐛 `MultiGrid` crash due to `scrollTo*` prop being `NaN` #829 by @mcordova47 - 🐛 `MultiGrid` invalid `tabIndex` prop type #818 by @kalley - 🎉 Column default sort direction #833 by @mbseid ##### 9.10.1 - 🐛 Server-side rendering `window` reference bugfix - 🐛 `Grid.defaultProps` bugfix ##### 9.10.0 - ✨ `Grid` uses `requestAnimationFrame` instead of `setTimeout` for improved scroll-ended debounce timing ([@guilhermefloriani](https://github.com/guilhermefloriani) - [#742](https://github.com/bvaughn/react-virtualized/pull/742)) - 🎉 `onRowRightClick` prop added to `Table` ([@damian-codilime](https://github.com/damian-codilime) - [#741](https://github.com/bvaughn/react-virtualized/pull/741)) - 🎉 `Table` component now allow children that extend `Column` ([@CptLemming](https://github.com/CptLemming) - [#748](https://github.com/bvaughn/react-virtualized/pull/748)) - 🐛 Firefox edge-case bugfix ([@ReinAkane](https://github.com/ReinAkane) - [#798](https://github.com/bvaughn/react-virtualized/pull/798)) - 🎉 `containerProps` prop added to `Grid` ([@implausible](https://github.com/implausible) - [#778](https://github.com/bvaughn/react-virtualized/pull/778)) - ✨ `Grid` accessibility improved via better aria attributes ([@smockle](https://github.com/smockle) - [#744](https://github.com/bvaughn/react-virtualized/pull/744)) - ✨ `CellMeasurererCache.clearAll` also sets row and column counts ([@tcosentino](https://github.com/tcosentino) - [#796](https://github.com/bvaughn/react-virtualized/pull/796)) ##### 9.9.0 - 🎉 `InfiniteLoader` API method `resetLoadMoreRowsCache` accepts optional parameter to auto-reload most recent range of rows. ([@BamaBoy](https://github.com/BamaBoy) - [#704](https://github.com/bvaughn/react-virtualized/pull/704)) - 🎉 `MultiGrid` now supports scrolling when hovering over fixed rows or columns by way of new `enableFixedColumnScroll` and `enableFixedRowScroll` props. ([@danalloway](https://github.com/danalloway) - [#708](https://github.com/bvaughn/react-virtualized/pull/708)) - 🎉 `WindowScroller` supports new configurable `scrollingResetTimeInterval` prop (similar to `Grid`). ([@djeeg](https://github.com/djeeg) - [#728](https://github.com/bvaughn/react-virtualized/pull/728)) - 🐛 Edge-case bugfix for style caching of `Grids` locked with `ScrollSync`. ([@nathanpower](https://github.com/nathanpower) - [#727](https://github.com/bvaughn/react-virtualized/pull/727)) - ✨ New `onScrollbarPresenceChange` prop added to `Grid`. ##### 9.8.0 - 🎉 `WindowScroller` supports `scrollToIndex` prop. ([@leoasis](https://github.com/leoasis) - [#643](https://github.com/bvaughn/react-virtualized/pull/643)) - 🎉 Allow `ArrowKeyStepper` to be used as a controlled component. ([@mking-clari](https://github.com/mking-clari) - [#688](https://github.com/bvaughn/react-virtualized/pull/688)) - 🎉New `handleScroll` method on `Grid` to better support custom scrollbars. ([@5angel](https://github.com/5angel) - [#693](https://github.com/bvaughn/react-virtualized/pull/693)) - 🐛 Added edge-case guard to `WindowScroller` to prevent calling `setState` when unmounted. ([@liorbrauer](https://github.com/liorbrauer) - [#689](https://github.com/bvaughn/react-virtualized/pull/689)) - 🐛 Fixed edge-case in `Grid` where setting an initial scroll-to offset with a `height` or `width` of 0 caused the scroll-to prop to be ignored when size later changed. ([#691](https://github.com/bvaughn/react-virtualized/pull/691)) ##### 9.7.6 - ✨ Better aria roles set for `Table` column cells and headers. ([@jchen527](https://github.com/jchen527) - [#681](https://github.com/bvaughn/react-virtualized/pull/681)) - 🐛 `CellMeasurer` restores `width` and `height` `style` values after measuring to avoid edge-case layout bugs. ([@marcelmokos](https://github.com/marcelmokos) - [#675](https://github.com/bvaughn/react-virtualized/pull/675)) ##### 9.7.5 - ✨ Improved performance for `CellMeasurerCache` by removing some unnecessary computations for fixed-width/fixed-height use cases. ([@RaviDasari](https://github.com/RaviDasari) - [#676](https://github.com/bvaughn/react-virtualized/pull/676)) - 🐛 `MultiGrid` ensures correct row/column indices are passed to `CellMeasurerCache` for top-right and bottom `Grid`s. ([#670](https://github.com/bvaughn/react-virtualized/pull/670)) ##### 9.7.4 - 🎉 Add `nonce` attribute to `AutoSizer` for better [Content Security Policy compliance](https://www.w3.org/TR/2016/REC-CSP2-20161215/#script-src-the-nonce-attribute). ([@akihikodaki](https://github.com/akihikodaki) - [#663](https://github.com/bvaughn/react-virtualized/pull/663)) - ✨ `Column` renderers now accept a `columnIndex` parameter as well. This allows multiple `Table` columns to more easily use `CellMeasurer` to compute the min row height. ([@BamaBoy](https://github.com/BamaBoy) - [#662](https://github.com/bvaughn/react-virtualized/pull/662)) ##### 9.7.3 - Clear cell and style cache when controlled-scroll mode `Grid` stops scrolling. ([@leoasis](https://github.com/leoasis) - [#649](https://github.com/bvaughn/react-virtualized/pull/649)) ##### 9.7.2 - ✨ Removed lingering `React.PropTypes` reference in `InfiniteLoader`. ##### 9.7.1 - ✨ Added `prop-types` dependency to avoid deprecation warnings for React 15.5+. ##### 9.7.0 - Added public animation-friendly API methods to `Grid`/`List`/`Table` for an alternative to props-based animating. ([@imadha](https://github.com/imadha) - [#641](https://github.com/bvaughn/react-virtualized/pull/641)) ##### 9.6.1 - 🐛 Fixed module syntax error in vendered file. ##### 9.6.0 - 🎉 `WindowScroller` and `Grid` now support horizontal window-scrolling via new `autoWidth` property. ([@maxnowack](https://github.com/maxnowack) - [#644](https://github.com/bvaughn/react-virtualized/pull/644)) - 🐛 Fixed a Content Security Policy (CSP) issue in an upstream dependency that impacted users of the `Masonry` component. For more information see issue [#640](https://github.com/bvaughn/react-virtualized/issues/640). - ✨ `List` and `Table` always overscan 1 row in the direction _not_ being scrolled to better support keyboard navigation (via TAB and SHIFT+TAB). For more information see [issue #625](https://github.com/bvaughn/react-virtualized/issues/625). - ✨ `Grid` no longer alters scroll direction for one axis (eg vertical) if a scroll event occurs for another axis (eg horizontal). ##### 9.5.0 - 🎉 `Grid` supports state-override of `isScrolling` value via new `isScrolling` prop. This enables cache-while-scrolling of cells when used with `WindowScroller`. ([@olavk](https://github.com/olavk) - [#639](https://github.com/bvaughn/react-virtualized/pull/639)) ##### 9.4.2 - 🐛 Small accessibility fix to `MultiGrid` so that focus outline shows through by default for main (bottom/right) `Grid`. Top and left `Grid`s are also not tab-focusable by default now since they are scroll-observers anyway. - ✨ Added `columnWidth` parameter to `ColumnSizer` and deprecated `getColumnWidth` callback. The callback was not necessary since `columnWidth` doesn't change without a re-render and fixed number values perform better in `Grid` due to some internal optimizations anyway. ##### 9.4.1 - 🐛 Edge-case `InfiniteLoader` bug fix; prevent jumping to the first row when scrolling fast. ([@reVrost](https://github.com/reVrost) - [#632](https://github.com/bvaughn/react-virtualized/pull/632)) - 🐛 Reverted unexpected regression from [#616](https://github.com/bvaughn/react-virtualized/pull/616) until a safer fix can be found. ##### 9.4.0 - 🎉 New `Masonry` component optimized for Pinterest-style layouts. Check out the [docs](https://github.com/bvaughn/react-virtualized/blob/master/docs/Masonry.md) and [demo page](https://bvaughn.github.io/react-virtualized/#/components/Masonry) to learn more. ([#618](https://github.com/bvaughn/react-virtualized/pull/618)) - 🎉 `MultiGrid` supports `scrollLeft` and `scrollTop` props for controlled scrolling. ([@julianwong94](https://github.com/julianwong94) - [#624](https://github.com/bvaughn/react-virtualized/pull/624)) - 🎉 New `direction` parameter passed to `overscanIndicesGetter` with values "horizontal" or "vertical". ([@offsky](https://github.com/offsky) - [#629](https://github.com/bvaughn/react-virtualized/pull/629)) - ✨ Replaced inline `require` statement with header `import` in `Grid` for better integration with the Rollup module bundler. ([@odogono](https://github.com/odogono) - [#617](https://github.com/bvaughn/react-virtualized/pull/617)) - 🐛 Improved guard for edge-case scrolling issue with rubberband scrolling in iOS. ([@dtoddtarsi](https://github.com/offsky) - [#616](https://github.com/bvaughn/react-virtualized/pull/616)) - ✨ Replaced `getBoundingClientRect()` with slightly faster `offsetWidth` and `offsetHeight` inside of `AutoSizer`. - ✨ `AutoSizer` no longer re-renders nor calls `onResize` callback unless `width` and/or `height` have changed (depending on which properties are being watched). ##### 9.3.0 - 🎉 Added `resetLoadMoreRowsCache` method to `InfiniteLoader` to reset any cached data about loaded rows. This method should be called if any/all loaded data needs to be refetched (eg a filtered list where the search criteria changes). ([#612](https://github.com/bvaughn/react-virtualized/issues/612)) ##### 9.2.3 - 🐛 `CellMeasurer` should work better out of the box with `MultiGrid`. - 🐛 `CellMeasurerCache` should return correct values from `rowHeight` and `columnWidth` functions when `keyMapper` is used. ([#613](https://github.com/bvaughn/react-virtualized/pull/613)) ##### 9.2.2 - 🐛 Fixed small scrollbar offset bug in `MultiGrid`. ([#609](https://github.com/bvaughn/react-virtualized/issues/609)) ##### 9.2.1 - 🐛 Fixed potential scrollbar offset bug in `MultiGrid` by giving top and left `Grid`s a little extra space to scroll into. ([#535](https://github.com/bvaughn/react-virtualized/pull/535)) ##### 9.2.0 - 🎉 New `Table` prop, `headerRowRenderer`. ([@kaoDev](https://github.com/kaoDev) - [#600](https://github.com/bvaughn/react-virtualized/pull/600)) - 🎉 All `Table` event handlers now receive a named `event` params ([@paulbrom](https://github.com/paulbrom) - [#605](https://github.com/bvaughn/react-virtualized/pull/605)) - 🎉 Aria roles for `Table` improved to specify `role="row"` for table rows and `role="rowgroup"` for inner `Grid`. ([@jchen527](https://github.com/jchen527) - [#607](https://github.com/bvaughn/react-virtualized/pull/607)) - 🐛 Calling `scrollToRow` for `List` or `Table` no longer potentially messes up horizontal scroll position. ([#603](https://github.com/bvaughn/react-virtualized/issues/603)) ##### 9.1.0 - 🎉 Public method `setScrollIndexes` added to `ArrowKeyStepper` to enable easier overrides of current/default focused cell. - ([@alexandro81](https://github.com/alexandro81) - [#592](https://github.com/bvaughn/react-virtualized/pull/592)) - ✨ Replaced `value instanceof Function` checks with `typeof value === 'function'` for improved robustness with iframes/frames/popups. (Learn more [here]().) ([@rickychien](https://github.com/rickychien) - [#596](https://github.com/bvaughn/react-virtualized/pull/596)) - 🐛 `Grid` props `scrollToColumn` and `scrollToRow` as well as `Collection` prop `scrollToCell` now default to `-1` to avoid false positives from `null>=0` check. - ([#595](https://github.com/bvaughn/react-virtualized/issues/595)) ##### 9.0.5 - 🎉 Explicitly set `width`/`height` style to "auto" before re-measuring `CellMeasurer` content so that new measurements can be taken. ([#593](https://github.com/bvaughn/react-virtualized/issues/593)) - 🐛 CellMeasurerCache now correctly recomputes cached row height and column width values when cells are cleared. ([#594](https://github.com/bvaughn/react-virtualized/issues/594)) ##### 9.0.4 - 🐛 Moved flow-bin from 'dependencies' to 'devDependencies'. This was accidentally placed as a dep before. ##### 9.0.3 - 🐛 `Grid` takes scrollbar size into account when aligning cells for `scrollToColumn` or `scrollToRow` usage. ([#543](https://github.com/bvaughn/react-virtualized/issues/543)) ##### 9.0.2 - 🎉 Added additional DEV-only warnings for improperly configured `CellMeasurerCache` based on user-feedback for the new API. - 🐛 Fixed edge-case where restoring `columnCount` from 0 wouldnt properly restore previous `scrollToRow` offset (and vice versa for `rowCount` and `scrollToColumn`) - Updated `Grid` and `Collection` to move some state-setting logic related to offsets from `componentWillUpdate` to `componentWillReceiveProps`. This change should have no externally visible impact. ([#585](https://github.com/bvaughn/react-virtualized/issues/585)) ##### 9.0.1 - 🐛 Edge-case bug with scroll-to-index and cell size function property ([#565](https://github.com/bvaughn/react-virtualized/issues/565)) - 🐛 Edge-case bug with `WindowScroller` and mocked `window` object # 9.0.0 Version 9 changes and upgrade steps are described in detail on the [version 9 pull request](https://github.com/bvaughn/react-virtualized/pull/577). ##### 8.11.4 - 🐛 Better guard against minification/uglification in `ColumnSizer` when verifying child is either a `Grid` or a `MultiGrid`. (#558) ##### 8.11.3 - Adding missing `scrollToRow` method to `List` and `Table` (as pass-thrus for `Grid.scrollToCell`). - 🐛 Bugfixes with `MultiGrid` resize handling and caching. ([@codingbull](https://github.com/codingbull) - [#552](https://github.com/bvaughn/react-virtualized/pull/552)) - 🐛 List checks it row-style object has been frozen before modifying width; (fix for [upcoming React 16 change](https://github.com/facebook/react/commit/977357765b44af8ff0cfea327866861073095c12#commitcomment-20648713)). - 🐛 `MultiGrid` better handles case where `rowCount === fixedRowCount`. ##### 8.11.2 - 🐛 Added `MultiGrid` method `measureAllCells`; deprecated misnamed `measureAllRows` method. ##### 8.11.1 - 🐛 Fixed regression in `WindowScroller` when browser is resized. ([@andrewbranch](https://github.com/andrewbranch) - [#548](https://github.com/bvaughn/react-virtualized/pull/548)) ##### 8.11.0 - 🐛 Minor Preact compat fix to element resize detector; see [developit/preact-compat/issues/228](https://github.com/developit/preact-compat/issues/228) - 🎉 New `scrollToCell` public method added to `Grid`. ##### 8.10.0 - 🎉 `WindowScroller` supports custom target element via a new `scrollElement` prop; defaults to `window` for backwards compatibility. ([@andrewbranch](https://github.com/andrewbranch) - [#481](https://github.com/bvaughn/react-virtualized/pull/481)) - 🐛 `MultiGrid` supports `onScroll` property. ([@Pana](https://github.com/Pana) - [#536](https://github.com/bvaughn/react-virtualized/pull/536)) - 🎉 New id-based `CellMeasurer` cell size cache, `idCellMeasurerCellSizeCache`. ([@bvaughn](https://github.com/bvaughn) - [#538](https://github.com/bvaughn/react-virtualized/pull/538)) ##### 8.9.0 - New `MultiGrid` reduces the boilerplate required to configure a `Grid` with fixed columns and/or rows. - `defaultTableRowRenderer` passes new `rowData` param to event handlers (in addition to `index`). - 🐛 Styles are no longer cached while scrolling for compressed lists ([@nickclaw](https://github.com/nickclaw) - [#527](https://github.com/bvaughn/react-virtualized/pull/527)) - 🐛 Cell cache is reset once `InfiniteLoader` load completes ([@nickclaw](https://github.com/nickclaw) - [#528](https://github.com/bvaughn/react-virtualized/pull/528)) - Add loose-envify support for Browserify users ([@chrisvasz](https://github.com/chrisvasz) - [#519](https://github.com/bvaughn/react-virtualized/pull/519), [#523](https://github.com/bvaughn/react-virtualized/pull/523)) - `dom-helpers` dependency relaxed to support 2.x and 3.x versions ([@danez](https://github.com/danez) - [#522](https://github.com/bvaughn/react-virtualized/pull/522)) - 🐛 `Collection` no longer drops its `overflow` style in certain conditions; see facebook/react/issues/8689 for more info. ##### 8.8.1 Fixed a bug with `Grid` style-cache that caused stale cell-sizes to be used when `Grid` resized. ##### 8.8.0 `Grid` now temporarily caches inline style objects to avoid causing shallow compare to fail unnecessarily (see [PR 506](https://github.com/bvaughn/react-virtualized/pull/506)). `AutoSizer` internal `detectElementResize` library now no longer creates duplicate `
================================================ FILE: playground/chat-no-resize.js ================================================ 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); ================================================ FILE: playground/chat.html ================================================ foo
Click body to resize ...
================================================ FILE: playground/chat.js ================================================ 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`; }); ================================================ FILE: playground/grid-test.html ================================================ foo
================================================ FILE: playground/grid-test.js ================================================ 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(); } }); ================================================ FILE: playground/grid.html ================================================ foo
================================================ FILE: playground/grid.js ================================================ 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')); ================================================ FILE: playground/helper.js ================================================ 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`); } ================================================ FILE: playground/hover.html ================================================ foo
================================================ FILE: playground/hover.js ================================================ 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')); ================================================ FILE: playground/render-counters.html ================================================ foo
================================================ FILE: playground/render-counters.js ================================================ 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'), ); ================================================ FILE: playground/scroll-sync.html ================================================ foo
================================================ FILE: playground/scroll-sync.js ================================================ 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')); ================================================ FILE: playground/table.html ================================================ foo
================================================ FILE: playground/table.js ================================================ 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(); } }); ================================================ FILE: playground/tests.js ================================================ 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(); } }; } ================================================ FILE: playground/tree.html ================================================ foo
================================================ FILE: playground/tree.js ================================================ 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; } ================================================ FILE: playground/utils.js ================================================ 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]; } ================================================ FILE: postcss.config.js ================================================ module.exports = { autoprefixer: { browsers: ['last 2 version', 'Firefox 15', 'iOS 8'], }, // The plugins section is used by postcss-loader with webpack plugins: [require('autoprefixer')], }; ================================================ FILE: rollup.config.js ================================================ 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, }, }), ], }; ================================================ FILE: source/ArrowKeyStepper/ArrowKeyStepper.example.css ================================================ .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; } ================================================ FILE: source/ArrowKeyStepper/ArrowKeyStepper.example.js ================================================ /** @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, }); } }; } ================================================ FILE: source/ArrowKeyStepper/ArrowKeyStepper.jest.js ================================================ 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); }); }); }); ================================================ FILE: source/ArrowKeyStepper/ArrowKeyStepper.js ================================================ /** @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; ================================================ FILE: source/ArrowKeyStepper/index.js ================================================ // @flow export type {ScrollIndices} from './types'; export {default} from './ArrowKeyStepper'; export {default as ArrowKeyStepper} from './ArrowKeyStepper'; ================================================ FILE: source/ArrowKeyStepper/types.js ================================================ // @flow export type ScrollIndices = { scrollToColumn: number, scrollToRow: number, }; ================================================ FILE: source/AutoSizer/AutoSizer.example.css ================================================ .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; } ================================================ FILE: source/AutoSizer/AutoSizer.example.js ================================================ /** @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}
); }; } ================================================ FILE: source/AutoSizer/AutoSizer.jest.js ================================================ 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'); }); }); }); ================================================ FILE: source/AutoSizer/AutoSizer.js ================================================ /** @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; }; } ================================================ FILE: source/AutoSizer/AutoSizer.ssr.js ================================================ /** * @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'); }); ================================================ FILE: source/AutoSizer/index.js ================================================ // @flow export {default} from './AutoSizer'; export {default as AutoSizer} from './AutoSizer'; ================================================ FILE: source/CellMeasurer/CellMeasurer.DynamicHeightGrid.example.js ================================================ 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}
); } } ================================================ FILE: source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js ================================================ 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}) => (
)}
); } } ================================================ FILE: source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js ================================================ 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); }; } ================================================ FILE: source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js ================================================ 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}
); } } ================================================ FILE: source/CellMeasurer/CellMeasurer.DynamicWidthMultiGrid.example.js ================================================ 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}
); } } ================================================ FILE: source/CellMeasurer/CellMeasurer.example.css ================================================ .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; } ================================================ FILE: source/CellMeasurer/CellMeasurer.example.js ================================================ 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 ( ); } ================================================ FILE: source/CellMeasurer/CellMeasurer.jest.js ================================================ 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'); }); }); ================================================ FILE: source/CellMeasurer/CellMeasurer.js ================================================ /** @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; } ================================================ FILE: source/CellMeasurer/CellMeasurerCache.jest.js ================================================ 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.', ); }); }); }); ================================================ FILE: source/CellMeasurer/CellMeasurerCache.js ================================================ /** @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}`; } ================================================ FILE: source/CellMeasurer/index.js ================================================ /** @flow */ import CellMeasurer from './CellMeasurer'; import CellMeasurerCache from './CellMeasurerCache'; export default CellMeasurer; export {CellMeasurer, CellMeasurerCache}; ================================================ FILE: source/CellMeasurer/types.js ================================================ // @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; } ================================================ FILE: source/Collection/Collection.example.css ================================================ .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; } ================================================ FILE: source/Collection/Collection.example.js ================================================ /** @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}); } } ================================================ FILE: source/Collection/Collection.jest.js ================================================ /** * 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(); }); }); ================================================ FILE: source/Collection/Collection.js ================================================ /** @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); } ================================================ FILE: source/Collection/CollectionView.js ================================================ /** @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; ================================================ FILE: source/Collection/Section.jest.js ================================================ 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'); }); }); ================================================ FILE: source/Collection/Section.js ================================================ /** @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}`; } } ================================================ FILE: source/Collection/SectionManager.jest.js ================================================ 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]); }); }); ================================================ FILE: source/Collection/SectionManager.js ================================================ /** * 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}), ); } } ================================================ FILE: source/Collection/TestData.js ================================================ /* 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; ================================================ FILE: source/Collection/index.js ================================================ /** @flow */ import Collection from './Collection'; export default Collection; export {Collection}; ================================================ FILE: source/Collection/types.js ================================================ /** @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, }; ================================================ FILE: source/Collection/utils/calculateSizeAndPositionData.jest.js ================================================ 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(); }); }); ================================================ FILE: source/Collection/utils/calculateSizeAndPositionData.js ================================================ 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, }; } ================================================ FILE: source/ColumnSizer/ColumnSizer.example.css ================================================ .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; } ================================================ FILE: source/ColumnSizer/ColumnSizer.example.js ================================================ /** * @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}`}
); } } ================================================ FILE: source/ColumnSizer/ColumnSizer.jest.js ================================================ 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(); }); }); ================================================ FILE: source/ColumnSizer/ColumnSizer.js ================================================ /** @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(); } } } ================================================ FILE: source/ColumnSizer/index.js ================================================ /** @flow */ import ColumnSizer from './ColumnSizer'; export default ColumnSizer; export {ColumnSizer}; ================================================ FILE: source/Grid/Grid.example.css ================================================ .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; } ================================================ FILE: source/Grid/Grid.example.js ================================================ 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}); } } ================================================ FILE: source/Grid/Grid.jest.js ================================================ 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(); }); }); ================================================ FILE: source/Grid/Grid.js ================================================ /** @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; ================================================ FILE: source/Grid/Grid.ssr.js ================================================ /** * @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'); }); ================================================ FILE: source/Grid/accessibilityOverscanIndicesGetter.jest.js ================================================ import overscanIndicesGetter, { SCROLL_DIRECTION_BACKWARD, SCROLL_DIRECTION_FORWARD, } from './accessibilityOverscanIndicesGetter'; describe('overscanIndicesGetter', () => { function testHelper({ cellCount, startIndex, stopIndex, overscanCellsCount, scrollDirection, }) { return overscanIndicesGetter({ cellCount, overscanCellsCount, scrollDirection, startIndex, stopIndex, }); } 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, }), ).toEqual({ overscanStartIndex: 9, overscanStopIndex: 21, }); expect( testHelper({ cellCount: 100, startIndex: 10, stopIndex: 20, overscanCellsCount: 0, scrollDirection: SCROLL_DIRECTION_FORWARD, }), ).toEqual({ overscanStartIndex: 9, overscanStopIndex: 21, }); }); it('should overscan forward', () => { expect( testHelper({ cellCount: 100, startIndex: 20, stopIndex: 30, overscanCellsCount: 10, scrollDirection: SCROLL_DIRECTION_FORWARD, }), ).toEqual({ overscanStartIndex: 19, overscanStopIndex: 40, }); }); it('should overscan backward', () => { expect( testHelper({ cellCount: 100, startIndex: 20, stopIndex: 30, overscanCellsCount: 10, scrollDirection: SCROLL_DIRECTION_BACKWARD, }), ).toEqual({ overscanStartIndex: 10, overscanStopIndex: 31, }); }); it('should not overscan beyond the start of the list', () => { expect( testHelper({ cellCount: 100, startIndex: 5, stopIndex: 15, overscanCellsCount: 10, scrollDirection: SCROLL_DIRECTION_BACKWARD, }), ).toEqual({ overscanStartIndex: 0, overscanStopIndex: 16, }); }); it('should not overscan beyond the end of the list', () => { expect( testHelper({ cellCount: 25, startIndex: 10, stopIndex: 20, overscanCellsCount: 10, scrollDirection: SCROLL_DIRECTION_FORWARD, }), ).toEqual({ overscanStartIndex: 9, overscanStopIndex: 24, }); }); }); ================================================ FILE: source/Grid/accessibilityOverscanIndicesGetter.js ================================================ // @flow import type {OverscanIndicesGetterParams, OverscanIndices} 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'; /** * 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({ cellCount, overscanCellsCount, scrollDirection, startIndex, 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 // For more info see issues #625 overscanCellsCount = Math.max(1, overscanCellsCount); if (scrollDirection === SCROLL_DIRECTION_FORWARD) { return { overscanStartIndex: Math.max(0, startIndex - 1), overscanStopIndex: Math.min( cellCount - 1, stopIndex + overscanCellsCount, ), }; } else { return { overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), overscanStopIndex: Math.min(cellCount - 1, stopIndex + 1), }; } } ================================================ FILE: source/Grid/defaultCellRangeRenderer.js ================================================ /** @flow */ import type {CellRangeRendererParams} from './types'; import React from 'react'; /** * Default implementation of cellRangeRenderer used by Grid. * This renderer supports cell-caching while the user is scrolling. */ export default function defaultCellRangeRenderer({ cellCache, cellRenderer, columnSizeAndPositionManager, columnStartIndex, columnStopIndex, deferredMeasurementCache, horizontalOffsetAdjustment, isScrolling, isScrollingOptOut, parent, // Grid (or List or Table) rowSizeAndPositionManager, rowStartIndex, rowStopIndex, styleCache, verticalOffsetAdjustment, visibleColumnIndices, visibleRowIndices, }: CellRangeRendererParams) { const renderedCells = []; // Browsers have native size limits for elements (eg Chrome 33M pixels, IE 1.5M pixes). // User cannot scroll beyond these size limitations. // In order to work around this, ScalingCellSizeAndPositionManager compresses offsets. // We should never cache styles for compressed offsets though as this can lead to bugs. // See issue #576 for more. const areOffsetsAdjusted = columnSizeAndPositionManager.areOffsetsAdjusted() || rowSizeAndPositionManager.areOffsetsAdjusted(); const canCacheStyle = !isScrolling && !areOffsetsAdjusted; for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) { let 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; // Cache style objects so shallow-compare doesn't re-render unnecessarily. if (canCacheStyle && styleCache[key]) { style = styleCache[key]; } else { // In deferred mode, cells will be initially rendered before we know their size. // Don't interfere with CellMeasurer's measurements by setting an invalid size. if ( deferredMeasurementCache && !deferredMeasurementCache.has(rowIndex, columnIndex) ) { // Position not-yet-measured cells at top/left 0,0, // 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', left: 0, position: 'absolute', top: 0, width: 'auto', }; } else { style = { height: rowDatum.size, left: columnDatum.offset + horizontalOffsetAdjustment, position: 'absolute', top: rowDatum.offset + verticalOffsetAdjustment, width: columnDatum.size, }; styleCache[key] = style; } } let cellRendererParams = { columnIndex, isScrolling, isVisible, key, parent, rowIndex, style, }; let renderedCell; // 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 completes. // However if we are scaling scroll positions and sizes, we should also avoid caching. // This is because the offset changes slightly as scroll position changes and caching leads to stale values. // For more info refer to issue #395 // // If isScrollingOptOut is specified, we always cache cells. // For more info refer to issue #1028 if ( (isScrollingOptOut || isScrolling) && !horizontalOffsetAdjustment && !verticalOffsetAdjustment ) { if (!cellCache[key]) { cellCache[key] = cellRenderer(cellRendererParams); } renderedCell = cellCache[key]; // If the user is no longer scrolling, don't cache cells. // This makes dynamic cell content difficult for users and would also lead to a heavier memory footprint. } else { renderedCell = cellRenderer(cellRendererParams); } if (renderedCell == null || renderedCell === false) { continue; } if (process.env.NODE_ENV !== 'production') { warnAboutMissingStyle(parent, renderedCell); } if (!renderedCell.props.role) { renderedCell = React.cloneElement(renderedCell, {role: 'gridcell'}); } renderedCells.push(renderedCell); } } return renderedCells; } function warnAboutMissingStyle(parent, renderedCell) { 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 && renderedCell.props && renderedCell.props.style === undefined && parent.__warnedAboutMissingStyle !== true ) { parent.__warnedAboutMissingStyle = true; console.warn( 'Rendered cell should include style property for positioning.', ); } } } } ================================================ FILE: source/Grid/defaultOverscanIndicesGetter.jest.js ================================================ import overscanIndicesGetter, { SCROLL_DIRECTION_BACKWARD, SCROLL_DIRECTION_FORWARD, } from './defaultOverscanIndicesGetter'; describe('overscanIndicesGetter', () => { function testHelper({ cellCount, startIndex, stopIndex, overscanCellsCount, scrollDirection, }) { return overscanIndicesGetter({ cellCount, overscanCellsCount, scrollDirection, startIndex, stopIndex, }); } it('should not overscan if :overscanCellsCount is 0', () => { expect( testHelper({ cellCount: 100, startIndex: 10, stopIndex: 20, overscanCellsCount: 0, scrollDirection: SCROLL_DIRECTION_BACKWARD, }), ).toEqual({ overscanStartIndex: 10, overscanStopIndex: 20, }); expect( testHelper({ cellCount: 100, startIndex: 10, stopIndex: 20, overscanCellsCount: 0, scrollDirection: SCROLL_DIRECTION_FORWARD, }), ).toEqual({ overscanStartIndex: 10, overscanStopIndex: 20, }); }); it('should overscan forward', () => { expect( testHelper({ cellCount: 100, startIndex: 20, stopIndex: 30, overscanCellsCount: 10, scrollDirection: SCROLL_DIRECTION_FORWARD, }), ).toEqual({ overscanStartIndex: 20, overscanStopIndex: 40, }); }); it('should overscan backward', () => { expect( testHelper({ cellCount: 100, startIndex: 20, stopIndex: 30, overscanCellsCount: 10, scrollDirection: SCROLL_DIRECTION_BACKWARD, }), ).toEqual({ overscanStartIndex: 10, overscanStopIndex: 30, }); }); it('should not overscan beyond the start of the list', () => { expect( testHelper({ cellCount: 100, startIndex: 5, stopIndex: 15, overscanCellsCount: 10, scrollDirection: SCROLL_DIRECTION_BACKWARD, }), ).toEqual({ overscanStartIndex: 0, overscanStopIndex: 15, }); }); it('should not overscan beyond the end of the list', () => { expect( testHelper({ cellCount: 25, startIndex: 10, stopIndex: 20, overscanCellsCount: 10, scrollDirection: SCROLL_DIRECTION_FORWARD, }), ).toEqual({ overscanStartIndex: 10, overscanStopIndex: 24, }); }); }); ================================================ FILE: source/Grid/defaultOverscanIndicesGetter.js ================================================ // @flow import type {OverscanIndicesGetterParams, OverscanIndices} 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'; /** * 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({ cellCount, overscanCellsCount, scrollDirection, startIndex, stopIndex, }: OverscanIndicesGetterParams): OverscanIndices { if (scrollDirection === SCROLL_DIRECTION_FORWARD) { return { overscanStartIndex: Math.max(0, startIndex), overscanStopIndex: Math.min( cellCount - 1, stopIndex + overscanCellsCount, ), }; } else { return { overscanStartIndex: Math.max(0, startIndex - overscanCellsCount), overscanStopIndex: Math.min(cellCount - 1, stopIndex), }; } } ================================================ FILE: source/Grid/index.js ================================================ /** @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'; ================================================ FILE: source/Grid/types.js ================================================ // @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, }; ================================================ FILE: source/Grid/utils/CellSizeAndPositionManager.jest.js ================================================ 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); }); }); }); ================================================ FILE: source/Grid/utils/CellSizeAndPositionManager.js ================================================ /** @flow */ import type {Alignment, CellSizeGetter, VisibleCellRange} from '../types'; type CellSizeAndPositionManagerParams = { cellCount: number, cellSizeGetter: CellSizeGetter, estimatedCellSize: number, }; type ConfigureParams = { cellCount: number, estimatedCellSize: number, cellSizeGetter: CellSizeGetter, }; type GetUpdatedOffsetForIndex = { align: Alignment, containerSize: number, currentOffset: number, targetIndex: number, }; type GetVisibleCellRangeParams = { containerSize: number, offset: number, }; 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 { // 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 = {}; // Measurements for cells up to this index can be trusted; cells afterward should be estimated. _lastMeasuredIndex = -1; // Used in deferred mode to track which cells have been queued for measurement. _lastBatchedIndex = -1; _cellCount: number; _cellSizeGetter: CellSizeGetter; _estimatedCellSize: number; constructor({ cellCount, cellSizeGetter, estimatedCellSize, }: CellSizeAndPositionManagerParams) { this._cellSizeGetter = cellSizeGetter; this._cellCount = cellCount; this._estimatedCellSize = estimatedCellSize; } areOffsetsAdjusted() { return false; } configure({cellCount, estimatedCellSize, cellSizeGetter}: ConfigureParams) { this._cellCount = cellCount; this._estimatedCellSize = estimatedCellSize; this._cellSizeGetter = cellSizeGetter; } getCellCount(): number { return this._cellCount; } getEstimatedCellSize(): number { return this._estimatedCellSize; } getLastMeasuredIndex(): number { return this._lastMeasuredIndex; } getOffsetAdjustment() { return 0; } /** * This method returns the size and position for the cell at the specified index. * It just-in-time calculates (or used cached values) for cells leading up to the index. */ getSizeAndPositionOfCell(index: number): SizeAndPositionData { if (index < 0 || index >= this._cellCount) { throw Error( `Requested index ${index} is outside of range 0..${this._cellCount}`, ); } if (index > this._lastMeasuredIndex) { let lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell(); let offset = lastMeasuredCellSizeAndPosition.offset + lastMeasuredCellSizeAndPosition.size; for (var i = this._lastMeasuredIndex + 1; i <= index; i++) { let 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. if (size === undefined || isNaN(size)) { throw Error(`Invalid size returned for cell ${i} of value ${size}`); } else if (size === null) { this._cellSizeAndPositionData[i] = { offset, size: 0, }; this._lastBatchedIndex = index; } else { this._cellSizeAndPositionData[i] = { offset, size, }; offset += size; this._lastMeasuredIndex = index; } } } return this._cellSizeAndPositionData[index]; } getSizeAndPositionOfLastMeasuredCell(): SizeAndPositionData { return this._lastMeasuredIndex >= 0 ? this._cellSizeAndPositionData[this._lastMeasuredIndex] : { offset: 0, size: 0, }; } /** * Total size of all cells being measured. * This value will be completely estimated initially. * As cells are measured, the estimate will be updated. */ getTotalSize(): number { const lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell(); const totalSizeOfMeasuredCells = lastMeasuredCellSizeAndPosition.offset + lastMeasuredCellSizeAndPosition.size; const numUnmeasuredCells = this._cellCount - this._lastMeasuredIndex - 1; const totalSizeOfUnmeasuredCells = numUnmeasuredCells * this._estimatedCellSize; return totalSizeOfMeasuredCells + totalSizeOfUnmeasuredCells; } /** * Determines a new offset that ensures a certain cell is visible, given the current offset. * 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 containerSize Size (width or height) of the container viewport * @param currentOffset Container's current (x or y) offset * @param totalSize Total size (width or height) of all cells * @return Offset to use to ensure the specified cell is visible */ getUpdatedOffsetForIndex({ align = 'auto', containerSize, currentOffset, targetIndex, }: GetUpdatedOffsetForIndex): number { if (containerSize <= 0) { return 0; } const datum = this.getSizeAndPositionOfCell(targetIndex); const maxOffset = datum.offset; const minOffset = maxOffset - containerSize + datum.size; let idealOffset; switch (align) { case 'start': idealOffset = maxOffset; break; case 'end': idealOffset = minOffset; break; case 'center': idealOffset = maxOffset - (containerSize - datum.size) / 2; break; default: idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset)); break; } const totalSize = this.getTotalSize(); return Math.max(0, Math.min(totalSize - containerSize, idealOffset)); } getVisibleCellRange(params: GetVisibleCellRangeParams): VisibleCellRange { let {containerSize, offset} = params; const totalSize = this.getTotalSize(); if (totalSize === 0) { return {}; } const maxOffset = offset + containerSize; const start = this._findNearestCell(offset); const datum = this.getSizeAndPositionOfCell(start); offset = datum.offset + datum.size; let stop = start; while (offset < maxOffset && stop < this._cellCount - 1) { stop++; offset += this.getSizeAndPositionOfCell(stop).size; } return { start, stop, }; } /** * Clear all cached values for cells after the specified index. * This method should be called for any cell that has changed its size. * It will not immediately perform any calculations; they'll be performed the next time getSizeAndPositionOfCell() is called. */ resetCell(index: number): void { this._lastMeasuredIndex = Math.min(this._lastMeasuredIndex, index - 1); } _binarySearch(high: number, low: number, offset: number): number { while (low <= high) { const middle = low + Math.floor((high - low) / 2); const currentOffset = this.getSizeAndPositionOfCell(middle).offset; if (currentOffset === offset) { return middle; } else if (currentOffset < offset) { low = middle + 1; } else if (currentOffset > offset) { high = middle - 1; } } if (low > 0) { return low - 1; } else { return 0; } } _exponentialSearch(index: number, offset: number): number { let interval = 1; while ( index < this._cellCount && this.getSizeAndPositionOfCell(index).offset < offset ) { index += interval; interval *= 2; } return this._binarySearch( Math.min(index, this._cellCount - 1), Math.floor(index / 2), offset, ); } /** * Searches for the cell (index) nearest the specified offset. * * If no exact match is found the next lowest cell index will be returned. * This allows partially visible cells (with offsets just before/above the fold) to be visible. */ _findNearestCell(offset: number): number { if (isNaN(offset)) { throw Error(`Invalid offset ${offset} specified`); } // Our search algorithms find the nearest match at or below the specified offset. // 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 lastMeasuredIndex = Math.max(0, this._lastMeasuredIndex); if (lastMeasuredCellSizeAndPosition.offset >= offset) { // If we've already measured cells within this range just use a binary search as it's faster. return this._binarySearch(lastMeasuredIndex, 0, offset); } else { // If we haven't yet measured this high, fallback to an exponential search with an inner binary search. // The exponential search avoids pre-computing sizes for the full set of cells as a binary search would. // The overall complexity for this approach is O(log n). return this._exponentialSearch(lastMeasuredIndex, offset); } } } ================================================ FILE: source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js ================================================ 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, }); }); }); }); ================================================ FILE: source/Grid/utils/ScalingCellSizeAndPositionManager.js ================================================ /** @flow */ import type {Alignment, CellSizeGetter, VisibleCellRange} from '../types'; import CellSizeAndPositionManager from './CellSizeAndPositionManager'; import {getMaxElementSize} from './maxElementSize.js'; type ContainerSizeAndOffset = { containerSize: number, offset: number, }; /** * Browsers have scroll offset limitations (eg Chrome stops scrolling at ~33.5M pixels where as Edge tops out at ~1.5M pixels). * After a certain position, the browser won't allow the user to scroll further (even via JavaScript scroll offset adjustments). * This util picks a lower ceiling for max size and artificially adjusts positions within to make it transparent for users. */ type Params = { 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 { _cellSizeAndPositionManager: CellSizeAndPositionManager; _maxScrollSize: number; constructor({maxScrollSize = getMaxElementSize(), ...params}: Params) { // Favor composition over inheritance to simplify IE10 support this._cellSizeAndPositionManager = new CellSizeAndPositionManager(params); this._maxScrollSize = maxScrollSize; } areOffsetsAdjusted(): boolean { return ( this._cellSizeAndPositionManager.getTotalSize() > this._maxScrollSize ); } configure(params: { cellCount: number, estimatedCellSize: number, cellSizeGetter: CellSizeGetter, }) { this._cellSizeAndPositionManager.configure(params); } getCellCount(): number { return this._cellSizeAndPositionManager.getCellCount(); } getEstimatedCellSize(): number { return this._cellSizeAndPositionManager.getEstimatedCellSize(); } getLastMeasuredIndex(): number { return this._cellSizeAndPositionManager.getLastMeasuredIndex(); } /** * Number of pixels a cell at the given position (offset) should be shifted in order to fit within the scaled container. * The offset passed to this function is scaled (safe) as well. */ getOffsetAdjustment({ containerSize, offset, // safe }: ContainerSizeAndOffset): number { const totalSize = this._cellSizeAndPositionManager.getTotalSize(); const safeTotalSize = this.getTotalSize(); const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, totalSize: safeTotalSize, }); return Math.round(offsetPercentage * (safeTotalSize - totalSize)); } getSizeAndPositionOfCell(index: number) { return this._cellSizeAndPositionManager.getSizeAndPositionOfCell(index); } getSizeAndPositionOfLastMeasuredCell() { return this._cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell(); } /** See CellSizeAndPositionManager#getTotalSize */ getTotalSize(): number { return Math.min( this._maxScrollSize, this._cellSizeAndPositionManager.getTotalSize(), ); } /** See CellSizeAndPositionManager#getUpdatedOffsetForIndex */ getUpdatedOffsetForIndex({ align = 'auto', containerSize, currentOffset, // safe targetIndex, }: { align: Alignment, containerSize: number, currentOffset: number, targetIndex: number, }) { currentOffset = this._safeOffsetToOffset({ containerSize, offset: currentOffset, }); const offset = this._cellSizeAndPositionManager.getUpdatedOffsetForIndex({ align, containerSize, currentOffset, targetIndex, }); return this._offsetToSafeOffset({ containerSize, offset, }); } /** See CellSizeAndPositionManager#getVisibleCellRange */ getVisibleCellRange({ containerSize, offset, // safe }: ContainerSizeAndOffset): VisibleCellRange { offset = this._safeOffsetToOffset({ containerSize, offset, }); return this._cellSizeAndPositionManager.getVisibleCellRange({ containerSize, offset, }); } resetCell(index: number): void { this._cellSizeAndPositionManager.resetCell(index); } _getOffsetPercentage({ containerSize, offset, // safe totalSize, }: { containerSize: number, offset: number, totalSize: number, }) { return totalSize <= containerSize ? 0 : offset / (totalSize - containerSize); } _offsetToSafeOffset({ containerSize, offset, // unsafe }: ContainerSizeAndOffset): number { const totalSize = this._cellSizeAndPositionManager.getTotalSize(); const safeTotalSize = this.getTotalSize(); if (totalSize === safeTotalSize) { return offset; } else { const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, totalSize, }); return Math.round(offsetPercentage * (safeTotalSize - containerSize)); } } _safeOffsetToOffset({ containerSize, offset, // safe }: ContainerSizeAndOffset): number { const totalSize = this._cellSizeAndPositionManager.getTotalSize(); const safeTotalSize = this.getTotalSize(); if (totalSize === safeTotalSize) { return offset; } else { const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, totalSize: safeTotalSize, }); return Math.round(offsetPercentage * (totalSize - containerSize)); } } } ================================================ FILE: source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js ================================================ 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); }); }); ================================================ FILE: source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js ================================================ // @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, // Width or height of cells for the current axis cellSize: ?number, // Method to invoke if cell metadata should be recalculated computeMetadataCallback: (props: T) => void, // Parameters to pass to :computeMetadataCallback computeMetadataCallbackProps: T, // Newly updated number of rows or columns in the current axis nextCellsCount: number, // Newly updated width or height of cells for the current axis nextCellSize: ?number, // Newly updated scroll-to-index nextScrollToIndex: number, // Scroll-to-index scrollToIndex: number, // Callback to invoke if the scroll position should be recalculated updateScrollOffsetForScrollToIndex: () => void, }; export default function calculateSizeAndPositionDataAndUpdateScrollOffset({ cellCount, cellSize, computeMetadataCallback, computeMetadataCallbackProps, nextCellsCount, nextCellSize, nextScrollToIndex, scrollToIndex, 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') && cellSize !== nextCellSize) ) { computeMetadataCallback(computeMetadataCallbackProps); // Updated cell metadata may have hidden the previous scrolled-to item. // In this case we should also update the scrollTop to ensure it stays visible. if (scrollToIndex >= 0 && scrollToIndex === nextScrollToIndex) { updateScrollOffsetForScrollToIndex(); } } } ================================================ FILE: source/Grid/utils/maxElementSize.js ================================================ // @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; }; ================================================ FILE: source/Grid/utils/updateScrollIndexHelper.jest.js ================================================ 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); }); }); ================================================ FILE: source/Grid/utils/updateScrollIndexHelper.js ================================================ // @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); } } } ================================================ FILE: source/InfiniteLoader/InfiniteLoader.example.css ================================================ .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; } ================================================ FILE: source/InfiniteLoader/InfiniteLoader.example.js ================================================ /** @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}
); } } ================================================ FILE: source/InfiniteLoader/InfiniteLoader.jest.js ================================================ 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); }); }); ================================================ FILE: source/InfiniteLoader/InfiniteLoader.js ================================================ /** @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(); } } ================================================ FILE: source/InfiniteLoader/index.js ================================================ /** @flow */ import InfiniteLoader from './InfiniteLoader'; export default InfiniteLoader; export {InfiniteLoader}; ================================================ FILE: source/List/List.example.css ================================================ .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; } ================================================ FILE: source/List/List.example.js ================================================ 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 )}
); } } ================================================ FILE: source/List/List.jest.js ================================================ 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'); }); }); ================================================ FILE: source/List/List.js ================================================ /** @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, }); }; } ================================================ FILE: source/List/index.js ================================================ /** @flow */ export type {RowRendererParams} from './types'; export {default} from './List'; export {default as List} from './List'; ================================================ FILE: source/List/types.js ================================================ // @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, }; ================================================ FILE: source/Masonry/Masonry.example.css ================================================ .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; } ================================================ FILE: source/Masonry/Masonry.example.js ================================================ /** @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; } } ================================================ FILE: source/Masonry/Masonry.jest.js ================================================ 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'); }); }); }); ================================================ FILE: source/Masonry/Masonry.js ================================================ /** @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; ================================================ FILE: source/Masonry/PositionCache.js ================================================ /** @flow */ 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 { // Tracks the height of each column _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} = {}; estimateTotalHeight( cellCount: number, columnCount: number, defaultCellHeight: number, ): number { const unmeasuredCellCount = cellCount - this.count; return ( this.tallestColumnSize + Math.ceil(unmeasuredCellCount / columnCount) * defaultCellHeight ); } // Render all cells visible within the viewport range defined. range( scrollTop: number, clientHeight: number, renderCallback: RenderCallback, ): void { this._intervalTree.queryInterval( scrollTop, scrollTop + clientHeight, ([top, _, index]) => renderCallback(index, this._leftMap[index], top), ); } setPosition(index: number, left: number, top: number, height: number): void { this._intervalTree.insert([top, top + height, index]); this._leftMap[index] = left; const columnSizeMap = this._columnSizeMap; const columnHeight = columnSizeMap[left]; if (columnHeight === undefined) { columnSizeMap[left] = top + height; } else { columnSizeMap[left] = Math.max(columnHeight, top + height); } } get count(): number { return this._intervalTree.count; } get shortestColumnSize(): number { const columnSizeMap = this._columnSizeMap; let size = 0; for (let i in columnSizeMap) { let height = columnSizeMap[(i: any)]; size = size === 0 ? height : Math.min(size, height); } return size; } get tallestColumnSize(): number { const columnSizeMap = this._columnSizeMap; let size = 0; for (let i in columnSizeMap) { let height = columnSizeMap[(i: any)]; size = Math.max(size, height); } return size; } } ================================================ FILE: source/Masonry/createCellPositioner.js ================================================ /** @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({ cellMeasurerCache, columnCount, columnWidth, spacer = 0, }: createCellPositionerParams): Positioner { let columnHeights; initOrResetDerivedValues(); function cellPositioner(index) { // Find the shortest column and use it. let columnIndex = 0; for (let i = 1; i < columnHeights.length; i++) { if (columnHeights[i] < columnHeights[columnIndex]) { columnIndex = i; } } const left = columnIndex * (columnWidth + spacer); const top = columnHeights[columnIndex] || 0; columnHeights[columnIndex] = top + cellMeasurerCache.getHeight(index) + spacer; return { left, top, }; } function initOrResetDerivedValues(): void { // Track the height of each column. // Layout algorithm below always inserts into the shortest column. columnHeights = []; for (let i = 0; i < columnCount; i++) { columnHeights[i] = 0; } } function reset(params: resetParams): void { columnCount = params.columnCount; columnWidth = params.columnWidth; spacer = params.spacer; initOrResetDerivedValues(); } cellPositioner.reset = reset; return cellPositioner; } ================================================ FILE: source/Masonry/index.js ================================================ /** @flow */ import createCellPositioner from './createCellPositioner'; import Masonry from './Masonry'; export default Masonry; export {createCellPositioner, Masonry}; ================================================ FILE: source/MultiGrid/CellMeasurerCacheDecorator.js ================================================ /** @flow */ import {CellMeasurerCache} from '../CellMeasurer'; type CellMeasurerCacheDecoratorParams = { cellMeasurerCache: CellMeasurerCache, columnIndexOffset: number, rowIndexOffset: number, }; type IndexParam = { index: number, }; /** * Caches measurements for a given cell. */ export default class CellMeasurerCacheDecorator { _cellMeasurerCache: CellMeasurerCache; _columnIndexOffset: number; _rowIndexOffset: number; constructor(params: CellMeasurerCacheDecoratorParams = {}) { const { cellMeasurerCache, columnIndexOffset = 0, rowIndexOffset = 0, } = params; this._cellMeasurerCache = cellMeasurerCache; this._columnIndexOffset = columnIndexOffset; this._rowIndexOffset = rowIndexOffset; } clear(rowIndex: number, columnIndex: number): void { this._cellMeasurerCache.clear( rowIndex + this._rowIndexOffset, columnIndex + this._columnIndexOffset, ); } clearAll(): void { this._cellMeasurerCache.clearAll(); } columnWidth = ({index}: IndexParam) => { this._cellMeasurerCache.columnWidth({ index: index + this._columnIndexOffset, }); }; get defaultHeight(): number { return this._cellMeasurerCache.defaultHeight; } get defaultWidth(): number { return this._cellMeasurerCache.defaultWidth; } hasFixedHeight(): boolean { return this._cellMeasurerCache.hasFixedHeight(); } hasFixedWidth(): boolean { return this._cellMeasurerCache.hasFixedWidth(); } getHeight(rowIndex: number, columnIndex: ?number = 0): ?number { return this._cellMeasurerCache.getHeight( rowIndex + this._rowIndexOffset, columnIndex + this._columnIndexOffset, ); } getWidth(rowIndex: number, columnIndex: ?number = 0): ?number { return this._cellMeasurerCache.getWidth( rowIndex + this._rowIndexOffset, columnIndex + this._columnIndexOffset, ); } has(rowIndex: number, columnIndex: ?number = 0): boolean { return this._cellMeasurerCache.has( rowIndex + this._rowIndexOffset, columnIndex + this._columnIndexOffset, ); } rowHeight = ({index}: IndexParam) => { this._cellMeasurerCache.rowHeight({ index: index + this._rowIndexOffset, }); }; set( rowIndex: number, columnIndex: number, width: number, height: number, ): void { this._cellMeasurerCache.set( rowIndex + this._rowIndexOffset, columnIndex + this._columnIndexOffset, (width: number), (height: number), ); } } ================================================ FILE: source/MultiGrid/MultiGrid.example.css ================================================ .Cell { display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #eee; border-right: 1px solid #eee; } ================================================ FILE: source/MultiGrid/MultiGrid.example.js ================================================ /** @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 ( ); } } ================================================ FILE: source/MultiGrid/MultiGrid.jest.js ================================================ 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(); }); }); }); ================================================ FILE: source/MultiGrid/MultiGrid.js ================================================ /** @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; ================================================ FILE: source/MultiGrid/index.js ================================================ /** @flow */ import MultiGrid from './MultiGrid'; export default MultiGrid; export {MultiGrid}; ================================================ FILE: source/ScrollSync/ScrollSync.example.css ================================================ .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; } ================================================ FILE: source/ScrollSync/ScrollSync.example.js ================================================ /** @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}; } ================================================ FILE: source/ScrollSync/ScrollSync.jest.js ================================================ 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'); }); }); ================================================ FILE: source/ScrollSync/ScrollSync.js ================================================ 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, }); } } ================================================ FILE: source/ScrollSync/index.js ================================================ /** @flow */ import ScrollSync from './ScrollSync'; export default ScrollSync; export {ScrollSync}; ================================================ FILE: source/Table/Column.jest.js ================================================ 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(); }); }); }); ================================================ FILE: source/Table/Column.js ================================================ /** @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: {}, }; } ================================================ FILE: source/Table/SortDirection.js ================================================ 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', /** * 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; ================================================ FILE: source/Table/SortIndicator.js ================================================ 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]), }; ================================================ FILE: source/Table/Table.example.css ================================================ .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; } ================================================ FILE: source/Table/Table.example.js ================================================ /** @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, }); } } ================================================ FILE: source/Table/Table.jest.js ================================================ 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(); }); }); ================================================ FILE: source/Table/Table.js ================================================ /** @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}); } } ================================================ FILE: source/Table/createMultiSort.jest.js ================================================ 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']); }); }); }); }); ================================================ FILE: source/Table/createMultiSort.js ================================================ /** @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, }; } ================================================ FILE: source/Table/defaultCellDataGetter.js ================================================ /** @flow */ import type {CellDataGetterParams} 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({ dataKey, rowData, }: CellDataGetterParams) { if (typeof rowData.get === 'function') { return rowData.get(dataKey); } else { return rowData[dataKey]; } } ================================================ FILE: source/Table/defaultCellRenderer.js ================================================ /** @flow */ import type {CellRendererParams} 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 { if (cellData == null) { return ''; } else { return String(cellData); } } ================================================ FILE: source/Table/defaultHeaderRenderer.js ================================================ /** @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; } ================================================ FILE: source/Table/defaultHeaderRowRenderer.js ================================================ /** @flow */ import * as React from 'react'; import type {HeaderRowRendererParams} from './types'; export default function defaultHeaderRowRenderer({ className, columns, style, }: HeaderRowRendererParams) { return (
{columns}
); } ================================================ FILE: source/Table/defaultRowRenderer.js ================================================ /** @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}
); } ================================================ FILE: source/Table/index.js ================================================ /* @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, }; ================================================ FILE: source/Table/types.js ================================================ /** @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, }; ================================================ FILE: source/TestUtils.js ================================================ 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; } }; ================================================ FILE: source/WindowScroller/WindowScroller.e2e.js ================================================ /** * @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}], ]); }); ================================================ FILE: source/WindowScroller/WindowScroller.example.css ================================================ .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; } ================================================ FILE: source/WindowScroller/WindowScroller.example.js ================================================ // @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); }; } ================================================ FILE: source/WindowScroller/WindowScroller.header-resize.e2e.js ================================================ /** * @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); } }); ================================================ FILE: source/WindowScroller/WindowScroller.jest.js ================================================ 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: