diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 4ca727123..000000000 --- a/.babelrc +++ /dev/null @@ -1,86 +0,0 @@ -{ - "env": { - "commonjs": { - "plugins": [ - [ - "transform-react-remove-prop-types", { - "mode": "wrap" - } - ] - ], - "presets": [ - "es2015", - "react", - "stage-1" - ] - }, - "development": { - "plugins": [ - "typecheck", - [ - "react-transform", - { - "transforms": [ - { - "transform": "react-transform-hmr", - "imports": [ - "react" - ], - "locals": [ - "module" - ] - }, - { - "transform": "react-transform-catch-errors", - "imports": [ - "react", - "redbox-react" - ] - } - ] - } - ] - ], - "presets": [ - "es2015", - "react", - "stage-1" - ] - }, - "es": { - "plugins": [ - "transform-runtime", - [ - "transform-react-remove-prop-types", { - "mode": "wrap" - } - ] - ], - "presets": [ - "es2015-rollup", - "react", - "stage-1" - ] - }, - "production": { - "comments": false, - "plugins": [ - "transform-react-remove-prop-types", - "transform-runtime" - ], - "presets": [ - "es2015", - "react", - "stage-1" - ] - }, - "test": { - "comments": false, - "presets": [ - "es2015", - "react", - "stage-1" - ] - } - } -} diff --git a/.babelrc.js b/.babelrc.js new file mode 100644 index 000000000..75c4651d4 --- /dev/null +++ b/.babelrc.js @@ -0,0 +1,76 @@ +const env = process.env.NODE_ENV; + +if (env === 'commonjs' || env === 'es') { + module.exports = { + ignore: [ + '*.jest.js', + '*.e2e.js', + '*.ssr.js', + '*.example.js', + 'source/demo', + 'source/jest-*.js', + 'source/TestUtils.js', + ], + plugins: [ + '@babel/plugin-transform-runtime', + '@babel/plugin-proposal-class-properties', + '@babel/plugin-transform-flow-comments', + ['flow-react-proptypes', {deadCode: true, useESModules: true}], + ['transform-react-remove-prop-types', {mode: 'wrap'}], + ], + presets: [ + ['@babel/preset-env', {modules: false}], + '@babel/preset-react', + '@babel/preset-flow', + ], + }; + + if (env === 'commonjs') { + module.exports.plugins.push('@babel/plugin-transform-modules-commonjs'); + } +} + +if (env === 'rollup') { + module.exports = { + comments: false, + plugins: [ + '@babel/plugin-external-helpers', + '@babel/plugin-proposal-class-properties', + ], + presets: [ + ['@babel/preset-env', {modules: false}], + '@babel/preset-react', + '@babel/preset-flow', + ], + }; +} + +if (env === 'development') { + module.exports = { + plugins: ['@babel/plugin-proposal-class-properties'], + presets: ['@babel/preset-react', '@babel/preset-flow'], + }; +} + +if (env === 'production') { + module.exports = { + comments: false, + plugins: [ + '@babel/plugin-transform-runtime', + '@babel/plugin-proposal-class-properties', + ], + presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-flow'], + }; +} + +if (env === 'test') { + module.exports = { + comments: false, + + plugins: [ + '@babel/plugin-transform-modules-commonjs', + '@babel/plugin-proposal-class-properties', + ], + presets: ['@babel/preset-react', '@babel/preset-flow'], + }; +} diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..af3d0ca18 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,119 @@ +version: 2.1 + +defaults: &defaults + working_directory: ~/repo + docker: + - image: circleci/node:10.16.3-stretch-browsers + +jobs: + dependencies: + <<: *defaults + steps: + - checkout + - restore_cache: + key: npm-cache-{{ checksum "yarn.lock" }} + - run: + name: Install NPM Dependencies + command: yarn --frozen-lockfile + - save_cache: + key: npm-cache-{{ checksum "yarn.lock" }} + paths: + - ~/repo/node_modules + + lint: + <<: *defaults + steps: + - checkout + - restore_cache: + key: npm-cache-{{ checksum "yarn.lock" }} + - run: + name: ESLint Code Analysis + command: yarn lint + + typecheck: + <<: *defaults + steps: + - checkout + - restore_cache: + key: npm-cache-{{ checksum "yarn.lock" }} + - run: + name: Typechecking with flow + command: yarn typecheck + + test: + <<: *defaults + steps: + - checkout + - attach_workspace: + at: ~/repo + - restore_cache: + key: npm-cache-{{ checksum "yarn.lock" }} + - run: + name: Run tests + command: yarn test + + coverage: + <<: *defaults + steps: + - checkout + - attach_workspace: + at: ~/repo + - restore_cache: + key: npm-cache-{{ checksum "yarn.lock" }} + - run: + name: Run tests and upload coverage results + command: yarn test:coverage + + prettier: + <<: *defaults + steps: + - checkout + - restore_cache: + key: npm-cache-{{ checksum "yarn.lock" }} + - run: + name: Check prettier diff + command: yarn prettier:diff + + build: + <<: *defaults + steps: + - checkout + - restore_cache: + key: npm-cache-{{ checksum "yarn.lock" }} + - run: + name: Build all distributions formats + command: yarn build + - persist_to_workspace: + root: . + paths: + - dist + +workflows: + version: 2 + build_and_test: + jobs: + - dependencies + - lint: + requires: + - dependencies + - typecheck: + requires: + - dependencies + - prettier: + requires: + - dependencies + - build: + requires: + - dependencies + - test: + requires: + - build + filters: + branches: + ignore: gh-pages + - coverage: + requires: + - build + filters: + branches: + ignore: gh-pages diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..b16304917 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,10 @@ +node_modules/* + +# Website dist +build/* + +# NPM dist +dist/* + +# Vendor files +source/vendor/* \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..ad28271c9 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,8 @@ +{ + "parser": "babel-eslint", + "plugins": ["react"], + "extends": ["fbjs", "eslint:recommended", "plugin:prettier/recommended"], + "rules": { + "no-console": 0 + } +} diff --git a/.flowconfig b/.flowconfig index 4a58bdcde..49d1bb13a 100644 --- a/.flowconfig +++ b/.flowconfig @@ -1,7 +1,20 @@ [ignore] +.*/Collection/.* +.*/ColumnSizer/.* +.*/InfiniteLoader/.* +.*/Masonry/.* +.*/MultiGrid/.* +.*/ScrollSync/.* +.*/Table/.* + +[untyped] +.*/node_modules/babel-plugin-transform-react-remove-prop-types/.* +.*/node_modules/graphql/.* +.*/node_modules/immutable/.* [include] [libs] [options] +munge_underscores=true diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 000000000..85ef34482 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,23 @@ +--- +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 | | diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 000000000..6eb38d03c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,12 @@ +--- +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.) diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 000000000..fc40bcfe5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,9 @@ +--- +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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..798e73834 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +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.) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..880e5ddc3 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,15 @@ +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 diff --git a/.gitignore b/.gitignore index 8e04b33e2..d4821a555 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ dist node_modules npm-debug.log styles.css +.vscode +.idea diff --git a/.nvmrc b/.nvmrc index b95ebc1a5..70047db82 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v6.6.0 +v10.16.3 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..fb944c939 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "bracketSpacing": false, + "jsxBracketSameLine": true, + "printWidth": 80, + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/.watchmanconfig b/.watchmanconfig new file mode 100644 index 000000000..e69de29bb diff --git a/CHANGELOG.md b/CHANGELOG.md index 450a8da50..082c3cc12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,151 +1,472 @@ -Changelog ------------- +## 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)) + +- πŸŽ‰ 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)) + +- πŸ› `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)) + +- πŸ› 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)) + +- πŸ› 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)) + +- πŸŽ‰ 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/instanceof#instanceof_and_multiple_context_(e.g._frames_or_windows)).) ([@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)) + +- πŸŽ‰ 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)) + +- πŸŽ‰ 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. + +- πŸ› 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)) + +- πŸ› `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)) + +- πŸŽ‰ 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 + +- πŸ› 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) + +- πŸ› 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`. + +- 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. + +- πŸ› 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)) + +- πŸ› 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`. + +- πŸ› 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)) + +- πŸŽ‰ `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. +- 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 ` + + +
+ + + + + + diff --git a/playground/chat-no-resize.js b/playground/chat-no-resize.js new file mode 100644 index 000000000..de179bbef --- /dev/null +++ b/playground/chat-no-resize.js @@ -0,0 +1,274 @@ +var cache = new ReactVirtualized.CellMeasurerCache({ + fixedWidth: true, +}); + +var list; +var mostRecentWidth; + +function rowRenderer(params) { + var datum = chatHistory[params.index]; + + return React.createElement( + ReactVirtualized.CellMeasurer, + { + cache: cache, + columnIndex: 0, + key: params.key, + parent: params.parent, + rowIndex: params.index, + width: mostRecentWidth, + }, + React.createElement( + 'div', + { + className: 'item', + key: params.key, + style: params.style, + }, + React.createElement('strong', null, datum.name), + ':', + datum.text, + ), + ); +} + +function cellRenderer(params) { + params.index = params.rowIndex; + + return rowRenderer(params); +} + +var App = React.createClass({ + render: function() { + return React.createElement( + 'div', + { + className: 'container', + }, + React.createElement(ReactVirtualized.AutoSizer, {}, function( + autoSizerParams, + ) { + if (mostRecentWidth && mostRecentWidth !== autoSizerParams.width) { + cache.clearAll(); + list.recomputeRowHeights(); + } + + mostRecentWidth = autoSizerParams.width; + + return React.createElement(ReactVirtualized.List, { + className: 'chat', + deferredMeasurementCache: cache, + height: autoSizerParams.height, + ref: function(ref) { + list = ref; + }, + rowCount: chatHistory.length, + rowHeight: cache.rowHeight, + rowRenderer: rowRenderer, + width: autoSizerParams.width, + }); + }), + ); + }, +}); + +var NAMES = [ + 'Peter Brimer', + 'Tera Gaona', + 'Kandy Liston', + 'Lonna Wrede', + 'Kristie Yard', + 'Raul Host', + 'Yukiko Binger', + 'Velvet Natera', + 'Donette Ponton', + 'Loraine Grim', + 'Shyla Mable', + 'Marhta Sing', + 'Alene Munden', + 'Holley Pagel', + 'Randell Tolman', + 'Wilfred Juneau', + 'Naida Madson', + 'Marine Amison', + 'Glinda Palazzo', + 'Lupe Island', + 'Cordelia Trotta', + 'Samara Berrier', + 'Era Stepp', + 'Malka Spradlin', + 'Edward Haner', + 'Clemencia Feather', + 'Loretta Rasnake', + 'Dana Hasbrouck', + 'Sanda Nery', + 'Soo Reiling', + 'Apolonia Volk', + 'Liliana Cacho', + 'Angel Couchman', + 'Yvonne Adam', + 'Jonas Curci', + 'Tran Cesar', + 'Buddy Panos', + 'Rosita Ells', + 'Rosalind Tavares', + 'Renae Keehn', + 'Deandrea Bester', + 'Kelvin Lemmon', + 'Guadalupe Mccullar', + 'Zelma Mayers', + 'Laurel Stcyr', + 'Edyth Everette', + 'Marylin Shevlin', + 'Hsiu Blackwelder', + 'Mark Ferguson', + 'Winford Noggle', + 'Shizuko Gilchrist', + 'Roslyn Cress', + 'Nilsa Lesniak', + 'Agustin Grant', + 'Earlie Jester', + 'Libby Daigle', + 'Shanna Maloy', + 'Brendan Wilken', + 'Windy Knittel', + 'Alice Curren', + 'Eden Lumsden', + 'Klara Morfin', + 'Sherryl Noack', + 'Gala Munsey', + 'Stephani Frew', + 'Twana Anthony', + 'Mauro Matlock', + 'Claudie Meisner', + 'Adrienne Petrarca', + 'Pearlene Shurtleff', + 'Rachelle Piro', + 'Louis Cocco', + 'Susann Mcsweeney', + 'Mandi Kempker', + 'Ola Moller', + 'Leif Mcgahan', + 'Tisha Wurster', + 'Hector Pinkett', + 'Benita Jemison', + 'Kaley Findley', + 'Jim Torkelson', + 'Freda Okafor', + 'Rafaela Markert', + 'Stasia Carwile', + 'Evia Kahler', + 'Rocky Almon', + 'Sonja Beals', + 'Dee Fomby', + 'Damon Eatman', + 'Alma Grieve', + 'Linsey Bollig', + 'Stefan Cloninger', + 'Giovanna Blind', + 'Myrtis Remy', + 'Marguerita Dostal', + 'Junior Baranowski', + 'Allene Seto', + 'Margery Caves', + 'Nelly Moudy', + 'Felix Sailer', +]; +var SENTENCES = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + 'Phasellus vulputate odio commodo tortor sodales, et vehicula ipsum viverra.', + 'In et mollis velit, accumsan volutpat libero.', + 'Nulla rutrum tellus ipsum, eget fermentum sem dictum quis.', + 'Suspendisse eget vehicula elit.', + 'Proin ut lacus lacus.', + 'Aliquam erat volutpat.', + 'Vivamus ac suscipit est, et elementum lectus.', + 'Cras tincidunt nisi in urna molestie varius.', + 'Integer in magna eu nibh imperdiet tristique.', + 'Curabitur eu pellentesque nisl.', + 'Etiam non consequat est.', + 'Duis mi massa, feugiat nec molestie sit amet, suscipit et metus.', + 'Curabitur ac enim dictum arcu varius fermentum vel sodales dui.', + 'Ut tristique augue at congue molestie.', + 'Integer semper sem lorem, scelerisque suscipit lacus consequat nec.', + 'Etiam euismod efficitur magna nec dignissim.', + 'Morbi vel neque lectus.', + 'Etiam ac accumsan elit, et pharetra ex.', + 'Suspendisse vitae gravida mauris.', + 'Pellentesque sed laoreet erat.', + 'Nam aliquet purus quis massa eleifend, et efficitur felis aliquam.', + 'Fusce faucibus diam erat, sed consectetur urna auctor at.', + 'Praesent et nulla velit.', + 'Cras eget enim nec odio feugiat tristique eu quis ante.', + 'Morbi blandit diam vitae odio sollicitudin finibus.', + 'Integer ac ante fermentum, placerat orci vel, fermentum lacus.', + 'Maecenas est elit, semper ut posuere et, congue ut orci.', + 'Phasellus eget enim vitae nunc luctus sodales a eu erat.', + 'Curabitur dapibus nisi sed nisi dictum, in imperdiet urna posuere.', + 'Vivamus commodo odio metus, tincidunt facilisis augue dictum quis.', + 'Curabitur sagittis a lectus ac sodales.', + 'Nam eget eros purus.', + 'Nam scelerisque et ante in porta.', + 'Proin vitae augue tristique, malesuada nisl ut, fermentum nisl.', + 'Nulla bibendum quam id velit blandit dictum.', + 'Cras tempus ac dolor ut convallis.', + 'Sed vel ipsum est.', + 'Nulla ut leo vestibulum, ultricies sapien ac, pellentesque dolor.', + 'Etiam ultricies maximus tempus.', + 'Donec dignissim mi ac libero feugiat, vitae lacinia odio viverra.', + 'Curabitur condimentum tellus sit amet neque posuere, condimentum tempus purus eleifend.', + 'Donec tempus, augue id hendrerit pretium, mauris leo congue nulla, ac iaculis erat nunc in dolor.', + 'Praesent vel lectus venenatis, elementum mauris vitae, ullamcorper nulla.', + 'Maecenas non diam cursus, imperdiet massa eget, pellentesque ex.', + 'Vestibulum luctus risus vel augue auctor blandit.', + 'Nullam augue diam, pulvinar sed sapien et, hendrerit venenatis risus.', + 'Quisque sollicitudin nulla nec tellus feugiat hendrerit.', + 'Vestibulum a eros accumsan, lacinia eros non, pretium diam.', + 'Aenean iaculis augue sit amet scelerisque aliquam.', + 'Donec ornare felis et dui hendrerit, eget bibendum nibh interdum.', + 'Maecenas tellus magna, tristique vitae orci vel, auctor tincidunt nisi.', + 'Fusce non libero quis velit porttitor maximus at eget enim.', + 'Sed in aliquet tellus.', + 'Etiam a tortor erat.', + 'Donec nec diam vel tellus egestas lobortis.', + 'Vivamus dictum erat nulla, sit amet accumsan dolor scelerisque eu.', + 'In nec eleifend ex, pellentesque dapibus sapien.', + 'Duis a mollis nisi.', + 'Sed ornare nisl sit amet dolor pellentesque, eu fermentum leo interdum.', + 'Sed eget mauris condimentum, molestie justo eu, feugiat felis.', + 'Nunc suscipit leo non dui blandit, ac malesuada ex consequat.', + 'Morbi varius placerat congue.', + 'Praesent id velit in nunc elementum aliquet.', + 'Sed luctus justo vitae nibh bibendum blandit.', + 'Sed et sapien turpis.', + 'Nulla ac eros vestibulum, mollis ante eu, rutrum nulla.', + 'Sed cursus magna ut vehicula rutrum.', + 'Ut consectetur feugiat consectetur.', + 'Nulla nec ligula posuere neque sollicitudin rutrum a a dui.', + 'Nulla ut quam odio.', + 'Integer dignissim sapien et orci sodales volutpat.', + 'Nullam a sapien leo.', + 'Praesent cursus semper purus, vitae gravida risus dapibus mattis.', + 'Sed pellentesque nulla lorem, in commodo arcu feugiat sed.', + 'Phasellus blandit arcu non diam varius ornare.', +]; +var chatHistory = []; + +for (var i = 0; i < 1000; i++) { + var name = NAMES[Math.floor(Math.random() * NAMES.length)]; + var sentences = Math.ceil(Math.random() * 5); + var texts = []; + + for (var x = 0; x < sentences; x++) { + texts.push(SENTENCES[Math.floor(Math.random() * SENTENCES.length)]); + } + + chatHistory.push({ + name, + text: texts.join(' '), + }); +} + +const container = document.getElementById('mount'); + +ReactDOM.render(React.createElement(App), container); diff --git a/playground/chat.js b/playground/chat.js index 17a2a7691..c69926a40 100644 --- a/playground/chat.js +++ b/playground/chat.js @@ -1,12 +1,12 @@ var cache = new ReactVirtualized.CellMeasurerCache({ - fixedWidth: true -}) + fixedWidth: true, +}); -var list -var mostRecentWidth +var list; +var mostRecentWidth; -function rowRenderer (params) { - var datum = chatHistory[params.index] +function rowRenderer(params) { + var datum = chatHistory[params.index]; return React.createElement( ReactVirtualized.CellMeasurer, @@ -16,70 +16,61 @@ function rowRenderer (params) { key: params.key, parent: params.parent, rowIndex: params.index, - width: mostRecentWidth + width: mostRecentWidth, }, React.createElement( 'div', { className: 'item', key: params.key, - style: params.style + style: params.style, }, - React.createElement( - 'strong', - null, - datum.name - ), + React.createElement('strong', null, datum.name), ':', - datum.text - ) - ) + datum.text, + ), + ); } -function cellRenderer (params) { - params.index = params.rowIndex +function cellRenderer(params) { + params.index = params.rowIndex; - return rowRenderer(params) + return rowRenderer(params); } var App = React.createClass({ - render: function () { + render: function() { return React.createElement( 'div', { - className: 'container' + className: 'container', }, - React.createElement( - ReactVirtualized.AutoSizer, - {}, - function (autoSizerParams) { - if (mostRecentWidth && mostRecentWidth !== autoSizerParams.width) { - cache.clearAll() - list.recomputeRowHeights() - } + React.createElement(ReactVirtualized.AutoSizer, {}, function( + autoSizerParams, + ) { + if (mostRecentWidth && mostRecentWidth !== autoSizerParams.width) { + cache.clearAll(); + list.recomputeRowHeights(); + } - mostRecentWidth = autoSizerParams.width + 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 - } - ) - } - ) - ) - } -}) + 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', @@ -181,8 +172,8 @@ var NAMES = [ 'Allene Seto', 'Margery Caves', 'Nelly Moudy', - 'Felix Sailer' -] + 'Felix Sailer', +]; var SENTENCES = [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 'Phasellus vulputate odio commodo tortor sodales, et vehicula ipsum viverra.', @@ -259,36 +250,34 @@ var SENTENCES = [ '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 = [] + '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 = [] + 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)]) + texts.push(SENTENCES[Math.floor(Math.random() * SENTENCES.length)]); } chatHistory.push({ name, - text: texts.join(' ') - }) + text: texts.join(' '), + }); } -const mount = document.querySelector('#mount') +const container = document.getElementById('mount'); -ReactDOM.render( - React.createElement(App), - mount -) +ReactDOM.render(React.createElement(App), container); -document.body.addEventListener('click', function () { - const bodyWidth = document.body.getBoundingClientRect().width - const minWidth = 300 +document.body.addEventListener('click', function() { + const bodyWidth = document.body.getBoundingClientRect().width; + const minWidth = 300; - mount.style.display = 'inline-block' - mount.style.maxWidth = `${minWidth + Math.round(Math.random() * (bodyWidth - minWidth))}px` -}) + container.style.display = 'inline-block'; + container.style.maxWidth = `${minWidth + + Math.round(Math.random() * (bodyWidth - minWidth))}px`; +}); diff --git a/playground/grid-test.js b/playground/grid-test.js index f2c959873..5d674716e 100644 --- a/playground/grid-test.js +++ b/playground/grid-test.js @@ -1,52 +1,46 @@ -function cellRenderer (params) { +function cellRenderer(params) { return React.createElement( 'div', { className: 'item', key: params.key, - style: params.style + style: params.style, }, - params.columnIndex - ) + 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 - } - ) - } - ) - } -}) + 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') -) +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) +const testCase = createScrollingTestCase( + document.querySelector('.ReactVirtualized__Grid'), +); +const TestRunner = FpsMeasurer.TestRunner; +const testRunner = new TestRunner(testCase, 5); -document.body.addEventListener('click', function (event) { +document.body.addEventListener('click', function(event) { if (testRunner.isRunning()) { - testRunner.stop() + testRunner.stop(); } else { - testRunner.start() + testRunner.start(); } -}) +}); diff --git a/playground/grid.js b/playground/grid.js index 55ca1db9a..c4c94d934 100644 --- a/playground/grid.js +++ b/playground/grid.js @@ -1,68 +1,64 @@ -var REACT_VIRTUALIZED_BANNER = 'https://cloud.githubusercontent.com/assets/29597/11737732/0ca1e55e-9f91-11e5-97f3-098f2f8ed866.png' +var REACT_VIRTUALIZED_BANNER = + 'https://cloud.githubusercontent.com/assets/29597/11737732/0ca1e55e-9f91-11e5-97f3-098f2f8ed866.png'; -function getColumnWidth (params) { +function getColumnWidth(params) { switch (params.index % 3) { case 0: - return 65 + return 65; case 1: - return 65 + return 65; case 2: - return 100 + return 100; } } -function cellRenderer (params) { - var key = `c:${params.columnIndex}, r:${params.rowIndex}` +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 - }) + onChange: function() {}, + style: params.style, + }); case 1: - return React.DOM.button({ - className: 'button', - key: params.key, - style: params.style - }, key) + 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 - }) + 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 - } - ) - } - ) - } -}) + 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') -) +ReactDOM.render(React.createElement(App), document.querySelector('#mount')); diff --git a/playground/helper.js b/playground/helper.js index ac0c88e61..bdc1ee7f0 100644 --- a/playground/helper.js +++ b/playground/helper.js @@ -1,48 +1,50 @@ -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 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 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') +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}/` + 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 + var styleSource = baseDir + 'styles.css'; + var scriptSource = baseDir + 'dist/umd/react-virtualized.js'; + var appSource = source; loadStyle(styleSource, function() { - loadScript(scriptSource) - loadScript(appSource) - }) + loadScript(scriptSource); + loadScript(appSource); + }); } -function loadReact () { - var baseDir = 'https://cdnjs.cloudflare.com/ajax/libs/react/15.4.2' - var reactParam = getUrlParam('react') +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}` + 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`) + loadScript(`${baseDir}/react.min.js`); + loadScript(`${baseDir}/react-dom.min.js`); } diff --git a/playground/hover.js b/playground/hover.js index 572a169f4..c1709faaa 100644 --- a/playground/hover.js +++ b/playground/hover.js @@ -1,61 +1,60 @@ var App = React.createClass({ getInitialState: function() { - return {} + return {}; }, render: function() { - var cellRenderer = this._cellRenderer + 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 - } - ) - } - ) + 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 + _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' + 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() + return React.DOM.div( + { + className: className, + key: params.key, + onMouseOver: function() { + setState({ + hoveredColumnIndex: columnIndex, + hoveredRowIndex: rowIndex, + }); + grid.forceUpdate(); + }, + style: params.style, }, - style: params.style - }, key) - } -}) + key, + ); + }, +}); -ReactDOM.render( - React.createElement(App), - document.querySelector('#mount') -) +ReactDOM.render(React.createElement(App), document.querySelector('#mount')); diff --git a/playground/render-counters.js b/playground/render-counters.js index 14a89296f..ff9fbe065 100644 --- a/playground/render-counters.js +++ b/playground/render-counters.js @@ -1,27 +1,25 @@ -const { PureComponent } = React -const { AutoSizer, List } = ReactVirtualized +const {PureComponent} = React; +const {AutoSizer, List} = ReactVirtualized; class ListExample extends PureComponent { render() { - return React.createElement( - AutoSizer, - null, - ({ height, width }) => React.createElement(List, { + return React.createElement(AutoSizer, null, ({height, width}) => + React.createElement(List, { height: height, overscanRowCount: 0, rowCount: 1000, rowHeight: 30, rowRenderer: this._rowRenderer, - width: width - }) + width: width, + }), ); } - _rowRenderer({ index, isScrolling, key, style }) { + _rowRenderer({index, isScrolling, key, style}) { return React.createElement(Row, { index: index, key: key, - style: style + style: style, }); } } @@ -31,7 +29,7 @@ class Row extends PureComponent { super(props, context); this.state = { - counter: 0 + counter: 0, }; this._renderCount = 0; @@ -40,8 +38,8 @@ class Row extends PureComponent { render() { this._renderCount++; - const { counter } = this.state; - const { index, style } = this.props; + const {counter} = this.state; + const {index, style} = this.props; return React.createElement( 'div', @@ -51,17 +49,17 @@ class Row extends PureComponent { counter: state.counter++; }); }, - style: style + style: style, }, 'Row ', index, ', ', - this._renderCount + this._renderCount, ); } } ReactDOM.render( React.createElement(ListExample), - document.querySelector('#mount') -) + document.querySelector('#mount'), +); diff --git a/playground/scroll-sync.js b/playground/scroll-sync.js index 6bd4b84f6..ceaf9165d 100644 --- a/playground/scroll-sync.js +++ b/playground/scroll-sync.js @@ -1,72 +1,59 @@ -function cellRenderer (params) { +function cellRenderer(params) { return React.createElement( 'div', { className: 'item', key: params.key, - style: params.style + style: params.style, }, - params.columnIndex - ) + params.columnIndex, + ); } var App = React.createClass({ render: function() { - return React.createElement( - ReactVirtualized.AutoSizer, - null, - function (autoSizerParams) { + return React.createElement(ReactVirtualized.AutoSizer, null, function( + autoSizerParams, + ) { + return React.createElement(ReactVirtualized.ScrollSync, null, function( + scrollSyncParams, + ) { 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 - } - ) - ) - } - ) - } - ) - } -}) + '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') -) +ReactDOM.render(React.createElement(App), document.querySelector('#mount')); diff --git a/playground/table.js b/playground/table.js index d3bebfe77..68b3d98fa 100644 --- a/playground/table.js +++ b/playground/table.js @@ -1,65 +1,58 @@ -const NUM_COLUMNS = 40 +const NUM_COLUMNS = 40; -function rowGetter (params) { - return new Array(NUM_COLUMNS).fill('').map( - function (_, index) { - return index - }) +function rowGetter(params) { + return new Array(NUM_COLUMNS).fill('').map(function(_, index) { + return index; + }); } var App = React.createClass({ render: function() { - const flexColumns = [] + const flexColumns = []; for (var i = 0; i < NUM_COLUMNS; i++) { flexColumns.push( - React.createElement( - ReactVirtualized.Column, - { - dataKey: i, - flexGrow: 1, - key: i, - width: 50 - } - ) - ) + 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 - ) - } - ) - } -}) + 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') -) +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) +const testCase = createScrollingTestCase( + document.querySelector('.ReactVirtualized__Grid'), +); +const TestRunner = FpsMeasurer.TestRunner; +const testRunner = new TestRunner(testCase, 5); -document.body.addEventListener('click', function (event) { +document.body.addEventListener('click', function(event) { if (testRunner.isRunning()) { - testRunner.stop() + testRunner.stop(); } else { - testRunner.start() + testRunner.start(); } -}) +}); diff --git a/playground/tests.js b/playground/tests.js index 4dc8e73db..bc25d23f0 100644 --- a/playground/tests.js +++ b/playground/tests.js @@ -1,54 +1,52 @@ -function createScrollingTestCase (component) { - var scrollDown = getUrlParam('direction') !== 'up' +function createScrollingTestCase(component) { + var scrollDown = getUrlParam('direction') !== 'up'; - return function testCase (completedCallback) { - component.scrollTop = scrollDown - ? 0 - : component.scrollHeight + return function testCase(completedCallback) { + component.scrollTop = scrollDown ? 0 : component.scrollHeight; - var maxScrollTop = component.scrollHeight + var maxScrollTop = component.scrollHeight; - var interval = 1 - var scrollTop = component.scrollTop + var interval = 1; + var scrollTop = component.scrollTop; - function incrementScrollDown () { + function incrementScrollDown() { if (!testRunner.isRunning()) { - return + return; } - interval *= 1.05 - scrollTop = Math.min(scrollTop + interval, maxScrollTop) + interval *= 1.05; + scrollTop = Math.min(scrollTop + interval, maxScrollTop); - component.scrollTop = scrollTop + component.scrollTop = scrollTop; if (scrollTop < maxScrollTop) { - requestAnimationFrame(incrementScrollDown) + requestAnimationFrame(incrementScrollDown); } else { - completedCallback() + completedCallback(); } } - function incrementScrollUp () { + function incrementScrollUp() { if (!testRunner.isRunning()) { - return + return; } - interval *= 1.05 - scrollTop = Math.max(scrollTop - interval, 0) + interval *= 1.05; + scrollTop = Math.max(scrollTop - interval, 0); - component.scrollTop = scrollTop + component.scrollTop = scrollTop; if (scrollTop > 0) { - requestAnimationFrame(incrementScrollUp) + requestAnimationFrame(incrementScrollUp); } else { - completedCallback() + completedCallback(); } } if (scrollDown) { - incrementScrollDown() + incrementScrollDown(); } else { - incrementScrollUp() + incrementScrollUp(); } - } + }; } diff --git a/playground/tree.js b/playground/tree.js index 214fd8801..68f11d8a7 100644 --- a/playground/tree.js +++ b/playground/tree.js @@ -1,129 +1,212 @@ -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 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() - } + event.stopPropagation(); + item.expanded = !item.expanded; + List.recomputeRowHeights(); + List.forceUpdate(); + }; - var props = { key: keyPrefix } - var children = [] - var itemText + var props = {key: keyPrefix}; + var children = []; + var itemText; if (item.expanded) { - props.onClick = onClick - itemText = '[-] ' + item.name + props.onClick = onClick; + itemText = '[-] ' + item.name; children = item.children.map(function(child, index) { - return renderItem(child, keyPrefix + '-' + index) - }) + return renderItem(child, keyPrefix + '-' + index); + }); } else if (item.children.length) { - props.onClick = onClick - itemText = '[+] ' + item.name + props.onClick = onClick; + itemText = '[+] ' + item.name; } else { - itemText = ' ' + item.name + 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)) + 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 +function getExpandedItemCount(item) { + var count = 1; if (item.expanded) { count += item.children .map(getExpandedItemCount) - .reduce(function (total, count) { return total + count }, 0) + .reduce(function(total, count) { + return total + count; + }, 0); } - return count + return count; } -var List -function setRef (ref) { - List = ref +var List; +function setRef(ref) { + List = ref; } -function cellRenderer (params) { - var renderedCell = renderItem(data[params.index], params.index) +function cellRenderer(params) { + var renderedCell = renderItem(data[params.index], params.index); return React.DOM.ul( { key: params.key, - style: params.style + style: params.style, }, - renderedCell - ) + renderedCell, + ); } -function rowHeight (params) { - return getExpandedItemCount(data[params.index]) * ROW_HEIGHT +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 = [] + 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)) + data.push(createRandomizedItem(0)); } - return data + return data; } -function createRandomizedItem (depth) { - var item = {} - item.children = [] - item.name = RANDOM_WORDS[ Math.floor(Math.random() * RANDOM_WORDS.length) ] +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 + var numChildren = depth < 3 ? Math.floor(Math.random() * 5) : 0; for (var i = 0; i < numChildren; i++) { - item.children.push(createRandomizedItem(depth + 1)) + item.children.push(createRandomizedItem(depth + 1)); } - item.expanded = numChildren > 0 && Math.random() < .25 + item.expanded = numChildren > 0 && Math.random() < 0.25; - return item + return item; } diff --git a/playground/utils.js b/playground/utils.js index 9a181b9ae..a8f7526ed 100644 --- a/playground/utils.js +++ b/playground/utils.js @@ -1,21 +1,18 @@ -function getUrlParams () { - var search = window.location.search +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 - }, - {} - ) - : {} + .substr(1) + .split('&') + .reduce(function(reduced, value) { + var matches = value.split('='); + reduced[matches[0]] = matches[1]; + return reduced; + }, {}) + : {}; } -function getUrlParam (key) { - return getUrlParams()[key] +function getUrlParam(key) { + return getUrlParams()[key]; } diff --git a/postcss.config.js b/postcss.config.js index 0408fd36f..5802d8908 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,9 +1,7 @@ module.exports = { - 'autoprefixer': { - 'browsers': ['last 2 version', 'Firefox 15', 'iOS 8'] + autoprefixer: { + browsers: ['last 2 version', 'Firefox 15', 'iOS 8'], }, // The plugins section is used by postcss-loader with webpack - plugins: [ - require('autoprefixer') - ] -} + plugins: [require('autoprefixer')], +}; diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 000000000..e3e73016b --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,38 @@ +import nodeResolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; +import babel from 'rollup-plugin-babel'; +import replace from 'rollup-plugin-replace'; +import {uglify} from 'rollup-plugin-uglify'; + +export default { + input: './source/index.js', + output: { + file: 'dist/umd/react-virtualized.js', + format: 'umd', + name: 'ReactVirtualized', + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + external: ['react', 'react-dom'], + plugins: [ + nodeResolve(), + commonjs({ + include: 'node_modules/**', + }), + babel({ + exclude: 'node_modules/**', + }), + replace({ + 'process.env.NODE_ENV': JSON.stringify('development'), + }), + uglify({ + mangle: false, + output: { + comments: true, + beautify: true, + }, + }), + ], +}; diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.example.css b/source/ArrowKeyStepper/ArrowKeyStepper.example.css index 78d73f69c..5ed691585 100644 --- a/source/ArrowKeyStepper/ArrowKeyStepper.example.css +++ b/source/ArrowKeyStepper/ArrowKeyStepper.example.css @@ -8,8 +8,10 @@ align-items: center; justify-content: center; text-align: center; + border: none; border-right: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0; + outline: none; } .FocusedCell { @@ -20,3 +22,11 @@ .Radio { margin-left: 0.5rem; } + +.checkboxLabel { + display: flex; + align-items: center; +} +.checkbox { + margin-right: 5px; +} diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.example.js b/source/ArrowKeyStepper/ArrowKeyStepper.example.js index c82dc8653..cfc9fbd22 100644 --- a/source/ArrowKeyStepper/ArrowKeyStepper.example.js +++ b/source/ArrowKeyStepper/ArrowKeyStepper.example.js @@ -1,43 +1,58 @@ /** @flow */ -import React, { PureComponent } from 'react' -import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' -import ArrowKeyStepper from './ArrowKeyStepper' -import AutoSizer from '../AutoSizer' -import Grid from '../Grid' -import cn from 'classnames' -import styles from './ArrowKeyStepper.example.css' - -export default class ArrowKeyStepperExample extends PureComponent { - constructor (props) { - super(props) - - this.state = { - mode: 'edges' - } - this._getColumnWidth = this._getColumnWidth.bind(this) - this._getRowHeight = this._getRowHeight.bind(this) - this._cellRenderer = this._cellRenderer.bind(this) - } +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 } = this.state + 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. + 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. + 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. @@ -47,9 +62,11 @@ export default class ArrowKeyStepperExample extends PureComponent { aria-label='Set mode equal to "cells"' checked={mode === 'cells'} className={styles.Radio} - type='radio' - onChange={event => event.target.checked && this.setState({ mode: 'cells' })} - value='cells' + type="radio" + onChange={event => + event.target.checked && this.setState({mode: 'cells'}) + } + value="cells" /> cells @@ -58,34 +75,61 @@ export default class ArrowKeyStepperExample extends PureComponent { aria-label='Set mode equal to "edges"' checked={mode === 'edges'} className={styles.Radio} - type='radio' - onChange={event => event.target.checked && this.setState({ mode: 'edges' })} - value='edges' + type="radio" + onChange={event => + event.target.checked && this.setState({mode: 'edges'}) + } + value="edges" /> edges (default) + + + + - {({ onSectionRendered, scrollToColumn, scrollToRow }) => ( + scrollToColumn={scrollToColumn} + scrollToRow={scrollToRow}> + {({onSectionRendered, scrollToColumn, scrollToRow}) => (
{`Most-recently-stepped column: ${scrollToColumn}, row: ${scrollToRow}`} - {({ width }) => ( + {({width}) => ( this._cellRenderer({ columnIndex, key, rowIndex, scrollToColumn, scrollToRow, style })} + cellRenderer={({columnIndex, key, rowIndex, style}) => + this._cellRenderer({ + columnIndex, + key, + rowIndex, + scrollToColumn, + scrollToRow, + style, + }) + } rowHeight={this._getRowHeight} rowCount={100} scrollToColumn={scrollToColumn} @@ -98,30 +142,67 @@ export default class ArrowKeyStepperExample extends PureComponent { )} - ) + ); } - _getColumnWidth ({ index }) { - return (1 + (index % 3)) * 60 - } + _getColumnWidth = ({index}: {index: number}) => { + return (1 + (index % 3)) * 60; + }; - _getRowHeight ({ index }) { - return (1 + (index % 3)) * 30 - } + _getRowHeight = ({index}: {index: number}) => { + return (1 + (index % 3)) * 30; + }; - _cellRenderer ({ columnIndex, key, rowIndex, scrollToColumn, scrollToRow, style }) { - const className = cn(styles.Cell, { - [styles.FocusedCell]: columnIndex === scrollToColumn && rowIndex === scrollToRow - }) + _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 ( -
+ onClick={ + this.state.isClickable && + (() => + this._selectCell({ + scrollToColumn: columnIndex, + scrollToRow: rowIndex, + })) + } + style={style}> {`r:${rowIndex}, c:${columnIndex}`} -
- ) - } + + ); + }; + + _selectCell = ({scrollToColumn, scrollToRow}: ScrollIndices) => { + this.setState({scrollToColumn, scrollToRow}); + }; + + _onClickableChange = (event: Event) => { + if (event.target instanceof HTMLInputElement) { + this.setState({ + isClickable: event.target.checked, + scrollToColumn: 0, + scrollToRow: 0, + }); + } + }; } diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.jest.js b/source/ArrowKeyStepper/ArrowKeyStepper.jest.js index 4dae3fa60..902266850 100644 --- a/source/ArrowKeyStepper/ArrowKeyStepper.jest.js +++ b/source/ArrowKeyStepper/ArrowKeyStepper.jest.js @@ -1,227 +1,269 @@ -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import ArrowKeyStepper from './ArrowKeyStepper' -import { Simulate } from 'react-addons-test-utils' - -function renderTextContent (scrollToColumn, scrollToRow) { - return `scrollToColumn:${scrollToColumn}, scrollToRow:${scrollToRow}` +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)}
- ) +function ChildComponent({scrollToColumn, scrollToRow}) { + return
{renderTextContent(scrollToColumn, scrollToRow)}
; } describe('ArrowKeyStepper', () => { - function renderHelper (props = {}) { - let onSectionRenderedCallback + function renderHelper(props = {}) { + let onSectionRenderedCallback; const component = render( - - {({ onSectionRendered, scrollToColumn, scrollToRow }) => { - onSectionRenderedCallback = onSectionRendered + + {({onSectionRendered, scrollToColumn, scrollToRow}) => { + onSectionRenderedCallback = onSectionRendered; return ( - ) + ); }} - - ) - const node = findDOMNode(component) + , + ); + const node = findDOMNode(component); return { component, node, - onSectionRendered: onSectionRenderedCallback - } + onSectionRendered: onSectionRenderedCallback, + }; } - function assertCurrentScrollTo (node, scrollToColumn, scrollToRow) { - expect(node.textContent).toEqual(renderTextContent(scrollToColumn, scrollToRow)) + 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') - }) + 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) - }) + 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({ + 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) - }) + 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({ + 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) - }) + 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({ + const {node} = renderHelper({ mode: 'cells', scrollToColumn: 2, - scrollToRow: 4 - }) - Simulate.keyDown(node, {key: 'ArrowDown'}) - assertCurrentScrollTo(node, 2, 5) + 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) - }) + 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({ + const {component, node} = renderHelper({ mode: 'cells', scrollToColumn: 2, - scrollToRow: 4 - }) - Simulate.keyDown(node, {key: 'ArrowDown'}) - assertCurrentScrollTo(node, 2, 5) + 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) - }) + 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) - }) + 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 + 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) + rowStopIndex: 6, + }); + Simulate.keyDown(node, {key: 'ArrowDown'}); + assertCurrentScrollTo(node, 0, 7); - onSectionRendered({ // Simulate a scroll + onSectionRendered({ + // Simulate a scroll columnStartIndex: 5, columnStopIndex: 10, rowStartIndex: 2, - rowStopIndex: 4 - }) - Simulate.keyDown(node, {key: 'ArrowUp'}) - assertCurrentScrollTo(node, 0, 1) + rowStopIndex: 4, + }); + Simulate.keyDown(node, {key: 'ArrowUp'}); + assertCurrentScrollTo(node, 0, 1); - onSectionRendered({ // Simulate a scroll + onSectionRendered({ + // Simulate a scroll columnStartIndex: 4, columnStopIndex: 8, rowStartIndex: 5, - rowStopIndex: 10 - }) - Simulate.keyDown(node, {key: 'ArrowRight'}) - assertCurrentScrollTo(node, 9, 1) + rowStopIndex: 10, + }); + Simulate.keyDown(node, {key: 'ArrowRight'}); + assertCurrentScrollTo(node, 9, 1); - onSectionRendered({ // Simulate a scroll + onSectionRendered({ + // Simulate a scroll columnStartIndex: 2, columnStopIndex: 4, rowStartIndex: 2, - rowStopIndex: 4 - }) - Simulate.keyDown(node, {key: 'ArrowLeft'}) - assertCurrentScrollTo(node, 1, 1) - }) - }) + 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({ + const {node, onSectionRendered} = renderHelper({ mode: 'cells', scrollToColumn: 5, - scrollToRow: 5 - }) + scrollToRow: 5, + }); - onSectionRendered({ // Simulate a scroll + 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 + 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) - }) - }) -}) + rowStopIndex: 15, + }); + Simulate.keyDown(node, {key: 'ArrowRight'}); + assertCurrentScrollTo(node, 6, 5); + Simulate.keyDown(node, {key: 'ArrowLeft'}); + assertCurrentScrollTo(node, 5, 5); + }); + }); +}); diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.js b/source/ArrowKeyStepper/ArrowKeyStepper.js index c25cd3d6c..e26758841 100644 --- a/source/ArrowKeyStepper/ArrowKeyStepper.js +++ b/source/ArrowKeyStepper/ArrowKeyStepper.js @@ -1,149 +1,194 @@ /** @flow */ -import React, { PropTypes, PureComponent } from 'react' + +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. */ -export default class ArrowKeyStepper extends PureComponent { + +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 + scrollToRow: 0, }; - static propTypes = { - children: PropTypes.func.isRequired, - className: PropTypes.string, - columnCount: PropTypes.number.isRequired, - disabled: PropTypes.bool.isRequired, - mode: PropTypes.oneOf(['cells', 'edges']), - rowCount: PropTypes.number.isRequired, - scrollToColumn: PropTypes.number.isRequired, - scrollToRow: PropTypes.number.isRequired + state = { + scrollToColumn: 0, + scrollToRow: 0, + instanceProps: { + prevScrollToColumn: 0, + prevScrollToRow: 0, + }, }; - constructor (props, context) { - super(props, context) - - this.state = { - scrollToColumn: props.scrollToColumn, - scrollToRow: props.scrollToRow + _columnStartIndex = 0; + _columnStopIndex = 0; + _rowStartIndex = 0; + _rowStopIndex = 0; + + static getDerivedStateFromProps( + nextProps: Props, + prevState: State, + ): $Shape { + if (nextProps.isControlled) { + return {}; } - this._columnStartIndex = 0 - this._columnStopIndex = 0 - this._rowStartIndex = 0 - this._rowStopIndex = 0 - - this._onKeyDown = this._onKeyDown.bind(this) - this._onSectionRendered = this._onSectionRendered.bind(this) - } - - componentWillReceiveProps (nextProps) { - const { scrollToColumn, scrollToRow } = nextProps - - const { - scrollToColumn: prevScrollToColumn, - scrollToRow: prevScrollToRow - } = this.props - if ( - prevScrollToColumn !== scrollToColumn && - prevScrollToRow !== scrollToRow + nextProps.scrollToColumn !== prevState.instanceProps.prevScrollToColumn || + nextProps.scrollToRow !== prevState.instanceProps.prevScrollToRow ) { - this.setState({ - scrollToColumn, - scrollToRow - }) - } else if (prevScrollToColumn !== scrollToColumn) { - this.setState({ scrollToColumn }) - } else if (prevScrollToRow !== scrollToRow) { - this.setState({ scrollToRow }) + return { + ...prevState, + scrollToColumn: nextProps.scrollToColumn, + scrollToRow: nextProps.scrollToRow, + instanceProps: { + prevScrollToColumn: nextProps.scrollToColumn, + prevScrollToRow: nextProps.scrollToRow, + }, + }; } + + return {}; } - setScrollIndexes ({ - scrollToColumn, - scrollToRow - }) { + setScrollIndexes({scrollToColumn, scrollToRow}: ScrollIndices) { this.setState({ scrollToRow, - scrollToColumn - }) + scrollToColumn, + }); } - render () { - const { className, children } = this.props - const { scrollToColumn, scrollToRow } = this.state + render() { + const {className, children} = this.props; + const {scrollToColumn, scrollToRow} = this._getScrollState(); return ( -
+
{children({ onSectionRendered: this._onSectionRendered, scrollToColumn, - scrollToRow + scrollToRow, })}
- ) + ); } - _onKeyDown (event) { - const { columnCount, disabled, mode, rowCount } = this.props + _onKeyDown = (event: KeyboardEvent) => { + const {columnCount, disabled, mode, rowCount} = this.props; if (disabled) { - return + return; } const { scrollToColumn: scrollToColumnPrevious, - scrollToRow: scrollToRowPrevious - } = this.state + scrollToRow: scrollToRowPrevious, + } = this._getScrollState(); - let { scrollToColumn, scrollToRow } = this.state + 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 + 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 + 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 + 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 + scrollToRow = + mode === 'cells' + ? Math.max(scrollToRow - 1, 0) + : Math.max(this._rowStartIndex - 1, 0); + break; } if ( scrollToColumn !== scrollToColumnPrevious || scrollToRow !== scrollToRowPrevious ) { - event.preventDefault() + event.preventDefault(); - this.setState({ scrollToColumn, scrollToRow }) + 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; } - _onSectionRendered ({ columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex }) { - this._columnStartIndex = columnStartIndex - this._columnStopIndex = columnStopIndex - this._rowStartIndex = rowStartIndex - this._rowStopIndex = rowStopIndex + _updateScrollState({scrollToColumn, scrollToRow}: ScrollIndices) { + const {isControlled, onScrollToChange} = this.props; + + if (typeof onScrollToChange === 'function') { + onScrollToChange({scrollToColumn, scrollToRow}); + } + + if (!isControlled) { + this.setState({scrollToColumn, scrollToRow}); + } } } + +polyfill(ArrowKeyStepper); + +export default ArrowKeyStepper; diff --git a/source/ArrowKeyStepper/index.js b/source/ArrowKeyStepper/index.js index bb6a72987..fe13226d3 100644 --- a/source/ArrowKeyStepper/index.js +++ b/source/ArrowKeyStepper/index.js @@ -1,2 +1,6 @@ -export default from './ArrowKeyStepper' -export ArrowKeyStepper from './ArrowKeyStepper' +// @flow + +export type {ScrollIndices} from './types'; + +export {default} from './ArrowKeyStepper'; +export {default as ArrowKeyStepper} from './ArrowKeyStepper'; diff --git a/source/ArrowKeyStepper/types.js b/source/ArrowKeyStepper/types.js new file mode 100644 index 000000000..9bfc2af61 --- /dev/null +++ b/source/ArrowKeyStepper/types.js @@ -0,0 +1,6 @@ +// @flow + +export type ScrollIndices = { + scrollToColumn: number, + scrollToRow: number, +}; diff --git a/source/AutoSizer/AutoSizer.example.js b/source/AutoSizer/AutoSizer.example.js index 8138017c6..677dd55b9 100644 --- a/source/AutoSizer/AutoSizer.example.js +++ b/source/AutoSizer/AutoSizer.example.js @@ -1,67 +1,80 @@ /** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' -import AutoSizer from './AutoSizer' -import List from '../List' -import styles from './AutoSizer.example.css' -export default class AutoSizerExample extends PureComponent { - static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired - } +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'; - constructor (props) { - super(props) +type State = { + hideDescription: boolean, +}; - this.state = { - hideDescription: false - } +export default class AutoSizerExample extends React.PureComponent<{}, State> { + static contextTypes = { + list: PropTypes.instanceOf(ImmutableList).isRequired, + }; - this._rowRenderer = this._rowRenderer.bind(this) - } + state = { + hideDescription: false, + }; - render () { - const { list } = this.context - const { hideDescription } = this.state + render() { + const {list} = this.context; + const {hideDescription} = this.state; return ( + height: 400, + }}> - {!hideDescription && + {!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. + 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 }) => ( + {({width, height}) => (
- ) + ); } - _rowRenderer ({ index, key, style }) { - const { list } = this.context - const row = list.get(index) + _rowRenderer = ({index, key, style}: RowRendererParams) => { + const {list} = this.context; + const row = list.get(index); return ( -
+
{row.name}
- ) - } + ); + }; } diff --git a/source/AutoSizer/AutoSizer.jest.js b/source/AutoSizer/AutoSizer.jest.js index cb988dfff..356685d60 100644 --- a/source/AutoSizer/AutoSizer.jest.js +++ b/source/AutoSizer/AutoSizer.jest.js @@ -1,50 +1,56 @@ -/* global Element, Event */ +import * as React from 'react'; +import {findDOMNode} from 'react-dom'; +import {render} from '../TestUtils'; +import AutoSizer from './AutoSizer'; -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import AutoSizer from './AutoSizer' - -function ChildComponent ({ height, width, foo, bar }) { +function DefaultChildComponent({height, width, foo, bar}) { return (
{`width:${width}, height:${height}, foo:${foo}, bar:${bar}`}
- ) + ); } describe('AutoSizer', () => { - function getMarkup ({ + 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, - width = 200 + style = undefined, + width = 200, } = {}) { - const style = { + const wrapperStyle = { boxSizing: 'border-box', height, paddingBottom, paddingLeft, paddingRight, paddingTop, - width - } - - // AutoSizer uses getBoundingClientRect(). - // Jest runs in JSDom which doesn't support measurements APIs. - Element.prototype.getBoundingClientRect = jest.fn(() => ({ width, - height - })) + }; + + mockOffsetSize(width, height); return ( -
- - {({ height, width }) => ( +
+ + {({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') - }) + 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') - }) + 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') - }) + 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') - }) + 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 }) { - // Specific to the implementation of detectElementResize helper - element.offsetHeight = height - element.offsetWidth = width + const rendered = findDOMNode(render(getMarkup({disableHeight: true}))); + expect(rendered.textContent).toContain('height:undefined'); + expect(rendered.textContent).toContain('width:200'); + }); - Element.prototype.getBoundingClientRect.mockReturnValue({ - width, - height - }) + 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')) + 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)) + 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() - }) -}) + it('should update :height after a resize event', async done => { + const rendered = findDOMNode( + render( + getMarkup({ + height: 100, + width: 200, + }), + ), + ); + expect(rendered.textContent).toContain('height:100'); + expect(rendered.textContent).toContain('width:200'); + await simulateResize({element: rendered, height: 400, width: 300}); + expect(rendered.textContent).toContain('height:400'); + expect(rendered.textContent).toContain('width:300'); + done(); + }); + + describe('onResize and (re)render', () => { + it('should trigger when size changes', async done => { + const onResize = jest.fn(); + const ChildComponent = jest + .fn() + .mockImplementation(DefaultChildComponent); + const rendered = findDOMNode( + render( + getMarkup({ + ChildComponent, + height: 100, + onResize, + width: 200, + }), + ), + ); + ChildComponent.mockClear(); // TODO Improve initial check in version 10; see AutoSizer render() + expect(onResize).toHaveBeenCalledTimes(1); + await simulateResize({element: rendered, height: 400, width: 300}); + expect(ChildComponent).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledTimes(2); + done(); + }); + + it('should only trigger when height changes for disableWidth == true', async done => { + const onResize = jest.fn(); + const ChildComponent = jest + .fn() + .mockImplementation(DefaultChildComponent); + const rendered = findDOMNode( + render( + getMarkup({ + ChildComponent, + disableWidth: true, + height: 100, + onResize, + width: 200, + }), + ), + ); + ChildComponent.mockClear(); // TODO Improve initial check in version 10; see AutoSizer render() + expect(onResize).toHaveBeenCalledTimes(1); + await simulateResize({element: rendered, height: 100, width: 300}); + expect(ChildComponent).toHaveBeenCalledTimes(0); + expect(onResize).toHaveBeenCalledTimes(1); + await simulateResize({element: rendered, height: 200, width: 300}); + expect(ChildComponent).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledTimes(2); + done(); + }); + + it('should only trigger when width changes for disableHeight == true', async done => { + const onResize = jest.fn(); + const ChildComponent = jest + .fn() + .mockImplementation(DefaultChildComponent); + const rendered = findDOMNode( + render( + getMarkup({ + ChildComponent, + disableHeight: true, + height: 100, + onResize, + width: 200, + }), + ), + ); + ChildComponent.mockClear(); // TODO Improve initial check in version 10; see AutoSizer render() + expect(onResize).toHaveBeenCalledTimes(1); + await simulateResize({element: rendered, height: 200, width: 200}); + expect(ChildComponent).toHaveBeenCalledTimes(0); + expect(onResize).toHaveBeenCalledTimes(1); + await simulateResize({element: rendered, height: 200, width: 300}); + expect(ChildComponent).toHaveBeenCalledTimes(1); + expect(onResize).toHaveBeenCalledTimes(2); + done(); + }); + }); + + describe('className and style', () => { + it('should use a custom :className if specified', () => { + const rendered = findDOMNode(render(getMarkup({className: 'foo'}))); + expect(rendered.firstChild.className).toContain('foo'); + }); + + it('should use a custom :style if specified', () => { + const style = {backgroundColor: 'red'}; + const rendered = findDOMNode(render(getMarkup({style}))); + expect(rendered.firstChild.style.backgroundColor).toEqual('red'); + }); + }); +}); diff --git a/source/AutoSizer/AutoSizer.js b/source/AutoSizer/AutoSizer.js index 19578a78e..7b0a142cc 100644 --- a/source/AutoSizer/AutoSizer.js +++ b/source/AutoSizer/AutoSizer.js @@ -1,120 +1,200 @@ /** @flow */ -import React, { PropTypes, PureComponent } from 'react' -import createDetectElementResize from '../vendor/detectElementResize' - -/** - * Decorator component that automatically adjusts the width and height of a single child. - * Child component should not be declared as a child but should rather be specified by a `ChildComponent` property. - * All other properties will be passed through to the child component. - */ -export default class AutoSizer extends PureComponent { - static propTypes = { - /** - * Function responsible for rendering children. - * This function should implement the following signature: - * ({ height, width }) => PropTypes.element - */ - children: PropTypes.func.isRequired, - /** Disable dynamic :height property */ - disableHeight: PropTypes.bool, +import * as React from 'react'; +import createDetectElementResize from '../vendor/detectElementResize'; - /** Disable dynamic :width property */ - disableWidth: PropTypes.bool, +type Size = { + height: number, + width: number, +}; - /** Callback to be invoked on-resize: ({ height, width }) */ - onResize: PropTypes.func.isRequired - }; +type Props = { + /** Function responsible for rendering children.*/ + children: Size => React.Element<*>, - static defaultProps = { - onResize: () => {} - }; + /** Optional custom CSS class name to attach to root AutoSizer element. */ + className?: string, - constructor (props) { - super(props) + /** Default height to use for initial render; useful for SSR */ + defaultHeight?: number, - this.state = { - height: 0, - width: 0 - } + /** Default width to use for initial render; useful for SSR */ + defaultWidth?: number, - this._onResize = this._onResize.bind(this) - this._setRef = this._setRef.bind(this) - } + /** Disable dynamic :height property */ + disableHeight: boolean, + + /** Disable dynamic :width property */ + disableWidth: boolean, - componentDidMount () { - // 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 + /** 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: {}, + }; - // Defer requiring resize handler in order to support server-side rendering. - // See issue #41 - this._detectElementResize = createDetectElementResize() - this._detectElementResize.addResizeListener(this._parentNode, this._onResize) + state = { + height: this.props.defaultHeight || 0, + width: this.props.defaultWidth || 0, + }; - this._onResize() + _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._detectElementResize.removeResizeListener(this._parentNode, this._onResize) + componentWillUnmount() { + if (this._detectElementResize && this._parentNode) { + this._detectElementResize.removeResizeListener( + this._parentNode, + this._onResize, + ); } } - render () { - const { children, disableHeight, disableWidth } = this.props - const { height, width } = this.state + 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 = { overflow: 'visible' } + const outerStyle: Object = {overflow: 'visible'}; + const childParams: Object = {}; if (!disableHeight) { - outerStyle.height = 0 + outerStyle.height = 0; + childParams.height = height; } if (!disableWidth) { - outerStyle.width = 0 + 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({ height, width })} + style={{ + ...outerStyle, + ...style, + }}> + {children(childParams)}
- ) + ); } - _onResize () { - const { onResize } = this.props - - // 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 boundingRect = this._parentNode.getBoundingClientRect() - const height = boundingRect.height || 0 - const width = boundingRect.width || 0 - - const style = window.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 - - this.setState({ - height: height - paddingTop - paddingBottom, - width: width - paddingLeft - paddingRight - }) - - onResize({ height, width }) - } + _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) { - this._autoSizer = autoSizer - } + _setRef = (autoSizer: ?HTMLElement) => { + this._autoSizer = autoSizer; + }; } diff --git a/source/AutoSizer/AutoSizer.ssr.js b/source/AutoSizer/AutoSizer.ssr.js new file mode 100644 index 000000000..9f45bd225 --- /dev/null +++ b/source/AutoSizer/AutoSizer.ssr.js @@ -0,0 +1,17 @@ +/** + * @jest-environment node + */ + +import * as React from 'react'; +import * as ReactDOMServer from 'react-dom/server'; +import AutoSizer from './AutoSizer'; + +test('should render content with default widths and heights initially', () => { + const rendered = ReactDOMServer.renderToString( + + {({height, width}) =>
{`height:${height};width:${width}`}
} +
, + ); + expect(rendered).toContain('height:100'); + expect(rendered).toContain('width:200'); +}); diff --git a/source/AutoSizer/index.js b/source/AutoSizer/index.js index 536492a81..27514059c 100644 --- a/source/AutoSizer/index.js +++ b/source/AutoSizer/index.js @@ -1,2 +1,4 @@ -export default from './AutoSizer' -export AutoSizer from './AutoSizer' +// @flow + +export {default} from './AutoSizer'; +export {default as AutoSizer} from './AutoSizer'; diff --git a/source/CellMeasurer/CellMeasurer.DynamiHeightGrid.example.js b/source/CellMeasurer/CellMeasurer.DynamicHeightGrid.example.js similarity index 52% rename from source/CellMeasurer/CellMeasurer.DynamiHeightGrid.example.js rename to source/CellMeasurer/CellMeasurer.DynamicHeightGrid.example.js index 3abdd5129..685e9b7cf 100644 --- a/source/CellMeasurer/CellMeasurer.DynamiHeightGrid.example.js +++ b/source/CellMeasurer/CellMeasurer.DynamicHeightGrid.example.js @@ -1,32 +1,32 @@ -/** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import CellMeasurer from './CellMeasurer' -import CellMeasurerCache from './CellMeasurerCache' -import Grid from '../Grid' -import styles from './CellMeasurer.example.css' +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 DynamiHeightGrid extends PureComponent { +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 - } + width: PropTypes.number.isRequired, + }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this._cache = new CellMeasurerCache({ defaultWidth: 150, - fixedWidth: true - }) + fixedWidth: true, + }); - this._cellRenderer = this._cellRenderer.bind(this) + this._cellRenderer = this._cellRenderer.bind(this); } - render () { - const { width } = this.props + render() { + const {width} = this.props; return ( - ) + ); } - _cellRenderer ({ columnIndex, key, parent, rowIndex, style }) { - const { getClassName, getContent, list } = this.props + _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 }) + const datum = list.get((rowIndex + columnIndex) % list.size); + const classNames = getClassName({columnIndex, rowIndex}); + const content = getContent({index: rowIndex, datum}); return ( + rowIndex={rowIndex}>
+ width: 150, + }}> {content}
- ) + ); } } diff --git a/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js b/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js index 1994bcd2d..e78facecf 100644 --- a/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js +++ b/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js @@ -1,31 +1,31 @@ -/** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import CellMeasurer from './CellMeasurer' -import CellMeasurerCache from './CellMeasurerCache' -import List from '../List' -import styles from './CellMeasurer.example.css' +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 PureComponent { +export default class DynamicHeightList extends React.PureComponent { static propTypes = { getClassName: PropTypes.func.isRequired, list: PropTypes.instanceOf(Immutable.List).isRequired, - width: PropTypes.number.isRequired - } + width: PropTypes.number.isRequired, + }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this._cache = new CellMeasurerCache({ fixedWidth: true, - minHeight: 50 - }) + minHeight: 50, + }); - this._rowRenderer = this._rowRenderer.bind(this) + this._rowRenderer = this._rowRenderer.bind(this); } - render () { - const { width } = this.props + render() { + const {width} = this.props; return ( - ) + ); } - _rowRenderer ({ index, isScrolling, key, parent, style }) { - const { getClassName, list } = this.props + _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 datum = list.get(index % list.size); + const classNames = getClassName({columnIndex: 0, rowIndex: index}); - const imageWidth = 300 - const imageHeight = datum.size + const imageWidth = 300; + const imageHeight = datum.size * (1 + (index % 3)); - const source = `http://lorempixel.com/${imageWidth}/${imageHeight}/` + const source = `https://www.fillmurray.com/${imageWidth}/${imageHeight}`; return ( - {({ measure }) => ( -
+ parent={parent}> + {({measure, registerChild}) => ( +
)} - ) + ); } } diff --git a/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js b/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js index 386880269..b61bc33ec 100644 --- a/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js +++ b/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js @@ -1,31 +1,31 @@ -/** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import CellMeasurer from './CellMeasurer' -import CellMeasurerCache from './CellMeasurerCache' -import { Column, Table } from '../Table' -import styles from './CellMeasurer.example.css' +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 PureComponent { +export default class DynamicHeightTableColumn extends React.PureComponent { static propTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired, - width: PropTypes.number.isRequired - } + width: PropTypes.number.isRequired, + }; - constructor (props, context) { - super(props, context) + _cache = new CellMeasurerCache({ + fixedWidth: true, + minHeight: 25, + }); - this._cache = new CellMeasurerCache({ - fixedWidth: true, - minHeight: 25 - }) + _lastRenderedWidth = this.props.width; - this._columnCellRenderer = this._columnCellRenderer.bind(this) - this._rowGetter = this._rowGetter.bind(this) - } + render() { + const {width} = this.props; - render () { - const { width } = this.props + if (this._lastRenderedWidth !== this.props.width) { + this._lastRenderedWidth = this.props.width; + this._cache.clearAll(); + } return ( + width={width}>
- ) + ); } - _columnCellRenderer ({ cellData, columnData, dataKey, parent, rowData, rowIndex }) { - const { list } = this.props + _columnCellRenderer = ({dataKey, parent, rowIndex}) => { + const {list} = this.props; - const datum = list.get(rowIndex % list.size) - const content = rowIndex % 5 === 0 - ? '' - : datum.randomLong + const datum = list.get(rowIndex % list.size); + const content = rowIndex % 5 === 0 ? '' : datum.randomLong; return ( + rowIndex={rowIndex}>
+ whiteSpace: 'normal', + }}> {content}
- ) - } + ); + }; - _rowGetter ({ index }) { - const { list } = this.props + _rowGetter = ({index}) => { + const {list} = this.props; - return list.get(index % list.size) - } + return list.get(index % list.size); + }; } diff --git a/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js b/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js index 55a5aeaf4..8fd5e1771 100644 --- a/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js +++ b/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js @@ -1,32 +1,32 @@ -/** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import CellMeasurer from './CellMeasurer' -import CellMeasurerCache from './CellMeasurerCache' -import Grid from '../Grid' -import styles from './CellMeasurer.example.css' +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 PureComponent { +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 - } + width: PropTypes.number.isRequired, + }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this._cache = new CellMeasurerCache({ defaultWidth: 100, - fixedHeight: true - }) + fixedHeight: true, + }); - this._cellRenderer = this._cellRenderer.bind(this) + this._cellRenderer = this._cellRenderer.bind(this); } - render () { - const { width } = this.props + render() { + const {width} = this.props; return ( - ) + ); } - _cellRenderer ({ columnIndex, key, parent, rowIndex, style }) { - const { getClassName, getContent, list } = this.props + _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 }) + const datum = list.get((rowIndex + columnIndex) % list.size); + const classNames = getClassName({columnIndex, rowIndex}); + const content = getContent({index: columnIndex, datum, long: false}); return ( + rowIndex={rowIndex}>
+ whiteSpace: 'nowrap', + }}> {content}
- ) + ); } } diff --git a/source/CellMeasurer/CellMeasurer.DynamiWidthMultiGrid.example.js b/source/CellMeasurer/CellMeasurer.DynamicWidthMultiGrid.example.js similarity index 52% rename from source/CellMeasurer/CellMeasurer.DynamiWidthMultiGrid.example.js rename to source/CellMeasurer/CellMeasurer.DynamicWidthMultiGrid.example.js index 72989ce74..601741469 100644 --- a/source/CellMeasurer/CellMeasurer.DynamiWidthMultiGrid.example.js +++ b/source/CellMeasurer/CellMeasurer.DynamicWidthMultiGrid.example.js @@ -1,33 +1,33 @@ -/** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import CellMeasurer from './CellMeasurer' -import CellMeasurerCache from './CellMeasurerCache' -import MultiGrid from '../MultiGrid' -import styles from './CellMeasurer.example.css' +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 DynamiHeightMultiGrid extends PureComponent { +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 - } + width: PropTypes.number.isRequired, + }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this._cache = new CellMeasurerCache({ defaultHeight: 30, defaultWidth: 150, - fixedHeight: true - }) + fixedHeight: true, + }); - this._cellRenderer = this._cellRenderer.bind(this) + this._cellRenderer = this._cellRenderer.bind(this); } - render () { - const { width } = this.props + render() { + const {width} = this.props; return ( - ) + ); } - _cellRenderer ({ columnIndex, key, parent, rowIndex, style }) { - const { getClassName, getContent, list } = this.props + _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 }) + 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) + content = content.substr(0, 50); } return ( @@ -65,18 +65,16 @@ export default class DynamiHeightMultiGrid extends PureComponent { columnIndex={columnIndex} key={key} parent={parent} - rowIndex={rowIndex} - > + rowIndex={rowIndex}>
+ whiteSpace: 'nowrap', + }}> {content}
- ) + ); } } diff --git a/source/CellMeasurer/CellMeasurer.example.js b/source/CellMeasurer/CellMeasurer.example.js index 5f0fd797c..20287eae5 100644 --- a/source/CellMeasurer/CellMeasurer.example.js +++ b/source/CellMeasurer/CellMeasurer.example.js @@ -1,78 +1,90 @@ -/** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' -import AutoSizer from '../AutoSizer' -import cn from 'classnames' -import styles from './CellMeasurer.example.css' -import DynamicWidthGrid from './CellMeasurer.DynamicWidthGrid.example.js' -import DynamiHeightGrid from './CellMeasurer.DynamiHeightGrid.example.js' -import DynamiWidthMultiGrid from './CellMeasurer.DynamiWidthMultiGrid.example.js' -import DynamicHeightList from './CellMeasurer.DynamicHeightList.example.js' -import DynamicHeightTableColumn from './CellMeasurer.DynamicHeightTableColumn.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, - DynamiHeightGrid, - DynamiWidthMultiGrid, + DynamicHeightGrid, + DynamicWidthMultiGrid, DynamicHeightList, - DynamicHeightTableColumn -] + DynamicHeightTableColumn, +]; -export default class CellMeasurerExample extends PureComponent { +export default class CellMeasurerExample extends React.PureComponent { static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired - } + list: PropTypes.instanceOf(Immutable.List).isRequired, + }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this.state = { - currentTab: 0 - } + currentTab: 0, + }; - this._onClick = this._onClick.bind(this) + this._onClick = this._onClick.bind(this); } - render () { - const { list } = this.context - const { currentTab } = this.state + render() { + const {list} = this.context; + const {currentTab} = this.state; const buttonProps = { currentTab, - onClick: this._onClick - } + onClick: this._onClick, + }; - const DemoComponent = demoComponents[currentTab] + const DemoComponent = demoComponents[currentTab]; return ( - This component can be used to just-in-time measure dynamic content (eg. messages in a chat interface). + This component can be used to just-in-time measure dynamic content + (eg. messages in a chat interface). - {({ width }) => ( -
+ {({width}) => ( +
Grid: - dynamic width text - dynamic height text - + + dynamic width text + + + dynamic height text + MultiGrid: - dynamic width text - + + dynamic width text + List: - dynamic height image - + + dynamic height image + Table: - mixed fixed and dynamic height text + + mixed fixed and dynamic height text +
- ) + ); } - _onClick (id) { + _onClick(id) { this.setState({ - currentTab: id - }) + currentTab: id, + }); } } -function getClassName ({ columnIndex, rowIndex }) { - const rowClass = rowIndex % 2 === 0 ? styles.evenRow : styles.oddRow +function getClassName({columnIndex, rowIndex}) { + const rowClass = rowIndex % 2 === 0 ? styles.evenRow : styles.oddRow; - return cn(rowClass, styles.cell, { - [styles.centeredCell]: columnIndex > 2 - }) + return clsx(rowClass, styles.cell, { + [styles.centeredCell]: columnIndex > 2, + }); } -function getContent ({ index, datum, long = true }) { +function getContent({index, datum, long = true}) { switch (index % 3) { case 0: - return datum.color + return datum.color; case 1: - return datum.name + return datum.name; case 2: - return long ? datum.randomLong : datum.random + return long ? datum.randomLong : datum.random; } } -function Tab ({ children, currentTab, id, onClick }) { - const classNames = cn(styles.Tab, { - [styles.ActiveTab]: currentTab === id - }) +function Tab({children, currentTab, id, onClick}) { + const classNames = clsx(styles.Tab, { + [styles.ActiveTab]: currentTab === id, + }); return ( - - ) + ); } diff --git a/source/CellMeasurer/CellMeasurer.jest.js b/source/CellMeasurer/CellMeasurer.jest.js index b9c6561fc..d42fc3022 100644 --- a/source/CellMeasurer/CellMeasurer.jest.js +++ b/source/CellMeasurer/CellMeasurer.jest.js @@ -1,53 +1,51 @@ -/* global Element */ - -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import CellMeasurer from './CellMeasurer' -import CellMeasurerCache, { DEFAULT_HEIGHT, DEFAULT_WIDTH } from './CellMeasurerCache' +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.defineProperty( - Element.prototype, - 'offsetHeight', - { - configurable: true, - get: jest.fn().mockReturnValue(height) - } - ) - - Object.defineProperty( - Element.prototype, - 'offsetWidth', - { - configurable: true, - get: jest.fn().mockReturnValue(width) - } - ) +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() -} = {}) { +function createParent({cache, invalidateCellSizeAfterRender = jest.fn()} = {}) { return { invalidateCellSizeAfterRender, props: { - deferredMeasurementCache: cache - } - } + deferredMeasurementCache: cache, + }, + }; } -function renderHelper ({ +function renderHelper({ cache = new CellMeasurerCache({ - fixedWidth: true + fixedWidth: true, }), children =
, - parent + parent, } = {}) { render( + style={{}}> {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 cache = new CellMeasurerCache({fixedWidth: true}); + const parent = createParent({cache}); - mockClientWidthAndHeight({ + const {heightFn, widthFn} = mockClientWidthAndHeight({ height: 20, - width: 100 - }) + width: 100, + }); - const offsetHeightMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetHeight').get - const offsetWidthMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetWidth').get + expect(heightFn).toHaveBeenCalledTimes(0); + expect(widthFn).toHaveBeenCalledTimes(0); + expect(cache.has(0, 0)).toBe(false); - expect(offsetHeightMock.mock.calls).toHaveLength(0) - expect(offsetWidthMock.mock.calls).toHaveLength(0) - expect(cache.has(0, 0)).toBe(false) + renderHelper({cache, parent}); - renderHelper({ cache, parent }) - - expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled() - expect(offsetHeightMock.mock.calls).toHaveLength(1) - expect(offsetWidthMock.mock.calls).toHaveLength(1) - expect(cache.has(0, 0)).toBe(true) - expect(cache.getWidth(0, 0)).toBe(100) - expect(cache.getHeight(0, 0)).toBe(20) - }) + 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 cache = new CellMeasurerCache({fixedWidth: true}); + cache.set(0, 0, 100, 20); - const parent = createParent({ cache }) + const parent = createParent({cache}); - mockClientWidthAndHeight({ + const {heightFn, widthFn} = mockClientWidthAndHeight({ height: 20, - width: 100 - }) + width: 100, + }); - expect(cache.has(0, 0)).toBe(true) + expect(cache.has(0, 0)).toBe(true); - renderHelper({ cache, parent }) + renderHelper({cache, parent}); - const offsetHeightMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetHeight').get - const offsetWidthMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetWidth').get - - expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled() - expect(offsetHeightMock.mock.calls).toHaveLength(0) - expect(offsetWidthMock.mock.calls).toHaveLength(0) - }) + 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 }) + const cache = new CellMeasurerCache({fixedWidth: true}); + const parent = createParent({cache}); - renderHelper({ cache, parent }) + renderHelper({cache, parent}); - cache.clear(0, 0) - parent.invalidateCellSizeAfterRender.mockReset() + 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) + expect(cache.has(0, 0)).toBe(false); + expect(cache.getWidth(0, 0)).toBe(DEFAULT_WIDTH); + expect(cache.getHeight(0, 0)).toBe(DEFAULT_HEIGHT); - mockClientWidthAndHeight({ + const {heightFn, widthFn} = mockClientWidthAndHeight({ height: 20, - width: 100 - }) + width: 100, + }); - const offsetHeightMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetHeight').get - const offsetWidthMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetWidth').get + renderHelper({cache, parent}); - renderHelper({ cache, parent }) + expect(cache.has(0, 0)).toBe(true); - expect(cache.has(0, 0)).toBe(true) - - expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled() - expect(offsetHeightMock.mock.calls).toHaveLength(1) - expect(offsetWidthMock.mock.calls).toHaveLength(1) - expect(cache.getWidth(0, 0)).toBe(100) - expect(cache.getHeight(0, 0)).toBe(20) - }) + 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 cache = new CellMeasurerCache({fixedWidth: true}); + cache.set(0, 0, 100, 20); - const parent = createParent({ cache }) + const parent = createParent({cache}); - expect(cache.has(0, 0)).toBe(true) + expect(cache.has(0, 0)).toBe(true); - mockClientWidthAndHeight({ + const {heightFn, widthFn} = mockClientWidthAndHeight({ height: 20, - width: 100 - }) - - renderHelper({ cache, parent }) - renderHelper({ cache, parent }) - - const offsetHeightMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetHeight').get - const offsetWidthMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetWidth').get - - expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled() - expect(offsetHeightMock.mock.calls).toHaveLength(0) - expect(offsetWidthMock.mock.calls).toHaveLength(0) - }) - - it('componentDidUpdate() should pass a :measure param to a function child', () => { + 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 - }) + fixedWidth: true, + }); - const children = jest.fn() - children.mockReturnValue(
) + const children = jest.fn().mockReturnValue(
); - renderHelper({ cache, children }) + renderHelper({cache, children}); - expect(children).toHaveBeenCalled() + expect(children).toHaveBeenCalled(); - const params = children.mock.calls[0][0] + const params = children.mock.calls[0][0]; - expect(typeof params.measure === 'function').toBe(true) - }) + expect(typeof params.measure === 'function').toBe(true); + }); it('should still update cache without a parent Grid', () => { - spyOn(console, 'warn') + jest.spyOn(console, 'warn'); - mockClientWidthAndHeight({ - height: 20, - width: 100 - }) + mockClientWidthAndHeight({height: 20, width: 100}); const cache = new CellMeasurerCache({ - fixedWidth: true - }) + fixedWidth: true, + }); - renderHelper({ cache }) // No parent Grid + renderHelper({cache}); // No parent Grid - expect(cache.has(0, 0)).toBe(true) + expect(cache.has(0, 0)).toBe(true); - expect(console.warn).not.toHaveBeenCalled() - }) + expect(console.warn).not.toHaveBeenCalled(); + }); // See issue #593 - it('should explicitly set widht/height style to "auto" before re-measuring', () => { + 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({ - fixedWidth: true - }) - const parent = createParent({ cache }) - const child = jest.fn() - child.mockImplementation( - (params) =>
- ) - - const node = findDOMNode(render( - - {child} - - )) - - node.style.width = 100 - node.style.height = 30 - - child.mock.calls[0][0].measure() - - expect(node.style.height).toBe('auto') - expect(node.style.width).not.toBe('auto') - }) -}) + fixedHeight: true, + }); + const parent = createParent({cache}); + const child = jest + .fn() + .mockReturnValue(
); + + const node = findDOMNode( + render( + + {child} + , + ), + ); + + node.style.width = 200; + node.style.height = 60; + + child.mock.calls[0][0].measure(); + + expect(node.style.height).toBe('30px'); + expect(node.style.width).toBe('100px'); + }); +}); diff --git a/source/CellMeasurer/CellMeasurer.js b/source/CellMeasurer/CellMeasurer.js index 2392e8c7b..57f118050 100644 --- a/source/CellMeasurer/CellMeasurer.js +++ b/source/CellMeasurer/CellMeasurer.js @@ -1,14 +1,25 @@ /** @flow */ -import { PureComponent } from 'react' -import { findDOMNode } from 'react-dom' +import * as React from 'react'; +import type {CellMeasureCache} from './types'; +import {cloneElement} from 'react'; -type Props = { - cache: mixed, - children: mixed, +type Children = (params: {measure: () => void}) => React.Element<*>; + +type Cell = { columnIndex: number, - parent: mixed, rowIndex: number, - style: mixed +}; + +type Props = { + cache: CellMeasureCache, + children: Children | React.Element<*>, + columnIndex?: number, + index?: number, + parent: { + invalidateCellSizeAfterRender?: (cell: Cell) => void, + recomputeGridSize?: (cell: Cell) => void, + }, + rowIndex?: number, }; /** @@ -16,59 +27,104 @@ type Props = { * Measurements are stored in a per-cell cache. * Cached-content is not be re-measured. */ -export default class CellMeasurer extends PureComponent { - props: Props; +export default class CellMeasurer extends React.PureComponent { + static __internalCellMeasurerFlag = false; - constructor (props, context) { - super(props, context) + _child: {current: HTMLElement | null} = React.createRef(); - this._measure = this._measure.bind(this) + componentDidMount() { + this._maybeMeasureCell(); } - componentDidMount () { - this._maybeMeasureCell() + componentDidUpdate() { + this._maybeMeasureCell(); } - componentDidUpdate (prevProps, prevState) { - this._maybeMeasureCell() - } + render() { + const {children} = this.props; - render () { - const { children } = this.props + const resolvedChildren = + typeof children === 'function' + ? children({measure: this._measure, registerChild: this._registerChild}) + : children; + + if (resolvedChildren === null) { + return resolvedChildren; + } - return typeof children === 'function' - ? children({ measure: this._measure }) - : children + 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; + }, + }); } - _maybeMeasureCell () { - const { cache, columnIndex, parent, rowIndex } = this.props + _getCellMeasurements() { + const {cache} = this.props; - if (!cache.has(rowIndex, columnIndex)) { - const node = findDOMNode(this) + const node = this._child.current; - // TODO Check for a bad combination of fixedWidth and missing numeric width or vice versa with height + // 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' + node.style.width = 'auto'; } if (!cache.hasFixedHeight()) { - node.style.height = 'auto' + node.style.height = 'auto'; } - const height = Math.ceil(node.offsetHeight) - const width = Math.ceil(node.offsetWidth) + 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; + } - cache.set( - rowIndex, - columnIndex, - width, - height - ) + 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 ( @@ -77,57 +133,51 @@ export default class CellMeasurer extends PureComponent { ) { parent.invalidateCellSizeAfterRender({ columnIndex, - rowIndex - }) + rowIndex, + }); } } } - _measure () { - const { cache, columnIndex, parent, rowIndex } = this.props - - const node = findDOMNode(this) - - // 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. - if (!cache.hasFixedWidth()) { - node.style.width = 'auto' - } - if (!cache.hasFixedHeight()) { - node.style.height = 'auto' - } + _measure = () => { + const { + cache, + columnIndex = 0, + parent, + rowIndex = this.props.index || 0, + } = this.props; - const height = Math.ceil(node.offsetHeight) - const width = Math.ceil(node.offsetWidth) + const {height, width} = this._getCellMeasurements(); if ( height !== cache.getHeight(rowIndex, columnIndex) || width !== cache.getWidth(rowIndex, columnIndex) ) { - cache.set( - rowIndex, - columnIndex, - width, - height - ) + cache.set(rowIndex, columnIndex, width, height); - if ( - parent && - typeof parent.recomputeGridSize === 'function' - ) { + if (parent && typeof parent.recomputeGridSize === 'function') { parent.recomputeGridSize({ columnIndex, - rowIndex - }) + 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 + CellMeasurer.__internalCellMeasurerFlag = true; } diff --git a/source/CellMeasurer/CellMeasurerCache.jest.js b/source/CellMeasurer/CellMeasurerCache.jest.js index 352304843..de0eb73e2 100644 --- a/source/CellMeasurer/CellMeasurerCache.jest.js +++ b/source/CellMeasurer/CellMeasurerCache.jest.js @@ -1,4 +1,7 @@ -import CellMeasurerCache, { DEFAULT_HEIGHT, DEFAULT_WIDTH } from './CellMeasurerCache' +import CellMeasurerCache, { + DEFAULT_HEIGHT, + DEFAULT_WIDTH, +} from './CellMeasurerCache'; describe('CellMeasurerCache', () => { it('should override defaultHeight/defaultWidth if minHeight/minWidth are greater', () => { @@ -8,239 +11,265 @@ describe('CellMeasurerCache', () => { 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) - }) + 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) - }) + 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) - }) + 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, - fixedHeight: true, - fixedWidth: true, 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) - }) + 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) - }) + 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) - }) + 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') + 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, - fixedHeight: true, - fixedWidth: true, - keyMapper - }) - cache.set(0, 0, 100, 20) - expect(cache.has(0, 0)).toBe(true) + 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) + 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) - }) + 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, - fixedWidth: 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) - }) + }); + 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({ - fixedHeight: true, - 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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') + spyOn(console, 'warn'); const cache = new CellMeasurerCache({ fixedHeight: false, - fixedWidth: false - }) + fixedWidth: false, + }); - expect(cache.hasFixedHeight()).toBe(false) - expect(cache.hasFixedWidth()).toBe(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.' - ) - }) + "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') + spyOn(console, 'warn'); const cache = new CellMeasurerCache({ defaultWidth: 0, - fixedHeight: true - }) + fixedHeight: true, + }); - expect(cache.getWidth(0, 0)).toBe(0) + 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.' - ) - }) + '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') + spyOn(console, 'warn'); const cache = new CellMeasurerCache({ defaultHeight: 0, - fixedWidth: true - }) + fixedWidth: true, + }); - expect(cache.getHeight(0, 0)).toBe(0) + 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.' - ) - }) - }) -}) + 'Failing to do so will lead to unnecessary layout and poor performance.', + ); + }); + }); +}); diff --git a/source/CellMeasurer/CellMeasurerCache.js b/source/CellMeasurer/CellMeasurerCache.js index 20a75b2f4..05975fdc9 100644 --- a/source/CellMeasurer/CellMeasurerCache.js +++ b/source/CellMeasurer/CellMeasurerCache.js @@ -1,48 +1,51 @@ /** @flow */ -export const DEFAULT_HEIGHT = 30 -export const DEFAULT_WIDTH = 100 +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 KeyMapper = (rowIndex: number, columnIndex: number) => any; type CellMeasurerCacheParams = { - defaultHeight ?: number, - defaultWidth ?: number, - fixedHeight ?: boolean, - fixedWidth ?: boolean, + defaultHeight?: number, + defaultWidth?: number, + fixedHeight?: boolean, + fixedWidth?: boolean, minHeight?: number, minWidth?: number, - keyMapper ?: KeyMapper + keyMapper?: KeyMapper, }; type Cache = { - [key: any]: number + [key: any]: number, }; type IndexParam = { - index: number + index: number, }; /** * Caches measurements for a given cell. */ -export default class CellMeasurerCache { - _cellHeightCache: Cache; - _cellWidthCache: Cache; - _columnWidthCache: Cache; - _defaultHeight: ?number; - _defaultWidth: ?number; - _minHeight: ?number; - _minWidth: ?number; +export default class CellMeasurerCache implements CellMeasureCache { + _cellHeightCache: Cache = {}; + _cellWidthCache: Cache = {}; + _columnWidthCache: Cache = {}; + _rowHeightCache: Cache = {}; + _defaultHeight: number; + _defaultWidth: number; + _minHeight: number; + _minWidth: number; _keyMapper: KeyMapper; - _rowHeightCache: Cache; + _hasFixedHeight: boolean; + _hasFixedWidth: boolean; + _columnCount = 0; + _rowCount = 0; - constructor (params : CellMeasurerCacheParams = {}) { + constructor(params: CellMeasurerCacheParams = {}) { const { defaultHeight, defaultWidth, @@ -50,193 +53,175 @@ export default class CellMeasurerCache { fixedWidth, keyMapper, minHeight, - minWidth - } = params + minWidth, + } = params; - this._hasFixedHeight = fixedHeight === true - this._hasFixedWidth = fixedWidth === true - this._minHeight = minHeight || 0 - this._minWidth = minWidth || 0 - this._keyMapper = keyMapper || defaultKeyMapper + 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 - ) + typeof defaultHeight === 'number' ? defaultHeight : DEFAULT_HEIGHT, + ); this._defaultWidth = Math.max( this._minWidth, - typeof defaultWidth === 'number' - ? defaultWidth - : DEFAULT_WIDTH - ) + typeof defaultWidth === 'number' ? defaultWidth : DEFAULT_WIDTH, + ); if (process.env.NODE_ENV !== 'production') { - if ( - this._hasFixedHeight === false && - this._hasFixedWidth === false - ) { + 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.' - ) + "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 - ) { + 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.' - ) + 'Failing to do so will lead to unnecessary layout and poor performance.', + ); } - if ( - this._hasFixedWidth === false && - this._defaultWidth === 0 - ) { + 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.' - ) + 'Failing to do so will lead to unnecessary layout and poor performance.', + ); } } - - this._columnCount = 0 - this._rowCount = 0 - - this._cellHeightCache = {} - this._cellWidthCache = {} - this._columnWidthCache = {} - this._rowHeightCache = {} } - clear ( - rowIndex: number, - columnIndex: number - ) : void { - const key = this._keyMapper(rowIndex, columnIndex) + clear(rowIndex: number, columnIndex: number = 0) { + const key = this._keyMapper(rowIndex, columnIndex); - delete this._cellHeightCache[key] - delete this._cellWidthCache[key] + delete this._cellHeightCache[key]; + delete this._cellWidthCache[key]; - this._updateCachedColumnAndRowSizes(rowIndex, columnIndex) + this._updateCachedColumnAndRowSizes(rowIndex, columnIndex); } - clearAll () : void { - this._cellHeightCache = {} - this._cellWidthCache = {} - this._columnWidthCache = {} - this._rowHeightCache = {} + clearAll() { + this._cellHeightCache = {}; + this._cellWidthCache = {}; + this._columnWidthCache = {}; + this._rowHeightCache = {}; + this._rowCount = 0; + this._columnCount = 0; } - columnWidth = ({ index } : IndexParam) => { - const key = this._keyMapper(0, index) + columnWidth = ({index}: IndexParam) => { + const key = this._keyMapper(0, index); - return this._columnWidthCache.hasOwnProperty(key) + return this._columnWidthCache[key] !== undefined ? this._columnWidthCache[key] - : this._defaultWidth + : this._defaultWidth; + }; + + get defaultHeight(): number { + return this._defaultHeight; } - hasFixedHeight () : boolean { - return this._hasFixedHeight + get defaultWidth(): number { + return this._defaultWidth; } - hasFixedWidth () : boolean { - return this._hasFixedWidth + hasFixedHeight(): boolean { + return this._hasFixedHeight; } - getHeight ( - rowIndex: number, - columnIndex: number - ) : ?number { - const key = this._keyMapper(rowIndex, columnIndex) + hasFixedWidth(): boolean { + return this._hasFixedWidth; + } - return this._cellHeightCache.hasOwnProperty(key) - ? Math.max(this._minHeight, this._cellHeightCache[key]) - : this._defaultHeight + 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 - ) : ?number { - const key = this._keyMapper(rowIndex, columnIndex) + getWidth(rowIndex: number, columnIndex: number = 0): number { + if (this._hasFixedWidth) { + return this._defaultWidth; + } else { + const key = this._keyMapper(rowIndex, columnIndex); - return this._cellWidthCache.hasOwnProperty(key) - ? Math.max(this._minWidth, this._cellWidthCache[key]) - : this._defaultWidth + return this._cellWidthCache[key] !== undefined + ? Math.max(this._minWidth, this._cellWidthCache[key]) + : this._defaultWidth; + } } - has ( - rowIndex: number, - columnIndex: number - ) : boolean { - const key = this._keyMapper(rowIndex, columnIndex) + has(rowIndex: number, columnIndex: number = 0): boolean { + const key = this._keyMapper(rowIndex, columnIndex); - return this._cellHeightCache.hasOwnProperty(key) + return this._cellHeightCache[key] !== undefined; } - rowHeight = ({ index } : IndexParam) => { - const key = this._keyMapper(index, 0) + rowHeight = ({index}: IndexParam) => { + const key = this._keyMapper(index, 0); - return this._rowHeightCache.hasOwnProperty(key) + return this._rowHeightCache[key] !== undefined ? this._rowHeightCache[key] - : this._defaultHeight - } + : this._defaultHeight; + }; - set ( + set( rowIndex: number, columnIndex: number, width: number, - height: number - ) : void { - const key = this._keyMapper(rowIndex, columnIndex) + height: number, + ): void { + const key = this._keyMapper(rowIndex, columnIndex); if (columnIndex >= this._columnCount) { - this._columnCount = columnIndex + 1 + this._columnCount = columnIndex + 1; } if (rowIndex >= this._rowCount) { - this._rowCount = rowIndex + 1 + 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._cellHeightCache[key] = height; + this._cellWidthCache[key] = width; - this._updateCachedColumnAndRowSizes(rowIndex, columnIndex) + this._updateCachedColumnAndRowSizes(rowIndex, columnIndex); } - _updateCachedColumnAndRowSizes ( - rowIndex: number, - columnIndex: number - ) : void { + _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. - let columnWidth = 0 - for (let i = 0; i < this._rowCount; i++) { - columnWidth = Math.max(columnWidth, this.getWidth(i, columnIndex)) + // 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; } - let rowHeight = 0 - for (let i = 0; i < this._columnCount; i++) { - rowHeight = Math.max(rowHeight, this.getHeight(rowIndex, i)) + 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; } - - const columnKey = this._keyMapper(0, columnIndex) - const rowKey = this._keyMapper(rowIndex, 0) - - this._columnWidthCache[columnKey] = columnWidth - this._rowHeightCache[rowKey] = rowHeight } } -function defaultKeyMapper ( - rowIndex: number, - columnIndex: number -): any { - return `${rowIndex}-${columnIndex}` +function defaultKeyMapper(rowIndex: number, columnIndex: number) { + return `${rowIndex}-${columnIndex}`; } diff --git a/source/CellMeasurer/index.js b/source/CellMeasurer/index.js index 33516996e..b287ce45b 100644 --- a/source/CellMeasurer/index.js +++ b/source/CellMeasurer/index.js @@ -1,3 +1,6 @@ -export default from './CellMeasurer' -export CellMeasurer from './CellMeasurer' -export CellMeasurerCache from './CellMeasurerCache' +/** @flow */ +import CellMeasurer from './CellMeasurer'; +import CellMeasurerCache from './CellMeasurerCache'; + +export default CellMeasurer; +export {CellMeasurer, CellMeasurerCache}; diff --git a/source/CellMeasurer/types.js b/source/CellMeasurer/types.js new file mode 100644 index 000000000..a673ed53e --- /dev/null +++ b/source/CellMeasurer/types.js @@ -0,0 +1,15 @@ +// @flow + +export interface CellMeasureCache { + hasFixedWidth(): boolean; + hasFixedHeight(): boolean; + has(rowIndex: number, columnIndex: number): boolean; + set( + rowIndex: number, + columnIndex: number, + width: number, + height: number, + ): void; + getHeight(rowIndex: number, columnIndex?: number): number; + getWidth(rowIndex: number, columnIndex?: number): number; +} diff --git a/source/Collection/Collection.example.js b/source/Collection/Collection.example.js index d0bcc989b..1101b13bc 100644 --- a/source/Collection/Collection.example.js +++ b/source/Collection/Collection.example.js @@ -1,24 +1,29 @@ /** @flow */ -import React, { PropTypes, PureComponent } 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' +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 +const GUTTER_SIZE = 3; +const CELL_WIDTH = 75; -export default class CollectionExample extends PureComponent { +export default class CollectionExample extends React.PureComponent { static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired + list: PropTypes.instanceOf(Immutable.List).isRequired, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this.state = { cellCount: context.list.size, @@ -27,45 +32,63 @@ export default class CollectionExample extends PureComponent { 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) + 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 + 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. + Renders scattered or non-linear data. Unlike Grid, which + renders checkerboard data, Collection can render + arbitrarily positioned- even overlapping- data. @@ -73,40 +96,40 @@ export default class CollectionExample extends PureComponent { - {({ width }) => ( + {({width}) => ( - ) + ); } - _cellRenderer ({ index, isScrolling, key, style }) { - const { list } = this.context - const { showScrollingPlaceholder } = this.state + _cellRenderer({index, isScrolling, key, style}) { + const {list} = this.context; + const {showScrollingPlaceholder} = this.state; - const datum = list.get(index % list.size) + const datum = list.get(index % list.size); // Customize style - style.backgroundColor = datum.color + style.backgroundColor = datum.color; return ( -
+
{showScrollingPlaceholder && isScrolling ? '...' : index}
- ) + ); } - _cellSizeAndPositionGetter ({ index }) { - const { list } = this.context - const { columnCount } = this.state + _cellSizeAndPositionGetter({index}) { + const {list} = this.context; + const {columnCount} = this.state; - const columnPosition = index % (columnCount || 1) - const datum = list.get(index % list.size) + 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 + 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 + this._columnYMap[columnPosition] = y + height + GUTTER_SIZE; return { height, width, x, - y - } + y, + }; } - _getColumnCount (cellCount) { - return Math.round(Math.sqrt(cellCount)) + _getColumnCount(cellCount) { + return Math.round(Math.sqrt(cellCount)); } - _onHorizontalOverscanSizeChange (event) { - const horizontalOverscanSize = parseInt(event.target.value, 10) || 0 + _onHorizontalOverscanSizeChange(event) { + const horizontalOverscanSize = parseInt(event.target.value, 10) || 0; - this.setState({ horizontalOverscanSize }) + this.setState({horizontalOverscanSize}); } - _noContentRenderer () { - return ( -
- No cells -
- ) + _noContentRenderer() { + return
No cells
; } - _onCellCountChange (event) { - const cellCount = parseInt(event.target.value, 10) || 0 + _onCellCountChange(event) { + const cellCount = parseInt(event.target.value, 10) || 0; - this._columnYMap = [] + this._columnYMap = []; this.setState({ cellCount, - columnCount: this._getColumnCount(cellCount) - }) + columnCount: this._getColumnCount(cellCount), + }); } - _onHeightChange (event) { - const height = parseInt(event.target.value, 10) || 0 + _onHeightChange(event) { + const height = parseInt(event.target.value, 10) || 0; - this.setState({ height }) + this.setState({height}); } - _onScrollToCellChange (event) { - const { cellCount } = this.state + _onScrollToCellChange(event) { + const {cellCount} = this.state; - let scrollToCell = Math.min(cellCount - 1, parseInt(event.target.value, 10)) + let scrollToCell = Math.min( + cellCount - 1, + parseInt(event.target.value, 10), + ); if (isNaN(scrollToCell)) { - scrollToCell = undefined + scrollToCell = undefined; } - this.setState({ scrollToCell }) + this.setState({scrollToCell}); } - _onVerticalOverscanSizeChange (event) { - const verticalOverscanSize = parseInt(event.target.value, 10) || 0 + _onVerticalOverscanSizeChange(event) { + const verticalOverscanSize = parseInt(event.target.value, 10) || 0; - this.setState({ verticalOverscanSize }) + this.setState({verticalOverscanSize}); } } diff --git a/source/Collection/Collection.jest.js b/source/Collection/Collection.jest.js index 38fa9405b..6999720c7 100644 --- a/source/Collection/Collection.jest.js +++ b/source/Collection/Collection.jest.js @@ -2,36 +2,30 @@ * Tests Collection and CollectionView. * @flow */ -import getScrollbarSize from 'dom-helpers/util/scrollbarSize' -import React from 'react' -import { findDOMNode } from 'react-dom' -import { Simulate } from 'react-addons-test-utils' -import { render } from '../TestUtils' -import Collection from './Collection' -import { CELLS, SECTION_SIZE } from './TestData' +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 }) { + function defaultCellRenderer({index, key, style}) { return ( -
+
cell:{index}
- ) + ); } - function getMarkup (props = {}) { - const { - cellCount = CELLS.length - } = props + function getMarkup(props = {}) { + const {cellCount = CELLS.length} = props; - function defaultCellSizeAndPositionGetter ({ index }) { - index = index % cellCount + function defaultCellSizeAndPositionGetter({index}) { + index %= cellCount; - return CELLS[index] + return CELLS[index]; } return ( @@ -44,694 +38,809 @@ describe('Collection', () => { width={SECTION_SIZE * 2} {...props} /> - ) + ); } - 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 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) + function compareArrays(array1, array2) { + expect(array1.length).toEqual(array2.length); array2.forEach(value => { - expect(array1).toContain(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) - }) + 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) - }) + 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 }) { + function cellRenderer({index, key, style}) { if (index > 2) { - return null + return null; } else { return ( -
+
{index}
- ) + ); } } - const rendered = findDOMNode(render(getMarkup({ cellRenderer }))) - expect(rendered.querySelectorAll('.cell').length).toEqual(3) - }) - }) + const rendered = findDOMNode(render(getMarkup({cellRenderer}))); + expect(rendered.querySelectorAll('.cell').length).toEqual(3); + }); + }); describe('shows and hides scrollbars based on rendered content', () => { - let scrollbarSize + let scrollbarSize; beforeAll(() => { - scrollbarSize = getScrollbarSize() - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) - }) + 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') - }) + 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 + 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]) - }) + 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') - }) - }) + 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) - }) + 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) - }) + 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) - }) + 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 - })) + 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 - })) + 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 - })) + 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) - }) + 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) - }) - }) + 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') + 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') - }) + 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') + 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') - }) + 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') - }) - }) + 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') - }) + 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('') - }) + 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 } + const offscreenSizeAndPosition = { + x: SECTION_SIZE * 3, + y: SECTION_SIZE * 3, + width: 1, + height: 1, + }; - function cellSizeAndPositionGetter ({ index }) { - return offscreenSizeAndPosition + function cellSizeAndPositionGetter() { + return offscreenSizeAndPosition; } - let list = findDOMNode(render(getMarkup({ - cellCount: 1, - cellSizeAndPositionGetter, - noContentRenderer: () =>
No data
- }))) - expect(list.textContent).not.toEqual('No data') - }) - }) + 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]) - }) + 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 + 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]) - }) + 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 + 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]) - }) + 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) - }) - }) + 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 - render(getMarkup({ - onSectionRendered: params => { - indices = params.indices - }, - scrollLeft: 2, - scrollTop: 2 - })) - compareArrays(indices, [3, 4, 5, 7, 8, 9]) - }) + 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]) - render(getMarkup({ - onSectionRendered: params => { - indices = params.indices - }, - scrollLeft: 2, - scrollTop: 2 - })) - compareArrays(indices, [3, 4, 5, 7, 8, 9]) - }) - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) - }) + 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 - }]) - }) + 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) - })) + const onScrollCalls = []; + const collection = render( + getMarkup({ + onScroll: params => onScrollCalls.push(params), + }), + ); simulateScroll({ collection, scrollLeft: 1, - scrollTop: 0 - }) - expect(onScrollCalls.length).toEqual(2) + 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 - }) - }) + scrollWidth: 6, + }); + }); it('should trigger callback when component scrolls vertically', () => { - const onScrollCalls = [] - const collection = render(getMarkup({ - onScroll: params => onScrollCalls.push(params) - })) + const onScrollCalls = []; + const collection = render( + getMarkup({ + onScroll: params => onScrollCalls.push(params), + }), + ); simulateScroll({ collection, scrollLeft: 0, - scrollTop: 2 - }) - expect(onScrollCalls.length).toEqual(2) + 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 - }) - }) + scrollWidth: 6, + }); + }); it('should not allow negative scroll values', () => { - const onScrollCalls = [] - const collection = render(getMarkup({ - onScroll: params => onScrollCalls.push(params) - })) + 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) - }) - }) + 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 }) => ( -
+ 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 }) + ); + 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 - })) + const collection = render( + getMarkup({ + cellRenderer, + }), + ); - expect(cellRendererCalls[0]).toEqual(false) + expect(cellRendererCalls[0]).toEqual(false); - cellRendererCalls.splice(0) + cellRendererCalls.splice(0); - simulateScroll({ collection, scrollTop: 1 }) + simulateScroll({collection, scrollTop: 1}); // Give React time to process the queued setState() - await new Promise(resolve => setTimeout(resolve, 1)) + await new Promise(resolve => setTimeout(resolve, 1)); - expect(cellRendererCalls[0]).toEqual(true) + expect(cellRendererCalls[0]).toEqual(true); - done() - }) + 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]) - }) + 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]) - }) + 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]) - }) - }) + 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 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('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 }) + 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 - } + scrollTop: 0, + }; - const collection = render(getMarkup(props)) - expect(cellRendererCalls.length).toEqual(4) - cellRendererCalls.forEach((call) => expect(call.isScrolling).toEqual(false)) + 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 }) + simulateScroll({collection, scrollTop: 2}); - cellRendererCalls.splice(0) + 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 }) + 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 - } + scrollTop: 0, + }; - const collection = render(getMarkup(props)) - simulateScroll({ collection, scrollTop: 1 }) + 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)) + await new Promise(resolve => setTimeout(resolve, 500)); - cellRendererCalls.splice(0) + cellRendererCalls.splice(0); - render(getMarkup({ - ...props, - scrollTop: 1 - })) - expect(cellRendererCalls.length).not.toEqual(0) + render( + getMarkup({ + ...props, + scrollTop: 1, + }), + ); + expect(cellRendererCalls.length).not.toEqual(0); - done() - }) - }) + done(); + }); + }); // See issue #568 for more it('forceUpdate will also forceUpdate the inner CollectionView', () => { - const cellRenderer = jest.fn() - cellRenderer.mockImplementation(({ key }) =>
) + const cellRenderer = jest.fn(); + cellRenderer.mockImplementation(({key}) =>
); - const rendered = render(getMarkup({ cellRenderer })) + const rendered = render(getMarkup({cellRenderer})); - expect(cellRenderer).toHaveBeenCalled() + expect(cellRenderer).toHaveBeenCalled(); - cellRenderer.mockReset() - rendered.forceUpdate() + cellRenderer.mockReset(); + rendered.forceUpdate(); - expect(cellRenderer).toHaveBeenCalled() - }) -}) + expect(cellRenderer).toHaveBeenCalled(); + }); +}); diff --git a/source/Collection/Collection.js b/source/Collection/Collection.js index df293d024..8d21a1677 100644 --- a/source/Collection/Collection.js +++ b/source/Collection/Collection.js @@ -1,15 +1,16 @@ /** @flow */ -import React, { PropTypes, PureComponent } from 'react' -import CollectionView from './CollectionView' -import calculateSizeAndPositionData from './utils/calculateSizeAndPositionData' -import getUpdatedOffsetForIndex from '../utils/getUpdatedOffsetForIndex' -import type { ScrollPosition, SizeInfo } from './types' +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 PureComponent { +export default class Collection extends React.PureComponent { static propTypes = { 'aria-label': PropTypes.string, @@ -43,43 +44,43 @@ export default class Collection extends PureComponent { /** * Optionally override the size of the sections a Collection's cells are split into. */ - sectionSize: PropTypes.number + sectionSize: PropTypes.number, }; static defaultProps = { 'aria-label': 'grid', - cellGroupRenderer: defaultCellGroupRenderer + cellGroupRenderer: defaultCellGroupRenderer, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); - this._cellMetadata = [] - this._lastRenderedCellIndices = [] + this._cellMetadata = []; + this._lastRenderedCellIndices = []; - // Cell cache during scroll (for perforamnce) - this._cellCache = [] + // Cell cache during scroll (for performance) + this._cellCache = []; - this._isScrollingChange = this._isScrollingChange.bind(this) - this._setCollectionViewRef = this._setCollectionViewRef.bind(this) + this._isScrollingChange = this._isScrollingChange.bind(this); + this._setCollectionViewRef = this._setCollectionViewRef.bind(this); } - forceUpdate () { + forceUpdate() { if (this._collectionView !== undefined) { - this._collectionView.forceUpdate() + this._collectionView.forceUpdate(); } } /** See Collection#recomputeCellSizesAndPositions */ - recomputeCellSizesAndPositions () { - this._cellCache = [] - this._collectionView.recomputeCellSizesAndPositions() + recomputeCellSizesAndPositions() { + this._cellCache = []; + this._collectionView.recomputeCellSizesAndPositions(); } /** React lifecycle methods */ - render () { - const { ...props } = this.props + render() { + const {...props} = this.props; return ( - ) + ); } /** CellLayoutManager interface */ - calculateSizeAndPositionData () { - const { cellCount, cellSizeAndPositionGetter, sectionSize } = this.props + calculateSizeAndPositionData() { + const {cellCount, cellSizeAndPositionGetter, sectionSize} = this.props; const data = calculateSizeAndPositionData({ cellCount, cellSizeAndPositionGetter, - sectionSize - }) + sectionSize, + }); - this._cellMetadata = data.cellMetadata - this._sectionManager = data.sectionManager - this._height = data.height - this._width = data.width + 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 + getLastRenderedIndices() { + return this._lastRenderedCellIndices; } /** * Calculates the minimum amount of change from the current scroll position to ensure the specified cell is (fully) visible. */ - getScrollPositionForCell ({ + getScrollPositionForCell({ align, cellIndex, height, scrollLeft, scrollTop, - width + width, }): ScrollPosition { - const { cellCount } = this.props + const {cellCount} = this.props; - if ( - cellIndex >= 0 && - cellIndex < cellCount - ) { - const cellMetadata = this._cellMetadata[cellIndex] + if (cellIndex >= 0 && cellIndex < cellCount) { + const cellMetadata = this._cellMetadata[cellIndex]; scrollLeft = getUpdatedOffsetForIndex({ align, @@ -140,8 +138,8 @@ export default class Collection extends PureComponent { cellSize: cellMetadata.width, containerSize: width, currentOffset: scrollLeft, - targetIndex: cellIndex - }) + targetIndex: cellIndex, + }); scrollTop = getUpdatedOffsetForIndex({ align, @@ -149,70 +147,65 @@ export default class Collection extends PureComponent { cellSize: cellMetadata.height, containerSize: height, currentOffset: scrollTop, - targetIndex: cellIndex - }) + targetIndex: cellIndex, + }); } return { scrollLeft, - scrollTop - } + scrollTop, + }; } - getTotalSize (): SizeInfo { + getTotalSize(): SizeInfo { return { height: this._height, - width: this._width - } + width: this._width, + }; } - cellRenderers ({ - height, - isScrolling, - width, - x, - y - }) { - const { cellGroupRenderer, cellRenderer } = this.props + 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 - }) + y, + }); return cellGroupRenderer({ cellCache: this._cellCache, cellRenderer, - cellSizeAndPositionGetter: ({ index }) => this._sectionManager.getCellMetadata({ index }), + cellSizeAndPositionGetter: ({index}) => + this._sectionManager.getCellMetadata({index}), indices: this._lastRenderedCellIndices, - isScrolling - }) + isScrolling, + }); } - _isScrollingChange (isScrolling) { + _isScrollingChange(isScrolling) { if (!isScrolling) { - this._cellCache = [] + this._cellCache = []; } } - _setCollectionViewRef (ref) { - this._collectionView = ref + _setCollectionViewRef(ref) { + this._collectionView = ref; } } -function defaultCellGroupRenderer ({ +function defaultCellGroupRenderer({ cellCache, cellRenderer, cellSizeAndPositionGetter, indices, - isScrolling + isScrolling, }) { return indices - .map((index) => { - const cellMetadata = cellSizeAndPositionGetter({ index }) + .map(index => { + const cellMetadata = cellSizeAndPositionGetter({index}); let cellRendererProps = { index, @@ -223,9 +216,9 @@ function defaultCellGroupRenderer ({ left: cellMetadata.x, position: 'absolute', top: cellMetadata.y, - width: cellMetadata.width - } - } + 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. @@ -233,13 +226,13 @@ function defaultCellGroupRenderer ({ // This cache will be thrown away once scrolling complets. if (isScrolling) { if (!(index in cellCache)) { - cellCache[index] = cellRenderer(cellRendererProps) + cellCache[index] = cellRenderer(cellRendererProps); } - return cellCache[index] + return cellCache[index]; } else { - return cellRenderer(cellRendererProps) + return cellRenderer(cellRendererProps); } }) - .filter((renderedCell) => !!renderedCell) + .filter(renderedCell => !!renderedCell); } diff --git a/source/Collection/CollectionView.js b/source/Collection/CollectionView.js index c60329445..2525990df 100644 --- a/source/Collection/CollectionView.js +++ b/source/Collection/CollectionView.js @@ -1,16 +1,18 @@ /** @flow */ -import React, { PropTypes, PureComponent } from 'react' -import cn from 'classnames' -import createCallbackMemoizer from '../utils/createCallbackMemoizer' -import getScrollbarSize from 'dom-helpers/util/scrollbarSize' +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 miliseconds during which to disable pointer events while a scroll is in progress. + * 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 +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. @@ -18,14 +20,14 @@ const IS_SCROLLING_TIMEOUT = 150 */ const SCROLL_POSITION_CHANGE_REASONS = { OBSERVED: 'observed', - REQUESTED: 'requested' -} + 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. */ -export default class CollectionView extends PureComponent { +class CollectionView extends React.PureComponent { static propTypes = { 'aria-label': PropTypes.string, @@ -96,7 +98,8 @@ export default class CollectionView extends PureComponent { * 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, + scrollToAlignment: PropTypes.oneOf(['auto', 'end', 'start', 'center']) + .isRequired, /** * Cell index to ensure visible (by forcefully scrolling if necessary). @@ -122,7 +125,7 @@ export default class CollectionView extends PureComponent { /** * Width of Collection; this property determines the number of visible (vs virtualized) columns. */ - width: PropTypes.number.isRequired + width: PropTypes.number.isRequired, }; static defaultProps = { @@ -134,29 +137,33 @@ export default class CollectionView extends PureComponent { scrollToAlignment: 'auto', scrollToCell: -1, style: {}, - verticalOverscanSize: 0 + verticalOverscanSize: 0, }; - constructor (props, context) { - super(props, context) + state = { + isScrolling: false, + scrollLeft: 0, + scrollTop: 0, + }; - this.state = { - isScrolling: false, - scrollLeft: 0, - scrollTop: 0 - } + _calculateSizeAndPositionDataOnNextUpdate = false; - this._calculateSizeAndPositionDataOnNextUpdate = false + // Invokes callbacks only when their values have changed. + _onSectionRenderedMemoizer = createCallbackMemoizer(); + _onScrollMemoizer = createCallbackMemoizer(false); - // Invokes callbacks only when their values have changed. - this._onSectionRenderedMemoizer = createCallbackMemoizer() - this._onScrollMemoizer = createCallbackMemoizer(false) + constructor(...args) { + super(...args); - // Bind functions to instance so they don't lose context when passed around. - this._invokeOnSectionRenderedHelper = this._invokeOnSectionRenderedHelper.bind(this) - this._onScroll = this._onScroll.bind(this) - this._setScrollingContainerRef = this._setScrollingContainerRef.bind(this) - this._updateScrollPositionForScrollToCell = this._updateScrollPositionForScrollToCell.bind(this) + // 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; + } } /** @@ -164,70 +171,109 @@ export default class CollectionView extends PureComponent { * 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() + recomputeCellSizesAndPositions() { + this._calculateSizeAndPositionDataOnNextUpdate = true; + this.forceUpdate(); } /* ---------------------------- Component lifecycle methods ---------------------------- */ - componentDidMount () { - const { cellLayoutManager, scrollLeft, scrollToCell, scrollTop } = this.props + /** + * @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({}) + this._scrollbarSize = getScrollbarSize(); + this._scrollbarSizeMeasured = true; + this.setState({}); } if (scrollToCell >= 0) { - this._updateScrollPositionForScrollToCell() + this._updateScrollPositionForScrollToCell(); } else if (scrollLeft >= 0 || scrollTop >= 0) { - this._setScrollPosition({ scrollLeft, scrollTop }) + this._setScrollPosition({scrollLeft, scrollTop}); } // Update onSectionRendered callback. - this._invokeOnSectionRenderedHelper() + this._invokeOnSectionRenderedHelper(); const { height: totalHeight, - width: totalWidth - } = cellLayoutManager.getTotalSize() + width: totalWidth, + } = cellLayoutManager.getTotalSize(); // Initialize onScroll callback. this._invokeOnScrollMemoizer({ scrollLeft: scrollLeft || 0, scrollTop: scrollTop || 0, totalHeight, - totalWidth - }) + totalWidth, + }); } - componentDidUpdate (prevProps, prevState) { - const { height, scrollToAlignment, scrollToCell, width } = this.props - const { scrollLeft, scrollPositionChangeReason, scrollTop } = this.state + 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 ( + scrollPositionChangeReason === SCROLL_POSITION_CHANGE_REASONS.REQUESTED + ) { if ( scrollLeft >= 0 && scrollLeft !== prevState.scrollLeft && scrollLeft !== this._scrollingContainer.scrollLeft ) { - this._scrollingContainer.scrollLeft = scrollLeft + this._scrollingContainer.scrollLeft = scrollLeft; } if ( scrollTop >= 0 && scrollTop !== prevState.scrollTop && scrollTop !== this._scrollingContainer.scrollTop ) { - this._scrollingContainer.scrollTop = scrollTop + this._scrollingContainer.scrollTop = scrollTop; } } @@ -238,80 +284,20 @@ export default class CollectionView extends PureComponent { scrollToCell !== prevProps.scrollToCell || width !== prevProps.width ) { - this._updateScrollPositionForScrollToCell() + this._updateScrollPositionForScrollToCell(); } // Update onRowsRendered callback if start/stop indices have changed - this._invokeOnSectionRenderedHelper() - } - - componentWillMount () { - const { cellLayoutManager } = this.props - - cellLayoutManager.calculateSizeAndPositionData() - - // 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 - } + this._invokeOnSectionRenderedHelper(); } - componentWillUnmount () { + componentWillUnmount() { if (this._disablePointerEventsTimeoutId) { - clearTimeout(this._disablePointerEventsTimeoutId) + clearTimeout(this._disablePointerEventsTimeoutId); } } - /** - * @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 - */ - componentWillReceiveProps (nextProps) { - const { scrollLeft, scrollTop } = this.state - - if ( - nextProps.cellCount === 0 && - ( - scrollLeft !== 0 || - scrollTop !== 0 - ) - ) { - this._setScrollPosition({ - scrollLeft: 0, - scrollTop: 0 - }) - } else if ( - nextProps.scrollLeft !== this.props.scrollLeft || - nextProps.scrollTop !== this.props.scrollTop - ) { - this._setScrollPosition({ - scrollLeft: nextProps.scrollLeft, - scrollTop: nextProps.scrollTop - }) - } - - if ( - nextProps.cellCount !== this.props.cellCount || - nextProps.cellLayoutManager !== this.props.cellLayoutManager || - this._calculateSizeAndPositionDataOnNextUpdate - ) { - nextProps.cellLayoutManager.calculateSizeAndPositionData() - } - - if (this._calculateSizeAndPositionDataOnNextUpdate) { - this._calculateSizeAndPositionDataOnNextUpdate = false - } - } - - render () { + render() { const { autoHeight, cellCount, @@ -323,34 +309,51 @@ export default class CollectionView extends PureComponent { noContentRenderer, style, verticalOverscanSize, - width - } = this.props + width, + } = this.props; - const { - isScrolling, - scrollLeft, - scrollTop - } = this.state + 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() + 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 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', @@ -359,61 +362,57 @@ export default class CollectionView extends PureComponent { position: 'relative', WebkitOverflowScrolling: 'touch', width, - willChange: 'transform' - } + 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 + 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' + collectionStyle.overflowX = + totalWidth + verticalScrollBarSize <= width ? 'hidden' : 'auto'; + collectionStyle.overflowY = + totalHeight + horizontalScrollBarSize <= height ? 'hidden' : 'auto'; return (
- {cellCount > 0 && + tabIndex={0}> + {cellCount > 0 && (
+ width: totalWidth, + }}> {childrenToDisplay}
- } - {cellCount === 0 && - noContentRenderer() - } + )} + {cellCount === 0 && noContentRenderer()}
- ) + ); } /* ---------------------------- Helper methods ---------------------------- */ @@ -423,38 +422,38 @@ export default class CollectionView extends PureComponent { * This flag is used to disable pointer events on the scrollable portion of the Collection. * This prevents jerky/stuttery mouse-wheel scrolling. */ - _enablePointerEventsAfterDelay () { + _enablePointerEventsAfterDelay() { if (this._disablePointerEventsTimeoutId) { - clearTimeout(this._disablePointerEventsTimeoutId) + clearTimeout(this._disablePointerEventsTimeoutId); } this._disablePointerEventsTimeoutId = setTimeout(() => { - const { isScrollingChange } = this.props + const {isScrollingChange} = this.props; - isScrollingChange(false) + isScrollingChange(false); - this._disablePointerEventsTimeoutId = null + this._disablePointerEventsTimeoutId = null; this.setState({ - isScrolling: false - }) - }, IS_SCROLLING_TIMEOUT) + isScrolling: false, + }); + }, IS_SCROLLING_TIMEOUT); } - _invokeOnSectionRenderedHelper () { - const { cellLayoutManager, onSectionRendered } = this.props + _invokeOnSectionRenderedHelper = () => { + const {cellLayoutManager, onSectionRendered} = this.props; this._onSectionRenderedMemoizer({ callback: onSectionRendered, indices: { - indices: cellLayoutManager.getLastRenderedIndices() - } - }) - } + indices: cellLayoutManager.getLastRenderedIndices(), + }, + }); + }; - _invokeOnScrollMemoizer ({ scrollLeft, scrollTop, totalHeight, totalWidth }) { + _invokeOnScrollMemoizer({scrollLeft, scrollTop, totalHeight, totalWidth}) { this._onScrollMemoizer({ - callback: ({ scrollLeft, scrollTop }) => { - const { height, onScroll, width } = this.props + callback: ({scrollLeft, scrollTop}) => { + const {height, onScroll, width} = this.props; onScroll({ clientHeight: height, @@ -462,50 +461,50 @@ export default class CollectionView extends PureComponent { scrollHeight: totalHeight, scrollLeft, scrollTop, - scrollWidth: totalWidth - }) + scrollWidth: totalWidth, + }); }, indices: { scrollLeft, - scrollTop - } - }) + scrollTop, + }, + }); } - _setScrollingContainerRef (ref) { - this._scrollingContainer = ref - } + _setScrollingContainerRef = ref => { + this._scrollingContainer = ref; + }; - _setScrollPosition ({ scrollLeft, scrollTop }) { + _setScrollPosition({scrollLeft, scrollTop}) { const newState = { - scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED - } + scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED, + }; if (scrollLeft >= 0) { - newState.scrollLeft = scrollLeft + newState.scrollLeft = scrollLeft; } if (scrollTop >= 0) { - newState.scrollTop = scrollTop + newState.scrollTop = scrollTop; } if ( - ( - scrollLeft >= 0 && - scrollLeft !== this.state.scrollLeft - ) || - ( - scrollTop >= 0 && - scrollTop !== this.state.scrollTop - ) + (scrollLeft >= 0 && scrollLeft !== this.state.scrollLeft) || + (scrollTop >= 0 && scrollTop !== this.state.scrollTop) ) { - this.setState(newState) + this.setState(newState); } } - _updateScrollPositionForScrollToCell () { - const { cellLayoutManager, height, scrollToAlignment, scrollToCell, width } = this.props - const { scrollLeft, scrollTop } = this.state + _updateScrollPositionForScrollToCell = () => { + const { + cellLayoutManager, + height, + scrollToAlignment, + scrollToCell, + width, + } = this.props; + const {scrollLeft, scrollTop} = this.state; if (scrollToCell >= 0) { const scrollPosition = cellLayoutManager.getScrollPositionForCell({ @@ -514,41 +513,47 @@ export default class CollectionView extends PureComponent { height, scrollLeft, scrollTop, - width - }) + width, + }); if ( scrollPosition.scrollLeft !== scrollLeft || scrollPosition.scrollTop !== scrollTop ) { - this._setScrollPosition(scrollPosition) + this._setScrollPosition(scrollPosition); } } - } + }; - _onScroll (event) { + _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 + return; } // Prevent pointer events from interrupting a smooth scroll - this._enablePointerEventsAfterDelay() + 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 {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)) + 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. @@ -564,26 +569,30 @@ export default class CollectionView extends PureComponent { // 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 + : 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) + isScrollingChange(true); } this.setState({ isScrolling: true, scrollLeft, scrollPositionChangeReason, - scrollTop - }) + scrollTop, + }); } this._invokeOnScrollMemoizer({ scrollLeft, scrollTop, totalWidth, - totalHeight - }) - } + totalHeight, + }); + }; } + +polyfill(CollectionView); + +export default CollectionView; diff --git a/source/Collection/Section.jest.js b/source/Collection/Section.jest.js index ee1224ff6..c894af93e 100644 --- a/source/Collection/Section.jest.js +++ b/source/Collection/Section.jest.js @@ -1,47 +1,42 @@ -import Section from './Section' +import Section from './Section'; describe('Section', () => { - function helper ({ - height = 100, - width = 200, - x = 0, - y = 0 - } = {}) { + function helper({height = 100, width = 200, x = 0, y = 0} = {}) { return new Section({ height, width, x, - y - }) + 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]) - }) + 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]) - }) + 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 - }) + y: 50, + }); - expect(section.toString()).toEqual('25,50 200x100') - }) -}) + expect(section.toString()).toEqual('25,50 200x100'); + }); +}); diff --git a/source/Collection/Section.js b/source/Collection/Section.js index 046fe2bb2..6eb654b79 100644 --- a/source/Collection/Section.js +++ b/source/Collection/Section.js @@ -1,5 +1,5 @@ -/** @rlow */ -import type { Index, SizeAndPositionInfo } from './types' +/** @flow */ +import type {Index, SizeAndPositionInfo} from './types'; /** * A section of the Window. @@ -8,38 +8,31 @@ import type { Index, SizeAndPositionInfo } from './types' * 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 + constructor({height, width, x, y}: SizeAndPositionInfo) { + this.height = height; + this.width = width; + this.x = x; + this.y = y; - this._indexMap = {} - this._indices = [] + this._indexMap = {}; + this._indices = []; } /** Add a cell to this section. */ - addCellIndex ({ - index - }: Index) { + addCellIndex({index}: Index) { if (!this._indexMap[index]) { - this._indexMap[index] = true - this._indices.push(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 + getCellIndices(): Array { + return this._indices; } /** Intended for debugger/test purposes only */ - toString () { - return `${this.x},${this.y} ${this.width}x${this.height}` + toString() { + return `${this.x},${this.y} ${this.width}x${this.height}`; } } diff --git a/source/Collection/SectionManager.jest.js b/source/Collection/SectionManager.jest.js index a9f522203..b08a0303a 100644 --- a/source/Collection/SectionManager.jest.js +++ b/source/Collection/SectionManager.jest.js @@ -1,52 +1,78 @@ -import SectionManager from './SectionManager' -import { CELLS, SECTION_SIZE } from './TestData' +import SectionManager from './SectionManager'; +import {CELLS, SECTION_SIZE} from './TestData'; -function initSectionManager () { - const sectionManager = new SectionManager(SECTION_SIZE) +function initSectionManager() { + const sectionManager = new SectionManager(SECTION_SIZE); CELLS.forEach((cellMetadatum, index) => { sectionManager.registerCell({ cellMetadatum, - index - }) - }) - return sectionManager + index, + }); + }); + return sectionManager; } -function verifySections (sectionManager, sizeAndPosition, expectedSizeAndPositionInfos) { - const sections = sectionManager.getSections(sizeAndPosition) - expect(sections.length).toEqual(expectedSizeAndPositionInfos.length) - expectedSizeAndPositionInfos.forEach((sizeAndPosition) => { +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) - }) + 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) - }) + 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 }]) - }) + 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]) - }) -}) + const sectionManager = initSectionManager(); + expect( + sectionManager.getCellIndices({x: 0, y: 0, width: 2, height: 2}), + ).toEqual([0]); + expect( + sectionManager.getCellIndices({x: 2, y: 0, width: 2, height: 2}), + ).toEqual([1, 2, 3]); + expect( + sectionManager.getCellIndices({x: 4, y: 0, width: 2, height: 2}), + ).toEqual([6]); + expect( + sectionManager.getCellIndices({x: 0, y: 2, width: 2, height: 2}), + ).toEqual([4]); + expect( + sectionManager.getCellIndices({x: 2, y: 2, width: 2, height: 2}), + ).toEqual([3, 4, 5]); + expect( + sectionManager.getCellIndices({x: 4, y: 2, width: 2, height: 2}), + ).toEqual([7, 8, 9]); + }); +}); diff --git a/source/Collection/SectionManager.js b/source/Collection/SectionManager.js index e4dc9190b..109c56801 100644 --- a/source/Collection/SectionManager.js +++ b/source/Collection/SectionManager.js @@ -3,14 +3,14 @@ * 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' +import Section from './Section'; +import type {Index, SizeAndPositionInfo} from './types'; -const SECTION_SIZE = 100 +const SECTION_SIZE = 100; type RegisterCellParams = { cellMetadatum: SizeAndPositionInfo, - index: number + index: number, }; /** @@ -19,99 +19,82 @@ type RegisterCellParams = { * Automatically adds cells to the appropriate Section(s). */ export default class SectionManager { - constructor (sectionSize = SECTION_SIZE) { - this._sectionSize = sectionSize + constructor(sectionSize = SECTION_SIZE) { + this._sectionSize = sectionSize; - this._cellMetadata = [] - this._sections = {} + 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 - } - ) - ) + 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]) + 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] + 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 = [] + 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}` + 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 - }) + y: sectionY * this._sectionSize, + }); } - sections.push(this._sections[key]) + sections.push(this._sections[key]); } } - return sections + return sections; } /** Total number of Sections based on the currently registered cells. */ - getTotalSectionCount () { - return Object.keys(this._sections).length + 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() - ) + 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 })) + registerCell({cellMetadatum, index}: RegisterCellParams) { + this._cellMetadata[index] = cellMetadatum; + + this.getSections(cellMetadatum).forEach(section => + section.addCellIndex({index}), + ); } } diff --git a/source/Collection/TestData.js b/source/Collection/TestData.js index 5e3b92ac9..7b77e4e94 100644 --- a/source/Collection/TestData.js +++ b/source/Collection/TestData.js @@ -18,16 +18,16 @@ Sections to Cells map: */ 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 } -] + {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 +export const SECTION_SIZE = 2; diff --git a/source/Collection/index.js b/source/Collection/index.js index a11d22605..e58329e5b 100644 --- a/source/Collection/index.js +++ b/source/Collection/index.js @@ -1,3 +1,5 @@ /** @flow */ -export default from './Collection' -export Collection from './Collection' +import Collection from './Collection'; + +export default Collection; +export {Collection}; diff --git a/source/Collection/types.js b/source/Collection/types.js index 65a5ac35a..5e0e2eda2 100644 --- a/source/Collection/types.js +++ b/source/Collection/types.js @@ -1,27 +1,27 @@ /** @flow */ export type Index = { - index: number + index: number, }; export type PositionInfo = { x: number, - y: number + y: number, }; export type ScrollPosition = { scrollLeft: number, - scrollTop: number + scrollTop: number, }; export type SizeAndPositionInfo = { height: number, width: number, x: number, - y: number + y: number, }; export type SizeInfo = { height: number, - width: number + width: number, }; diff --git a/source/Collection/utils/calculateSizeAndPositionData.jest.js b/source/Collection/utils/calculateSizeAndPositionData.jest.js index ff32561da..cb1dc0e1b 100644 --- a/source/Collection/utils/calculateSizeAndPositionData.jest.js +++ b/source/Collection/utils/calculateSizeAndPositionData.jest.js @@ -1,31 +1,31 @@ -import calculateSizeAndPositionData from './calculateSizeAndPositionData' +import calculateSizeAndPositionData from './calculateSizeAndPositionData'; describe('calculateSizeAndPositionData', () => { it('should query for size and position of each cell', () => { - const cellSizeAndPositionGetterCalls = [] - function cellSizeAndPositionGetter ({ index }) { - cellSizeAndPositionGetterCalls.push(index) + const cellSizeAndPositionGetterCalls = []; + function cellSizeAndPositionGetter({index}) { + cellSizeAndPositionGetterCalls.push(index); return { x: index * 50, y: 0, width: 50, - height: 50 - } + height: 50, + }; } - const { sectionManager } = calculateSizeAndPositionData({ + const {sectionManager} = calculateSizeAndPositionData({ cellCount: 3, - cellSizeAndPositionGetter - }) - expect(cellSizeAndPositionGetterCalls).toEqual([0, 1, 2]) - expect(sectionManager.getTotalSectionCount()).toEqual(2) - }) + 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(() => ( + expect(() => calculateSizeAndPositionData({ cellCount: 3, - cellSizeAndPositionGetter: ({ index }) => {} - }) - )).toThrow() - }) -}) + cellSizeAndPositionGetter: () => {}, + }), + ).toThrow(); + }); +}); diff --git a/source/Collection/utils/calculateSizeAndPositionData.js b/source/Collection/utils/calculateSizeAndPositionData.js index 60ad611e1..904e759d9 100644 --- a/source/Collection/utils/calculateSizeAndPositionData.js +++ b/source/Collection/utils/calculateSizeAndPositionData.js @@ -1,44 +1,48 @@ -import SectionManager from '../SectionManager' +import SectionManager from '../SectionManager'; -export default function calculateSizeAndPositionData ({ +export default function calculateSizeAndPositionData({ cellCount, cellSizeAndPositionGetter, - sectionSize + sectionSize, }) { - const cellMetadata = [] - const sectionManager = new SectionManager(sectionSize) - let height = 0 - let width = 0 + const cellMetadata = []; + const sectionManager = new SectionManager(sectionSize); + let height = 0; + let width = 0; for (let index = 0; index < cellCount; index++) { - const cellMetadatum = cellSizeAndPositionGetter({ 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) + 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}` - ) + 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) + height = Math.max(height, cellMetadatum.y + cellMetadatum.height); + width = Math.max(width, cellMetadatum.x + cellMetadatum.width); - cellMetadata[index] = cellMetadatum + cellMetadata[index] = cellMetadatum; sectionManager.registerCell({ cellMetadatum, - index - }) + index, + }); } return { cellMetadata, height, sectionManager, - width - } + width, + }; } diff --git a/source/ColumnSizer/ColumnSizer.example.js b/source/ColumnSizer/ColumnSizer.example.js index e2363428d..bbfc7fc3e 100644 --- a/source/ColumnSizer/ColumnSizer.example.js +++ b/source/ColumnSizer/ColumnSizer.example.js @@ -1,66 +1,67 @@ /** * @flow */ -import React, { PureComponent } 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 PureComponent { - constructor (props) { - super(props) +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) + 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 + 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. + This component decorates a Grid and calculates the width + of its columns based on the current (Grid) width. @@ -68,25 +69,23 @@ export default class ColumnSizerExample extends PureComponent {
- {({ width }) => ( + {({width}) => ( - {({ adjustedWidth, getColumnWidth, registerChild }) => ( + key="GridColumnSizer" + width={width}> + {({adjustedWidth, columnWidth, registerChild}) => (
+ width: adjustedWidth, + }}>
- ) + ); } - _noColumnMaxWidthChange (event) { - let columnMaxWidth = parseInt(event.target.value, 10) + _noColumnMaxWidthChange(event) { + let columnMaxWidth = parseInt(event.target.value, 10); if (isNaN(columnMaxWidth)) { - columnMaxWidth = undefined + columnMaxWidth = undefined; } else { - columnMaxWidth = Math.min(1000, columnMaxWidth) + columnMaxWidth = Math.min(1000, columnMaxWidth); } - this.setState({ columnMaxWidth }) + this.setState({columnMaxWidth}); } - _noColumnMinWidthChange (event) { - let columnMinWidth = parseInt(event.target.value, 10) + _noColumnMinWidthChange(event) { + let columnMinWidth = parseInt(event.target.value, 10); if (isNaN(columnMinWidth)) { - columnMinWidth = undefined + columnMinWidth = undefined; } else { - columnMinWidth = Math.max(1, columnMinWidth) + columnMinWidth = Math.max(1, columnMinWidth); } - this.setState({ columnMinWidth }) + this.setState({columnMinWidth}); } - _onColumnCountChange (event) { - this.setState({ columnCount: parseInt(event.target.value, 10) || 0 }) + _onColumnCountChange(event) { + this.setState({columnCount: parseInt(event.target.value, 10) || 0}); } - _noContentRenderer () { - return ( -
- No cells -
- ) + _noContentRenderer() { + return
No cells
; } - _cellRenderer ({ columnIndex, key, rowIndex, style }) { - const className = columnIndex === 0 - ? styles.firstCell - : styles.cell + _cellRenderer({columnIndex, key, rowIndex, style}) { + const className = columnIndex === 0 ? styles.firstCell : styles.cell; return ( -
+
{`R:${rowIndex}, C:${columnIndex}`}
- ) + ); } } diff --git a/source/ColumnSizer/ColumnSizer.jest.js b/source/ColumnSizer/ColumnSizer.jest.js index c9364e170..1ed0064fe 100644 --- a/source/ColumnSizer/ColumnSizer.jest.js +++ b/source/ColumnSizer/ColumnSizer.jest.js @@ -1,26 +1,22 @@ -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import ColumnSizer from './ColumnSizer' -import Grid from '../Grid' +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 ({ + function getMarkup({ columnMinWidth = undefined, columnMaxWidth = undefined, columnCount = 10, - width = 200 + width = 200, } = {}) { - function cellRenderer ({ columnIndex, key, rowIndex, style }) { + function cellRenderer({columnIndex, key, rowIndex, style}) { return ( -
+
{`row:${rowIndex}, column:${columnIndex}`}
- ) + ); } return ( @@ -28,13 +24,12 @@ describe('ColumnSizer', () => { columnMinWidth={columnMinWidth} columnMaxWidth={columnMaxWidth} columnCount={columnCount} - width={width} - > - {({ adjustedWidth, getColumnWidth, registerChild }) => ( + width={width}> + {({adjustedWidth, columnWidth, registerChild}) => (
{ rowCount={1} width={adjustedWidth} /> -
- {`adjustedWidth:${adjustedWidth} columnWidth:${getColumnWidth()}`} +
+ {`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') - }) + 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') - }) + 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') - }) + 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') + 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) + 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') - }) + helper({columnMinWidth: 30}, 'columnWidth:30'); + }); it('should recompute metadata sizes if :columnMaxWidth changes', () => { - helper({ columnMaxWidth: 15 }, 'columnWidth:15') - }) + helper({columnMaxWidth: 15}, 'columnWidth:15'); + }); it('should recompute metadata sizes if :width changes', () => { - helper({ width: 300 }, 'columnWidth:30') - }) + helper({width: 300}, 'columnWidth:30'); + }); it('should recompute metadata sizes if :columnCount changes', () => { - helper({ columnCount: 2 }, 'columnWidth:100') - }) - }) + 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') - }) + 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') - }) + 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') + spyOn(console, 'error'); expect(() => { render( @@ -119,13 +144,10 @@ describe('ColumnSizer', () => { columnMinWidth={100} columnMaxWidth={100} columnCount={100} - width={100} - > - {({ adjustedWidth, getColumnWidth, registerChild }) => ( -
- )} - - ) - }).toThrow() - }) -}) + width={100}> + {({registerChild}) =>
} + , + ); + }).toThrow(); + }); +}); diff --git a/source/ColumnSizer/ColumnSizer.js b/source/ColumnSizer/ColumnSizer.js index fb22b99b3..1f2e01cd9 100644 --- a/source/ColumnSizer/ColumnSizer.js +++ b/source/ColumnSizer/ColumnSizer.js @@ -1,10 +1,11 @@ /** @flow */ -import { PropTypes, PureComponent } from 'react' +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 PureComponent { +export default class ColumnSizer extends React.PureComponent { static propTypes = { /** * Function responsible for rendering a virtualized Grid. @@ -27,22 +28,17 @@ export default class ColumnSizer extends PureComponent { columnCount: PropTypes.number.isRequired, /** Width of Grid or Table child */ - width: PropTypes.number.isRequired + width: PropTypes.number.isRequired, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); - this._registerChild = this._registerChild.bind(this) + this._registerChild = this._registerChild.bind(this); } - componentDidUpdate (prevProps, prevState) { - const { - columnMaxWidth, - columnMinWidth, - columnCount, - width - } = this.props + componentDidUpdate(prevProps) { + const {columnMaxWidth, columnMinWidth, columnCount, width} = this.props; if ( columnMaxWidth !== prevProps.columnMaxWidth || @@ -51,52 +47,52 @@ export default class ColumnSizer extends PureComponent { width !== prevProps.width ) { if (this._registeredChild) { - this._registeredChild.recomputeGridSize() + this._registeredChild.recomputeGridSize(); } } } - render () { + render() { const { children, columnMaxWidth, columnMinWidth, columnCount, - width - } = this.props + width, + } = this.props; - const safeColumnMinWidth = columnMinWidth || 1 + const safeColumnMinWidth = columnMinWidth || 1; const safeColumnMaxWidth = columnMaxWidth ? Math.min(columnMaxWidth, width) - : width + : width; - let columnWidth = width / columnCount - columnWidth = Math.max(safeColumnMinWidth, columnWidth) - columnWidth = Math.min(safeColumnMaxWidth, columnWidth) - columnWidth = Math.floor(columnWidth) + 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) + let adjustedWidth = Math.min(width, columnWidth * columnCount); return children({ adjustedWidth, + columnWidth, getColumnWidth: () => columnWidth, - registerChild: this._registerChild - }) + registerChild: this._registerChild, + }); } - _registerChild (child) { - if ( - child && - typeof child.recomputeGridSize !== 'function' - ) { - throw Error('Unexpected child type registered; only Grid/MultiGrid children are supported.') + _registerChild(child) { + if (child && typeof child.recomputeGridSize !== 'function') { + throw Error( + 'Unexpected child type registered; only Grid/MultiGrid children are supported.', + ); } - this._registeredChild = child + this._registeredChild = child; if (this._registeredChild) { - this._registeredChild.recomputeGridSize() + this._registeredChild.recomputeGridSize(); } } } diff --git a/source/ColumnSizer/index.js b/source/ColumnSizer/index.js index 9873d42bc..45353594f 100644 --- a/source/ColumnSizer/index.js +++ b/source/ColumnSizer/index.js @@ -1,3 +1,5 @@ /** @flow */ -export default from './ColumnSizer' -export ColumnSizer from './ColumnSizer' +import ColumnSizer from './ColumnSizer'; + +export default ColumnSizer; +export {ColumnSizer}; diff --git a/source/Grid/Grid.example.js b/source/Grid/Grid.example.js index 6ae88f13f..20935135a 100644 --- a/source/Grid/Grid.example.js +++ b/source/Grid/Grid.example.js @@ -1,20 +1,24 @@ -/** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' -import { LabeledInput, InputRow } from '../demo/LabeledInput' -import AutoSizer from '../AutoSizer' -import Grid from './Grid' -import cn from 'classnames' -import styles from './Grid.example.css' - -export default class GridExample extends PureComponent { +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 + list: PropTypes.instanceOf(Immutable.List).isRequired, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this.state = { columnCount: 1000, @@ -25,23 +29,23 @@ export default class GridExample extends PureComponent { 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) + 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 () { + render() { const { columnCount, height, @@ -51,30 +55,34 @@ export default class GridExample extends PureComponent { rowCount, scrollToColumn, scrollToRow, - useDynamicRowHeight - } = this.state + 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. + 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. @@ -82,60 +90,74 @@ export default class GridExample extends PureComponent { this.setState({ height: parseInt(event.target.value, 10) || 1 })} + label="List height" + name="height" + onChange={event => + this.setState({height: parseInt(event.target.value, 10) || 1}) + } value={height} /> this.setState({ rowHeight: parseInt(event.target.value, 10) || 1 })} + label="Row height" + name="rowHeight" + onChange={event => + this.setState({ + rowHeight: parseInt(event.target.value, 10) || 1, + }) + } value={rowHeight} /> this.setState({ overscanColumnCount: parseInt(event.target.value, 10) || 0 })} + label="Overscan columns" + name="overscanColumnCount" + onChange={event => + this.setState({ + overscanColumnCount: parseInt(event.target.value, 10) || 0, + }) + } value={overscanColumnCount} /> this.setState({ overscanRowCount: parseInt(event.target.value, 10) || 0 })} + label="Overscan rows" + name="overscanRowCount" + onChange={event => + this.setState({ + overscanRowCount: parseInt(event.target.value, 10) || 0, + }) + } value={overscanRowCount} /> - {({ width }) => ( + {({width}) => ( - ) + ); } - _cellRenderer ({ columnIndex, key, rowIndex, style }) { + _cellRenderer({columnIndex, key, rowIndex, style}) { if (columnIndex === 0) { - return this._renderLeftSideCell({ columnIndex, key, rowIndex, style }) + return this._renderLeftSideCell({columnIndex, key, rowIndex, style}); } else { - return this._renderBodyCell({ columnIndex, key, rowIndex, style }) + return this._renderBodyCell({columnIndex, key, rowIndex, style}); } } - _getColumnWidth ({ index }) { + _getColumnWidth({index}) { switch (index) { case 0: - return 50 + return 50; case 1: - return 100 + return 100; case 2: - return 300 + return 300; default: - return 80 + return 80; } } - _getDatum (index) { - const { list } = this.context + _getDatum(index) { + const {list} = this.context; - return list.get(index % list.size) + return list.get(index % list.size); } - _getRowClassName (row) { - return row % 2 === 0 ? styles.evenRow : styles.oddRow + _getRowClassName(row) { + return row % 2 === 0 ? styles.evenRow : styles.oddRow; } - _getRowHeight ({ index }) { - return this._getDatum(index).size + _getRowHeight({index}) { + return this._getDatum(index).size; } - _noContentRenderer () { - return ( -
- No cells -
- ) + _noContentRenderer() { + return
No cells
; } - _renderBodyCell ({ columnIndex, key, rowIndex, style }) { - const rowClass = this._getRowClassName(rowIndex) - const datum = this._getDatum(rowIndex) + _renderBodyCell({columnIndex, key, rowIndex, style}) { + const rowClass = this._getRowClassName(rowIndex); + const datum = this._getDatum(rowIndex); - let content + let content; switch (columnIndex) { case 1: - content = datum.name - break + content = datum.name; + break; case 2: - content = datum.random - break + content = datum.random; + break; default: - content = `r:${rowIndex}, c:${columnIndex}` - break + content = `r:${rowIndex}, c:${columnIndex}`; + break; } - const classNames = cn(rowClass, styles.cell, { - [styles.centeredCell]: columnIndex > 2 - }) + const classNames = clsx(rowClass, styles.cell, { + [styles.centeredCell]: columnIndex > 2, + }); return ( -
+
{content}
- ) + ); } - _renderLeftSideCell ({ key, rowIndex, style }) { - const datum = this._getDatum(rowIndex) + _renderLeftSideCell({key, rowIndex, style}) { + const datum = this._getDatum(rowIndex); - const classNames = cn(styles.cell, styles.letterCell) + 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 - } + backgroundColor: datum.color, + }; return ( -
+
{datum.name.charAt(0)}
- ) + ); } - _updateUseDynamicRowHeights (value) { + _updateUseDynamicRowHeights(value) { this.setState({ - useDynamicRowHeight: value - }) + useDynamicRowHeight: value, + }); } - _onColumnCountChange (event) { - const columnCount = parseInt(event.target.value, 10) || 0 + _onColumnCountChange(event) { + const columnCount = parseInt(event.target.value, 10) || 0; - this.setState({ columnCount }) + this.setState({columnCount}); } - _onRowCountChange (event) { - const rowCount = parseInt(event.target.value, 10) || 0 + _onRowCountChange(event) { + const rowCount = parseInt(event.target.value, 10) || 0; - this.setState({ rowCount }) + this.setState({rowCount}); } - _onScrollToColumnChange (event) { - const { columnCount } = this.state - let scrollToColumn = Math.min(columnCount - 1, parseInt(event.target.value, 10)) + _onScrollToColumnChange(event) { + const {columnCount} = this.state; + let scrollToColumn = Math.min( + columnCount - 1, + parseInt(event.target.value, 10), + ); if (isNaN(scrollToColumn)) { - scrollToColumn = undefined + scrollToColumn = undefined; } - this.setState({ scrollToColumn }) + this.setState({scrollToColumn}); } - _onScrollToRowChange (event) { - const { rowCount } = this.state - let scrollToRow = Math.min(rowCount - 1, parseInt(event.target.value, 10)) + _onScrollToRowChange(event) { + const {rowCount} = this.state; + let scrollToRow = Math.min(rowCount - 1, parseInt(event.target.value, 10)); if (isNaN(scrollToRow)) { - scrollToRow = undefined + scrollToRow = undefined; } - this.setState({ scrollToRow }) + this.setState({scrollToRow}); } } diff --git a/source/Grid/Grid.jest.js b/source/Grid/Grid.jest.js index 96cd9b361..e030b9f16 100644 --- a/source/Grid/Grid.jest.js +++ b/source/Grid/Grid.jest.js @@ -1,51 +1,48 @@ -import React from 'react' -import { findDOMNode } from 'react-dom' -import { Simulate } from 'react-addons-test-utils' -import { render } from '../TestUtils' -import Grid, { DEFAULT_SCROLLING_RESET_TIME_INTERVAL } from './Grid' -import { CellMeasurer, CellMeasurerCache } from '../CellMeasurer' -import { SCROLL_DIRECTION_BACKWARD, SCROLL_DIRECTION_FORWARD } from './utils/defaultOverscanIndicesGetter' -import { DEFAULT_MAX_SCROLL_SIZE } from './utils/ScalingCellSizeAndPositionManager' - -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 +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 +function getScrollbarSize20() { + return 20; } describe('Grid', () => { - function defaultCellRenderer ({ columnIndex, key, rowIndex, style }) { + 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 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 = {}) { + function getMarkup(props = {}) { return ( { width={DEFAULT_WIDTH} {...props} /> - ) + ); } 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 - }) + 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 - }) + 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 - }) + 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 }) { + function cellRenderer({columnIndex, key, rowIndex, style}) { if (columnIndex === 0) { - return null + return null; } else if (rowIndex === 0) { - return false + 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') - }) - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) - }) + 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) - }) + 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 })) - // 100 columns * 50 item width = 5,000 total item width + 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) - }) + expect(grid.state.scrollLeft).toEqual(1050); + }); it('should scroll to the far right', () => { - const grid = render(getMarkup({ scrollToColumn: 49 })) - // 100 columns * 50 item width = 5,000 total item width - // Target offset for the last item then is 5,000 - 200 - expect(grid.state.scrollLeft).toEqual(2300) - }) + 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) - }) + 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 })) + 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) - }) + expect(grid.state.scrollTop).toEqual(900); + }); it('should scroll to the bottom', () => { - const grid = render(getMarkup({ scrollToRow: 99 })) + 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) - }) + 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) - }) + 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) - }) + 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 - })) - // 100 columns * 50 item width = 5,000 total item width + 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) - }) + 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 - })) - // 100 columns * 50 item width = 5,000 total item width + 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) - }) + 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 - })) - // 100 columns * 50 item width = 5,000 total item width + 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) + 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) - }) + 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, + const expectedScrollTop = + 100 * DEFAULT_ROW_HEIGHT - DEFAULT_HEIGHT + DEFAULT_ROW_HEIGHT; + render( + getMarkup({ + columnCount: 0, rowCount: 150, - scrollToRow: 100 - }))).scrollTop - ).toEqual(expectedScrollTop) - }) + 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) + 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 - }) - // 100 columns * 50 item width = 5,000 total item width + 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) + 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(grid.state.scrollTop).toEqual(900) - }) + 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 + let rowHeight = 20; const props = { height: 100, rowCount: 100, - rowHeight: ({ index }) => index === 99 ? rowHeight : 20, - scrollToRow: 99 - } + rowHeight: ({index}) => (index === 99 ? rowHeight : 20), + scrollToRow: 99, + }; - const grid = render(getMarkup(props)) - const node = findDOMNode(grid) + const grid = render(getMarkup(props)); + const node = findDOMNode(grid); - expect(node.scrollTop).toBe(1900) + expect(node.scrollTop).toBe(1900); - rowHeight = 40 + rowHeight = 40; grid.recomputeGridSize({ - rowIndex: 99 - }) + rowIndex: 99, + }); - expect(node.scrollTop).toBe(1920) - }) + expect(node.scrollTop).toBe(1920); + }); - it('should restore scroll offset for column when row count increases from 0 (and vice versa)', () => { + it('should not restore scrollLeft when scrolling left and recomputeGridSize with columnIndex smaller than scrollToColumn', () => { const props = { columnWidth: 50, columnCount: 100, @@ -399,478 +664,650 @@ describe('Grid', () => { 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({ + 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, - getScrollbarSize: getScrollbarSize20, height: 100, rowCount: 100, rowHeight: 20, scrollToColumn: 50, scrollToRow: 50, - width: 100 - })) + width: 100, + }; + const grid = render(getMarkup(props)); - expect(grid.state.scrollLeft).toEqual(2450 + getScrollbarSize20()) - expect(grid.state.scrollTop).toEqual(920 + getScrollbarSize20()) - }) - }) + 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') + 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') - }) + 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') + 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') - }) + 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') + 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') - }) + 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') + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) - }) + 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') - }) + 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') - }) + 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
+ 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') - }) + 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('') - }) + 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('') - }) + 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('') - }) - }) + 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) - }) + 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 + 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) - }) + 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 + 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) - }) + 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: params => numCalls++ - })) - expect(numCalls).toEqual(0) - }) - }) + 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) - }) + 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, and ids', () => { + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) - }) + 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 - }]) - }) + 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) - })) + const onScrollCalls = []; + const grid = render( + getMarkup({ + onScroll: params => onScrollCalls.push(params), + }), + ); simulateScroll({ grid, scrollLeft: 100, - scrollTop: 0 - }) - expect(onScrollCalls.length).toEqual(2) + scrollTop: 0, + }); + expect(onScrollCalls.length).toEqual(2); expect(onScrollCalls[1]).toEqual({ clientHeight: 100, clientWidth: 200, scrollHeight: 2000, scrollLeft: 100, scrollTop: 0, - scrollWidth: 2500 - }) - }) + scrollWidth: 2500, + }); + }); it('should trigger callback when component scrolls vertically', () => { - const onScrollCalls = [] - const grid = render(getMarkup({ - onScroll: params => onScrollCalls.push(params) - })) + const onScrollCalls = []; + const grid = render( + getMarkup({ + onScroll: params => onScrollCalls.push(params), + }), + ); simulateScroll({ grid, scrollLeft: 0, - scrollTop: 100 - }) - expect(onScrollCalls.length).toEqual(2) + scrollTop: 100, + }); + expect(onScrollCalls.length).toEqual(2); expect(onScrollCalls[1]).toEqual({ clientHeight: 100, clientWidth: 200, scrollHeight: 2000, scrollLeft: 0, scrollTop: 100, - scrollWidth: 2500 - }) - }) + 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 - })) + 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) + scrollTop: 0, + }); + expect(onScrollCalls.length).toEqual(2); expect(onScrollCalls[1]).toEqual({ clientHeight: 100, clientWidth: 200, scrollHeight: 2000, scrollLeft: 0, scrollTop: 0, - scrollWidth: 50 - }) - }) + 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 - })) + 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) + scrollTop: 0, + }); + expect(onScrollCalls.length).toEqual(2); expect(onScrollCalls[1]).toEqual({ clientHeight: 200, clientWidth: 200, scrollHeight: 50, scrollLeft: 0, scrollTop: 0, - scrollWidth: 2500 - }) - }) + 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 - }]) - }) - }) + 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 + 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 { @@ -882,283 +1319,416 @@ describe('Grid', () => { rowOverscanStartIndex: () => rowOverscanStartIndex, rowOverscanStopIndex: () => rowOverscanStopIndex, rowStartIndex: () => rowStartIndex, - rowStopIndex: () => rowStopIndex - } + 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()) - }) + 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) - }) + 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) - }) + 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()) + // 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 - }) + scrollTop: 50, + }); - expect(grid.state.scrollDirectionHorizontal).toEqual(SCROLL_DIRECTION_FORWARD) - expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_FORWARD) + expect(grid.state.scrollDirectionHorizontal).toEqual( + SCROLL_DIRECTION_FORWARD, + ); + expect(grid.state.scrollDirectionVertical).toEqual( + SCROLL_DIRECTION_FORWARD, + ); simulateScroll({ grid, scrollLeft: 0, - scrollTop: 0 - }) + scrollTop: 0, + }); - expect(grid.state.scrollDirectionHorizontal).toEqual(SCROLL_DIRECTION_BACKWARD) - expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_BACKWARD) + expect(grid.state.scrollDirectionHorizontal).toEqual( + SCROLL_DIRECTION_BACKWARD, + ); + expect(grid.state.scrollDirectionVertical).toEqual( + SCROLL_DIRECTION_BACKWARD, + ); simulateScroll({ grid, scrollLeft: 100, - scrollTop: 100 - }) + scrollTop: 100, + }); - expect(grid.state.scrollDirectionHorizontal).toEqual(SCROLL_DIRECTION_FORWARD) - expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_FORWARD) - }) + 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) + 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()); - grid = render(getMarkup({ + // Simulate a scroll to set the initial internal state + simulateScroll({ + grid, scrollLeft: 0, - scrollTop: 0 - })) + scrollTop: 5, + }); - expect(grid.state.scrollDirectionHorizontal).toEqual(SCROLL_DIRECTION_BACKWARD) - expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_BACKWARD) + expect(grid.state.scrollDirectionHorizontal).toEqual( + SCROLL_DIRECTION_FORWARD, + ); + expect(grid.state.scrollDirectionVertical).toEqual( + SCROLL_DIRECTION_FORWARD, + ); - grid = render(getMarkup({ - scrollLeft: 100, - scrollTop: 100 - })) + 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_FORWARD) - expect(grid.state.scrollDirectionVertical).toEqual(SCROLL_DIRECTION_FORWARD) - }) + 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() + it('should overscan in the direction being scrolled', async done => { + const helper = createHelper(); - let onSectionRenderedResolve + let onSectionRenderedResolve; - function onSectionRendered (params) { - helper.onSectionRendered(params) + function onSectionRendered(params) { + helper.onSectionRendered(params); if (onSectionRenderedResolve) { - onSectionRenderedResolve() + onSectionRenderedResolve(); } } - const grid = render(getMarkup({ - onSectionRendered, - overscanColumnCount: 2, - overscanRowCount: 5 - })) + const grid = render( + getMarkup({ + onSectionRendered, + overscanColumnCount: 2, + overscanRowCount: 5, + }), + ); // Wait until the onSectionRendered handler / debouncer has processed let onSectionRenderedPromise = new Promise(resolve => { - onSectionRenderedResolve = resolve - }) + onSectionRenderedResolve = resolve; + }); simulateScroll({ grid, scrollLeft: 200, - scrollTop: 200 - }) + scrollTop: 200, + }); - await onSectionRenderedPromise + 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) + 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 - }) + onSectionRenderedResolve = resolve; + }); simulateScroll({ grid, scrollLeft: 100, - scrollTop: 100 - }) + scrollTop: 100, + }); - await onSectionRenderedPromise + 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() - }) - }) + 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') - }) - }) + 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) - }) + 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({ + 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) + 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)) + await new Promise(resolve => setTimeout(resolve, 1)); + + simulateScroll({grid, scrollTop: 100}); + expect(cellRendererCalls[0]).toEqual(true); - simulateScroll({ grid, scrollTop: 100 }) - expect(cellRendererCalls[0]).toEqual(true) + done(); + }); - 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) + 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) => { + 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 - }) - }) + 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 cellRendererCalls = []; + function cellRenderer({columnIndex, key, rowIndex, style}) { + cellRendererCalls.push({columnIndex, rowIndex}); + return defaultCellRenderer({columnIndex, key, rowIndex, style}); } const props = { cellRenderer, @@ -1166,74 +1736,121 @@ describe('Grid', () => { height: 40, rowHeight: 20, scrollToRow: 0, - width: 100 + 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}); } - - render(getMarkup({ - ...props, - scrollToRow: 0 - })) + 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 - })) + {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 } - ]) - }) + {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 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 - })) + width: 100, + }; + + const grid = render( + getMarkup({ + ...props, + scrollToRow: 0, + }), + ); expect(cellRendererCalls).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 } - ]) + {columnIndex: 0, rowIndex: 0}, + {columnIndex: 0, rowIndex: 1}, + ]); - simulateScroll({ grid, scrollTop: 1 }) + simulateScroll({grid, scrollTop: 1}); - cellRendererCalls.splice(0) + 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 }) + 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, @@ -1241,36 +1858,85 @@ describe('Grid', () => { height: 40, rowHeight: 20, scrollToRow: 0, - width: 100 - } + width: 100, + }; - const grid = render(getMarkup(props)) + const grid = render(getMarkup(props)); expect(cellRendererCalls).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 } - ]) + {columnIndex: 0, rowIndex: 0}, + {columnIndex: 0, rowIndex: 1}, + ]); - simulateScroll({ grid, scrollTop: 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)) + 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(); - cellRendererCalls.splice(0) + render( + getMarkup({ + ...props, + isScrolling: false, + }), + ); - render(getMarkup({ - ...props, - scrollToRow: 1 - })) - expect(cellRendererCalls.length).not.toEqual(0) + expect(cellRenderer.mock.calls[0][0].style).toBe(scrollingStyle); + expect(cellRenderer).toHaveBeenCalledTimes(1); // Reset cache - done() - }) + 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 cellRendererCalls = []; + function cellRenderer({columnIndex, key, rowIndex, style}) { + cellRendererCalls.push({columnIndex, rowIndex}); + return defaultCellRenderer({columnIndex, key, rowIndex, style}); } const props = { cellRenderer, @@ -1278,94 +1944,195 @@ describe('Grid', () => { height: 40, rowHeight: 20, scrollTop: 0, - width: 100 - } + width: 100, + }; - const grid = render(getMarkup(props)) + const grid = render(getMarkup(props)); expect(cellRendererCalls).toEqual([ - { columnIndex: 0, rowIndex: 0 }, - { columnIndex: 0, rowIndex: 1 } - ]) + {columnIndex: 0, rowIndex: 0}, + {columnIndex: 0, rowIndex: 1}, + ]); - simulateScroll({ grid, scrollTop: 1 }) + simulateScroll({grid, scrollTop: 1}); - cellRendererCalls.splice(0) + cellRendererCalls.splice(0); - grid.recomputeGridSize() + grid.recomputeGridSize(); - expect(cellRendererCalls.length).not.toEqual(0) - }) + expect(cellRendererCalls.length).not.toEqual(0); + }); - 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 }) + 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, - 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)) + columnWidth: 100, + height: 40, + rowHeight: 20, + scrollTop: 0, + width: 100, + isScrollingOptOut: true, + }; - cellRendererCalls.splice(0) - render(getMarkup({ - ...props, - className: 'bar' - })) - expect(cellRendererCalls.length).not.toEqual(0) + 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); + } - done() - }) - }) + 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._columnSizeAndPositionManager.getTotalSize()).toEqual(1500) - expect(grid._rowSizeAndPositionManager.getTotalSize()).toEqual(150) - grid.measureAllCells() - expect(grid._columnSizeAndPositionManager.getTotalSize()).toEqual(1000) - expect(grid._rowSizeAndPositionManager.getTotalSize()).toEqual(200) - }) - }) + 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 + const columnIndices = []; + const rowIndices = []; + function columnWidth({index}) { + columnIndices.push(index); + return 10; } - function rowHeight ({ index }) { - rowIndices.push(index) - return 10 + function rowHeight({index}) { + rowIndices.push(index); + return 10; } const props = { columnCount: 50, @@ -1373,154 +2140,202 @@ describe('Grid', () => { height: 50, rowHeight, rowCount: 50, - width: 100 - } - const component = render(getMarkup(props)) + width: 100, + }; + const component = render(getMarkup(props)); - columnIndices.splice(0) - rowIndices.splice(0) + columnIndices.splice(0); + rowIndices.splice(0); - component.recomputeGridSize() + 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) + 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) + columnIndices.splice(0); + rowIndices.splice(0); component.recomputeGridSize({ columnIndex: 4, - rowIndex: 2 - }) + 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) - }) - }) + 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') - }) + 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 - }) - }) + 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') - }) + 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 - }) + 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._rowSizeAndPositionManager.getTotalSize()).toEqual(2000) - }) - }) + 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) - }) + 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) - }) - }) + 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') - }) + 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) - }) - }) + 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
+ 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) - }) + 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 + let componentUpdates = 0; class GridComponent extends React.PureComponent { - componentDidUpdate () { - componentUpdates++ + componentDidUpdate() { + componentUpdates++; } - render () { - const { columnIndex, rowIndex, style } = this.props + render() { + const {columnIndex, rowIndex, style} = this.props; return ( -
+
{`row:${rowIndex}, column:${columnIndex}`}
- ) + ); } } - function cellRenderer ({ columnIndex, key, rowIndex, style }) { - return + function cellRenderer({columnIndex, key, rowIndex, style}) { + return ( + + ); } const props = { @@ -1529,40 +2344,42 @@ describe('Grid', () => { height: 40, rowHeight: 20, scrollTop: 0, - width: 100 - } + width: 100, + }; - const grid = render(getMarkup(props)) - simulateScroll({ grid, scrollTop: 1 }) + const grid = render(getMarkup(props)); + simulateScroll({grid, scrollToIndex: 1}); - expect(componentUpdates).toEqual(0) - }) + expect(componentUpdates).toEqual(0); + }); - it('should clear all but the visible rows from the style cache once :isScrolling is false', async (done) => { + 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 - } + width: 100, + }; - const grid = render(getMarkup(props)) + const grid = render(getMarkup(props)); - expect(Object.keys(grid._styleCache).length).toBe(4) + expect(Object.keys(grid._styleCache).length).toBe(4); - simulateScroll({ grid, scrollTop: 50 }) + simulateScroll({grid, scrollTop: 50}); - expect(Object.keys(grid._styleCache).length).toBe(6) + 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)) + await new Promise(resolve => + setTimeout(resolve, DEFAULT_SCROLLING_RESET_TIME_INTERVAL * 2), + ); - expect(Object.keys(grid._styleCache).length).toBe(4) + expect(Object.keys(grid._styleCache).length).toBe(4); - done() - }) + done(); + }); it('should clear style cache if :recomputeGridSize is called', () => { const props = { @@ -1571,30 +2388,32 @@ describe('Grid', () => { overscanColumnCount: 0, overscanRowCount: 0, rowHeight: 50, - width: 100 - } + width: 100, + }; - const grid = render(getMarkup(props)) + const grid = render(getMarkup(props)); - expect(Object.keys(grid._styleCache).length).toBe(4) + expect(Object.keys(grid._styleCache).length).toBe(4); - render(getMarkup({ - ...props, - scrollTop: 50 - })) + render( + getMarkup({ + ...props, + scrollTop: 50, + }), + ); - expect(Object.keys(grid._styleCache).length).toBe(6) + expect(Object.keys(grid._styleCache).length).toBe(6); - grid.recomputeGridSize() + grid.recomputeGridSize(); - expect(Object.keys(grid._styleCache).length).toBe(4) - }) + 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 cellRendererCalls = []; + function cellRenderer(params) { + cellRendererCalls.push(params); + return
; } const props = { @@ -1604,237 +2423,338 @@ describe('Grid', () => { overscanColumnCount: 0, overscanRowCount: 0, rowHeight: 100, - width: 100 - } + width: 100, + }; - render(getMarkup(props)) + render(getMarkup(props)); - expect(cellRendererCalls.length).toEqual(1) - expect(cellRendererCalls[0].style.width).toEqual(100) + expect(cellRendererCalls.length).toEqual(1); + expect(cellRendererCalls[0].style.width).toEqual(100); - render(getMarkup({ - ...props, - columnWidth: 50, - width: 50 - })) + render( + getMarkup({ + ...props, + columnWidth: 50, + width: 50, + }), + ); - expect(cellRendererCalls.length).toEqual(2) - expect(cellRendererCalls[1].style.width).toEqual(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
+ let cellRendererCalls = []; + function cellRenderer(params) { + cellRendererCalls.push(params); + return
; } - const grid = render(getMarkup({ - cellRenderer, - width: 100, - height: 100, - rowHeight: 100, - columnWidth: 100, - rowCount: DEFAULT_MAX_SCROLL_SIZE * 2 / 100, // lots of offset - scrollTop: 2000 - })) + 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 - }) + scrollTop: 2100, + }); // cellRendererCalls[0] is the element at rowIndex 0 - const firstProps = cellRendererCalls[1] - const secondProps = cellRendererCalls[2] + // only two calls. Since the scrollTop is updated in getDerivedStateFromProps + const firstProps = cellRendererCalls[0]; + const secondProps = cellRendererCalls[1]; - expect(cellRendererCalls.length).toEqual(3) - expect(firstProps.style).not.toBe(secondProps.style) - }) + 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) + 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 grid = render( + getMarkup({ + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 2, + }), + ); - const keys = Object.keys(grid._styleCache) + const keys = Object.keys(grid._styleCache); - expect(keys).toEqual(['0-0', '1-1']) - }) + 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') + spyOn(console, 'warn'); - function cellRenderer (params) { - return
+ function cellRenderer(params) { + return
; } - render(getMarkup({ - cellRenderer - })) + render( + getMarkup({ + cellRenderer, + }), + ); - expect(console.warn).toHaveBeenCalledWith('Rendered cell should include style property for positioning.') - expect(console.warn).toHaveBeenCalledTimes(1) - }) + 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') + 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) - }) - }) + 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
- }) + 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 - } + rowCount: 2, + }; - render(getMarkup(props)) + render(getMarkup(props)); // 4 times for initial render + 4 once cellCache was cleared - expect(cellRenderer).toHaveBeenCalledTimes(8) - }) + expect(cellRenderer).toHaveBeenCalledTimes(8); + }); it('should invalidate cache and refresh displayed cells after update', () => { const cache = new CellMeasurerCache({ - fixedWidth: true - }) + fixedWidth: true, + }); - const cellRenderer = jest.fn() - cellRenderer.mockImplementation( - (params) =>
- ) + const cellRenderer = jest.fn(); + cellRenderer.mockImplementation(params => ( +
+ )); const props = { cellRenderer, columnCount: 2, deferredMeasurementCache: cache, - rowCount: 2 - } + rowCount: 2, + }; - const grid = render(getMarkup(props)) + const grid = render(getMarkup(props)); - expect(cellRenderer).toHaveBeenCalledTimes(4) + expect(cellRenderer).toHaveBeenCalledTimes(4); - let invalidateCellSizeAfterRender = false + 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
- }) + 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() + invalidateCellSizeAfterRender = true; + grid.recomputeGridSize(); // 4 times for initial render + 4 once cellCache was cleared - expect(cellRenderer).toHaveBeenCalledTimes(8) - }) + expect(cellRenderer).toHaveBeenCalledTimes(8); + }); it('should not cache cells until they have been measured by CellMeasurer', () => { const cache = new CellMeasurerCache({ - fixedWidth: true - }) + fixedWidth: true, + }); // Fake measure cell 0,0 but not cell 0,1 - cache.set(0, 0, 100, 30) + cache.set(0, 0, 100, 30); - const cellRenderer = jest.fn() - cellRenderer.mockImplementation( - (params) =>
- ) + const cellRenderer = jest.fn(); + cellRenderer.mockImplementation(params => ( +
+ )); const props = { cellRenderer, columnCount: 2, deferredMeasurementCache: cache, - rowCount: 1 - } + 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() + 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) - }) - }) -}) + expect(cellRenderer).toHaveBeenCalledTimes(4); + const style00A = cellRenderer.mock.calls[0][0].style; + const style01A = cellRenderer.mock.calls[1][0].style; + const style00B = cellRenderer.mock.calls[2][0].style; + const style01B = cellRenderer.mock.calls[3][0].style; + expect(style00A).toBe(style00B); + expect(style01A).not.toBe(style01B); + }); + }); + + describe('onScrollbarPresenceChange', () => { + it('should not trigger on-mount if scrollbars are hidden', () => { + const onScrollbarPresenceChange = jest.fn(); + + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1, + }), + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + }); + + it('should trigger on-mount if scrollbars are visible', () => { + const onScrollbarPresenceChange = jest.fn(); + + render( + getMarkup({ + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100, + }), + ); + expect(onScrollbarPresenceChange).toHaveBeenCalled(); + + const args = onScrollbarPresenceChange.mock.calls[0][0]; + expect(args.horizontal).toBe(true); + expect(args.size).toBe(getScrollbarSize20()); + expect(args.vertical).toBe(true); + }); + + it('should trigger on-update if scrollbar visibility has changed', () => { + const onScrollbarPresenceChange = jest.fn(); + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1, + }), + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + + render( + getMarkup({ + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100, + }), + ); + expect(onScrollbarPresenceChange).toHaveBeenCalled(); + + const args = onScrollbarPresenceChange.mock.calls[0][0]; + expect(args.horizontal).toBe(true); + expect(args.size).toBe(getScrollbarSize20()); + expect(args.vertical).toBe(true); + }); + + it('should not trigger on-update if scrollbar visibility does not change', () => { + const onScrollbarPresenceChange = jest.fn(); + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1, + }), + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + render( + getMarkup({ + columnCount: 2, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 2, + }), + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + }); + }); + + it('should not complain when using react-test-renderer', () => { + const instance = TestRenderer.create(getMarkup()).getInstance(); + expect(instance).toBeTruthy(); + }); +}); diff --git a/source/Grid/Grid.js b/source/Grid/Grid.js index 3b40dcf0d..e7caff40b 100644 --- a/source/Grid/Grid.js +++ b/source/Grid/Grid.js @@ -1,19 +1,45 @@ /** @flow */ -import React, { PropTypes, PureComponent } from 'react' -import cn from 'classnames' -import calculateSizeAndPositionDataAndUpdateScrollOffset from './utils/calculateSizeAndPositionDataAndUpdateScrollOffset' -import ScalingCellSizeAndPositionManager from './utils/ScalingCellSizeAndPositionManager' -import createCallbackMemoizer from '../utils/createCallbackMemoizer' -import defaultOverscanIndicesGetter, { SCROLL_DIRECTION_BACKWARD, SCROLL_DIRECTION_FORWARD } from './utils/defaultOverscanIndicesGetter' -import updateScrollIndexHelper from './utils/updateScrollIndexHelper' -import defaultCellRangeRenderer from './defaultCellRangeRenderer' -import scrollbarSize from 'dom-helpers/util/scrollbarSize' + +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 miliseconds during which to disable pointer events while a scroll is in progress. + * 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 +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. @@ -21,211 +47,234 @@ export const DEFAULT_SCROLLING_RESET_TIME_INTERVAL = 150 */ const SCROLL_POSITION_CHANGE_REASONS = { OBSERVED: 'observed', - REQUESTED: 'requested' -} + 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. */ -export default class Grid extends PureComponent { - static propTypes = { - 'aria-label': PropTypes.string, - - /** - * 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: PropTypes.bool, - - /** - * 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, - - /** - * Responsible for rendering a cell given an row and column index. - * Should implement the following interface: ({ columnIndex: number, rowIndex: number }): PropTypes.node - */ - cellRenderer: PropTypes.func.isRequired, - - /** - * Responsible for rendering a group of cells given their index ranges. - * Should implement the following interface: ({ - * cellCache: Map, - * cellRenderer: Function, - * columnSizeAndPositionManager: CellSizeAndPositionManager, - * columnStartIndex: number, - * columnStopIndex: number, - * isScrolling: boolean, - * rowSizeAndPositionManager: CellSizeAndPositionManager, - * rowStartIndex: number, - * rowStopIndex: number, - * scrollLeft: number, - * scrollTop: number - * }): Array - */ - cellRangeRenderer: PropTypes.func.isRequired, - - /** - * Optional custom CSS class name to attach to root Grid element. - */ - className: PropTypes.string, - - /** - * Number of columns in grid. - */ - columnCount: PropTypes.number.isRequired, - - /** - * Either a fixed column width (number) or a function that returns the width of a column given its index. - * Should implement the following interface: (index: number): number - */ - columnWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.func]).isRequired, - - /** Optional inline style applied to inner cell-container */ - containerStyle: PropTypes.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: PropTypes.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: PropTypes.number.isRequired, - - /** - * 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: PropTypes.number.isRequired, - - /** - * Exposed for testing purposes only. - */ - getScrollbarSize: PropTypes.func.isRequired, - - /** - * Height of Grid; this property determines the number of visible (vs virtualized) rows. - */ - height: PropTypes.number.isRequired, - - /** - * Optional custom id to attach to root Grid element. - */ - id: PropTypes.string, - - /** - * Optional renderer to be used in place of rows when either :rowCount or :columnCount 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 Grid that was just rendered. - * ({ columnStartIndex, columnStopIndex, rowStartIndex, rowStopIndex }): void - */ - onSectionRendered: PropTypes.func.isRequired, - - /** - * 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: PropTypes.number.isRequired, - - /** - * Calculates the number of cells to overscan before and after a specified range. - * This function ensures that overscanning doesn't exceed the available cells. - * Should implement the following interface: ({ - * cellCount: number, - * overscanCellsCount: number, - * scrollDirection: number, - * startIndex: number, - * stopIndex: number - * }): {overscanStartIndex: number, overscanStopIndex: number} - */ - overscanIndicesGetter: PropTypes.func.isRequired, - - /** - * 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: PropTypes.number.isRequired, - - /** - * ARIA role for the grid element. - */ - role: PropTypes.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: PropTypes.oneOfType([PropTypes.number, PropTypes.func]).isRequired, - - /** - * Number of rows in grid. - */ - rowCount: PropTypes.number.isRequired, - - /** Wait this amount of time after the last scroll event before resetting Grid `pointer-events`. */ - scrollingResetTimeInterval: PropTypes.number, - - /** 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, - - /** - * Column index to ensure visible (by forcefully scrolling if necessary) - */ - scrollToColumn: PropTypes.number.isRequired, - - /** Vertical offset. */ - scrollTop: PropTypes.number, - - /** - * Row index to ensure visible (by forcefully scrolling if necessary) - */ - scrollToRow: PropTypes.number.isRequired, - - /** Optional inline style */ - style: PropTypes.object, - - /** Tab index for focus */ - tabIndex: PropTypes.number, - - /** - * Width of Grid; this property determines the number of visible (vs virtualized) columns. - */ - width: PropTypes.number.isRequired - }; - +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: () => null, - onScroll: () => null, - onSectionRendered: () => null, + noContentRenderer: renderNull, + onScroll: () => {}, + onScrollbarPresenceChange: () => {}, + onSectionRendered: () => {}, overscanColumnCount: 0, overscanIndicesGetter: defaultOverscanIndicesGetter, overscanRowCount: 10, @@ -235,59 +284,216 @@ export default class Grid extends PureComponent { scrollToColumn: -1, scrollToRow: -1, style: {}, - tabIndex: 0 + tabIndex: 0, + isScrollingOptOut: false, }; - constructor (props, context) { - super(props, context) + // 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 + 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, + ); } + } - // Invokes onSectionRendered callback only when start/stop row or column indices change - this._onGridRenderedMemoizer = createCallbackMemoizer() - this._onScrollMemoizer = createCallbackMemoizer(false) + /** + * 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), + }; + } - // Bind functions to instance so they don't lose context when passed around - this._debounceScrollEndedCallback = this._debounceScrollEndedCallback.bind(this) - this._invokeOnGridRenderedHelper = this._invokeOnGridRenderedHelper.bind(this) - this._onScroll = this._onScroll.bind(this) - this._setScrollingContainerRef = this._setScrollingContainerRef.bind(this) - this._updateScrollLeftForScrollToColumn = this._updateScrollLeftForScrollToColumn.bind(this) - this._updateScrollTopForScrollToRow = this._updateScrollTopForScrollToRow.bind(this) + /** + * Gets estimated total rows' height. + */ + getTotalRowsHeight() { + return this.state.instanceProps.rowSizeAndPositionManager.getTotalSize(); + } - this._columnWidthGetter = this._wrapSizeGetter(props.columnWidth) - this._rowHeightGetter = this._wrapSizeGetter(props.rowHeight) + /** + * 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; + } - this._deferredInvalidateColumnIndex = null - this._deferredInvalidateRowIndex = null - this._recomputeScrollLeftFlag = false - this._recomputeScrollTopFlag = false + // Prevent pointer events from interrupting a smooth scroll + this._debounceScrollEnded(); - const deferredMeasurementCache = props.deferredMeasurementCache - const deferredMode = typeof deferredMeasurementCache !== 'undefined' + const {autoHeight, autoWidth, height, width} = this.props; + const {instanceProps} = this.state; - this._columnSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ - batchAllCells: deferredMode && !deferredMeasurementCache.hasFixedHeight(), - cellCount: props.columnCount, - cellSizeGetter: (params) => this._columnWidthGetter(params), - estimatedCellSize: this._getEstimatedColumnSize(props) - }) - this._rowSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ - batchAllCells: deferredMode && !deferredMeasurementCache.hasFixedWidth(), - cellCount: props.rowCount, - cellSizeGetter: (params) => this._rowHeightGetter(params), - estimatedCellSize: this._getEstimatedRowSize(props) - }) + // 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, + }; - // See defaultCellRangeRenderer() for more information on the usage of these caches - this._cellCache = {} - this._styleCache = {} + if (!autoHeight) { + newState.scrollTop = scrollTop; + } + + if (!autoWidth) { + newState.scrollLeft = scrollLeft; + } + + newState.needToResetStyleCache = false; + this.setState(newState); + } + + this._invokeOnScrollMemoizer({ + scrollLeft, + scrollTop, + totalColumnsWidth, + totalRowsHeight, + }); } /** @@ -297,16 +503,15 @@ export default class Grid extends PureComponent { * This method is intended for advanced use-cases like CellMeasurer. */ // @TODO (bvaughn) Add automated test coverage for this. - invalidateCellSizeAfterRender ({ - columnIndex, - rowIndex - }) { - this._deferredInvalidateColumnIndex = typeof this._deferredInvalidateColumnIndex === 'number' - ? Math.min(this._deferredInvalidateColumnIndex, columnIndex) - : columnIndex - this._deferredInvalidateRowIndex = typeof this._deferredInvalidateRowIndex === 'number' - ? Math.min(this._deferredInvalidateRowIndex, rowIndex) - : rowIndex + 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; } /** @@ -314,11 +519,15 @@ export default class Grid extends PureComponent { * 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 - - this._columnSizeAndPositionManager.getSizeAndPositionOfCell(columnCount - 1) - this._rowSizeAndPositionManager.getSizeAndPositionOfCell(rowCount - 1) + measureAllCells() { + const {columnCount, rowCount} = this.props; + const {instanceProps} = this.state; + instanceProps.columnSizeAndPositionManager.getSizeAndPositionOfCell( + columnCount - 1, + ); + instanceProps.rowSizeAndPositionManager.getSizeAndPositionOfCell( + rowCount - 1, + ); } /** @@ -326,89 +535,141 @@ export default class Grid extends PureComponent { * 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 - } = {}) { - const { scrollToColumn, scrollToRow } = this.props + recomputeGridSize({columnIndex = 0, rowIndex = 0}: CellPosition = {}) { + const {scrollToColumn, scrollToRow} = this.props; + const {instanceProps} = this.state; - this._columnSizeAndPositionManager.resetCell(columnIndex) - this._rowSizeAndPositionManager.resetCell(rowIndex) + 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 && columnIndex <= scrollToColumn - this._recomputeScrollTopFlag = scrollToRow >= 0 && rowIndex <= scrollToRow + 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._cellCache = {} - this._styleCache = {} + this._styleCache = {}; + this._cellCache = {}; - this.forceUpdate() + this.forceUpdate(); } /** * Ensure column and row are visible. */ - scrollToCell ({ - columnIndex, - rowIndex - }) { - const { columnCount } = this.props + scrollToCell({columnIndex, rowIndex}: CellPosition) { + const {columnCount} = this.props; - const props = 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) { + if (columnCount > 1 && columnIndex !== undefined) { this._updateScrollLeftForScrollToColumn({ ...props, - scrollToColumn: columnIndex - }) + scrollToColumn: columnIndex, + }); } - this._updateScrollTopForScrollToRow({ - ...props, - scrollToRow: rowIndex - }) + if (rowIndex !== undefined) { + this._updateScrollTopForScrollToRow({ + ...props, + scrollToRow: rowIndex, + }); + } } - componentDidMount () { - const { getScrollbarSize, scrollLeft, scrollToColumn, scrollTop, scrollToRow } = this.props + 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() + this._handleInvalidatedGridSize(); // 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 (!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); + } } - if (scrollLeft >= 0 || scrollTop >= 0) { - this._setScrollPosition({ scrollLeft, scrollTop }) + // 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; + } } - if (scrollToColumn >= 0 || scrollToRow >= 0) { - this._updateScrollLeftForScrollToColumn() - this._updateScrollTopForScrollToRow() + // 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() + this._invokeOnGridRenderedHelper(); // Initialize onScroll callback this._invokeOnScrollMemoizer({ scrollLeft: scrollLeft || 0, scrollTop: scrollTop || 0, - totalColumnsWidth: this._columnSizeAndPositionManager.getTotalSize(), - totalRowsHeight: this._rowSizeAndPositionManager.getTotalSize() - }) + totalColumnsWidth: instanceProps.columnSizeAndPositionManager.getTotalSize(), + totalRowsHeight: instanceProps.rowSizeAndPositionManager.getTotalSize(), + }); + + this._maybeCallOnScrollbarPresenceChange(); } /** @@ -416,64 +677,79 @@ export default class Grid extends PureComponent { * This method updates scrollLeft/scrollTop in state for the following conditions: * 1) New scroll-to-cell props have been set */ - componentDidUpdate (prevProps, prevState) { - const { autoHeight, columnCount, height, rowCount, scrollToAlignment, scrollToColumn, scrollToRow, width } = this.props - const { scrollLeft, scrollPositionChangeReason, scrollTop } = this.state - + 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() + 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 - ) + 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) { + 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 !== prevState.scrollLeft && - scrollLeft !== this._scrollingContainer.scrollLeft || - columnOrRowCountJustIncreasedFromZero - ) + (scrollLeft !== this._scrollingContainer.scrollLeft || + columnOrRowCountJustIncreasedFromZero) ) { - this._scrollingContainer.scrollLeft = scrollLeft + this._scrollingContainer.scrollLeft = scrollLeft; } - - // @TRICKY :autoHeight property instructs Grid to leave :scrollTop management to an external HOC (eg WindowScroller). - // In this case we should avoid checking scrollingContainer.scrollTop since it forces layout/flow. if ( !autoHeight && scrollTop >= 0 && - ( - scrollTop !== prevState.scrollTop && - scrollTop !== this._scrollingContainer.scrollTop || - columnOrRowCountJustIncreasedFromZero - ) + (scrollTop !== this._scrollingContainer.scrollTop || + columnOrRowCountJustIncreasedFromZero) ) { - this._scrollingContainer.scrollTop = scrollTop + 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) + this._recomputeScrollLeftFlag = false; + this._updateScrollLeftForScrollToColumn(this.props); } else { updateScrollIndexHelper({ - cellSizeAndPositionManager: this._columnSizeAndPositionManager, + cellSizeAndPositionManager: instanceProps.columnSizeAndPositionManager, previousCellsCount: prevProps.columnCount, previousCellSize: prevProps.columnWidth, previousScrollToAlignment: prevProps.scrollToAlignment, @@ -483,16 +759,18 @@ export default class Grid extends PureComponent { scrollToAlignment, scrollToIndex: scrollToColumn, size: width, - updateScrollIndexCallback: (scrollToColumn) => this._updateScrollLeftForScrollToColumn(this.props) - }) + sizeJustIncreasedFromZero, + updateScrollIndexCallback: () => + this._updateScrollLeftForScrollToColumn(this.props), + }); } if (this._recomputeScrollTopFlag) { - this._recomputeScrollTopFlag = false - this._updateScrollTopForScrollToRow(this.props) + this._recomputeScrollTopFlag = false; + this._updateScrollTopForScrollToRow(this.props); } else { updateScrollIndexHelper({ - cellSizeAndPositionManager: this._rowSizeAndPositionManager, + cellSizeAndPositionManager: instanceProps.rowSizeAndPositionManager, previousCellsCount: prevProps.rowCount, previousCellSize: prevProps.rowHeight, previousScrollToAlignment: prevProps.scrollToAlignment, @@ -502,149 +780,197 @@ export default class Grid extends PureComponent { scrollToAlignment, scrollToIndex: scrollToRow, size: height, - updateScrollIndexCallback: (scrollToRow) => this._updateScrollTopForScrollToRow(this.props) - }) + sizeJustIncreasedFromZero, + updateScrollIndexCallback: () => + this._updateScrollTopForScrollToRow(this.props), + }); } // Update onRowsRendered callback if start/stop indices have changed - this._invokeOnGridRenderedHelper() + this._invokeOnGridRenderedHelper(); // Changes to :scrollLeft or :scrollTop should also notify :onScroll listeners if ( scrollLeft !== prevState.scrollLeft || scrollTop !== prevState.scrollTop ) { - const totalRowsHeight = this._rowSizeAndPositionManager.getTotalSize() - const totalColumnsWidth = this._columnSizeAndPositionManager.getTotalSize() - - this._invokeOnScrollMemoizer({ scrollLeft, scrollTop, totalColumnsWidth, totalRowsHeight }) - } - } - - componentWillMount () { - const { getScrollbarSize } = this.props + const totalRowsHeight = instanceProps.rowSizeAndPositionManager.getTotalSize(); + const totalColumnsWidth = instanceProps.columnSizeAndPositionManager.getTotalSize(); - // 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 + this._invokeOnScrollMemoizer({ + scrollLeft, + scrollTop, + totalColumnsWidth, + totalRowsHeight, + }); } - this._calculateChildrenToRender() + this._maybeCallOnScrollbarPresenceChange(); } - componentWillUnmount () { + componentWillUnmount() { if (this._disablePointerEventsTimeoutId) { - clearTimeout(this._disablePointerEventsTimeoutId) + cancelAnimationTimeout(this._disablePointerEventsTimeoutId); } } /** - * @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 */ - componentWillReceiveProps (nextProps) { - const { scrollLeft, scrollTop } = this.state + static getDerivedStateFromProps( + nextProps: Props, + prevState: State, + ): $Shape { + const newState = {}; if ( - nextProps.columnCount === 0 && - scrollLeft !== 0 || - nextProps.rowCount === 0 && - scrollTop !== 0 + (nextProps.columnCount === 0 && prevState.scrollLeft !== 0) || + (nextProps.rowCount === 0 && prevState.scrollTop !== 0) ) { - this._setScrollPosition({ - scrollLeft: 0, - 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 !== this.props.scrollLeft || - nextProps.scrollTop !== this.props.scrollTop + (nextProps.scrollLeft !== prevState.scrollLeft && + nextProps.scrollToColumn < 0) || + (nextProps.scrollTop !== prevState.scrollTop && nextProps.scrollToRow < 0) ) { - const newState = {} - - if (nextProps.scrollLeft != null) { - newState.scrollLeft = nextProps.scrollLeft - } - if (nextProps.scrollTop != null) { - newState.scrollTop = nextProps.scrollTop - } - - this._setScrollPosition(newState) + 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 !== this.props.columnWidth || - nextProps.rowHeight !== this.props.rowHeight + nextProps.columnWidth !== instanceProps.prevColumnWidth || + nextProps.rowHeight !== instanceProps.prevRowHeight ) { - this._styleCache = {} + // Reset cache. set it to {} in render + newState.needToResetStyleCache = true; } - this._columnWidthGetter = this._wrapSizeGetter(nextProps.columnWidth) - this._rowHeightGetter = this._wrapSizeGetter(nextProps.rowHeight) - - this._columnSizeAndPositionManager.configure({ + instanceProps.columnSizeAndPositionManager.configure({ cellCount: nextProps.columnCount, - estimatedCellSize: this._getEstimatedColumnSize(nextProps) - }) - this._rowSizeAndPositionManager.configure({ + estimatedCellSize: Grid._getEstimatedColumnSize(nextProps), + cellSizeGetter: Grid._wrapSizeGetter(nextProps.columnWidth), + }); + + instanceProps.rowSizeAndPositionManager.configure({ cellCount: nextProps.rowCount, - estimatedCellSize: this._getEstimatedRowSize(nextProps) - }) + estimatedCellSize: Grid._getEstimatedRowSize(nextProps), + cellSizeGetter: Grid._wrapSizeGetter(nextProps.rowHeight), + }); - let { columnCount, rowCount } = this.props + if ( + instanceProps.prevColumnCount === 0 || + instanceProps.prevRowCount === 0 + ) { + instanceProps.prevColumnCount = 0; + instanceProps.prevRowCount = 0; + } - // Special case when either cols or rows were 0 - // This would prevent any cells from rendering - // So we need to reset row scroll if cols changed from 0 (and vice versa) + // If scrolling is controlled outside this component, clear cache when scrolling stops if ( - columnCount === 0 || - rowCount === 0 + nextProps.autoHeight && + nextProps.isScrolling === false && + instanceProps.prevIsScrolling === true ) { - columnCount = 0 - rowCount = 0 + Object.assign(newState, { + isScrolling: false, + }); } - // Update scroll offsets if the size or number of cells have changed, invalidating the previous value + let maybeStateA; + let maybeStateB; + calculateSizeAndPositionDataAndUpdateScrollOffset({ - cellCount: columnCount, - cellSize: this.props.columnWidth, - computeMetadataCallback: () => this._columnSizeAndPositionManager.resetCell(0), + cellCount: instanceProps.prevColumnCount, + cellSize: + typeof instanceProps.prevColumnWidth === 'number' + ? instanceProps.prevColumnWidth + : null, + computeMetadataCallback: () => + instanceProps.columnSizeAndPositionManager.resetCell(0), computeMetadataCallbackProps: nextProps, nextCellsCount: nextProps.columnCount, - nextCellSize: nextProps.columnWidth, + nextCellSize: + typeof nextProps.columnWidth === 'number' + ? nextProps.columnWidth + : null, nextScrollToIndex: nextProps.scrollToColumn, - scrollToIndex: this.props.scrollToColumn, - updateScrollOffsetForScrollToIndex: () => this._updateScrollLeftForScrollToColumn(nextProps, this.state) - }) + scrollToIndex: instanceProps.prevScrollToColumn, + updateScrollOffsetForScrollToIndex: () => { + maybeStateA = Grid._getScrollLeftForScrollToColumnStateUpdate( + nextProps, + prevState, + ); + }, + }); calculateSizeAndPositionDataAndUpdateScrollOffset({ - cellCount: rowCount, - cellSize: this.props.rowHeight, - computeMetadataCallback: () => this._rowSizeAndPositionManager.resetCell(0), + cellCount: instanceProps.prevRowCount, + cellSize: + typeof instanceProps.prevRowHeight === 'number' + ? instanceProps.prevRowHeight + : null, + computeMetadataCallback: () => + instanceProps.rowSizeAndPositionManager.resetCell(0), computeMetadataCallbackProps: nextProps, nextCellsCount: nextProps.rowCount, - nextCellSize: nextProps.rowHeight, + nextCellSize: + typeof nextProps.rowHeight === 'number' ? nextProps.rowHeight : null, nextScrollToIndex: nextProps.scrollToRow, - scrollToIndex: this.props.scrollToRow, - updateScrollOffsetForScrollToIndex: () => this._updateScrollTopForScrollToRow(nextProps, this.state) - }) - } + 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; - componentWillUpdate (nextProps, nextState) { - this._calculateChildrenToRender(nextProps, nextState) + return {...newState, ...maybeStateA, ...maybeStateB}; } - render () { + render() { const { autoContainerWidth, autoHeight, + autoWidth, className, + containerProps, + containerRole, containerStyle, height, id, @@ -652,67 +978,89 @@ export default class Grid extends PureComponent { role, style, tabIndex, - width - } = this.props + width, + } = this.props; + const {instanceProps, needToResetStyleCache} = this.state; - const { isScrolling } = this.state + const isScrolling = this._isScrolling(); - const gridStyle = { + const gridStyle: Object = { boxSizing: 'border-box', direction: 'ltr', height: autoHeight ? 'auto' : height, position: 'relative', - width, + width: autoWidth ? 'auto' : width, WebkitOverflowScrolling: 'touch', - willChange: 'transform' + willChange: 'transform', + }; + + if (needToResetStyleCache) { + this._styleCache = {}; } - const totalColumnsWidth = this._columnSizeAndPositionManager.getTotalSize() - const totalRowsHeight = this._rowSizeAndPositionManager.getTotalSize() + // 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 ? this._scrollbarSize : 0 - const horizontalScrollBarSize = totalColumnsWidth > width ? this._scrollbarSize : 0 + 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' + gridStyle.overflowX = + totalColumnsWidth + verticalScrollBarSize <= width ? 'hidden' : 'auto'; + gridStyle.overflowY = + totalRowsHeight + horizontalScrollBarSize <= height ? 'hidden' : 'auto'; - const childrenToDisplay = this._childrenToDisplay + const childrenToDisplay = this._childrenToDisplay; - const showNoContentRenderer = ( - childrenToDisplay.length === 0 && - height > 0 && - width > 0 - ) + const showNoContentRenderer = + childrenToDisplay.length === 0 && height > 0 && width > 0; return (
- {childrenToDisplay.length > 0 && + tabIndex={tabIndex}> + {childrenToDisplay.length > 0 && (
+ ...containerStyle, + }}> {childrenToDisplay}
- } - {showNoContentRenderer && - noContentRenderer() - } + )} + {showNoContentRenderer && noContentRenderer()}
- ) + ); } /* ---------------------------- Helper methods ---------------------------- */ - _calculateChildrenToRender (props = this.props, state = this.state) { + _calculateChildrenToRender( + props: Props = this.props, + state: State = this.state, + ) { const { cellRenderer, cellRangeRenderer, @@ -747,89 +1095,162 @@ export default class Grid extends PureComponent { overscanIndicesGetter, overscanRowCount, rowCount, - width - } = props + width, + isScrollingOptOut, + } = props; const { - isScrolling, scrollDirectionHorizontal, scrollDirectionVertical, - scrollLeft, - scrollTop - } = state + 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 = [] + this._childrenToDisplay = []; // Render only enough columns and rows to cover the visible area of the grid. if (height > 0 && width > 0) { - const visibleColumnIndices = this._columnSizeAndPositionManager.getVisibleCellRange({ - containerSize: width, - offset: scrollLeft - }) - const visibleRowIndices = this._rowSizeAndPositionManager.getVisibleCellRange({ - containerSize: height, - offset: scrollTop - }) - - const horizontalOffsetAdjustment = this._columnSizeAndPositionManager.getOffsetAdjustment({ - containerSize: width, - offset: scrollLeft - }) - const verticalOffsetAdjustment = this._rowSizeAndPositionManager.getOffsetAdjustment({ - containerSize: height, - offset: scrollTop - }) + 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 + 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: this._renderedColumnStartIndex, - stopIndex: this._renderedColumnStopIndex - }) + 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: this._renderedRowStartIndex, - stopIndex: this._renderedRowStopIndex - }) + startIndex: + typeof visibleRowIndices.start === 'number' + ? visibleRowIndices.start + : 0, + stopIndex: + typeof visibleRowIndices.stop === 'number' + ? visibleRowIndices.stop + : -1, + }); // Store for _invokeOnGridRenderedHelper() - this._columnStartIndex = overscanColumnIndices.overscanStartIndex - this._columnStopIndex = overscanColumnIndices.overscanStopIndex - this._rowStartIndex = overscanRowIndices.overscanStartIndex - this._rowStopIndex = overscanRowIndices.overscanStopIndex + 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: this._columnSizeAndPositionManager, - columnStartIndex: this._columnStartIndex, - columnStopIndex: this._columnStopIndex, + columnSizeAndPositionManager: + instanceProps.columnSizeAndPositionManager, + columnStartIndex, + columnStopIndex, deferredMeasurementCache, horizontalOffsetAdjustment, isScrolling, + isScrollingOptOut, parent: this, - rowSizeAndPositionManager: this._rowSizeAndPositionManager, - rowStartIndex: this._rowStartIndex, - rowStopIndex: this._rowStopIndex, + rowSizeAndPositionManager: instanceProps.rowSizeAndPositionManager, + rowStartIndex, + rowStopIndex, scrollLeft, scrollTop, styleCache: this._styleCache, verticalOffsetAdjustment, visibleColumnIndices, - visibleRowIndices - }) + visibleRowIndices, + }); + + // update the indices + this._columnStartIndex = columnStartIndex; + this._columnStopIndex = columnStopIndex; + this._rowStartIndex = rowStartIndex; + this._rowStopIndex = rowStopIndex; } } @@ -838,74 +1259,61 @@ export default class Grid extends PureComponent { * 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 + _debounceScrollEnded() { + const {scrollingResetTimeInterval} = this.props; if (this._disablePointerEventsTimeoutId) { - clearTimeout(this._disablePointerEventsTimeoutId) + cancelAnimationTimeout(this._disablePointerEventsTimeoutId); } - this._disablePointerEventsTimeoutId = setTimeout( + this._disablePointerEventsTimeoutId = requestAnimationTimeout( this._debounceScrollEndedCallback, - scrollingResetTimeInterval - ) + scrollingResetTimeInterval, + ); } - _debounceScrollEndedCallback () { - this._disablePointerEventsTimeoutId = null - - const styleCache = this._styleCache - - // 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. - 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] - } - } - + _debounceScrollEndedCallback = () => { + this._disablePointerEventsTimeoutId = null; + // isScrolling is used to determine if we reset styleCache this.setState({ - isScrolling: false - }) - } + isScrolling: false, + needToResetStyleCache: false, + }); + }; - _getEstimatedColumnSize (props) { + static _getEstimatedColumnSize(props: Props) { return typeof props.columnWidth === 'number' ? props.columnWidth - : props.estimatedColumnSize + : props.estimatedColumnSize; } - _getEstimatedRowSize (props) { + static _getEstimatedRowSize(props: Props) { return typeof props.rowHeight === 'number' ? props.rowHeight - : props.estimatedRowSize + : 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') { - const columnIndex = this._deferredInvalidateColumnIndex - const rowIndex = this._deferredInvalidateRowIndex + _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._deferredInvalidateColumnIndex = null; + this._deferredInvalidateRowIndex = null; - this.recomputeGridSize({ columnIndex, rowIndex }) + this.recomputeGridSize({columnIndex, rowIndex}); } } - _invokeOnGridRenderedHelper () { - const { onSectionRendered } = this.props + _invokeOnGridRenderedHelper = () => { + const {onSectionRendered} = this.props; this._onGridRenderedMemoizer({ callback: onSectionRendered, @@ -917,15 +1325,25 @@ export default class Grid extends PureComponent { rowOverscanStartIndex: this._rowStartIndex, rowOverscanStopIndex: this._rowStopIndex, rowStartIndex: this._renderedRowStartIndex, - rowStopIndex: this._renderedRowStopIndex - } - }) - } + rowStopIndex: this._renderedRowStopIndex, + }, + }); + }; - _invokeOnScrollMemoizer ({ scrollLeft, scrollTop, totalColumnsWidth, totalRowsHeight }) { + _invokeOnScrollMemoizer({ + scrollLeft, + scrollTop, + totalColumnsWidth, + totalRowsHeight, + }: { + scrollLeft: number, + scrollTop: number, + totalColumnsWidth: number, + totalRowsHeight: number, + }) { this._onScrollMemoizer({ - callback: ({ scrollLeft, scrollTop }) => { - const { height, onScroll, width } = this.props + callback: ({scrollLeft, scrollTop}) => { + const {height, onScroll, width} = this.props; onScroll({ clientHeight: height, @@ -933,169 +1351,307 @@ export default class Grid extends PureComponent { scrollHeight: totalRowsHeight, scrollLeft, scrollTop, - scrollWidth: totalColumnsWidth - }) + scrollWidth: totalColumnsWidth, + }); }, indices: { scrollLeft, - scrollTop - } - }) + scrollTop, + }, + }); } - _setScrollingContainerRef (ref) { - this._scrollingContainer = ref + _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); } - _setScrollPosition ({ scrollLeft, scrollTop }) { - const newState = { - scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.REQUESTED + _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 (scrollLeft >= 0) { - newState.scrollDirectionHorizontal = scrollLeft > this.state.scrollLeft - ? SCROLL_DIRECTION_FORWARD - : SCROLL_DIRECTION_BACKWARD - newState.scrollLeft = scrollLeft + if (typeof this.props.elementRef === 'function') { + this.props.elementRef(ref); + } else if (typeof this.props.elementRef === 'object') { + this.props.elementRef.current = ref; } + }; - if (scrollTop >= 0) { - newState.scrollDirectionVertical = scrollTop > this.state.scrollTop - ? SCROLL_DIRECTION_FORWARD - : SCROLL_DIRECTION_BACKWARD - newState.scrollTop = scrollTop + /** + * 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 ( - scrollLeft >= 0 && scrollLeft !== this.state.scrollLeft || - scrollTop >= 0 && scrollTop !== this.state.scrollTop + (typeof scrollLeft === 'number' && + scrollLeft >= 0 && + scrollLeft !== prevState.scrollLeft) || + (typeof scrollTop === 'number' && + scrollTop >= 0 && + scrollTop !== prevState.scrollTop) ) { - this.setState(newState) + return newState; } + return {}; } - _wrapPropertyGetter (value) { - return typeof value === 'function' - ? value - : () => value + /** + * 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); + } } - _wrapSizeGetter (size) { - return this._wrapPropertyGetter(size) + static _wrapSizeGetter(value: CellSize): CellSizeGetter { + return typeof value === 'function' ? value : () => (value: any); } - _updateScrollLeftForScrollToColumn (props = this.props, state = this.state) { - const { columnCount, height, scrollToAlignment, scrollToColumn, width } = props - const { scrollLeft } = state + 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; + } - if (scrollToColumn >= 0 && columnCount > 0) { - const targetIndex = Math.max(0, Math.min(columnCount - 1, scrollToColumn)) - const totalRowsHeight = this._rowSizeAndPositionManager.getTotalSize() - const scrollBarSize = totalRowsHeight > height ? this._scrollbarSize : 0 + _getCalculatedScrollLeft( + props: Props = this.props, + state: State = this.state, + ) { + return Grid._getCalculatedScrollLeft(props, state); + } - const calculatedScrollLeft = this._columnSizeAndPositionManager.getUpdatedOffsetForIndex({ - align: scrollToAlignment, - containerSize: width - scrollBarSize, - currentOffset: scrollLeft, - targetIndex - }) - - if (scrollLeft !== calculatedScrollLeft) { - this._setScrollPosition({ - scrollLeft: calculatedScrollLeft - }) - } + 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 {}; } - _updateScrollTopForScrollToRow (props = this.props, state = this.state) { - const { height, rowCount, scrollToAlignment, scrollToRow, width } = props - const { scrollTop } = state - - if (scrollToRow >= 0 && rowCount > 0) { - const targetIndex = Math.max(0, Math.min(rowCount - 1, scrollToRow)) - const totalColumnsWidth = this._columnSizeAndPositionManager.getTotalSize() - const scrollBarSize = totalColumnsWidth > width ? this._scrollbarSize : 0 + _updateScrollLeftForScrollToColumn( + props: Props = this.props, + state: State = this.state, + ) { + const stateUpdate = Grid._getScrollLeftForScrollToColumnStateUpdate( + props, + state, + ); + if (stateUpdate) { + stateUpdate.needToResetStyleCache = false; + this.setState(stateUpdate); + } + } - const calculatedScrollTop = this._rowSizeAndPositionManager.getUpdatedOffsetForIndex({ + 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 - }) - - if (scrollTop !== calculatedScrollTop) { - this._setScrollPosition({ - scrollTop: calculatedScrollTop - }) - } + targetIndex, + }); } + return 0; } - _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 - } + _getCalculatedScrollTop( + props: Props = this.props, + state: State = this.state, + ) { + return Grid._getCalculatedScrollTop(props, state); + } - const { autoHeight, height, width } = this.props + _resetStyleCache() { + const styleCache = this._styleCache; + const cellCache = this._cellCache; + const {isScrollingOptOut} = this.props; - const { - scrollLeft: eventScrollLeft, - scrollTop: eventScrollTop - } = event.target + // 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 = {}; - // 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 = this._scrollbarSize - const totalRowsHeight = this._rowSizeAndPositionManager.getTotalSize() - const totalColumnsWidth = this._columnSizeAndPositionManager.getTotalSize() - const scrollLeft = Math.min(Math.max(0, totalColumnsWidth - width + scrollbarSize), eventScrollLeft) - const scrollTop = Math.min(Math.max(0, totalRowsHeight - height + scrollbarSize), 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 ( - eventScrollLeft !== scrollLeft || - eventScrollTop !== scrollTop + // Copy over the visible cell styles so avoid unnecessary re-render. + for ( + let rowIndex = this._rowStartIndex; + rowIndex <= this._rowStopIndex; + rowIndex++ ) { - return + 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]; + } + } } + } - // Prevent pointer events from interrupting a smooth scroll - this._debounceScrollEnded() + static _getScrollTopForScrollToRowStateUpdate( + nextProps: Props, + prevState: State, + ): $Shape { + const {scrollTop} = prevState; + const calculatedScrollTop = Grid._getCalculatedScrollTop( + nextProps, + prevState, + ); - // 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 + typeof calculatedScrollTop === 'number' && + calculatedScrollTop >= 0 && + scrollTop !== calculatedScrollTop ) { - // Track scrolling direction so we can more efficiently overscan rows to reduce empty space around the edges while scrolling. - const scrollDirectionHorizontal = scrollLeft > this.state.scrollLeft ? SCROLL_DIRECTION_FORWARD : SCROLL_DIRECTION_BACKWARD - const scrollDirectionVertical = scrollTop > this.state.scrollTop ? SCROLL_DIRECTION_FORWARD : SCROLL_DIRECTION_BACKWARD - - const newState = { - isScrolling: true, - scrollDirectionHorizontal, - scrollDirectionVertical, - scrollLeft, - scrollPositionChangeReason: SCROLL_POSITION_CHANGE_REASONS.OBSERVED - } - - if (!autoHeight) { - newState.scrollTop = scrollTop - } - - this.setState(newState) + return Grid._getScrollToPositionStateUpdate({ + prevState, + scrollLeft: -1, + scrollTop: calculatedScrollTop, + }); } + return {}; + } - this._invokeOnScrollMemoizer({ scrollLeft, scrollTop, totalColumnsWidth, totalRowsHeight }) + _updateScrollTopForScrollToRow( + props: Props = this.props, + state: State = this.state, + ) { + const stateUpdate = Grid._getScrollTopForScrollToRowStateUpdate( + props, + state, + ); + if (stateUpdate) { + stateUpdate.needToResetStyleCache = false; + this.setState(stateUpdate); + } } + + _onScroll = (event: Event) => { + // In certain edge-cases React dispatches an onScroll event with an invalid target.scrollLeft / target.scrollTop. + // This invalid event can be detected by comparing event.target to this component's scrollable DOM element. + // See issue #404 for more information. + if (event.target === this._scrollingContainer) { + this.handleScrollEvent((event.target: any)); + } + }; } + +polyfill(Grid); +export default Grid; diff --git a/source/Grid/Grid.ssr.js b/source/Grid/Grid.ssr.js new file mode 100644 index 000000000..eed80cf15 --- /dev/null +++ b/source/Grid/Grid.ssr.js @@ -0,0 +1,56 @@ +/** + * @flow + * @jest-environment node + */ + +import * as React from 'react'; +import * as ReactDOMServer from 'react-dom/server'; +import Grid from './Grid'; + +declare var test: any; +declare var expect: any; + +test('should render Grid with dom server', () => { + const rendered = ReactDOMServer.renderToString( + ( +
+ {rowIndex + ':' + columnIndex} +
+ )} + columnCount={1000} + columnWidth={20} + height={500} + rowCount={1000} + rowHeight={20} + width={500} + />, + ); + + expect(rendered).toContain('0:0'); + expect(rendered).toContain('24:24'); + expect(rendered).not.toContain('25:25'); +}); + +test('should support :scrollToColumn and :scrollToRow in server render', () => { + const rendered = ReactDOMServer.renderToString( + ( +
+ {rowIndex + ':' + columnIndex} +
+ )} + columnCount={1000} + columnWidth={20} + scrollToColumn={250} + height={500} + rowCount={1000} + rowHeight={20} + scrollToRow={250} + width={500} + />, + ); + + expect(rendered).toContain('250:250'); + expect(rendered).not.toContain('0:0'); +}); diff --git a/source/Grid/accessibilityOverscanIndicesGetter.jest.js b/source/Grid/accessibilityOverscanIndicesGetter.jest.js new file mode 100644 index 000000000..4184e1a95 --- /dev/null +++ b/source/Grid/accessibilityOverscanIndicesGetter.jest.js @@ -0,0 +1,110 @@ +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, + }); + }); +}); diff --git a/source/Grid/accessibilityOverscanIndicesGetter.js b/source/Grid/accessibilityOverscanIndicesGetter.js new file mode 100644 index 000000000..2dfa86d5b --- /dev/null +++ b/source/Grid/accessibilityOverscanIndicesGetter.js @@ -0,0 +1,42 @@ +// @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), + }; + } +} diff --git a/source/Grid/defaultCellRangeRenderer.js b/source/Grid/defaultCellRangeRenderer.js index 787e55600..38caaac55 100644 --- a/source/Grid/defaultCellRangeRenderer.js +++ b/source/Grid/defaultCellRangeRenderer.js @@ -1,9 +1,14 @@ /** @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 ({ + +export default function defaultCellRangeRenderer({ cellCache, cellRenderer, columnSizeAndPositionManager, @@ -12,55 +17,56 @@ export default function defaultCellRangeRenderer ({ deferredMeasurementCache, horizontalOffsetAdjustment, isScrolling, + isScrollingOptOut, parent, // Grid (or List or Table) rowSizeAndPositionManager, rowStartIndex, rowStopIndex, - scrollLeft, - scrollTop, styleCache, verticalOffsetAdjustment, visibleColumnIndices, - visibleRowIndices -}: DefaultCellRangeRendererParams) { - const deferredMode = typeof deferredMeasurementCache !== 'undefined' - - const renderedCells = [] + 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 = ( + const areOffsetsAdjusted = columnSizeAndPositionManager.areOffsetsAdjusted() || - rowSizeAndPositionManager.areOffsetsAdjusted() - ) + rowSizeAndPositionManager.areOffsetsAdjusted(); - const canCacheStyle = !isScrolling || !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 = ( + 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 + 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] + 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 ( - deferredMode && + deferredMeasurementCache && !deferredMeasurementCache.has(rowIndex, columnIndex) ) { // Position not-yet-measured cells at top/left 0,0, @@ -71,18 +77,18 @@ export default function defaultCellRangeRenderer ({ left: 0, position: 'absolute', top: 0, - width: 'auto' - } + width: 'auto', + }; } else { style = { height: rowDatum.size, left: columnDatum.offset + horizontalOffsetAdjustment, position: 'absolute', top: rowDatum.offset + verticalOffsetAdjustment, - width: columnDatum.size - } + width: columnDatum.size, + }; - styleCache[key] = style + styleCache[key] = style; } } @@ -93,10 +99,10 @@ export default function defaultCellRangeRenderer ({ key, parent, rowIndex, - style - } + style, + }; - let renderedCell + 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. @@ -105,48 +111,52 @@ export default function defaultCellRangeRenderer ({ // 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 ( - isScrolling && + (isScrollingOptOut || isScrolling) && !horizontalOffsetAdjustment && !verticalOffsetAdjustment ) { if (!cellCache[key]) { - cellCache[key] = cellRenderer(cellRendererParams) + cellCache[key] = cellRenderer(cellRendererParams); } - renderedCell = cellCache[key] + 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. + // 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) + renderedCell = cellRenderer(cellRendererParams); } if (renderedCell == null || renderedCell === false) { - continue + continue; } if (process.env.NODE_ENV !== 'production') { - warnAboutMissingStyle(parent, renderedCell) + warnAboutMissingStyle(parent, renderedCell); + } + + if (!renderedCell.props.role) { + renderedCell = React.cloneElement(renderedCell, {role: 'gridcell'}); } - renderedCells.push(renderedCell) + renderedCells.push(renderedCell); } } - return renderedCells + return renderedCells; } -function warnAboutMissingStyle (parent, renderedCell) { +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.type && renderedCell.type.__internalCellMeasurerFlag) { + renderedCell = renderedCell.props.children; } if ( @@ -155,29 +165,12 @@ function warnAboutMissingStyle (parent, renderedCell) { renderedCell.props.style === undefined && parent.__warnedAboutMissingStyle !== true ) { - parent.__warnedAboutMissingStyle = true + parent.__warnedAboutMissingStyle = true; - console.warn('Rendered cell should include style property for positioning.') + console.warn( + 'Rendered cell should include style property for positioning.', + ); } } } } - -type DefaultCellRangeRendererParams = { - cellCache: Object, - cellRenderer: Function, - columnSizeAndPositionManager: Object, - columnStartIndex: number, - columnStopIndex: number, - horizontalOffsetAdjustment: number, - isScrolling: boolean, - rowSizeAndPositionManager: Object, - rowStartIndex: number, - rowStopIndex: number, - scrollLeft: number, - scrollTop: number, - styleCache: Object, - verticalOffsetAdjustment: number, - visibleColumnIndices: Object, - visibleRowIndices: Object -}; diff --git a/source/Grid/utils/defaultOverscanIndicesGetter.jest.js b/source/Grid/defaultOverscanIndicesGetter.jest.js similarity index 68% rename from source/Grid/utils/defaultOverscanIndicesGetter.jest.js rename to source/Grid/defaultOverscanIndicesGetter.jest.js index 3d18e6860..c8984e3fd 100644 --- a/source/Grid/utils/defaultOverscanIndicesGetter.jest.js +++ b/source/Grid/defaultOverscanIndicesGetter.jest.js @@ -1,23 +1,23 @@ import overscanIndicesGetter, { SCROLL_DIRECTION_BACKWARD, - SCROLL_DIRECTION_FORWARD -} from './defaultOverscanIndicesGetter' + SCROLL_DIRECTION_FORWARD, +} from './defaultOverscanIndicesGetter'; describe('overscanIndicesGetter', () => { - function testHelper ({ + function testHelper({ cellCount, startIndex, stopIndex, overscanCellsCount, - scrollDirection + scrollDirection, }) { return overscanIndicesGetter({ cellCount, overscanCellsCount, scrollDirection, startIndex, - stopIndex - }) + stopIndex, + }); } it('should not overscan if :overscanCellsCount is 0', () => { @@ -27,12 +27,12 @@ describe('overscanIndicesGetter', () => { startIndex: 10, stopIndex: 20, overscanCellsCount: 0, - scrollDirection: SCROLL_DIRECTION_BACKWARD - }) + scrollDirection: SCROLL_DIRECTION_BACKWARD, + }), ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 20 - }) + overscanStopIndex: 20, + }); expect( testHelper({ @@ -40,13 +40,13 @@ describe('overscanIndicesGetter', () => { startIndex: 10, stopIndex: 20, overscanCellsCount: 0, - scrollDirection: SCROLL_DIRECTION_FORWARD - }) + scrollDirection: SCROLL_DIRECTION_FORWARD, + }), ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 20 - }) - }) + overscanStopIndex: 20, + }); + }); it('should overscan forward', () => { expect( @@ -55,13 +55,13 @@ describe('overscanIndicesGetter', () => { startIndex: 20, stopIndex: 30, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_FORWARD - }) + scrollDirection: SCROLL_DIRECTION_FORWARD, + }), ).toEqual({ overscanStartIndex: 20, - overscanStopIndex: 40 - }) - }) + overscanStopIndex: 40, + }); + }); it('should overscan backward', () => { expect( @@ -70,13 +70,13 @@ describe('overscanIndicesGetter', () => { startIndex: 20, stopIndex: 30, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_BACKWARD - }) + scrollDirection: SCROLL_DIRECTION_BACKWARD, + }), ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 30 - }) - }) + overscanStopIndex: 30, + }); + }); it('should not overscan beyond the start of the list', () => { expect( @@ -85,13 +85,13 @@ describe('overscanIndicesGetter', () => { startIndex: 5, stopIndex: 15, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_BACKWARD - }) + scrollDirection: SCROLL_DIRECTION_BACKWARD, + }), ).toEqual({ overscanStartIndex: 0, - overscanStopIndex: 15 - }) - }) + overscanStopIndex: 15, + }); + }); it('should not overscan beyond the end of the list', () => { expect( @@ -100,11 +100,11 @@ describe('overscanIndicesGetter', () => { startIndex: 10, stopIndex: 20, overscanCellsCount: 10, - scrollDirection: SCROLL_DIRECTION_FORWARD - }) + scrollDirection: SCROLL_DIRECTION_FORWARD, + }), ).toEqual({ overscanStartIndex: 10, - overscanStopIndex: 24 - }) - }) -}) + overscanStopIndex: 24, + }); + }); +}); diff --git a/source/Grid/defaultOverscanIndicesGetter.js b/source/Grid/defaultOverscanIndicesGetter.js new file mode 100644 index 000000000..c4603dcfb --- /dev/null +++ b/source/Grid/defaultOverscanIndicesGetter.js @@ -0,0 +1,37 @@ +// @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), + }; + } +} diff --git a/source/Grid/index.js b/source/Grid/index.js index fec1b6b92..1507c9b0d 100644 --- a/source/Grid/index.js +++ b/source/Grid/index.js @@ -1,4 +1,18 @@ /** @flow */ -export default from './Grid' -export Grid from './Grid' -export defaultCellRangeRenderer from './defaultCellRangeRenderer' + +export type { + NoContentRenderer, + Alignment, + CellPosition, + CellSize, + OverscanIndicesGetter, + RenderedSection, + CellRendererParams, + Scroll, +} from './types'; + +export {default} from './Grid'; +export {default as Grid} from './Grid'; +export {default as accessibilityOverscanIndicesGetter} from './accessibilityOverscanIndicesGetter'; +export {default as defaultCellRangeRenderer} from './defaultCellRangeRenderer'; +export {default as defaultOverscanIndicesGetter} from './defaultOverscanIndicesGetter'; diff --git a/source/Grid/types.js b/source/Grid/types.js new file mode 100644 index 000000000..190d48e59 --- /dev/null +++ b/source/Grid/types.js @@ -0,0 +1,115 @@ +// @flow + +import * as React from 'react'; +import ScalingCellSizeAndPositionManager from './utils/ScalingCellSizeAndPositionManager'; + +export type CellPosition = {columnIndex: number, rowIndex: number}; + +export type CellRendererParams = { + columnIndex: number, + isScrolling: boolean, + isVisible: boolean, + key: string, + parent: Object, + rowIndex: number, + style: Object, +}; + +export type CellRenderer = (props: CellRendererParams) => React.Element<*>; + +export type CellCache = {[key: string]: React.Element<*>}; +export type StyleCache = {[key: string]: Object}; + +export type CellRangeRendererParams = { + cellCache: CellCache, + cellRenderer: CellRenderer, + columnSizeAndPositionManager: ScalingCellSizeAndPositionManager, + columnStartIndex: number, + columnStopIndex: number, + deferredMeasurementCache?: Object, + horizontalOffsetAdjustment: number, + isScrolling: boolean, + isScrollingOptOut: boolean, + parent: Object, + rowSizeAndPositionManager: ScalingCellSizeAndPositionManager, + rowStartIndex: number, + rowStopIndex: number, + scrollLeft: number, + scrollTop: number, + styleCache: StyleCache, + verticalOffsetAdjustment: number, + visibleColumnIndices: Object, + visibleRowIndices: Object, +}; + +export type CellRangeRenderer = ( + params: CellRangeRendererParams, +) => React.Element<*>[]; + +export type CellSizeGetter = (params: {index: number}) => number; + +export type CellSize = CellSizeGetter | number; + +export type NoContentRenderer = () => React.Element<*> | null; + +export type Scroll = { + clientHeight: number, + clientWidth: number, + scrollHeight: number, + scrollLeft: number, + scrollTop: number, + scrollWidth: number, +}; + +export type ScrollbarPresenceChange = { + horizontal: boolean, + vertical: boolean, + size: number, +}; + +export type RenderedSection = { + columnOverscanStartIndex: number, + columnOverscanStopIndex: number, + columnStartIndex: number, + columnStopIndex: number, + rowOverscanStartIndex: number, + rowOverscanStopIndex: number, + rowStartIndex: number, + rowStopIndex: number, +}; + +export type OverscanIndicesGetterParams = { + // One of SCROLL_DIRECTION_HORIZONTAL or SCROLL_DIRECTION_VERTICAL + direction: 'horizontal' | 'vertical', + + // One of SCROLL_DIRECTION_BACKWARD or SCROLL_DIRECTION_FORWARD + scrollDirection: -1 | 1, + + // Number of rows or columns in the current axis + cellCount: number, + + // Maximum number of cells to over-render in either direction + overscanCellsCount: number, + + // Begin of range of visible cells + startIndex: number, + + // End of range of visible cells + stopIndex: number, +}; + +export type OverscanIndices = { + overscanStartIndex: number, + overscanStopIndex: number, +}; + +export type OverscanIndicesGetter = ( + params: OverscanIndicesGetterParams, +) => OverscanIndices; + +export type Alignment = 'auto' | 'end' | 'start' | 'center'; + +export type VisibleCellRange = { + start?: number, + stop?: number, +}; diff --git a/source/Grid/utils/CellSizeAndPositionManager.jest.js b/source/Grid/utils/CellSizeAndPositionManager.jest.js index 8a3f82ee9..9c9cf5394 100644 --- a/source/Grid/utils/CellSizeAndPositionManager.jest.js +++ b/source/Grid/utils/CellSizeAndPositionManager.jest.js @@ -1,386 +1,418 @@ -/** @flow */ -import CellSizeAndPositionManager from './CellSizeAndPositionManager' +import CellSizeAndPositionManager from './CellSizeAndPositionManager'; describe('CellSizeAndPositionManager', () => { - function getCellSizeAndPositionManager ({ - batchAllCells, + function getCellSizeAndPositionManager({ cellCount = 100, - estimatedCellSize = 15 + estimatedCellSize = 15, } = {}) { - const cellSizeGetterCalls = [] + const cellSizeGetterCalls = []; const cellSizeAndPositionManager = new CellSizeAndPositionManager({ - batchAllCells, cellCount, - cellSizeGetter: ({ index }) => { - cellSizeGetterCalls.push(index) - return 10 + cellSizeGetter: ({index}) => { + cellSizeGetterCalls.push(index); + return 10; }, - estimatedCellSize - }) + estimatedCellSize, + }); return { cellSizeAndPositionManager, - cellSizeGetterCalls - } + cellSizeGetterCalls, + }; } describe('configure', () => { it('should update inner :cellCount and :estimatedCellSize', () => { - const { cellSizeAndPositionManager } = getCellSizeAndPositionManager() - expect(cellSizeAndPositionManager.getCellCount()).toEqual(100) - expect(cellSizeAndPositionManager.getEstimatedCellSize()).toEqual(15) + 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) - }) - }) + 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() - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) - }) + 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 returnt he 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) - }) + 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]) - }) + 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]) - }) + 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]) - }) + 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([]) - }) - }) + 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({ + const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell(), + ).toEqual({ offset: 0, - size: 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({ + const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + expect( + cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell(), + ).toEqual({ offset: 50, - size: 10 - }) - }) - }) + 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) - }) + 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) - }) + 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) - }) - }) + const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(99); + expect(cellSizeAndPositionManager.getTotalSize()).toEqual(1000); + }); + }); describe('getUpdatedOffsetForIndex', () => { - function getUpdatedOffsetForIndexHelper ({ + function getUpdatedOffsetForIndexHelper({ align = 'auto', cellCount = 10, cellSize = 10, containerSize = 50, currentOffset = 0, estimatedCellSize = 15, - targetIndex = 0 + targetIndex = 0, }) { const cellSizeAndPositionManager = new CellSizeAndPositionManager({ cellCount, cellSizeGetter: () => cellSize, - estimatedCellSize - }) + estimatedCellSize, + }); return cellSizeAndPositionManager.getUpdatedOffsetForIndex({ align, containerSize, currentOffset, - targetIndex - }) + targetIndex, + }); } it('should scroll to the beginning', () => { - expect(getUpdatedOffsetForIndexHelper({ - currentOffset: 100, - targetIndex: 0 - })).toEqual(0) - }) + expect( + getUpdatedOffsetForIndexHelper({ + currentOffset: 100, + targetIndex: 0, + }), + ).toEqual(0); + }); it('should scroll to the end', () => { - expect(getUpdatedOffsetForIndexHelper({ - currentOffset: 0, - targetIndex: 9 - })).toEqual(50) - }) + expect( + getUpdatedOffsetForIndexHelper({ + currentOffset: 0, + targetIndex: 9, + }), + ).toEqual(50); + }); it('should scroll forward to the middle', () => { - expect(getUpdatedOffsetForIndexHelper({ - currentOffset: 0, - targetIndex: 6 - })).toEqual(20) - }) + expect( + getUpdatedOffsetForIndexHelper({ + currentOffset: 0, + targetIndex: 6, + }), + ).toEqual(20); + }); it('should scroll backward to the middle', () => { - expect(getUpdatedOffsetForIndexHelper({ - currentOffset: 50, - targetIndex: 2 - })).toEqual(20) - }) + 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) - }) + 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) - }) + 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) + 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) - }) + 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) - }) - }) + 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({ + const {cellSizeAndPositionManager} = getCellSizeAndPositionManager({ + cellCount: 0, + }); + const {start, stop} = cellSizeAndPositionManager.getVisibleCellRange({ containerSize: 50, - offset: 0 - }) - expect(start).toEqual(undefined) - expect(stop).toEqual(undefined) - }) + 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({ + const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); + const {start, stop} = cellSizeAndPositionManager.getVisibleCellRange({ containerSize: 50, - offset: 0 - }) - expect(start).toEqual(0) - expect(stop).toEqual(4) - }) + 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({ + const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); + const {start, stop} = cellSizeAndPositionManager.getVisibleCellRange({ containerSize: 50, - offset: 425 - }) + offset: 425, + }); // 42 and 47 are partially visible - expect(start).toEqual(42) - expect(stop).toEqual(47) - }) + 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({ + const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); + const {start, stop} = cellSizeAndPositionManager.getVisibleCellRange({ containerSize: 50, - offset: 950 - }) - expect(start).toEqual(95) - expect(stop).toEqual(99) - }) - - it('should return all cells if :batchAllCells param was used (for CellMeasurer support)', () => { - const { cellSizeAndPositionManager } = getCellSizeAndPositionManager({ - batchAllCells: true, - cellCount: 100 - }) - const { - start, - stop - } = cellSizeAndPositionManager.getVisibleCellRange({ - containerSize: 50, - offset: 950 - }) - expect(start).toEqual(0) - expect(stop).toEqual(99) - }) - }) + 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) - }) + 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]) - }) + 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) - }) - }) -}) + const {cellSizeAndPositionManager} = getCellSizeAndPositionManager(); + cellSizeAndPositionManager.getSizeAndPositionOfCell(5); + cellSizeAndPositionManager.resetCell(2); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); + cellSizeAndPositionManager.resetCell(4); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(1); + cellSizeAndPositionManager.resetCell(0); + expect(cellSizeAndPositionManager.getLastMeasuredIndex()).toEqual(-1); + }); + }); +}); diff --git a/source/Grid/utils/CellSizeAndPositionManager.js b/source/Grid/utils/CellSizeAndPositionManager.js index 648b22d33..4e48e18d1 100644 --- a/source/Grid/utils/CellSizeAndPositionManager.js +++ b/source/Grid/utils/CellSizeAndPositionManager.js @@ -1,123 +1,161 @@ /** @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 { - constructor ({ - batchAllCells = false, + // 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 - }: CellSizeAndPositionManagerConstructorParams) { - this._batchAllCells = batchAllCells - this._cellSizeGetter = cellSizeGetter - this._cellCount = cellCount - this._estimatedCellSize = estimatedCellSize - - // 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 - this._cellSizeAndPositionData = {} - - // Measurements for cells up to this index can be trusted; cells afterward should be estimated. - this._lastMeasuredIndex = -1 - - // Used in deferred mode to track which cells have been queued for measurement. - this._lastBatchedIndex = -1 + estimatedCellSize, + }: CellSizeAndPositionManagerParams) { + this._cellSizeGetter = cellSizeGetter; + this._cellCount = cellCount; + this._estimatedCellSize = estimatedCellSize; } - areOffsetsAdjusted (): bool { - return false + areOffsetsAdjusted() { + return false; } - configure ({ - cellCount, - estimatedCellSize - }: ConfigureParams): void { - this._cellCount = cellCount - this._estimatedCellSize = estimatedCellSize + configure({cellCount, estimatedCellSize, cellSizeGetter}: ConfigureParams) { + this._cellCount = cellCount; + this._estimatedCellSize = estimatedCellSize; + this._cellSizeGetter = cellSizeGetter; } - getCellCount (): number { - return this._cellCount + getCellCount(): number { + return this._cellCount; } - getEstimatedCellSize (): number { - return this._estimatedCellSize + getEstimatedCellSize(): number { + return this._estimatedCellSize; } - getLastMeasuredIndex (): number { - return this._lastMeasuredIndex + getLastMeasuredIndex(): number { + return this._lastMeasuredIndex; } - getOffsetAdjustment ({ - containerSize, - offset // safe - }: ContainerSizeAndOffset): number { - return 0 + 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 { + getSizeAndPositionOfCell(index: number): SizeAndPositionData { if (index < 0 || index >= this._cellCount) { - throw Error(`Requested index ${index} is outside of range 0..${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 + 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 }) + 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}`) + throw Error(`Invalid size returned for cell ${i} of value ${size}`); } else if (size === null) { this._cellSizeAndPositionData[i] = { offset, - size: 0 - } + size: 0, + }; - this._lastBatchedIndex = index + this._lastBatchedIndex = index; } else { this._cellSizeAndPositionData[i] = { offset, - size - } + size, + }; - offset += size + offset += size; - this._lastMeasuredIndex = index + this._lastMeasuredIndex = index; } } } - return this._cellSizeAndPositionData[index] + return this._cellSizeAndPositionData[index]; } - getSizeAndPositionOfLastMeasuredCell (): SizeAndPositionData { + getSizeAndPositionOfLastMeasuredCell(): SizeAndPositionData { return this._lastMeasuredIndex >= 0 ? this._cellSizeAndPositionData[this._lastMeasuredIndex] : { - offset: 0, - size: 0 - } + offset: 0, + size: 0, + }; } /** * Total size of all cells being measured. - * This value will be completedly estimated initially. - * As cells as measured the estimate will be updated. + * This value will be completely estimated initially. + * As cells are measured, the estimate will be updated. */ - getTotalSize (): number { - const lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell() - - return lastMeasuredCellSizeAndPosition.offset + lastMeasuredCellSizeAndPosition.size + (this._cellCount - this._lastMeasuredIndex - 1) * this._estimatedCellSize + 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; } /** @@ -131,84 +169,69 @@ export default class CellSizeAndPositionManager { * @param totalSize Total size (width or height) of all cells * @return Offset to use to ensure the specified cell is visible */ - getUpdatedOffsetForIndex ({ + getUpdatedOffsetForIndex({ align = 'auto', containerSize, currentOffset, - targetIndex - }) { + targetIndex, + }: GetUpdatedOffsetForIndex): number { if (containerSize <= 0) { - return 0 + return 0; } - const datum = this.getSizeAndPositionOfCell(targetIndex) - const maxOffset = datum.offset - const minOffset = maxOffset - containerSize + datum.size + const datum = this.getSizeAndPositionOfCell(targetIndex); + const maxOffset = datum.offset; + const minOffset = maxOffset - containerSize + datum.size; - let idealOffset + let idealOffset; switch (align) { case 'start': - idealOffset = maxOffset - break + idealOffset = maxOffset; + break; case 'end': - idealOffset = minOffset - break + idealOffset = minOffset; + break; case 'center': - idealOffset = maxOffset - ((containerSize - datum.size) / 2) - break + idealOffset = maxOffset - (containerSize - datum.size) / 2; + break; default: - idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset)) - break + idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset)); + break; } - const totalSize = this.getTotalSize() + const totalSize = this.getTotalSize(); - return Math.max(0, Math.min(totalSize - containerSize, idealOffset)) + return Math.max(0, Math.min(totalSize - containerSize, idealOffset)); } - getVisibleCellRange (params: GetVisibleCellRangeParams): VisibleCellRange { - // Advanced use-cases (eg CellMeasurer) require batched measurements to determine accurate sizes. - // eg we can't know a row's height without measuring the height of all columns within that row. - if (this._batchAllCells) { - return { - start: 0, - stop: this._cellCount - 1 - } - } + getVisibleCellRange(params: GetVisibleCellRangeParams): VisibleCellRange { + let {containerSize, offset} = params; - let { - containerSize, - offset - } = params - - const totalSize = this.getTotalSize() + const totalSize = this.getTotalSize(); if (totalSize === 0) { - return {} + return {}; } - const maxOffset = offset + containerSize - const start = this._findNearestCell(offset) + const maxOffset = offset + containerSize; + const start = this._findNearestCell(offset); - const datum = this.getSizeAndPositionOfCell(start) - offset = datum.offset + datum.size + const datum = this.getSizeAndPositionOfCell(start); + offset = datum.offset + datum.size; - let stop = start + let stop = start; - while ( - offset < maxOffset && - stop < this._cellCount - 1 - ) { - stop++ + while (offset < maxOffset && stop < this._cellCount - 1) { + stop++; - offset += this.getSizeAndPositionOfCell(stop).size + offset += this.getSizeAndPositionOfCell(stop).size; } return { start, - stop - } + stop, + }; } /** @@ -216,55 +239,47 @@ export default class CellSizeAndPositionManager { * 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) + resetCell(index: number): void { + this._lastMeasuredIndex = Math.min(this._lastMeasuredIndex, index - 1); } - _binarySearch ({ - high, - low, - offset - }): number { - let middle - let currentOffset - + _binarySearch(high: number, low: number, offset: number): number { while (low <= high) { - middle = low + Math.floor((high - low) / 2) - currentOffset = this.getSizeAndPositionOfCell(middle).offset + const middle = low + Math.floor((high - low) / 2); + const currentOffset = this.getSizeAndPositionOfCell(middle).offset; if (currentOffset === offset) { - return middle + return middle; } else if (currentOffset < offset) { - low = middle + 1 + low = middle + 1; } else if (currentOffset > offset) { - high = middle - 1 + high = middle - 1; } } if (low > 0) { - return low - 1 + return low - 1; + } else { + return 0; } } - _exponentialSearch ({ - index, - offset - }): number { - let interval = 1 + _exponentialSearch(index: number, offset: number): number { + let interval = 1; while ( index < this._cellCount && this.getSizeAndPositionOfCell(index).offset < offset ) { - index += interval - interval *= 2 + index += interval; + interval *= 2; } - return this._binarySearch({ - high: Math.min(index, this._cellCount - 1), - low: Math.floor(index / 2), - offset - }) + return this._binarySearch( + Math.min(index, this._cellCount - 1), + Math.floor(index / 2), + offset, + ); } /** @@ -273,65 +288,26 @@ export default class CellSizeAndPositionManager { * 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 { + _findNearestCell(offset: number): number { if (isNaN(offset)) { - throw Error(`Invalid offset ${offset} specified`) + 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) + offset = Math.max(0, offset); - const lastMeasuredCellSizeAndPosition = this.getSizeAndPositionOfLastMeasuredCell() - const lastMeasuredIndex = Math.max(0, this._lastMeasuredIndex) + 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({ - high: lastMeasuredIndex, - low: 0, - offset - }) + 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({ - index: lastMeasuredIndex, - offset - }) + return this._exponentialSearch(lastMeasuredIndex, offset); } } } - -type CellSizeAndPositionManagerConstructorParams = { - batchAllCells ?: boolean, - cellCount: number, - cellSizeGetter: Function, - estimatedCellSize: number -}; - -type ConfigureParams = { - cellCount: number, - estimatedCellSize: number -}; - -type ContainerSizeAndOffset = { - containerSize: number, - offset: number -}; - -type GetVisibleCellRangeParams = { - containerSize: number, - offset: number -}; - -type SizeAndPositionData = { - offset: number, - size: number -}; - -type VisibleCellRange = { - start: ?number, - stop: ?number -}; diff --git a/source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js b/source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js index 8b0802f54..dd57cfeb8 100644 --- a/source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js +++ b/source/Grid/utils/ScalingCellSizeAndPositionManager.jest.js @@ -1,181 +1,200 @@ -/** @flow */ -import ScalingCellSizeAndPositionManager from './ScalingCellSizeAndPositionManager' +import ScalingCellSizeAndPositionManager from './ScalingCellSizeAndPositionManager'; describe('ScalingCellSizeAndPositionManager', () => { - function init ({ + function init({ cellCount = 10, cellSize = 10, estimatedCellSize = 10, - maxScrollSize = 50 + maxScrollSize = 50, } = {}) { const cellSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ cellCount, cellSizeGetter: () => cellSize, estimatedCellSize, - maxScrollSize - }) + maxScrollSize, + }); - return cellSizeAndPositionManager + return cellSizeAndPositionManager; } describe('_getOffsetPercentage', () => { - it('should eturn the correct offset fraction', () => { + 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) - }) - }) - }) + {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 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) - }) - }) - }) + 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) - }) - }) - }) + {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 maxScrollSizes = [500, 750]; + maxScrollSizes.forEach(maxScrollSize => { const instance = init({ cellCount: 50, - maxScrollSize - }) - expect(instance.getTotalSize()).toEqual(500) - }) - }) + 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) - }) - }) + 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) - }) - }) + {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) - }) - }) + {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) - }) - }) + 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({ + const instance = init(); + expect( + instance.getVisibleCellRange({ + containerSize: 30, + offset: 0, + }), + ).toEqual({ start: 0, - stop: 2 - }) - }) + stop: 2, + }); + }); it('should correct identify cells in the middle', () => { - const instance = init() - expect(instance.getVisibleCellRange({ - containerSize: 30, - offset: 2.85 // (unsafe: 10) - })).toEqual({ + const instance = init(); + expect( + instance.getVisibleCellRange({ + containerSize: 30, + offset: 2.85, // (unsafe: 10) + }), + ).toEqual({ start: 1, - stop: 3 - }) - }) + stop: 3, + }); + }); it('should correct identify partially visible cells', () => { - const instance = init() - expect(instance.getVisibleCellRange({ - containerSize: 30, - offset: 10 // (unsafe: 35) - })).toEqual({ + const instance = init(); + expect( + instance.getVisibleCellRange({ + containerSize: 30, + offset: 10, // (unsafe: 35) + }), + ).toEqual({ start: 3, - stop: 6 - }) - }) + stop: 6, + }); + }); it('should correct identify the last set of cells', () => { - const instance = init() - expect(instance.getVisibleCellRange({ - containerSize: 30, - offset: 20 - })).toEqual({ + const instance = init(); + expect( + instance.getVisibleCellRange({ + containerSize: 30, + offset: 20, + }), + ).toEqual({ start: 7, - stop: 9 - }) - }) - }) -}) + stop: 9, + }); + }); + }); +}); diff --git a/source/Grid/utils/ScalingCellSizeAndPositionManager.js b/source/Grid/utils/ScalingCellSizeAndPositionManager.js index 769f60353..78f39fbab 100644 --- a/source/Grid/utils/ScalingCellSizeAndPositionManager.js +++ b/source/Grid/utils/ScalingCellSizeAndPositionManager.js @@ -1,182 +1,203 @@ /** @flow */ -import CellSizeAndPositionManager from './CellSizeAndPositionManager' + +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. */ -export const DEFAULT_MAX_SCROLL_SIZE = 1500000 + +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 { - constructor ({ - maxScrollSize = DEFAULT_MAX_SCROLL_SIZE, - ...params - }) { + _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 + this._cellSizeAndPositionManager = new CellSizeAndPositionManager(params); + this._maxScrollSize = maxScrollSize; } - areOffsetsAdjusted (): bool { - return this._cellSizeAndPositionManager.getTotalSize() > this._maxScrollSize + areOffsetsAdjusted(): boolean { + return ( + this._cellSizeAndPositionManager.getTotalSize() > this._maxScrollSize + ); } - configure (params): void { - this._cellSizeAndPositionManager.configure(params) + configure(params: { + cellCount: number, + estimatedCellSize: number, + cellSizeGetter: CellSizeGetter, + }) { + this._cellSizeAndPositionManager.configure(params); } - getCellCount (): number { - return this._cellSizeAndPositionManager.getCellCount() + getCellCount(): number { + return this._cellSizeAndPositionManager.getCellCount(); } - getEstimatedCellSize (): number { - return this._cellSizeAndPositionManager.getEstimatedCellSize() + getEstimatedCellSize(): number { + return this._cellSizeAndPositionManager.getEstimatedCellSize(); } - getLastMeasuredIndex (): number { - return this._cellSizeAndPositionManager.getLastMeasuredIndex() + 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 scalled (safe) as well. + * The offset passed to this function is scaled (safe) as well. */ - getOffsetAdjustment ({ + getOffsetAdjustment({ containerSize, - offset // safe + offset, // safe }: ContainerSizeAndOffset): number { - const totalSize = this._cellSizeAndPositionManager.getTotalSize() - const safeTotalSize = this.getTotalSize() + const totalSize = this._cellSizeAndPositionManager.getTotalSize(); + const safeTotalSize = this.getTotalSize(); const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, - totalSize: safeTotalSize - }) + totalSize: safeTotalSize, + }); - return Math.round(offsetPercentage * (safeTotalSize - totalSize)) + return Math.round(offsetPercentage * (safeTotalSize - totalSize)); } - getSizeAndPositionOfCell (index: number) { - return this._cellSizeAndPositionManager.getSizeAndPositionOfCell(index) + getSizeAndPositionOfCell(index: number) { + return this._cellSizeAndPositionManager.getSizeAndPositionOfCell(index); } - getSizeAndPositionOfLastMeasuredCell () { - return this._cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell() + getSizeAndPositionOfLastMeasuredCell() { + return this._cellSizeAndPositionManager.getSizeAndPositionOfLastMeasuredCell(); } /** See CellSizeAndPositionManager#getTotalSize */ - getTotalSize (): number { - return Math.min(this._maxScrollSize, this._cellSizeAndPositionManager.getTotalSize()) + getTotalSize(): number { + return Math.min( + this._maxScrollSize, + this._cellSizeAndPositionManager.getTotalSize(), + ); } /** See CellSizeAndPositionManager#getUpdatedOffsetForIndex */ - getUpdatedOffsetForIndex ({ + getUpdatedOffsetForIndex({ align = 'auto', containerSize, currentOffset, // safe targetIndex, - totalSize + }: { + align: Alignment, + containerSize: number, + currentOffset: number, + targetIndex: number, }) { currentOffset = this._safeOffsetToOffset({ containerSize, - offset: currentOffset - }) + offset: currentOffset, + }); const offset = this._cellSizeAndPositionManager.getUpdatedOffsetForIndex({ align, containerSize, currentOffset, targetIndex, - totalSize - }) + }); return this._offsetToSafeOffset({ containerSize, - offset - }) + offset, + }); } /** See CellSizeAndPositionManager#getVisibleCellRange */ - getVisibleCellRange ({ + getVisibleCellRange({ containerSize, - offset // safe + offset, // safe }: ContainerSizeAndOffset): VisibleCellRange { offset = this._safeOffsetToOffset({ containerSize, - offset - }) + offset, + }); return this._cellSizeAndPositionManager.getVisibleCellRange({ containerSize, - offset - }) + offset, + }); } - resetCell (index: number): void { - this._cellSizeAndPositionManager.resetCell(index) + resetCell(index: number): void { + this._cellSizeAndPositionManager.resetCell(index); } - _getOffsetPercentage ({ + _getOffsetPercentage({ containerSize, offset, // safe - totalSize + totalSize, + }: { + containerSize: number, + offset: number, + totalSize: number, }) { return totalSize <= containerSize ? 0 - : offset / (totalSize - containerSize) + : offset / (totalSize - containerSize); } - _offsetToSafeOffset ({ + _offsetToSafeOffset({ containerSize, - offset // unsafe + offset, // unsafe }: ContainerSizeAndOffset): number { - const totalSize = this._cellSizeAndPositionManager.getTotalSize() - const safeTotalSize = this.getTotalSize() + const totalSize = this._cellSizeAndPositionManager.getTotalSize(); + const safeTotalSize = this.getTotalSize(); if (totalSize === safeTotalSize) { - return offset + return offset; } else { const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, - totalSize - }) + totalSize, + }); - return Math.round(offsetPercentage * (safeTotalSize - containerSize)) + return Math.round(offsetPercentage * (safeTotalSize - containerSize)); } } - _safeOffsetToOffset ({ + _safeOffsetToOffset({ containerSize, - offset // safe + offset, // safe }: ContainerSizeAndOffset): number { - const totalSize = this._cellSizeAndPositionManager.getTotalSize() - const safeTotalSize = this.getTotalSize() + const totalSize = this._cellSizeAndPositionManager.getTotalSize(); + const safeTotalSize = this.getTotalSize(); if (totalSize === safeTotalSize) { - return offset + return offset; } else { const offsetPercentage = this._getOffsetPercentage({ containerSize, offset, - totalSize: safeTotalSize - }) + totalSize: safeTotalSize, + }); - return Math.round(offsetPercentage * (totalSize - containerSize)) + return Math.round(offsetPercentage * (totalSize - containerSize)); } } } - -type ContainerSizeAndOffset = { - containerSize: number, - offset: number -}; - -type VisibleCellRange = { - start: ?number, - stop: ?number -}; diff --git a/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js b/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js index 0a4a02ddc..ee9ce455c 100644 --- a/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js +++ b/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.jest.js @@ -1,75 +1,77 @@ -import calculateSizeAndPositionDataAndUpdateScrollOffset from './calculateSizeAndPositionDataAndUpdateScrollOffset' +import calculateSizeAndPositionDataAndUpdateScrollOffset from './calculateSizeAndPositionDataAndUpdateScrollOffset'; describe('calculateSizeAndPositionDataAndUpdateScrollOffset', () => { - function helper ({ + function helper({ cellCount = 100, cellSize = 10, computeMetadataCallbackProps = {}, nextCellsCount = 100, nextCellSize = 10, nextScrollToIndex, - scrollToIndex + scrollToIndex, } = {}) { - const computeMetadataCallbackCalls = [] - const updateScrollOffsetForScrollToIndexCalls = [] + const computeMetadataCallbackCalls = []; + const updateScrollOffsetForScrollToIndexCalls = []; calculateSizeAndPositionDataAndUpdateScrollOffset({ cellCount, cellSize, - computeMetadataCallback: params => computeMetadataCallbackCalls.push(params), + computeMetadataCallback: params => + computeMetadataCallbackCalls.push(params), computeMetadataCallbackProps, nextCellsCount, nextCellSize, nextScrollToIndex, scrollToIndex, - updateScrollOffsetForScrollToIndex: params => updateScrollOffsetForScrollToIndexCalls.push(params) - }) + updateScrollOffsetForScrollToIndex: params => + updateScrollOffsetForScrollToIndexCalls.push(params), + }); return { computeMetadataCallbackCalls, - updateScrollOffsetForScrollToIndexCalls - } + updateScrollOffsetForScrollToIndexCalls, + }; } it('should call :computeMetadataCallback if :cellCount has changed', () => { - const { computeMetadataCallbackCalls } = helper({ + const {computeMetadataCallbackCalls} = helper({ cellCount: 100, - nextCellsCount: 200 - }) - expect(computeMetadataCallbackCalls.length).toEqual(1) - }) + nextCellsCount: 200, + }); + expect(computeMetadataCallbackCalls.length).toEqual(1); + }); it('should call :computeMetadataCallback if numeric :cellSize has changed', () => { - const { computeMetadataCallbackCalls } = helper({ + const {computeMetadataCallbackCalls} = helper({ cellSize: 10, - nextCellSize: 20 - }) - expect(computeMetadataCallbackCalls.length).toEqual(1) - }) + nextCellSize: 20, + }); + expect(computeMetadataCallbackCalls.length).toEqual(1); + }); it('should not call :computeMetadataCallback if :cellSize callback has changed', () => { - const { computeMetadataCallbackCalls } = helper({ + const {computeMetadataCallbackCalls} = helper({ cellSize: () => {}, - nextCellSize: () => {} - }) - expect(computeMetadataCallbackCalls.length).toEqual(0) - }) + nextCellSize: () => {}, + }); + expect(computeMetadataCallbackCalls.length).toEqual(0); + }); it('should not call :updateScrollOffsetForScrollToIndex if :scrollToIndex is not specified', () => { - const { updateScrollOffsetForScrollToIndexCalls } = helper() - expect(updateScrollOffsetForScrollToIndexCalls.length).toEqual(0) - }) + const {updateScrollOffsetForScrollToIndexCalls} = helper(); + expect(updateScrollOffsetForScrollToIndexCalls.length).toEqual(0); + }); it('should not call :updateScrollOffsetForScrollToIndex if :scrollToIndex has also changed', () => { - const { updateScrollOffsetForScrollToIndexCalls } = helper({ + const {updateScrollOffsetForScrollToIndexCalls} = helper({ scrollToIndex: 10, - nextScrollToIndex: 20 - }) - expect(updateScrollOffsetForScrollToIndexCalls.length).toEqual(0) - }) + 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) - }) -}) + const {computeMetadataCallbackCalls} = helper(); + expect(computeMetadataCallbackCalls.length).toEqual(0); + }); +}); diff --git a/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js b/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js index a2f734b70..7a9108990 100644 --- a/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js +++ b/source/Grid/utils/calculateSizeAndPositionDataAndUpdateScrollOffset.js @@ -1,17 +1,39 @@ +// @flow + /** * Helper method that determines when to recalculate row or column metadata. - * - * @param cellCount Number of rows or columns in the current axis - * @param cellsSize Width or height of cells for the current axis - * @param computeMetadataCallback Method to invoke if cell metadata should be recalculated - * @param computeMetadataCallbackProps Parameters to pass to :computeMetadataCallback - * @param nextCellsCount Newly updated number of rows or columns in the current axis - * @param nextCellsSize Newly updated width or height of cells for the current axis - * @param nextScrollToIndex Newly updated scroll-to-index - * @param scrollToIndex Scroll-to-index - * @param updateScrollOffsetForScrollToIndex Callback to invoke if the scroll position should be recalculated */ -export default function calculateSizeAndPositionDataAndUpdateScrollOffset ({ + +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, @@ -20,26 +42,21 @@ export default function calculateSizeAndPositionDataAndUpdateScrollOffset ({ nextCellSize, nextScrollToIndex, scrollToIndex, - updateScrollOffsetForScrollToIndex -}) { + 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 - ) + ((typeof cellSize === 'number' || typeof nextCellSize === 'number') && + cellSize !== nextCellSize) ) { - computeMetadataCallback(computeMetadataCallbackProps) + 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() + updateScrollOffsetForScrollToIndex(); } } } diff --git a/source/Grid/utils/defaultOverscanIndicesGetter.js b/source/Grid/utils/defaultOverscanIndicesGetter.js deleted file mode 100644 index 3dbb1896b..000000000 --- a/source/Grid/utils/defaultOverscanIndicesGetter.js +++ /dev/null @@ -1,36 +0,0 @@ -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. - * - * @param direction One of SCROLL_DIRECTION_HORIZONTAL or SCROLL_DIRECTION_VERTICAL - * @param cellCount Number of rows or columns in the current axis - * @param scrollDirection One of SCROLL_DIRECTION_BACKWARD or SCROLL_DIRECTION_FORWARD - * @param overscanCellsCount Maximum number of cells to over-render in either direction - * @param startIndex Begin of range of visible cells - * @param stopIndex End of range of visible cells - */ -export default function defaultOverscanIndicesGetter ({ direction, cellCount, overscanCellsCount, scrollDirection, startIndex, stopIndex }) { - let overscanStartIndex - let overscanStopIndex - - switch (scrollDirection) { - case SCROLL_DIRECTION_FORWARD: - overscanStartIndex = startIndex - overscanStopIndex = stopIndex + overscanCellsCount - break - case SCROLL_DIRECTION_BACKWARD: - overscanStartIndex = startIndex - overscanCellsCount - overscanStopIndex = stopIndex - break - } - - return { - overscanStartIndex: Math.max(0, overscanStartIndex), - overscanStopIndex: Math.min(cellCount - 1, overscanStopIndex) - } -} diff --git a/source/Grid/utils/maxElementSize.js b/source/Grid/utils/maxElementSize.js new file mode 100644 index 000000000..618c08dab --- /dev/null +++ b/source/Grid/utils/maxElementSize.js @@ -0,0 +1,17 @@ +// @flow + +const DEFAULT_MAX_ELEMENT_SIZE = 1500000; +const CHROME_MAX_ELEMENT_SIZE = 1.67771e7; + +const isBrowser = () => typeof window !== 'undefined'; + +const isChrome = () => !!window.chrome; + +export const getMaxElementSize = (): number => { + if (isBrowser()) { + if (isChrome()) { + return CHROME_MAX_ELEMENT_SIZE; + } + } + return DEFAULT_MAX_ELEMENT_SIZE; +}; diff --git a/source/Grid/utils/updateScrollIndexHelper.jest.js b/source/Grid/utils/updateScrollIndexHelper.jest.js index 1c6fe2e26..50f55ea75 100644 --- a/source/Grid/utils/updateScrollIndexHelper.jest.js +++ b/source/Grid/utils/updateScrollIndexHelper.jest.js @@ -1,16 +1,16 @@ -import updateScrollIndexHelper from './updateScrollIndexHelper' -import CellSizeAndPositionManager from './CellSizeAndPositionManager' +import updateScrollIndexHelper from './updateScrollIndexHelper'; +import CellSizeAndPositionManager from './CellSizeAndPositionManager'; // Default cell sizes and offsets for use in shared tests -export function getCellSizeAndPositionManager ({ +export function getCellSizeAndPositionManager({ cellCount = CELL_SIZES.length, - estimatedCellSize = 10 + estimatedCellSize = 10, }) { return new CellSizeAndPositionManager({ cellCount, - cellSizeGetter: ({ index }) => CELL_SIZES[index % CELL_SIZES.length], - estimatedCellSize - }) + cellSizeGetter: ({index}) => CELL_SIZES[index % CELL_SIZES.length], + estimatedCellSize, + }); } const CELL_SIZES = [ @@ -22,11 +22,11 @@ const CELL_SIZES = [ 30, // 5: 50..70 20, // 6: 70..100 10, // 7: 80..110 - 30 // 8: 110..110 (max) -] + 30, // 8: 110..110 (max) +]; describe('updateScrollIndexHelper', () => { - function helper ({ + function helper({ cellCount = undefined, cellSizeAndPositionManager, cellSize = 10, @@ -38,20 +38,21 @@ describe('updateScrollIndexHelper', () => { scrollOffset = 0, scrollToAlignment = 'auto', scrollToIndex, - size = 50 + size = 50, } = {}) { - cellSizeAndPositionManager = cellSizeAndPositionManager || getCellSizeAndPositionManager({ cellCount }) - cellCount = cellCount === undefined - ? cellSizeAndPositionManager.getCellCount() - : cellCount - previousCellsCount = previousCellsCount === undefined - ? cellCount - : previousCellsCount - - let updateScrollIndexCallbackCalled = false - - function updateScrollIndexCallback (params) { - updateScrollIndexCallbackCalled = true + cellSizeAndPositionManager = + cellSizeAndPositionManager || getCellSizeAndPositionManager({cellCount}); + cellCount = + cellCount === undefined + ? cellSizeAndPositionManager.getCellCount() + : cellCount; + previousCellsCount = + previousCellsCount === undefined ? cellCount : previousCellsCount; + + let updateScrollIndexCallbackCalled = false; + + function updateScrollIndexCallback() { + updateScrollIndexCallbackCalled = true; } updateScrollIndexHelper({ @@ -67,105 +68,127 @@ describe('updateScrollIndexHelper', () => { scrollToAlignment, scrollToIndex, size, - updateScrollIndexCallback - }) + updateScrollIndexCallback, + }); - return updateScrollIndexCallbackCalled + return updateScrollIndexCallbackCalled; } it('should not call :updateScrollIndexCallback if there is no :scrollToIndex and size has not changed', () => { - expect(helper()).toEqual(false) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) + 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) - }) -}) + expect( + helper({ + previousScrollToAlignment: 'start', + scrollToAlignment: 'end', + }), + ).toEqual(false); + }); +}); diff --git a/source/Grid/utils/updateScrollIndexHelper.js b/source/Grid/utils/updateScrollIndexHelper.js index 47500727f..95cc810fd 100644 --- a/source/Grid/utils/updateScrollIndexHelper.js +++ b/source/Grid/utils/updateScrollIndexHelper.js @@ -1,19 +1,53 @@ +// @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. - * - * @param cellsSize Width or height of cells for the current axis - * @param cellSizeAndPositionManager Manages size and position metadata of cells - * @param previousCellsCount Previous number of rows or columns - * @param previousCellsSize Previous width or height of cells - * @param previousScrollToIndex Previous scroll-to-index - * @param previousSize Previous width or height of the virtualized container - * @param scrollOffset Current scrollLeft or scrollTop - * @param scrollToIndex Scroll-to-index - * @param size Width or height of the virtualized container - * @param updateScrollIndexCallback Callback to invoke with an scroll-to-index value */ -export default function updateScrollIndexHelper ({ + +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, @@ -25,47 +59,40 @@ export default function updateScrollIndexHelper ({ scrollToAlignment, scrollToIndex, size, - updateScrollIndexCallback -}) { - const cellCount = cellSizeAndPositionManager.getCellCount() - const hasScrollToIndex = scrollToIndex >= 0 && scrollToIndex < cellCount - const sizeHasChanged = ( + sizeJustIncreasedFromZero, + updateScrollIndexCallback, +}: Params) { + const cellCount = cellSizeAndPositionManager.getCellCount(); + const hasScrollToIndex = scrollToIndex >= 0 && scrollToIndex < cellCount; + const sizeHasChanged = size !== previousSize || + sizeJustIncreasedFromZero || !previousCellSize || - ( - typeof cellSize === 'number' && - cellSize !== 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 || + (sizeHasChanged || scrollToAlignment !== previousScrollToAlignment || - scrollToIndex !== previousScrollToIndex - ) + scrollToIndex !== previousScrollToIndex) ) { - updateScrollIndexCallback(scrollToIndex) + 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. + // 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 - ) + (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) + updateScrollIndexCallback(cellCount - 1); } } } diff --git a/source/InfiniteLoader/InfiniteLoader.example.js b/source/InfiniteLoader/InfiniteLoader.example.js index 7296e7b21..902c68905 100644 --- a/source/InfiniteLoader/InfiniteLoader.example.js +++ b/source/InfiniteLoader/InfiniteLoader.example.js @@ -1,66 +1,69 @@ /** @flow */ -import React, { PropTypes, PureComponent } from 'react' -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 PureComponent { +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 - } + list: PropTypes.instanceOf(Immutable.List).isRequired, + }; - constructor (props) { - super(props) + constructor(props) { + super(props); this.state = { loadedRowCount: 0, loadedRowsMap: {}, - loadingRowCount: 0 - } + loadingRowCount: 0, + }; - this._timeoutIdMap = {} + 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) + 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 () { + componentWillUnmount() { Object.keys(this._timeoutIdMap).forEach(timeoutId => { - clearTimeout(timeoutId) - }) + clearTimeout(timeoutId); + }); } - render () { - const { list } = this.context - const { loadedRowCount, loadingRowCount } = this.state + 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). + 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).
- @@ -73,11 +76,10 @@ export default class InfiniteLoaderExample extends PureComponent { - {({ onRowsRendered, registerChild }) => ( + rowCount={list.size}> + {({onRowsRendered, registerChild}) => ( - {({ width }) => ( + {({width}) => ( - ) + ); } - _clearData () { + _clearData() { this.setState({ loadedRowCount: 0, loadedRowsMap: {}, - loadingRowCount: 0 - }) + loadingRowCount: 0, + }); } - _isRowLoaded ({ index }) { - const { loadedRowsMap } = this.state - return !!loadedRowsMap[index] // STATUS_LOADING or STATUS_LOADED + _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 + _loadMoreRows({startIndex, stopIndex}) { + const {loadedRowsMap, loadingRowCount} = this.state; + const increment = stopIndex - startIndex + 1; for (var i = startIndex; i <= stopIndex; i++) { - loadedRowsMap[i] = STATUS_LOADING + loadedRowsMap[i] = STATUS_LOADING; } this.setState({ - loadingRowCount: loadingRowCount + increment - }) + loadingRowCount: loadingRowCount + increment, + }); const timeoutId = setTimeout(() => { - const { loadedRowCount, loadingRowCount } = this.state + const {loadedRowCount, loadingRowCount} = this.state; - delete this._timeoutIdMap[timeoutId] + delete this._timeoutIdMap[timeoutId]; for (var i = startIndex; i <= stopIndex; i++) { - loadedRowsMap[i] = STATUS_LOADED + loadedRowsMap[i] = STATUS_LOADED; } this.setState({ loadingRowCount: loadingRowCount - increment, - loadedRowCount: loadedRowCount + increment - }) + loadedRowCount: loadedRowCount + increment, + }); - promiseResolver() - }, 1000 + Math.round(Math.random() * 2000)) + promiseResolver(); + }, 1000 + Math.round(Math.random() * 2000)); - this._timeoutIdMap[timeoutId] = true + this._timeoutIdMap[timeoutId] = true; - let promiseResolver + let promiseResolver; return new Promise(resolve => { - promiseResolver = resolve - }) + promiseResolver = resolve; + }); } - _rowRenderer ({ index, key, style }) { - const { list } = this.context - const { loadedRowsMap } = this.state + _rowRenderer({index, key, style}) { + const {list} = this.context; + const {loadedRowsMap} = this.state; - const row = list.get(index) - let content + const row = list.get(index); + let content; if (loadedRowsMap[index] === STATUS_LOADED) { - content = row.name + content = row.name; } else { content = ( -
- ) +
+ ); } return ( -
+
{content}
- ) + ); } } diff --git a/source/InfiniteLoader/InfiniteLoader.jest.js b/source/InfiniteLoader/InfiniteLoader.jest.js index 47354ece1..598680631 100644 --- a/source/InfiniteLoader/InfiniteLoader.jest.js +++ b/source/InfiniteLoader/InfiniteLoader.jest.js @@ -1,39 +1,41 @@ -import InfiniteLoader, { forceUpdateReactVirtualizedComponent, isRangeVisible, scanForUnloadedRanges } from './InfiniteLoader' -import React, { Component } from 'react' -import List from '../List' -import { render } from '../TestUtils' +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 = [] + let innerOnRowsRendered; + let isRowLoadedCalls = []; + let isRowLoadedMap = {}; + let loadMoreRowsCalls = []; + let rowRendererCalls = []; beforeEach(() => { - isRowLoadedCalls = [] - isRowLoadedMap = {} - loadMoreRowsCalls = [] - rowRendererCalls = [] - }) - - function defaultIsRowLoaded ({ index }) { - isRowLoadedCalls.push(index) - return !!isRowLoadedMap[index] + isRowLoadedCalls = []; + isRowLoadedMap = {}; + loadMoreRowsCalls = []; + rowRendererCalls = []; + }); + + function defaultIsRowLoaded({index}) { + isRowLoadedCalls.push(index); + return !!isRowLoadedMap[index]; } - function defaultLoadMoreRows ({ startIndex, stopIndex }) { - loadMoreRowsCalls.push({ startIndex, stopIndex }) + function defaultLoadMoreRows({startIndex, stopIndex}) { + loadMoreRowsCalls.push({startIndex, stopIndex}); } - function rowRenderer ({ index, key, style }) { - rowRendererCalls.push(index) - return ( -
- ) + function rowRenderer({index, key, style}) { + rowRendererCalls.push(index); + return
; } - function getMarkup ({ + function getMarkup({ height = 100, isRowLoaded = defaultIsRowLoaded, loadMoreRows = defaultLoadMoreRows, @@ -42,7 +44,7 @@ describe('InfiniteLoader', () => { rowCount = 100, scrollToIndex, threshold = 10, - width = 200 + width = 200, } = {}) { return ( { loadMoreRows={loadMoreRows} minimumBatchSize={minimumBatchSize} rowCount={rowCount} - threshold={threshold} - > - {({ onRowsRendered, registerChild }) => { - innerOnRowsRendered = onRowsRendered + threshold={threshold}> + {({onRowsRendered, registerChild}) => { + innerOnRowsRendered = onRowsRendered; return ( { scrollToIndex={scrollToIndex} width={width} /> - ) + ); }} - ) + ); } 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]) - }) + 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]) - }) + 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 }]) - }) + 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({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})); + 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 })) + 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() - }) + 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 }]) - }) + 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 }]) - }) + 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 }]) - }) + 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 = {} + const isRowLoadedIndices = {}; - function isRowLoaded ({ index }) { + function isRowLoaded({index}) { if (!isRowLoadedIndices[index]) { - isRowLoadedIndices[index] = true + isRowLoadedIndices[index] = true; - return false + return false; } else { - return true + return true; } } - render(getMarkup({ - isRowLoaded, - minimumBatchSize: 10, - threshold: 0 - })) + render( + getMarkup({ + isRowLoaded, + minimumBatchSize: 10, + threshold: 0, + }), + ); // Simulate a new range of rows being loaded - innerOnRowsRendered({ startIndex: 5, stopIndex: 10 }) + innerOnRowsRendered({startIndex: 5, stopIndex: 10}); expect(loadMoreRowsCalls).toEqual([ - { startIndex: 0, stopIndex: 9 }, - { startIndex: 10, stopIndex: 19 } - ]) - }) + {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 - })) + render( + getMarkup({ + minimumBatchSize: 10, + rowCount: 25, + threshold: 0, + }), + ); // Simulate a new range of rows being loaded - innerOnRowsRendered({ startIndex: 18, stopIndex: 22 }) + innerOnRowsRendered({startIndex: 18, stopIndex: 22}); expect(loadMoreRowsCalls).toEqual([ - { startIndex: 0, stopIndex: 9 }, - { startIndex: 15, stopIndex: 24 } - ]) - }) + {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 }]) - }) - }) + 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 }]) - }) + 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 }]) - }) -}) + 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] + 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([]) - }) + 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 } - ]) - }) + 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 } - ]) - }) + 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 } - ]) - }) -}) + 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) - }) + 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) - }) + 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) - }) + 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) - }) -}) + 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', () => { - let recomputedGridSize = false - class TestComponent extends Component { - recomputeGridSize () { - recomputedGridSize = true - } - render () { - return
+ const recomputeGridSize = jest.fn(); + class TestComponent extends React.Component { + recomputeGridSize = recomputeGridSize; + render() { + return
; } } - forceUpdateReactVirtualizedComponent( - render() - ) - expect(recomputedGridSize).toEqual(true) - }) + forceUpdateReactVirtualizedComponent(render(), 10); + expect(recomputeGridSize).toHaveBeenCalledTimes(1); + expect(recomputeGridSize).toHaveBeenCalledWith(10); + }); it('should called :recomputeRowHeights if defined', () => { - let recomputedRowHeights = false - class TestComponent extends Component { - recomputeRowHeights () { - recomputedRowHeights = true - } - render () { - return
+ const recomputeRowHeights = jest.fn(); + class TestComponent extends React.Component { + recomputeRowHeights = recomputeRowHeights; + render() { + return
; } } - forceUpdateReactVirtualizedComponent( - render() - ) - expect(recomputedRowHeights).toEqual(true) - }) + forceUpdateReactVirtualizedComponent(render(), 10); + expect(recomputeRowHeights).toHaveBeenCalledTimes(1); + expect(recomputeRowHeights).toHaveBeenCalledWith(10); + }); it('should call :forceUpdate otherwise', () => { - let forceUpdated = false - class TestComponent extends Component { - forceUpdate () { - forceUpdated = true - } - render () { - return
+ const forceUpdate = jest.fn(); + class TestComponent extends React.Component { + forceUpdate = forceUpdate; + render() { + return
; } } - forceUpdateReactVirtualizedComponent( - render() - ) - expect(forceUpdated).toEqual(true) - }) -}) + forceUpdateReactVirtualizedComponent(render(), 10); + expect(forceUpdate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/source/InfiniteLoader/InfiniteLoader.js b/source/InfiniteLoader/InfiniteLoader.js index 5045c0768..b6e1bc85d 100644 --- a/source/InfiniteLoader/InfiniteLoader.js +++ b/source/InfiniteLoader/InfiniteLoader.js @@ -1,13 +1,14 @@ /** @flow */ -import { PropTypes, PureComponent } from 'react' -import createCallbackMemoizer from '../utils/createCallbackMemoizer' +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 PureComponent { +export default class InfiniteLoader extends React.PureComponent { static propTypes = { /** * Function responsible for rendering a virtualized component. @@ -50,42 +51,46 @@ export default class InfiniteLoader extends PureComponent { * 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 + threshold: PropTypes.number.isRequired, }; static defaultProps = { minimumBatchSize: 10, rowCount: 0, - threshold: 15 + threshold: 15, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); - this._loadMoreRowsMemoizer = createCallbackMemoizer() + this._loadMoreRowsMemoizer = createCallbackMemoizer(); - this._onRowsRendered = this._onRowsRendered.bind(this) - this._registerChild = this._registerChild.bind(this) + this._onRowsRendered = this._onRowsRendered.bind(this); + this._registerChild = this._registerChild.bind(this); } - resetLoadMoreRowsCache () { - this._loadMoreRowsMemoizer = createCallbackMemoizer() + resetLoadMoreRowsCache(autoReload) { + this._loadMoreRowsMemoizer = createCallbackMemoizer(); + + if (autoReload) { + this._doStuff(this._lastRenderedStartIndex, this._lastRenderedStopIndex); + } } - render () { - const { children } = this.props + render() { + const {children} = this.props; return children({ onRowsRendered: this._onRowsRendered, - registerChild: this._registerChild - }) + registerChild: this._registerChild, + }); } - _loadUnloadedRanges (unloadedRanges) { - const { loadMoreRows } = this.props + _loadUnloadedRanges(unloadedRanges) { + const {loadMoreRows} = this.props; unloadedRanges.forEach(unloadedRange => { - let promise = loadMoreRows(unloadedRange) + let promise = loadMoreRows(unloadedRange); if (promise) { promise.then(() => { // Refresh the visible rows if any of them have just been loaded. @@ -95,93 +100,104 @@ export default class InfiniteLoader extends PureComponent { lastRenderedStartIndex: this._lastRenderedStartIndex, lastRenderedStopIndex: this._lastRenderedStopIndex, startIndex: unloadedRange.startIndex, - stopIndex: unloadedRange.stopIndex + stopIndex: unloadedRange.stopIndex, }) ) { if (this._registeredChild) { - forceUpdateReactVirtualizedComponent(this._registeredChild) + forceUpdateReactVirtualizedComponent( + this._registeredChild, + this._lastRenderedStartIndex, + ); } } - }) + }); } - }) + }); } - _onRowsRendered ({ startIndex, stopIndex }) { - const { isRowLoaded, minimumBatchSize, rowCount, threshold } = this.props + _onRowsRendered({startIndex, stopIndex}) { + this._lastRenderedStartIndex = startIndex; + this._lastRenderedStopIndex = stopIndex; + + this._doStuff(startIndex, stopIndex); + } - this._lastRenderedStartIndex = startIndex - this._lastRenderedStopIndex = 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) - }) + stopIndex: Math.min(rowCount - 1, stopIndex + threshold), + }); // For memoize comparison - const squashedUnloadedRanges = unloadedRanges.reduce( - (reduced, unloadedRange) => reduced.concat([unloadedRange.startIndex, unloadedRange.stopIndex]), - [] - ) + const squashedUnloadedRanges = [].concat( + ...unloadedRanges.map(({startIndex, stopIndex}) => [ + startIndex, + stopIndex, + ]), + ); this._loadMoreRowsMemoizer({ callback: () => { - this._loadUnloadedRanges(unloadedRanges) + this._loadUnloadedRanges(unloadedRanges); }, - indices: { squashedUnloadedRanges } - }) + indices: {squashedUnloadedRanges}, + }); } - _registerChild (registeredChild) { - this._registeredChild = registeredChild + _registerChild(registeredChild) { + this._registeredChild = registeredChild; } } /** * Determines if the specified start/stop range is visible based on the most recently rendered range. */ -export function isRangeVisible ({ +export function isRangeVisible({ lastRenderedStartIndex, lastRenderedStopIndex, startIndex, - stopIndex + stopIndex, }) { - return !(startIndex > lastRenderedStopIndex || stopIndex < lastRenderedStartIndex) + return !( + startIndex > lastRenderedStopIndex || stopIndex < lastRenderedStartIndex + ); } /** * Returns all of the ranges within a larger range that contain unloaded rows. */ -export function scanForUnloadedRanges ({ +export function scanForUnloadedRanges({ isRowLoaded, minimumBatchSize, rowCount, startIndex, - stopIndex + stopIndex, }) { - const unloadedRanges = [] + const unloadedRanges = []; - let rangeStartIndex = null - let rangeStopIndex = null + let rangeStartIndex = null; + let rangeStopIndex = null; for (let index = startIndex; index <= stopIndex; index++) { - let loaded = isRowLoaded({ index }) + let loaded = isRowLoaded({index}); if (!loaded) { - rangeStopIndex = index + rangeStopIndex = index; if (rangeStartIndex === null) { - rangeStartIndex = index + rangeStartIndex = index; } } else if (rangeStopIndex !== null) { unloadedRanges.push({ startIndex: rangeStartIndex, - stopIndex: rangeStopIndex - }) + stopIndex: rangeStopIndex, + }); - rangeStartIndex = rangeStopIndex = null + rangeStartIndex = rangeStopIndex = null; } } @@ -189,47 +205,45 @@ export function scanForUnloadedRanges ({ // Scan forward to try filling our :minimumBatchSize. if (rangeStopIndex !== null) { const potentialStopIndex = Math.min( - Math.max( - rangeStopIndex, - rangeStartIndex + minimumBatchSize - 1 - ), - rowCount - 1 - ) + Math.max(rangeStopIndex, rangeStartIndex + minimumBatchSize - 1), + rowCount - 1, + ); for (let index = rangeStopIndex + 1; index <= potentialStopIndex; index++) { - if (!isRowLoaded({ index })) { - rangeStopIndex = index + if (!isRowLoaded({index})) { + rangeStopIndex = index; } else { - break + break; } } unloadedRanges.push({ startIndex: rangeStartIndex, - stopIndex: rangeStopIndex - }) + 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] + const firstUnloadedRange = unloadedRanges[0]; while ( - firstUnloadedRange.stopIndex - firstUnloadedRange.startIndex + 1 < minimumBatchSize && + firstUnloadedRange.stopIndex - firstUnloadedRange.startIndex + 1 < + minimumBatchSize && firstUnloadedRange.startIndex > 0 ) { - let index = firstUnloadedRange.startIndex - 1 + let index = firstUnloadedRange.startIndex - 1; - if (!isRowLoaded({ index })) { - firstUnloadedRange.startIndex = index + if (!isRowLoaded({index})) { + firstUnloadedRange.startIndex = index; } else { - break + break; } } } - return unloadedRanges + return unloadedRanges; } /** @@ -243,14 +257,18 @@ export function scanForUnloadedRanges ({ * So it's important to invalidate that cache by recalculating sizes * before forcing a rerender. */ -export function forceUpdateReactVirtualizedComponent (component) { - const recomputeSize = typeof component.recomputeGridSize === 'function' - ? component.recomputeGridSize - : component.recomputeRowHeights +export function forceUpdateReactVirtualizedComponent( + component, + currentIndex = 0, +) { + const recomputeSize = + typeof component.recomputeGridSize === 'function' + ? component.recomputeGridSize + : component.recomputeRowHeights; if (recomputeSize) { - recomputeSize.call(component) + recomputeSize.call(component, currentIndex); } else { - component.forceUpdate() + component.forceUpdate(); } } diff --git a/source/InfiniteLoader/index.js b/source/InfiniteLoader/index.js index 38c2fa868..781d65d1e 100644 --- a/source/InfiniteLoader/index.js +++ b/source/InfiniteLoader/index.js @@ -1,3 +1,5 @@ /** @flow */ -export default from './InfiniteLoader' -export InfiniteLoader from './InfiniteLoader' +import InfiniteLoader from './InfiniteLoader'; + +export default InfiniteLoader; +export {InfiniteLoader}; diff --git a/source/List/List.example.js b/source/List/List.example.js index 3e22ea559..8dd2bfff2 100644 --- a/source/List/List.example.js +++ b/source/List/List.example.js @@ -1,22 +1,24 @@ -/** - * @flow - */ -import cn from 'classnames' -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } 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 PureComponent { +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 + list: PropTypes.instanceOf(Immutable.List).isRequired, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this.state = { listHeight: 300, @@ -25,17 +27,17 @@ export default class ListExample extends PureComponent { 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) + 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 () { + render() { const { listHeight, listRowHeight, @@ -43,41 +45,48 @@ export default class ListExample extends PureComponent { rowCount, scrollToIndex, showScrollingPlaceholder, - useDynamicRowHeight - } = this.state + 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. + 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. @@ -85,50 +94,64 @@ export default class ListExample extends PureComponent { this.setState({ listHeight: parseInt(event.target.value, 10) || 1 })} + label="List height" + name="listHeight" + onChange={event => + this.setState({ + listHeight: parseInt(event.target.value, 10) || 1, + }) + } value={listHeight} /> this.setState({ listRowHeight: parseInt(event.target.value, 10) || 1 })} + label="Row height" + name="listRowHeight" + onChange={event => + this.setState({ + listRowHeight: parseInt(event.target.value, 10) || 1, + }) + } value={listRowHeight} /> this.setState({ overscanRowCount: parseInt(event.target.value, 10) || 0 })} + label="Overscan" + name="overscanRowCount" + onChange={event => + this.setState({ + overscanRowCount: parseInt(event.target.value, 10) || 0, + }) + } value={overscanRowCount} />
- {({ width }) => ( + {({width}) => (
- ) + ); } - _getDatum (index) { - const { list } = this.context + _getDatum(index) { + const {list} = this.context; - return list.get(index % list.size) + return list.get(index % list.size); } - _getRowHeight ({ index }) { - return this._getDatum(index).size + _getRowHeight({index}) { + return this._getDatum(index).size; } - _noRowsRenderer () { - return ( -
- No rows -
- ) + _noRowsRenderer() { + return
No rows
; } - _onRowCountChange (event) { - const rowCount = parseInt(event.target.value, 10) || 0 + _onRowCountChange(event) { + const rowCount = parseInt(event.target.value, 10) || 0; - this.setState({ rowCount }) + this.setState({rowCount}); } - _onScrollToRowChange (event) { - const { rowCount } = this.state - let scrollToIndex = Math.min(rowCount - 1, parseInt(event.target.value, 10)) + _onScrollToRowChange(event) { + const {rowCount} = this.state; + let scrollToIndex = Math.min( + rowCount - 1, + parseInt(event.target.value, 10), + ); if (isNaN(scrollToIndex)) { - scrollToIndex = undefined + scrollToIndex = undefined; } - this.setState({ scrollToIndex }) + this.setState({scrollToIndex}); } - _rowRenderer ({ index, isScrolling, key, style }) { - const { - showScrollingPlaceholder, - useDynamicRowHeight - } = this.state + _rowRenderer({index, isScrolling, key, style}) { + const {showScrollingPlaceholder, useDynamicRowHeight} = this.state; - if ( - showScrollingPlaceholder && - isScrolling - ) { + if (showScrollingPlaceholder && isScrolling) { return (
+ style={style}> Scrolling...
- ) + ); } - const datum = this._getDatum(index) + const datum = this._getDatum(index); - let additionalContent + let additionalContent; if (useDynamicRowHeight) { switch (datum.size) { case 75: - additionalContent =
It is medium-sized.
- break + additionalContent =
It is medium-sized.
; + break; case 100: - additionalContent =
It is large-sized.
It has a 3rd row.
- break + additionalContent = ( +
+ It is large-sized. +
+ It has a 3rd row. +
+ ); + break; } } return ( -
+
+ backgroundColor: datum.color, + }}> {datum.name.charAt(0)}
-
- {datum.name} -
-
- This is row {index} -
+
{datum.name}
+
This is row {index}
{additionalContent}
- {useDynamicRowHeight && - - {datum.size}px - - } + {useDynamicRowHeight && ( + {datum.size}px + )}
- ) + ); } } diff --git a/source/List/List.jest.js b/source/List/List.jest.js index efb20ae98..cbaa5729e 100644 --- a/source/List/List.jest.js +++ b/source/List/List.jest.js @@ -1,33 +1,40 @@ -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import { Simulate } from 'react-addons-test-utils' -import Immutable from 'immutable' -import List from './List' +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 = [] + const array = []; for (var i = 0; i < 100; i++) { - array.push(`Name ${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, + }; } - const names = Immutable.fromJS(array) - function getMarkup (props = {}) { - function rowRenderer ({ index, key, style }) { + function getMarkup(props = {}) { + function rowRenderer({index, key, style}) { return ( -
+
{names.get(index)}
- ) + ); } return ( { width={100} {...props} /> - ) + ); } 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) - }) + 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) - }) - }) + 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') - }) + 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 }))) + 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') - }) + expect(rendered.textContent).toContain('Name 49'); + }); it('should scroll to the bottom', () => { - const rendered = findDOMNode(render(getMarkup({ scrollToIndex: 99 }))) + 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') - }) + 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 - }))) + 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') - }) + 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 - }))) + 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') - }) + 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 - }))) + 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') - }) - }) + 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') + 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') - }) + 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') + 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') - }) + 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') - }) + 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') - }) - }) + 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!') - }) + 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('') - }) - }) + 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) - }) + 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 + 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) - }) + 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 + 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) - }) + 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) - }) - }) + 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) - }) + 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) - }) - }) + 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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%') - }) - }) + const rendered = findDOMNode(render(getMarkup())); + const cell = rendered.querySelector('.listItem'); + expect(cell.style.width).toEqual('100%'); + }); + }); describe('overscanRowCount', () => { it('should not overscan by default', () => { - let overscanStartIndex, overscanStopIndex, startIndex, stopIndex - render(getMarkup({ - onRowsRendered: params => ({ overscanStartIndex, overscanStopIndex, startIndex, stopIndex } = params) - })) - expect(overscanStartIndex).toEqual(startIndex) - expect(overscanStopIndex).toEqual(stopIndex) - }) + 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', () => { - let overscanStartIndex, overscanStopIndex, startIndex, stopIndex - render(getMarkup({ - onRowsRendered: params => ({ overscanStartIndex, overscanStopIndex, startIndex, stopIndex } = params), - overscanRowCount: 10, - scrollToIndex: 30 - })) - expect(overscanStartIndex).toEqual(21) - expect(startIndex).toEqual(21) - expect(stopIndex).toEqual(30) - expect(overscanStopIndex).toEqual(40) - }) - - it('should not overscan beyond the start of the list', () => { - let overscanStartIndex, overscanStopIndex, startIndex, stopIndex - render(getMarkup({ - onRowsRendered: params => ({ overscanStartIndex, overscanStopIndex, startIndex, stopIndex } = params), - overscanRowCount: 10 - })) - expect(overscanStartIndex).toEqual(0) - expect(startIndex).toEqual(0) - expect(stopIndex).toEqual(9) - expect(overscanStopIndex).toEqual(19) - }) - - it('should not overscan beyond the end of the list', () => { - let overscanStartIndex, overscanStopIndex, startIndex, stopIndex - render(getMarkup({ - onRowsRendered: params => ({ overscanStartIndex, overscanStopIndex, startIndex, stopIndex } = params), - overscanRowCount: 10, - rowCount: 15 - })) - expect(overscanStartIndex).toEqual(0) - expect(startIndex).toEqual(0) - expect(stopIndex).toEqual(9) - expect(overscanStopIndex).toEqual(14) - }) - }) + 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 - }]) - }) + 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 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 }) + 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 - }) - }) - }) + 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._rowSizeAndPositionManager.getTotalSize()).toEqual(150) - rendered.measureAllRows() - expect(rendered.Grid._rowSizeAndPositionManager.getTotalSize()).toEqual(200) - }) - }) + 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() + 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) + expect(indices[0]).toEqual(0); + expect(indices[indices.length - 1]).toEqual(9); - indices.splice(0) - component.recomputeRowHeights(4) + indices.splice(0); + component.recomputeRowHeights(4); - expect(indices[0]).toEqual(4) - expect(indices[indices.length - 1]).toEqual(9) - }) - }) + 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 }) { + let marker = 'a'; + function rowRenderer({index, key, style}) { return ( -
- {index}{marker} +
+ {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') - }) - }) + 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) - }) + 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) - }) - }) + 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 + const rowRendererCalls = []; + function rowRenderer(props) { + rowRendererCalls.push(props); + return null; } - findDOMNode(render(getMarkup({ - height: 50, - overscanRowCount: 1, - rowHeight: 50, - rowRenderer - }))) - expect(rowRendererCalls[0].isVisible).toEqual(true) - expect(rowRendererCalls[1].isVisible).toEqual(false) - }) + 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() - }) + 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 + 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) - }) - }) + 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') - }) -}) + const rendered = findDOMNode(render(getMarkup())); + expect( + rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer') + .style.width, + ).toEqual('auto'); + }); +}); diff --git a/source/List/List.js b/source/List/List.js index 7a4f257cf..fd85ec691 100644 --- a/source/List/List.js +++ b/source/List/List.js @@ -1,7 +1,20 @@ /** @flow */ -import Grid from '../Grid' -import React, { PropTypes, PureComponent } from 'react' -import cn from 'classnames' + +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 @@ -11,134 +24,172 @@ import cn from 'classnames' * * This component renders a virtualized list of elements with either fixed or dynamic heights. */ -export default class List extends 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, - - /** Optional CSS class name */ - className: PropTypes.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: PropTypes.number.isRequired, - - /** Height constraint for list (determines how many actual rows are rendered) */ - height: PropTypes.number.isRequired, - - /** Optional renderer to be used in place of rows when rowCount is 0 */ - noRowsRenderer: PropTypes.func.isRequired, - - /** - * Callback invoked with information about the slice of rows that were just rendered. - * ({ startIndex, stopIndex }): void - */ - onRowsRendered: 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, - - /** - * 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, - - /** - * 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, - - /** Responsible for rendering a row given an index; ({ index: number }): node */ - rowRenderer: PropTypes.func.isRequired, - - /** Number of rows in list. */ - rowCount: PropTypes.number.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, - - /** Optional inline style */ - style: PropTypes.object, - - /** Tab index for focus */ - tabIndex: PropTypes.number, - - /** Width of list */ - width: PropTypes.number.isRequired - }; +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: () => null, - onScroll: () => null, + onRowsRendered: () => {}, + overscanIndicesGetter: accessibilityOverscanIndicesGetter, overscanRowCount: 10, scrollToAlignment: 'auto', scrollToIndex: -1, - style: {} + style: {}, }; - constructor (props, context) { - super(props, context) + Grid: ?React.ElementRef; - this._cellRenderer = this._cellRenderer.bind(this) - this._onScroll = this._onScroll.bind(this) - this._onSectionRendered = this._onSectionRendered.bind(this) - this._setRef = this._setRef.bind(this) + forceUpdateGrid() { + if (this.Grid) { + this.Grid.forceUpdate(); + } } - forceUpdateGrid () { - 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 () { - this.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) { - this.Grid.recomputeGridSize({ - rowIndex: index - }) + 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 = 0) { - this.Grid.scrollToCell({ - columnIndex: 0, - rowIndex: index - }) + scrollToRow(index: number = 0) { + if (this.Grid) { + this.Grid.scrollToCell({ + columnIndex: 0, + rowIndex: index, + }); + } } - render () { - const { - className, - noRowsRenderer, - scrollToIndex, - width - } = this.props + render() { + const {className, noRowsRenderer, scrollToIndex, width} = this.props; - const classNames = cn('ReactVirtualized__List', className) + const classNames = clsx('ReactVirtualized__List', className); return ( - ) + ); } - _cellRenderer ({ rowIndex, style, ...rest }) { - const { rowRenderer } = this.props + _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 { writable } = Object.getOwnPropertyDescriptor(style, 'width') - if (writable) { + 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%' + style.width = '100%'; } return rowRenderer({ index: rowIndex, style, - ...rest - }) - } + isScrolling, + isVisible, + key, + parent, + }); + }; - _setRef (ref) { - this.Grid = ref - } + _setRef = (ref: ?React.ElementRef) => { + this.Grid = ref; + }; - _onScroll ({ clientHeight, scrollHeight, scrollTop }) { - const { onScroll } = this.props + _onScroll = ({clientHeight, scrollHeight, scrollTop}: GridScroll) => { + const {onScroll} = this.props; - onScroll({ clientHeight, scrollHeight, scrollTop }) - } + onScroll({clientHeight, scrollHeight, scrollTop}); + }; - _onSectionRendered ({ rowOverscanStartIndex, rowOverscanStopIndex, rowStartIndex, rowStopIndex }) { - const { onRowsRendered } = this.props + _onSectionRendered = ({ + rowOverscanStartIndex, + rowOverscanStopIndex, + rowStartIndex, + rowStopIndex, + }: RenderedSection) => { + const {onRowsRendered} = this.props; onRowsRendered({ overscanStartIndex: rowOverscanStartIndex, overscanStopIndex: rowOverscanStopIndex, startIndex: rowStartIndex, - stopIndex: rowStopIndex - }) - } + stopIndex: rowStopIndex, + }); + }; } diff --git a/source/List/index.js b/source/List/index.js index 22407e51e..e45ba29b9 100644 --- a/source/List/index.js +++ b/source/List/index.js @@ -1,3 +1,6 @@ /** @flow */ -export default from './List' -export List from './List' + +export type {RowRendererParams} from './types'; + +export {default} from './List'; +export {default as List} from './List'; diff --git a/source/List/types.js b/source/List/types.js new file mode 100644 index 000000000..3ff004a44 --- /dev/null +++ b/source/List/types.js @@ -0,0 +1,27 @@ +// @flow + +import * as React from 'react'; + +export type RowRendererParams = { + index: number, + isScrolling: boolean, + isVisible: boolean, + key: string, + parent: Object, + style: Object, +}; + +export type RowRenderer = (params: RowRendererParams) => React.Element<*>; + +export type RenderedRows = { + overscanStartIndex: number, + overscanStopIndex: number, + startIndex: number, + stopIndex: number, +}; + +export type Scroll = { + clientHeight: number, + scrollHeight: number, + scrollTop: number, +}; diff --git a/source/Masonry/Masonry.example.css b/source/Masonry/Masonry.example.css new file mode 100644 index 000000000..118ac5dc3 --- /dev/null +++ b/source/Masonry/Masonry.example.css @@ -0,0 +1,18 @@ +.Cell { + display: flex; + flex-direction: column; + border-radius: .5rem; + padding: 0.5rem; + background-color: #f7f7f7; + word-break: break-all; +} + +.checkboxLabel { + margin-left: .5rem; +} +.checkboxLabel:first-of-type { + margin-left: 0; +} +.checkbox { + margin-right: 5px; +} \ No newline at end of file diff --git a/source/Masonry/Masonry.example.js b/source/Masonry/Masonry.example.js new file mode 100644 index 000000000..c0b956c3f --- /dev/null +++ b/source/Masonry/Masonry.example.js @@ -0,0 +1,301 @@ +/** @flow */ +import Immutable from 'immutable'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { + ContentBox, + ContentBoxHeader, + ContentBoxParagraph, +} from '../demo/ContentBox'; +import {LabeledInput, InputRow} from '../demo/LabeledInput'; +import {CellMeasurer, CellMeasurerCache} from '../CellMeasurer'; +import AutoSizer from '../AutoSizer'; +import WindowScroller from '../WindowScroller'; +import createCellPositioner from './createCellPositioner'; +import Masonry from './Masonry'; +import styles from './Masonry.example.css'; + +export default class GridExample extends React.PureComponent { + static contextTypes = { + list: PropTypes.instanceOf(Immutable.List).isRequired, + }; + + constructor(props, context) { + super(props, context); + + this._columnCount = 0; + + this._cache = new CellMeasurerCache({ + defaultHeight: 250, + defaultWidth: 200, + fixedWidth: true, + }); + + this.state = { + columnWidth: 200, + height: 300, + gutterSize: 10, + overscanByPixels: 0, + windowScrollerEnabled: false, + }; + + this._cellRenderer = this._cellRenderer.bind(this); + this._onResize = this._onResize.bind(this); + this._renderAutoSizer = this._renderAutoSizer.bind(this); + this._renderMasonry = this._renderMasonry.bind(this); + this._setMasonryRef = this._setMasonryRef.bind(this); + } + + render() { + const { + columnWidth, + height, + gutterSize, + overscanByPixels, + windowScrollerEnabled, + } = this.state; + + let child; + + if (windowScrollerEnabled) { + child = ( + + {this._renderAutoSizer} + + ); + } else { + child = this._renderAutoSizer({height}); + } + + return ( + + + + + Optimized for masonry layouts. Cells are j.i.t. measured and layed out + as a user scrolls. Sizes are cached so that resize/reflow is fast and + does not require re-measuring. + + + + + + + + + { + this.setState({ + height: parseInt(event.target.value, 10) || 300, + }); + }} + value={height} + /> + { + this._cache.clearAll(); + this.setState( + { + columnWidth: parseInt(event.target.value, 10) || 200, + }, + () => { + this._calculateColumnCount(); + this._resetCellPositioner(); + this._masonry.clearCellPositions(); + }, + ); + }} + value={columnWidth} + /> + { + this.setState( + { + gutterSize: parseInt(event.target.value, 10) || 10, + }, + () => { + this._calculateColumnCount(); + this._resetCellPositioner(); + this._masonry.recomputeCellPositions(); + }, + ); + }} + value={gutterSize} + /> + { + this.setState({ + overscanByPixels: parseInt(event.target.value, 10) || 0, + }); + }} + value={overscanByPixels} + /> + + + {child} + + ); + } + + _calculateColumnCount() { + const {columnWidth, gutterSize} = this.state; + + this._columnCount = Math.floor(this._width / (columnWidth + gutterSize)); + } + + _cellRenderer({index, key, parent, style}) { + const {list} = this.context; + const {columnWidth} = this.state; + + const datum = list.get(index % list.size); + + return ( + +
+
+ {index} +
+ {datum.random} +
+
+ ); + } + + _initCellPositioner() { + if (typeof this._cellPositioner === 'undefined') { + const {columnWidth, gutterSize} = this.state; + + this._cellPositioner = createCellPositioner({ + cellMeasurerCache: this._cache, + columnCount: this._columnCount, + columnWidth, + spacer: gutterSize, + }); + } + } + + _onResize({width}) { + this._width = width; + + this._calculateColumnCount(); + this._resetCellPositioner(); + this._masonry.recomputeCellPositions(); + } + + _renderAutoSizer({height, scrollTop}) { + this._height = height; + this._scrollTop = scrollTop; + + const {overscanByPixels} = this.state; + + return ( + + {this._renderMasonry} + + ); + } + + _renderMasonry({width}) { + this._width = width; + + this._calculateColumnCount(); + this._initCellPositioner(); + + const {height, overscanByPixels, windowScrollerEnabled} = this.state; + + return ( + + ); + } + + // This is a bit of a hack to simulate newly loaded cells + _resetList = () => { + const ROW_HEIGHTS = [25, 50, 75, 100]; + + const {list} = this.context; + list.forEach(datum => { + datum.size = ROW_HEIGHTS[Math.floor(Math.random() * ROW_HEIGHTS.length)]; + }); + + this._cache.clearAll(); + this._resetCellPositioner(); + this._masonry.clearCellPositions(); + }; + + _resetCellPositioner() { + const {columnWidth, gutterSize} = this.state; + + this._cellPositioner.reset({ + columnCount: this._columnCount, + columnWidth, + spacer: gutterSize, + }); + } + + _setMasonryRef(ref) { + this._masonry = ref; + } +} diff --git a/source/Masonry/Masonry.jest.js b/source/Masonry/Masonry.jest.js new file mode 100644 index 000000000..23a00189b --- /dev/null +++ b/source/Masonry/Masonry.jest.js @@ -0,0 +1,472 @@ +import * as React from 'react'; +import {findDOMNode} from 'react-dom'; +import {Simulate} from 'react-dom/test-utils'; +import {render} from '../TestUtils'; +import createCellPositionerUtil from './createCellPositioner'; +import Masonry from './Masonry'; +import {CellMeasurer, CellMeasurerCache} from '../CellMeasurer'; + +const ALTERNATING_CELL_HEIGHTS = [100, 50, 100, 150]; +const CELL_SIZE_MULTIPLIER = 50; +const COLUMN_COUNT = 3; + +function assertVisibleCells(rendered, text) { + expect( + Array.from(rendered.querySelectorAll('.cell')) + .map(node => node.textContent) + .sort() + .join(','), + ).toEqual(text); +} + +function createCellMeasurerCache(props = {}) { + return new CellMeasurerCache({ + defaultHeight: CELL_SIZE_MULTIPLIER, + defaultWidth: CELL_SIZE_MULTIPLIER, + fixedWidth: true, + keyMapper: index => index, + ...props, + }); +} + +function createCellPositioner(cache) { + return createCellPositionerUtil({ + cellMeasurerCache: cache, + columnCount: COLUMN_COUNT, + columnWidth: CELL_SIZE_MULTIPLIER, + }); +} + +function createCellRenderer(cache, renderCallback) { + renderCallback = + typeof renderCallback === 'function' ? renderCallback : index => index; + + return function cellRenderer({index, isScrolling, key, parent, style}) { + const height = + ALTERNATING_CELL_HEIGHTS[index % ALTERNATING_CELL_HEIGHTS.length]; + const width = CELL_SIZE_MULTIPLIER; + + return ( + +
{ + if (ref) { + // Accounts for the fact that JSDom doesn't support measurements. + Object.defineProperty(ref, 'offsetHeight', { + configurable: true, + value: height, + }); + Object.defineProperty(ref, 'offsetWidth', { + configurable: true, + value: width, + }); + } + }} + style={{ + ...style, + minHeight: height, + minWidth: width, + }}> + {renderCallback(index, {index, isScrolling, key, parent, style})} +
+
+ ); + }; +} + +function getMarkup(props = {}) { + const cellMeasurerCache = + props.cellMeasurerCache || createCellMeasurerCache(); + + return ( + + ); +} + +function simulateScroll(masonry, scrollTop = 0) { + const target = {scrollTop}; + masonry._scrollingContainer = target; // HACK to work around _onScroll target check + + const masonryNode = findDOMNode(masonry); + masonryNode.scrollTop = scrollTop; + Simulate.scroll(masonryNode); +} + +describe('Masonry', () => { + beforeEach(render.unmount); + + describe('layout and measuring', () => { + it('should measure only enough cells required for initial render', () => { + // avg cell size: CELL_SIZE_MULTIPLIER + // width: CELL_SIZE_MULTIPLIER * 3 + // height: CELL_SIZE_MULTIPLIER * 2 + // overcsan by: CELL_SIZE_MULTIPLIER + // Expected to measure 9 cells + const cellMeasurerCache = createCellMeasurerCache(); + render(getMarkup({cellMeasurerCache})); + for (let i = 0; i <= 8; i++) { + expect(cellMeasurerCache.has(i)).toBe(true); + } + expect(cellMeasurerCache.has(9)).toBe(false); + }); + + it('should not measure cells while scrolling until they are needed', () => { + // Expected to measure 9 cells + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = jest.fn().mockImplementation(index => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback, + ); + const rendered = findDOMNode( + render(getMarkup({cellMeasurerCache, cellRenderer})), + ); + renderCallback.mockClear(); + // Scroll a little bit, but not so much to require re-measuring + simulateScroll(rendered, 51); + // Verify that render was only called enough times to fill view port (no extra for measuring) + expect(renderCallback).toHaveBeenCalledTimes(9); + }); + + it('should measure additional cells on scroll when it runs out of measured cells', () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = jest.fn().mockImplementation(index => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback, + ); + const rendered = findDOMNode( + render(getMarkup({cellRenderer, cellMeasurerCache})), + ); + expect(cellMeasurerCache.has(9)).toBe(false); + + renderCallback.mockClear(); + + simulateScroll(rendered, 101); + expect(cellMeasurerCache.has(9)).toBe(true); + expect(cellMeasurerCache.has(10)).toBe(false); + }); + + // Masonry used to do a render pass for only unmeasured cells, + // But this resulting in removing (and later re-adding) measured cells from the DOM, + // Which was bad for performance. See GitHub issue #875 + it('should not remove previously-measured cells when measuring new ones', () => { + const log = []; + + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = index => { + log.push(index); + }; + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback, + ); + + const rendered = findDOMNode( + render( + getMarkup({ + cellMeasurerCache, + cellRenderer, + }), + ), + ); + + // Expected to have rendered twice: + // 1st time to measure 9 cells (b'c of esimated size) + // 2nd time to render and position 9 cells (b'c of actual size) + expect(log).toHaveLength(18); + + log.splice(0); + + simulateScroll(rendered, 101); + + // Expected to have rendered twice: + // 1st time to measure additional cells (based on estimated size) + // 2nd time to render and position with new cells + // The 1st render should also have included the pre-measured cells, + // To prevent them from being removed, recreated, and re-added to the DOM. + expect(log).toHaveLength(18); + }); + + it('should only render enough cells to fill the viewport', () => { + const rendered = findDOMNode( + render( + getMarkup({ + overscanByPixels: 0, + }), + ), + ); + assertVisibleCells(rendered, '0,1,2,3,4,5'); + simulateScroll(rendered, 51); + assertVisibleCells(rendered, '0,2,3,4,5,6'); + simulateScroll(rendered, 101); + assertVisibleCells(rendered, '3,4,5,6,7,8'); + simulateScroll(rendered, 1001); + assertVisibleCells(rendered, '30,31,32,33,34,35'); + }); + + it('should only render enough cells to fill the viewport plus overscanByPixels', () => { + const rendered = findDOMNode( + render( + getMarkup({ + overscanByPixels: 100, + }), + ), + ); + assertVisibleCells(rendered, '0,1,10,11,2,3,4,5,6,7,8,9'); + simulateScroll(rendered, 51); + assertVisibleCells(rendered, '0,1,10,11,2,3,4,5,6,7,8,9'); + simulateScroll(rendered, 101); + assertVisibleCells(rendered, '0,1,10,11,2,3,4,5,6,7,8,9'); + simulateScroll(rendered, 1001); + assertVisibleCells(rendered, '26,27,28,29,30,31,32,33,34,35,36,37'); + }); + + it('should still render correctly when autoHeight is true (eg WindowScroller)', () => { + // Share instances between renders to avoid resetting state in ways we don't intend + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + + let rendered = findDOMNode( + render( + getMarkup({ + autoHeight: true, + cellMeasurerCache, + cellPositioner, + }), + ), + ); + assertVisibleCells(rendered, '0,1,2,3,4,5,6,7,8'); + rendered = findDOMNode( + render( + getMarkup({ + autoHeight: true, + cellMeasurerCache, + cellPositioner, + scrollTop: 51, + }), + ), + ); + assertVisibleCells(rendered, '0,1,2,3,4,5,6,7,8'); + rendered = findDOMNode( + render( + getMarkup({ + autoHeight: true, + cellMeasurerCache, + cellPositioner, + scrollTop: 101, + }), + ), + ); + assertVisibleCells(rendered, '0,2,3,4,5,6,7,8,9'); + rendered = findDOMNode( + render( + getMarkup({ + autoHeight: true, + cellMeasurerCache, + cellPositioner, + scrollTop: 1001, + }), + ), + ); + assertVisibleCells(rendered, '27,29,30,31,32,33,34,35,36'); + }); + + it('should set right instead of left in a cell styles for rtl row direction', () => { + // Share instances between renders to avoid resetting state in ways we don't intend + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = createCellPositioner(cellMeasurerCache); + + let rendered = findDOMNode( + render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + rowDirection: 'rtl', + }), + ), + ); + Array.from(rendered.querySelectorAll('.cell')).map(node => { + expect(node.style.right).toMatch(/px/); + }); + }); + + it('should consider scroll only of the container element and not of any ancestor element', () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderScrollableCell = index => ( +
+
{index}
+
+ ); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderScrollableCell, + ); + + const rendered = findDOMNode( + render( + getMarkup({ + overscanByPixels: 0, + cellMeasurerCache, + cellRenderer, + }), + ), + ); + assertVisibleCells(rendered, '0,1,2,3,4,5'); + const cellEl = rendered.querySelector('#scrollable-cell-1'); + Simulate.scroll(cellEl, {target: {scrollTop: 100}}); + assertVisibleCells(rendered, '0,1,2,3,4,5'); + }); + }); + + describe('recomputeCellPositions', () => { + it('should refresh all cell positions', () => { + // Share instances between renders to avoid resetting state in ways we don't intend + const cellMeasurerCache = createCellMeasurerCache(); + const cellPositioner = jest + .fn() + .mockImplementation(createCellPositioner(cellMeasurerCache)); + + let rendered = findDOMNode( + render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + }), + ), + ); + assertVisibleCells(rendered, '0,1,2,3,4,5,6,7,8'); + + cellPositioner.mockImplementation(index => ({ + left: 0, + top: index * CELL_SIZE_MULTIPLIER, + })); + + const component = render( + getMarkup({ + cellMeasurerCache, + cellPositioner, + }), + ); + rendered = findDOMNode(component); + assertVisibleCells(rendered, '0,1,2,3,4,5,6,7,8'); + component.recomputeCellPositions(); + assertVisibleCells(rendered, '0,1,2,3,4'); + }); + + it('should not reset measurement cache', () => { + const cellMeasurerCache = createCellMeasurerCache(); + const component = render(getMarkup({cellMeasurerCache})); + const rendered = findDOMNode(component); + simulateScroll(rendered, 101); + expect(cellMeasurerCache.has(9)).toBe(true); + simulateScroll(rendered, 0); + component.recomputeCellPositions(); + for (let i = 0; i <= 9; i++) { + expect(cellMeasurerCache.has(i)).toBe(true); + } + }); + }); + + describe('isScrolling', () => { + it('should be true for cellRenderer while scrolling is in progress', () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = jest.fn().mockImplementation(index => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback, + ); + const rendered = findDOMNode( + render(getMarkup({cellMeasurerCache, cellRenderer})), + ); + renderCallback.mockClear(); + simulateScroll(rendered, 51); + expect(renderCallback.mock.calls[0][1].isScrolling).toEqual(true); + }); + + it('should be reset after a small debounce when scrolling stops', () => { + const cellMeasurerCache = createCellMeasurerCache(); + const renderCallback = jest.fn().mockImplementation(index => index); + const cellRenderer = createCellRenderer( + cellMeasurerCache, + renderCallback, + ); + const rendered = findDOMNode( + render(getMarkup({cellMeasurerCache, cellRenderer})), + ); + simulateScroll(rendered, 51); + renderCallback.mockClear(); + setTimeout(() => { + expect(renderCallback.mock.calls[0][1].isScrolling).toEqual(false); + }, 0); + }); + }); + + describe('callbacks', () => { + it('should call onCellsRendered when rendered cells change', () => { + const onCellsRendered = jest.fn(); + const rendered = findDOMNode(render(getMarkup({onCellsRendered}))); + expect(onCellsRendered.mock.calls).toEqual([ + [{startIndex: 0, stopIndex: 8}], + ]); + simulateScroll(rendered, 51); + expect(onCellsRendered.mock.calls).toEqual([ + [{startIndex: 0, stopIndex: 8}], + ]); + simulateScroll(rendered, 101); + expect(onCellsRendered.mock.calls).toEqual([ + [{startIndex: 0, stopIndex: 8}], + [{startIndex: 0, stopIndex: 9}], + ]); + }); + + it('should call onScroll when scroll position changes', () => { + const onScroll = jest.fn(); + const rendered = findDOMNode(render(getMarkup({onScroll}))); + expect(onScroll.mock.calls).toEqual([ + [{clientHeight: 100, scrollHeight: 16900, scrollTop: 0}], + ]); + simulateScroll(rendered, 51); + expect(onScroll.mock.calls).toEqual([ + [{clientHeight: 100, scrollHeight: 16900, scrollTop: 0}], + [{clientHeight: 100, scrollHeight: 16900, scrollTop: 51}], + ]); + simulateScroll(rendered, 0); + expect(onScroll.mock.calls).toEqual([ + [{clientHeight: 100, scrollHeight: 16900, scrollTop: 0}], + [{clientHeight: 100, scrollHeight: 16900, scrollTop: 51}], + [{clientHeight: 100, scrollHeight: 16900, scrollTop: 0}], + ]); + }); + }); + + describe('keyMapper', () => { + it('should pass the correct key to rendered cells', () => { + const keyMapper = jest.fn().mockImplementation(index => `key:${index}`); + const cellRenderer = jest + .fn() + .mockImplementation(({index, key, style}) => ( +
+ {index} +
+ )); + findDOMNode(render(getMarkup({cellRenderer, keyMapper}))); + expect(keyMapper).toHaveBeenCalled(); + expect(cellRenderer).toHaveBeenCalled(); + expect(cellRenderer.mock.calls[0][0].key).toEqual('key:0'); + }); + }); +}); diff --git a/source/Masonry/Masonry.js b/source/Masonry/Masonry.js new file mode 100644 index 000000000..f5cf8657e --- /dev/null +++ b/source/Masonry/Masonry.js @@ -0,0 +1,490 @@ +/** @flow */ +import clsx from 'clsx'; +import * as React from 'react'; +import {polyfill} from 'react-lifecycles-compat'; +import PositionCache from './PositionCache'; +import { + requestAnimationTimeout, + cancelAnimationTimeout, +} from '../utils/requestAnimationTimeout'; + +import type {AnimationTimeoutId} from '../utils/requestAnimationTimeout'; + +type Props = { + autoHeight: boolean, + cellCount: number, + cellMeasurerCache: CellMeasurerCache, + cellPositioner: Positioner, + cellRenderer: CellRenderer, + className: ?string, + height: number, + id: ?string, + keyMapper: KeyMapper, + onCellsRendered: ?OnCellsRenderedCallback, + onScroll: ?OnScrollCallback, + overscanByPixels: number, + role: string, + scrollingResetTimeInterval: number, + style: mixed, + tabIndex: number, + width: number, + rowDirection: string, + scrollTop?: number, +}; + +type State = { + isScrolling: boolean, + scrollTop: number, +}; + +const emptyObject = {}; + +/** + * Specifies the number of milliseconds during which to disable pointer events while a scroll is in progress. + * This improves performance and makes scrolling smoother. + */ +export const DEFAULT_SCROLLING_RESET_TIME_INTERVAL = 150; + +/** + * This component efficiently displays arbitrarily positioned cells using windowing techniques. + * Cell position is determined by an injected `cellPositioner` property. + * Windowing is vertical; this component does not support horizontal scrolling. + * + * Rendering occurs in two phases: + * 1) First pass uses estimated cell sizes (provided by the cache) to determine how many cells to measure in a batch. + * Batch size is chosen using a fast, naive layout algorithm that stacks images in order until the viewport has been filled. + * After measurement is complete (componentDidMount or componentDidUpdate) this component evaluates positioned cells + * in order to determine if another measurement pass is required (eg if actual cell sizes were less than estimated sizes). + * All measurements are permanently cached (keyed by `keyMapper`) for performance purposes. + * 2) Second pass uses the external `cellPositioner` to layout cells. + * At this time the positioner has access to cached size measurements for all cells. + * The positions it returns are cached by Masonry for fast access later. + * Phase one is repeated if the user scrolls beyond the current layout's bounds. + * If the layout is invalidated due to eg a resize, cached positions can be cleared using `recomputeCellPositions()`. + * + * Animation constraints: + * Simple animations are supported (eg translate/slide into place on initial reveal). + * More complex animations are not (eg flying from one position to another on resize). + * + * Layout constraints: + * This component supports multi-column layout. + * The height of each item may vary. + * The width of each item must not exceed the width of the column it is "in". + * The left position of all items within a column must align. + * (Items may not span multiple columns.) + */ +class Masonry extends React.PureComponent { + static defaultProps = { + autoHeight: false, + keyMapper: identity, + onCellsRendered: noop, + onScroll: noop, + overscanByPixels: 20, + role: 'grid', + scrollingResetTimeInterval: DEFAULT_SCROLLING_RESET_TIME_INTERVAL, + style: emptyObject, + tabIndex: 0, + rowDirection: 'ltr', + }; + + state = { + isScrolling: false, + scrollTop: 0, + }; + + _debounceResetIsScrollingId: AnimationTimeoutId; + _invalidateOnUpdateStartIndex: ?number = null; + _invalidateOnUpdateStopIndex: ?number = null; + _positionCache: PositionCache = new PositionCache(); + _startIndex: ?number = null; + _startIndexMemoized: ?number = null; + _stopIndex: ?number = null; + _stopIndexMemoized: ?number = null; + + clearCellPositions() { + this._positionCache = new PositionCache(); + this.forceUpdate(); + } + + // HACK This method signature was intended for Grid + invalidateCellSizeAfterRender({rowIndex: index}) { + if (this._invalidateOnUpdateStartIndex === null) { + this._invalidateOnUpdateStartIndex = index; + this._invalidateOnUpdateStopIndex = index; + } else { + this._invalidateOnUpdateStartIndex = Math.min( + this._invalidateOnUpdateStartIndex, + index, + ); + this._invalidateOnUpdateStopIndex = Math.max( + this._invalidateOnUpdateStopIndex, + index, + ); + } + } + + recomputeCellPositions() { + const stopIndex = this._positionCache.count - 1; + + this._positionCache = new PositionCache(); + this._populatePositionCache(0, stopIndex); + + this.forceUpdate(); + } + + static getDerivedStateFromProps( + nextProps: Props, + prevState: State, + ): $Shape { + if ( + nextProps.scrollTop !== undefined && + prevState.scrollTop !== nextProps.scrollTop + ) { + return { + isScrolling: true, + scrollTop: nextProps.scrollTop, + }; + } + + return null; + } + + componentDidMount() { + this._checkInvalidateOnUpdate(); + this._invokeOnScrollCallback(); + this._invokeOnCellsRenderedCallback(); + } + + componentDidUpdate(prevProps: Props, prevState: State) { + this._checkInvalidateOnUpdate(); + this._invokeOnScrollCallback(); + this._invokeOnCellsRenderedCallback(); + + if (this.props.scrollTop !== prevProps.scrollTop) { + this._debounceResetIsScrolling(); + } + } + + componentWillUnmount() { + if (this._debounceResetIsScrollingId) { + cancelAnimationTimeout(this._debounceResetIsScrollingId); + } + } + + render() { + const { + autoHeight, + cellCount, + cellMeasurerCache, + cellRenderer, + className, + height, + id, + keyMapper, + overscanByPixels, + role, + style, + tabIndex, + width, + rowDirection, + } = this.props; + + const {isScrolling, scrollTop} = this.state; + + const children = []; + + const estimateTotalHeight = this._getEstimatedTotalHeight(); + + const shortestColumnSize = this._positionCache.shortestColumnSize; + const measuredCellCount = this._positionCache.count; + + let startIndex = 0; + let stopIndex; + + this._positionCache.range( + Math.max(0, scrollTop - overscanByPixels), + height + overscanByPixels * 2, + (index: number, left: number, top: number) => { + if (typeof stopIndex === 'undefined') { + startIndex = index; + stopIndex = index; + } else { + startIndex = Math.min(startIndex, index); + stopIndex = Math.max(stopIndex, index); + } + + children.push( + cellRenderer({ + index, + isScrolling, + key: keyMapper(index), + parent: this, + style: { + height: cellMeasurerCache.getHeight(index), + [rowDirection === 'ltr' ? 'left' : 'right']: left, + position: 'absolute', + top, + width: cellMeasurerCache.getWidth(index), + }, + }), + ); + }, + ); + + // We need to measure additional cells for this layout + if ( + shortestColumnSize < scrollTop + height + overscanByPixels && + measuredCellCount < cellCount + ) { + const batchSize = Math.min( + cellCount - measuredCellCount, + Math.ceil( + (((scrollTop + height + overscanByPixels - shortestColumnSize) / + cellMeasurerCache.defaultHeight) * + width) / + cellMeasurerCache.defaultWidth, + ), + ); + + for ( + let index = measuredCellCount; + index < measuredCellCount + batchSize; + index++ + ) { + stopIndex = index; + + children.push( + cellRenderer({ + index: index, + isScrolling, + key: keyMapper(index), + parent: this, + style: { + width: cellMeasurerCache.getWidth(index), + }, + }), + ); + } + } + + this._startIndex = startIndex; + this._stopIndex = stopIndex; + + return ( +
+
+ {children} +
+
+ ); + } + + _checkInvalidateOnUpdate() { + if (typeof this._invalidateOnUpdateStartIndex === 'number') { + const startIndex = this._invalidateOnUpdateStartIndex; + const stopIndex = this._invalidateOnUpdateStopIndex; + + this._invalidateOnUpdateStartIndex = null; + this._invalidateOnUpdateStopIndex = null; + + // Query external layout logic for position of newly-measured cells + this._populatePositionCache(startIndex, stopIndex); + + this.forceUpdate(); + } + } + + _debounceResetIsScrolling() { + const {scrollingResetTimeInterval} = this.props; + + if (this._debounceResetIsScrollingId) { + cancelAnimationTimeout(this._debounceResetIsScrollingId); + } + + this._debounceResetIsScrollingId = requestAnimationTimeout( + this._debounceResetIsScrollingCallback, + scrollingResetTimeInterval, + ); + } + + _debounceResetIsScrollingCallback = () => { + this.setState({ + isScrolling: false, + }); + }; + + _getEstimatedTotalHeight() { + const {cellCount, cellMeasurerCache, width} = this.props; + + const estimatedColumnCount = Math.max( + 1, + Math.floor(width / cellMeasurerCache.defaultWidth), + ); + + return this._positionCache.estimateTotalHeight( + cellCount, + estimatedColumnCount, + cellMeasurerCache.defaultHeight, + ); + } + + _invokeOnScrollCallback() { + const {height, onScroll} = this.props; + const {scrollTop} = this.state; + + if (this._onScrollMemoized !== scrollTop) { + onScroll({ + clientHeight: height, + scrollHeight: this._getEstimatedTotalHeight(), + scrollTop, + }); + + this._onScrollMemoized = scrollTop; + } + } + + _invokeOnCellsRenderedCallback() { + if ( + this._startIndexMemoized !== this._startIndex || + this._stopIndexMemoized !== this._stopIndex + ) { + const {onCellsRendered} = this.props; + + onCellsRendered({ + startIndex: this._startIndex, + stopIndex: this._stopIndex, + }); + + this._startIndexMemoized = this._startIndex; + this._stopIndexMemoized = this._stopIndex; + } + } + + _populatePositionCache(startIndex: number, stopIndex: number) { + const {cellMeasurerCache, cellPositioner} = this.props; + + for (let index = startIndex; index <= stopIndex; index++) { + const {left, top} = cellPositioner(index); + + this._positionCache.setPosition( + index, + left, + top, + cellMeasurerCache.getHeight(index), + ); + } + } + + _setScrollingContainerRef = ref => { + this._scrollingContainer = ref; + }; + + _onScroll = event => { + const {height} = this.props; + + const eventScrollTop = event.currentTarget.scrollTop; + + // When this component is shrunk drastically, React dispatches a series of back-to-back scroll events, + // Gradually converging on a scrollTop that is within the bounds of the new, smaller height. + // This causes a series of rapid renders that is slow for long lists. + // We can avoid that by doing some simple bounds checking to ensure that scroll offsets never exceed their bounds. + const scrollTop = Math.min( + Math.max(0, this._getEstimatedTotalHeight() - height), + eventScrollTop, + ); + + // On iOS, we can arrive at negative offsets by swiping past the start or end. + // Avoid re-rendering in this case as it can cause problems; see #532 for more. + if (eventScrollTop !== scrollTop) { + return; + } + + // Prevent pointer events from interrupting a smooth scroll + this._debounceResetIsScrolling(); + + // Certain devices (like Apple touchpad) rapid-fire duplicate events. + // Don't force a re-render if this is the case. + // The mouse may move faster then the animation frame does. + // Use requestAnimationFrame to avoid over-updating. + if (this.state.scrollTop !== scrollTop) { + this.setState({ + isScrolling: true, + scrollTop, + }); + } + }; +} + +function identity(value) { + return value; +} + +function noop() {} + +type KeyMapper = (index: number) => mixed; + +export type CellMeasurerCache = { + defaultHeight: number, + defaultWidth: number, + getHeight: (index: number) => number, + getWidth: (index: number) => number, +}; + +type CellRenderer = (params: {| + index: number, + isScrolling: boolean, + key: mixed, + parent: mixed, + style: mixed, +|}) => mixed; + +type OnCellsRenderedCallback = (params: {| + startIndex: number, + stopIndex: number, +|}) => void; + +type OnScrollCallback = (params: {| + clientHeight: number, + scrollHeight: number, + scrollTop: number, +|}) => void; + +type Position = { + left: number, + top: number, +}; + +polyfill(Masonry); + +export default Masonry; + +export type Positioner = (index: number) => Position; diff --git a/source/Masonry/PositionCache.js b/source/Masonry/PositionCache.js new file mode 100644 index 000000000..cc7801040 --- /dev/null +++ b/source/Masonry/PositionCache.js @@ -0,0 +1,86 @@ +/** @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; + } +} diff --git a/source/Masonry/createCellPositioner.js b/source/Masonry/createCellPositioner.js new file mode 100644 index 000000000..86a8ba26b --- /dev/null +++ b/source/Masonry/createCellPositioner.js @@ -0,0 +1,68 @@ +/** @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; +} diff --git a/source/Masonry/index.js b/source/Masonry/index.js new file mode 100644 index 000000000..609fc182e --- /dev/null +++ b/source/Masonry/index.js @@ -0,0 +1,6 @@ +/** @flow */ +import createCellPositioner from './createCellPositioner'; +import Masonry from './Masonry'; + +export default Masonry; +export {createCellPositioner, Masonry}; diff --git a/source/MultiGrid/CellMeasurerCacheDecorator.js b/source/MultiGrid/CellMeasurerCacheDecorator.js new file mode 100644 index 000000000..ac70c4af8 --- /dev/null +++ b/source/MultiGrid/CellMeasurerCacheDecorator.js @@ -0,0 +1,107 @@ +/** @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), + ); + } +} diff --git a/source/MultiGrid/MultiGrid.example.js b/source/MultiGrid/MultiGrid.example.js index c796a4c12..0f49a3506 100644 --- a/source/MultiGrid/MultiGrid.example.js +++ b/source/MultiGrid/MultiGrid.example.js @@ -1,79 +1,97 @@ /** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } 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' +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', - overflow: 'hidden' -} +}; const STYLE_BOTTOM_LEFT_GRID = { borderRight: '2px solid #aaa', - backgroundColor: '#f7f7f7' -} + backgroundColor: '#f7f7f7', +}; const STYLE_TOP_LEFT_GRID = { borderBottom: '2px solid #aaa', borderRight: '2px solid #aaa', - fontWeight: 'bold' -} + fontWeight: 'bold', +}; const STYLE_TOP_RIGHT_GRID = { borderBottom: '2px solid #aaa', - fontWeight: 'bold' -} + fontWeight: 'bold', +}; -export default class MultiGridExample extends PureComponent { +export default class MultiGridExample extends React.PureComponent { static contextTypes = { - list: PropTypes.instanceOf(Immutable.List).isRequired + list: PropTypes.instanceOf(Immutable.List).isRequired, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this.state = { fixedColumnCount: 2, fixedRowCount: 1, scrollToColumn: 0, - scrollToRow: 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') + 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 () { + render() { return ( - This component stitches together several grids to provide a fixed column/row interface. + 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( + 'fixedColumnCount', + this._onFixedColumnCountChange, + )} + {this._createLabeledInput( + 'fixedRowCount', + this._onFixedRowCountChange, + )} + {this._createLabeledInput( + 'scrollToColumn', + this._onScrollToColumnChange, + )} {this._createLabeledInput('scrollToRow', this._onScrollToRowChange)} - {({ width }) => ( + {({width}) => ( )} - ) + ); } - _cellRenderer ({ columnIndex, key, rowIndex, style }) { + _cellRenderer({columnIndex, key, rowIndex, style}) { return ( -
+
{columnIndex}, {rowIndex}
- ) + ); } - _createEventHandler (property) { - return (event) => { - const value = parseInt(event.target.value, 10) || 0 + _createEventHandler(property) { + return event => { + const value = parseInt(event.target.value, 10) || 0; this.setState({ - [property]: value - }) - } + [property]: value, + }); + }; } - _createLabeledInput (property, eventHandler) { - const value = this.state[property] + _createLabeledInput(property, eventHandler) { + const value = this.state[property]; return ( - ) + ); } } diff --git a/source/MultiGrid/MultiGrid.jest.js b/source/MultiGrid/MultiGrid.jest.js index 37023dbd3..06d352556 100644 --- a/source/MultiGrid/MultiGrid.jest.js +++ b/source/MultiGrid/MultiGrid.jest.js @@ -1,24 +1,21 @@ -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import MultiGrid from './MultiGrid' +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 }) { + function defaultCellRenderer({columnIndex, key, rowIndex, style}) { return ( -
+
{`row:${rowIndex}, column:${columnIndex}`}
- ) + ); } - function getMarkup (props = {}) { + function getMarkup(props = {}) { return ( { width={400} {...props} /> - ) + ); } 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') - }) + 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') - }) + 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') - }) + 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') - }) + 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) - }) - }) + 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 + 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 + 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') - }) - }) + 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 - }))) + 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] + const grid = rendered.querySelectorAll('.ReactVirtualized__Grid')[3]; // 20th column, less 2 for the fixed-column Grid, 50px column width - expect(grid.scrollLeft).toEqual(850) - }) + 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 - }))) + 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] + const grid = rendered.querySelectorAll('.ReactVirtualized__Grid')[3]; // 20th row, less 1 for the fixed-row Grid, 50px row width - expect(grid.scrollTop).toEqual(900) - }) - }) + expect(grid.scrollTop).toEqual(900); + }); + }); describe('#forceUpdateGrids', () => { it('should call forceUpdate() on inner Grids', () => { - const cellRenderer = jest.fn() - cellRenderer.mockImplementation(({ key }) =>
) + const cellRenderer = jest.fn(); + cellRenderer.mockImplementation(({key}) =>
); - const rendered = render(getMarkup({ - cellRenderer, - columnCount: 2, - fixedColumnCount: 1, - fixedRowCount: 1, - rowCount: 2 - })) + const rendered = render( + getMarkup({ + cellRenderer, + columnCount: 2, + fixedColumnCount: 1, + fixedRowCount: 1, + rowCount: 2, + }), + ); - expect(cellRenderer.mock.calls).toHaveLength(4) + expect(cellRenderer.mock.calls).toHaveLength(4); - cellRenderer.mockReset() - rendered.forceUpdateGrids() + cellRenderer.mockReset(); + rendered.forceUpdateGrids(); - expect(cellRenderer.mock.calls).toHaveLength(4) - }) - }) + 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() + 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 - }) + rowIndex: 0, + }); - rendered.forceUpdate() + rendered.forceUpdate(); - expect(cellRenderer.mock.calls).toHaveLength(4) - }) + 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
+ let savedParent; + function cellRenderer({key, parent}) { + savedParent = parent; + return
; } - const rendered = render(getMarkup({ - cellRenderer, - columnCount: 2, - fixedColumnCount: 1, - fixedRowCount: 1, - rowCount: 2 - })) + const rendered = render( + getMarkup({ + cellRenderer, + columnCount: 2, + fixedColumnCount: 1, + fixedRowCount: 1, + rowCount: 2, + }), + ); - expect(savedParent).toBe(rendered) - }) - }) + 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') - }) + 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') - }) - }) + 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) - }) + 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) - }) + 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) - }) - }) -}) + render(getMarkup()); + const rendered = findDOMNode( + render( + getMarkup({ + scrollTop: 750, + scrollLeft: 900, + }), + ), + ); + const grids = rendered.querySelectorAll('.ReactVirtualized__Grid'); + const topRightGrid = grids[1]; + const bottomLeftGrid = grids[2]; + const bottomRightGrid = grids[3]; + expect(topRightGrid.scrollLeft).toEqual(900); + expect(bottomRightGrid.scrollLeft).toEqual(900); + expect(bottomLeftGrid.scrollTop).toEqual(750); + expect(bottomRightGrid.scrollTop).toEqual(750); + }); + + it('should not crash when decreasing :rowCount', () => { + render(getMarkup()); + const updated = render( + getMarkup({ + rowCount: 2, + }), + ); + expect(updated.props.rowCount).toEqual(2); + }); + + it('should not crash when decreasing :columnCount', () => { + render(getMarkup()); + const updated = render( + getMarkup({ + columnCount: 3, + }), + ); + expect(updated.props.columnCount).toEqual(3); + }); + }); + + describe('deferredMeasurementCache', () => { + function getDeferredMeasurementCache() { + const deferredMeasurementCache = new CellMeasurerCache({ + fixedHeight: true, + fixedWidth: true, + }); + + deferredMeasurementCache._columnIndices = {}; + deferredMeasurementCache._rowIndices = {}; + deferredMeasurementCache.has = (rowIndex, columnIndex) => { + deferredMeasurementCache._columnIndices[columnIndex] = columnIndex; + deferredMeasurementCache._rowIndices[rowIndex] = rowIndex; + return true; + }; + + return deferredMeasurementCache; + } + + it('should wrap top-right and bottom-right deferredMeasurementCache if fixedColumnCount is > 0', () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + render( + getMarkup({ + deferredMeasurementCache: deferredMeasurementCache, + columnCount: 3, + fixedColumnCount: 1, + fixedRowCount: 0, + rowCount: 1, + }), + ); + + expect(Object.keys(deferredMeasurementCache._columnIndices)).toEqual([ + '0', + '1', + '2', + ]); + }); + + it('should not wrap top-right and bottom-right deferredMeasurementCache if fixedColumnCount is 0', () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + render( + getMarkup({ + deferredMeasurementCache: deferredMeasurementCache, + columnCount: 2, + fixedColumnCount: 0, + fixedRowCount: 0, + rowCount: 1, + }), + ); + + expect(Object.keys(deferredMeasurementCache._columnIndices)).toEqual([ + '0', + '1', + ]); + }); + + it('should wrap bottom-left and bottom-right deferredMeasurementCache if fixedRowCount is > 0', () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + render( + getMarkup({ + deferredMeasurementCache: deferredMeasurementCache, + columnCount: 1, + fixedColumnCount: 0, + fixedRowCount: 1, + rowCount: 3, + }), + ); + + expect(Object.keys(deferredMeasurementCache._rowIndices)).toEqual([ + '0', + '1', + '2', + ]); + }); + + it('should not wrap bottom-left and bottom-right deferredMeasurementCache if fixedRowCount is 0', () => { + const deferredMeasurementCache = getDeferredMeasurementCache(); + render( + getMarkup({ + deferredMeasurementCache: deferredMeasurementCache, + columnCount: 1, + fixedColumnCount: 0, + fixedRowCount: 0, + rowCount: 2, + }), + ); + + expect(Object.keys(deferredMeasurementCache._rowIndices)).toEqual([ + '0', + '1', + ]); + }); + }); + + describe('onScrollbarPresenceChange', () => { + function getScrollbarSize20() { + return 20; + } + + it('should not trigger on-mount if scrollbars are hidden', () => { + const onScrollbarPresenceChange = jest.fn(); + + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1, + }), + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + }); + + it('should trigger on-mount if scrollbars are visible', () => { + const onScrollbarPresenceChange = jest.fn(); + + render( + getMarkup({ + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100, + }), + ); + expect(onScrollbarPresenceChange).toHaveBeenCalled(); + + const args = onScrollbarPresenceChange.mock.calls[0][0]; + expect(args.horizontal).toBe(true); + expect(args.size).toBe(getScrollbarSize20()); + expect(args.vertical).toBe(true); + }); + + it('should trigger on-update if scrollbar visibility has changed', () => { + const onScrollbarPresenceChange = jest.fn(); + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1, + }), + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + + render( + getMarkup({ + columnCount: 100, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 100, + }), + ); + expect(onScrollbarPresenceChange).toHaveBeenCalled(); + + const args = onScrollbarPresenceChange.mock.calls[0][0]; + expect(args.horizontal).toBe(true); + expect(args.size).toBe(getScrollbarSize20()); + expect(args.vertical).toBe(true); + }); + + it('should not trigger on-update if scrollbar visibility does not change', () => { + const onScrollbarPresenceChange = jest.fn(); + render( + getMarkup({ + columnCount: 1, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 1, + }), + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + render( + getMarkup({ + columnCount: 2, + getScrollbarSize: getScrollbarSize20, + onScrollbarPresenceChange, + rowCount: 2, + }), + ); + expect(onScrollbarPresenceChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/source/MultiGrid/MultiGrid.js b/source/MultiGrid/MultiGrid.js index 9d4c469f2..4a1eb8e99 100644 --- a/source/MultiGrid/MultiGrid.js +++ b/source/MultiGrid/MultiGrid.js @@ -1,8 +1,11 @@ /** @flow */ -import React, { PropTypes, PureComponent } from 'react' -import Grid from '../Grid' +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 +const SCROLLBAR_SIZE_BUFFER = 20; /** * Renders 1, 2, or 4 Grids depending on configuration. @@ -11,204 +14,221 @@ const SCROLLBAR_SIZE_BUFFER = 20 * If no sticky columns, only 1 sticky header Grid will be rendered. * If sticky columns, 2 sticky header Grids will be rendered. */ -export default class MultiGrid extends PureComponent { +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 + 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: {} + styleTopRightGrid: {}, + hideTopRightGridScrollbar: false, + hideBottomLeftGridScrollbar: false, }; - constructor (props, context) { - super(props, context) + state = { + scrollLeft: 0, + scrollTop: 0, + scrollbarSize: 0, + showHorizontalScrollbar: false, + showVerticalScrollbar: false, + }; - this.state = { - scrollLeft: 0, - scrollTop: 0 + _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; } - - this._deferredInvalidateColumnIndex = null - this._deferredInvalidateRowIndex = null - - this._bottomLeftGridRef = this._bottomLeftGridRef.bind(this) - this._bottomRightGridRef = this._bottomRightGridRef.bind(this) - this._cellRendererBottomLeftGrid = this._cellRendererBottomLeftGrid.bind(this) - this._cellRendererBottomRightGrid = this._cellRendererBottomRightGrid.bind(this) - this._cellRendererTopRightGrid = this._cellRendererTopRightGrid.bind(this) - this._columnWidthRightGrid = this._columnWidthRightGrid.bind(this) - this._onScroll = this._onScroll.bind(this) - this._rowHeightBottomGrid = this._rowHeightBottomGrid.bind(this) - this._topLeftGridRef = this._topLeftGridRef.bind(this) - this._topRightGridRef = this._topRightGridRef.bind(this) } - forceUpdateGrids () { - this._bottomLeftGrid && this._bottomLeftGrid.forceUpdate() - this._bottomRightGrid && this._bottomRightGrid.forceUpdate() - this._topLeftGrid && this._topLeftGrid.forceUpdate() - this._topRightGrid && this._topRightGrid.forceUpdate() + 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 + 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() + 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(null, this.props, null, this.state) - } + recomputeGridSize({columnIndex = 0, rowIndex = 0} = {}) { + const {fixedColumnCount, fixedRowCount} = this.props; - componentDidMount () { - const { scrollLeft, scrollTop } = this.props - - if (scrollLeft > 0 || scrollTop > 0) { - const newState = {} + const adjustedColumnIndex = Math.max(0, columnIndex - fixedColumnCount); + const adjustedRowIndex = Math.max(0, rowIndex - fixedRowCount); - if (scrollLeft > 0) { - newState.scrollLeft = scrollLeft - } - - if (scrollTop > 0) { - newState.scrollTop = scrollTop - } - - this.setState(newState) - } - this._handleInvalidatedGridSize() - } - - componentDidUpdate (prevProps, prevState) { - this._handleInvalidatedGridSize() - } - - componentWillMount () { - this._maybeCalculateCachedStyles(null, this.props, null, this.state) + 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); } - componentWillReceiveProps (nextProps, nextState) { - const { columnWidth, fixedColumnCount, fixedRowCount, rowHeight } = this.props - + static getDerivedStateFromProps(nextProps, prevState) { if ( - columnWidth !== nextProps.columnWidth || - fixedColumnCount !== nextProps.fixedColumnCount + nextProps.scrollLeft !== prevState.scrollLeft || + nextProps.scrollTop !== prevState.scrollTop ) { - this._leftGridWidth = null + return { + scrollLeft: + nextProps.scrollLeft != null && nextProps.scrollLeft >= 0 + ? nextProps.scrollLeft + : prevState.scrollLeft, + scrollTop: + nextProps.scrollTop != null && nextProps.scrollTop >= 0 + ? nextProps.scrollTop + : prevState.scrollTop, + }; } - if ( - fixedRowCount !== nextProps.fixedRowCount || - rowHeight !== nextProps.rowHeight - ) { - this._topGridHeight = null - } + return null; + } - if ( - nextProps.scrollLeft !== this.props.scrollLeft || - nextProps.scrollTop !== this.props.scrollTop - ) { - const newState = {} + componentDidMount() { + const {scrollLeft, scrollTop} = this.props; - if ( - nextProps.scrollLeft != null && - nextProps.scrollLeft >= 0 - ) { - newState.scrollLeft = nextProps.scrollLeft + if (scrollLeft > 0 || scrollTop > 0) { + const newState = {}; + + if (scrollLeft > 0) { + newState.scrollLeft = scrollLeft; } - if ( - nextProps.scrollTop != null && - nextProps.scrollTop >= 0 - ) { - newState.scrollTop = nextProps.scrollTop + if (scrollTop > 0) { + newState.scrollTop = scrollTop; } - this.setState(newState) + this.setState(newState); } + this._handleInvalidatedGridSize(); + } - this._maybeCalculateCachedStyles(this.props, nextProps, this.state, nextState) + componentDidUpdate() { + this._handleInvalidatedGridSize(); } - render () { + 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.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 + return null; } // scrollTop and scrollLeft props are explicitly filtered out and ignored - const { - scrollLeft, - scrollTop - } = this.state + const {scrollLeft, scrollTop} = this.state; return (
@@ -216,13 +236,15 @@ export default class MultiGrid extends PureComponent { {this._renderTopLeftGrid(rest)} {this._renderTopRightGrid({ ...rest, - scrollLeft + onScroll, + scrollLeft, })}
{this._renderBottomLeftGrid({ ...rest, - scrollTop + onScroll, + scrollTop, })} {this._renderBottomRightGrid({ ...rest, @@ -231,27 +253,23 @@ export default class MultiGrid extends PureComponent { scrollLeft, scrollToColumn, scrollToRow, - scrollTop + scrollTop, })}
- ) + ); } - _bottomLeftGridRef (ref) { - this._bottomLeftGrid = ref - } + _bottomLeftGridRef = ref => { + this._bottomLeftGrid = ref; + }; - _bottomRightGridRef (ref) { - this._bottomRightGrid = ref - } + _bottomRightGridRef = ref => { + this._bottomRightGrid = ref; + }; - _cellRendererBottomLeftGrid ({ rowIndex, ...rest }) { - const { - cellRenderer, - fixedRowCount, - rowCount - } = this.props + _cellRendererBottomLeftGrid = ({rowIndex, ...rest}) => { + const {cellRenderer, fixedRowCount, rowCount} = this.props; if (rowIndex === rowCount - fixedRowCount) { return ( @@ -259,36 +277,32 @@ export default class MultiGrid extends PureComponent { key={rest.key} style={{ ...rest.style, - height: SCROLLBAR_SIZE_BUFFER + height: SCROLLBAR_SIZE_BUFFER, }} /> - ) + ); } else { return cellRenderer({ ...rest, parent: this, - rowIndex: rowIndex + fixedRowCount - }) + rowIndex: rowIndex + fixedRowCount, + }); } - } + }; - _cellRendererBottomRightGrid ({ columnIndex, rowIndex, ...rest }) { - const { cellRenderer, fixedColumnCount, fixedRowCount } = this.props + _cellRendererBottomRightGrid = ({columnIndex, rowIndex, ...rest}) => { + const {cellRenderer, fixedColumnCount, fixedRowCount} = this.props; return cellRenderer({ ...rest, columnIndex: columnIndex + fixedColumnCount, parent: this, - rowIndex: rowIndex + fixedRowCount - }) - } + rowIndex: rowIndex + fixedRowCount, + }); + }; - _cellRendererTopRightGrid ({ columnIndex, ...rest }) { - const { - cellRenderer, - columnCount, - fixedColumnCount - } = this.props + _cellRendererTopRightGrid = ({columnIndex, ...rest}) => { + const {cellRenderer, columnCount, fixedColumnCount} = this.props; if (columnIndex === columnCount - fixedColumnCount) { return ( @@ -296,104 +310,105 @@ export default class MultiGrid extends PureComponent { key={rest.key} style={{ ...rest.style, - width: SCROLLBAR_SIZE_BUFFER + width: SCROLLBAR_SIZE_BUFFER, }} /> - ) + ); } else { return cellRenderer({ ...rest, columnIndex: columnIndex + fixedColumnCount, - parent: this - }) + parent: this, + }); } - } + }; - _columnWidthRightGrid ({ index }) { - const { columnCount, fixedColumnCount, columnWidth } = this.props + _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 (index === columnCount - fixedColumnCount) { - return SCROLLBAR_SIZE_BUFFER + if (showHorizontalScrollbar && index === columnCount - fixedColumnCount) { + return scrollbarSize; } return typeof columnWidth === 'function' - ? columnWidth({ index: index + fixedColumnCount }) - : columnWidth - } + ? columnWidth({index: index + fixedColumnCount}) + : columnWidth; + }; - _getBottomGridHeight (props) { - const { height } = props + _getBottomGridHeight(props) { + const {height} = props; - let topGridHeight = this._getTopGridHeight(props) + let topGridHeight = this._getTopGridHeight(props); - return height - topGridHeight + return height - topGridHeight; } - _getLeftGridWidth (props) { - const { fixedColumnCount, columnWidth } = props + _getLeftGridWidth(props) { + const {fixedColumnCount, columnWidth} = props; if (this._leftGridWidth == null) { if (typeof columnWidth === 'function') { - let leftGridWidth = 0 + let leftGridWidth = 0; for (let index = 0; index < fixedColumnCount; index++) { - leftGridWidth += columnWidth({ index }) + leftGridWidth += columnWidth({index}); } - this._leftGridWidth = leftGridWidth + this._leftGridWidth = leftGridWidth; } else { - this._leftGridWidth = columnWidth * fixedColumnCount + this._leftGridWidth = columnWidth * fixedColumnCount; } } - return this._leftGridWidth + return this._leftGridWidth; } - _getRightGridWidth (props) { - const { width } = props + _getRightGridWidth(props) { + const {width} = props; - let leftGridWidth = this._getLeftGridWidth(props) + let leftGridWidth = this._getLeftGridWidth(props); - return width - leftGridWidth + return width - leftGridWidth; } - _getTopGridHeight (props) { - const { fixedRowCount, rowHeight } = props + _getTopGridHeight(props) { + const {fixedRowCount, rowHeight} = props; if (this._topGridHeight == null) { if (typeof rowHeight === 'function') { - let topGridHeight = 0 + let topGridHeight = 0; for (let index = 0; index < fixedRowCount; index++) { - topGridHeight += rowHeight({ index }) + topGridHeight += rowHeight({index}); } - this._topGridHeight = topGridHeight + this._topGridHeight = topGridHeight; } else { - this._topGridHeight = rowHeight * fixedRowCount + this._topGridHeight = rowHeight * fixedRowCount; } } - return this._topGridHeight + return this._topGridHeight; } - _handleInvalidatedGridSize () { + _handleInvalidatedGridSize() { if (typeof this._deferredInvalidateColumnIndex === 'number') { - const columnIndex = this._deferredInvalidateColumnIndex - const rowIndex = this._deferredInvalidateRowIndex + const columnIndex = this._deferredInvalidateColumnIndex; + const rowIndex = this._deferredInvalidateRowIndex; - this._deferredInvalidateColumnIndex = null - this._deferredInvalidateRowIndex = null + this._deferredInvalidateColumnIndex = null; + this._deferredInvalidateRowIndex = null; this.recomputeGridSize({ columnIndex, - rowIndex - }) - this.forceUpdate() + rowIndex, + }); + this.forceUpdate(); } } @@ -401,9 +416,11 @@ export default class MultiGrid extends PureComponent { * Avoid recreating inline styles each render; this bypasses Grid's shallowCompare. * This method recalculates styles only when specific props change. */ - _maybeCalculateCachedStyles (prevProps, props, prevState, state) { + _maybeCalculateCachedStyles(resetAll) { const { columnWidth, + enableFixedColumnScroll, + enableFixedRowScroll, height, fixedColumnCount, fixedRowCount, @@ -413,174 +430,263 @@ export default class MultiGrid extends PureComponent { styleBottomRightGrid, styleTopLeftGrid, styleTopRightGrid, - width - } = props - - const firstRender = !prevProps - const sizeChange = ( - firstRender || - height !== prevProps.height || - width !== prevProps.width - ) - const leftSizeChange = ( - firstRender || - columnWidth !== prevProps.columnWidth || - fixedColumnCount !== prevProps.fixedColumnCount - ) - const topSizeChange = ( - firstRender || - fixedRowCount !== prevProps.fixedRowCount || - rowHeight !== prevProps.rowHeight - ) - - if ( - firstRender || - sizeChange || - style !== prevProps.style - ) { + 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 - } + ...style, + }; } - if ( - firstRender || - sizeChange || - topSizeChange - ) { + if (resetAll || sizeChange || topSizeChange) { this._containerTopStyle = { - height: this._getTopGridHeight(props), + height: this._getTopGridHeight(this.props), position: 'relative', - width - } + width, + }; this._containerBottomStyle = { - height: height - this._getTopGridHeight(props), - overflow: 'hidden', + height: height - this._getTopGridHeight(this.props), + overflow: 'visible', // Let :focus outline show through position: 'relative', - width - } + width, + }; } if ( - firstRender || - styleBottomLeftGrid !== prevProps.styleBottomLeftGrid + resetAll || + styleBottomLeftGrid !== this._lastRenderedStyleBottomLeftGrid ) { this._bottomLeftGridStyle = { left: 0, - outline: 0, overflowX: 'hidden', - overflowY: 'hidden', + overflowY: enableFixedColumnScroll ? 'auto' : 'hidden', position: 'absolute', - ...styleBottomLeftGrid - } + ...styleBottomLeftGrid, + }; } if ( - firstRender || + resetAll || leftSizeChange || - styleBottomRightGrid !== prevProps.styleBottomRightGrid + styleBottomRightGrid !== this._lastRenderedStyleBottomRightGrid ) { this._bottomRightGridStyle = { - left: this._getLeftGridWidth(props), - outline: 0, + left: this._getLeftGridWidth(this.props), position: 'absolute', - ...styleBottomRightGrid - } + ...styleBottomRightGrid, + }; } - if ( - firstRender || - styleTopLeftGrid !== prevProps.styleTopLeftGrid - ) { + if (resetAll || styleTopLeftGrid !== this._lastRenderedStyleTopLeftGrid) { this._topLeftGridStyle = { left: 0, - outline: 0, overflowX: 'hidden', overflowY: 'hidden', position: 'absolute', top: 0, - ...styleTopLeftGrid - } + ...styleTopLeftGrid, + }; } if ( - firstRender || + resetAll || leftSizeChange || - styleTopRightGrid !== prevProps.styleTopRightGrid + styleTopRightGrid !== this._lastRenderedStyleTopRightGrid ) { this._topRightGridStyle = { - left: this._getLeftGridWidth(props), - outline: 0, - overflowX: 'hidden', + left: this._getLeftGridWidth(this.props), + overflowX: enableFixedRowScroll ? 'auto' : 'hidden', overflowY: 'hidden', position: 'absolute', top: 0, - ...styleTopRightGrid - } + ...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; } - _onScroll (scrollInfo) { - const {scrollLeft, scrollTop} = scrollInfo + _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 + scrollTop, + }); + const onScroll = this.props.onScroll; if (onScroll) { - onScroll(scrollInfo) + onScroll(scrollInfo); } - } + }; + + _onScrollbarPresenceChange = ({horizontal, size, vertical}) => { + const {showHorizontalScrollbar, showVerticalScrollbar} = this.state; - _renderBottomLeftGrid (props) { + 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, - scrollTop - } = props + hideBottomLeftGridScrollbar, + } = props; + const {showVerticalScrollbar} = this.state; if (!fixedColumnCount) { - return null + return null; } - return ( + 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) { + _renderBottomRightGrid(props) { const { columnCount, fixedColumnCount, fixedRowCount, rowCount, scrollToColumn, - scrollToRow - } = props + scrollToRow, + } = props; return ( - ) + ); } - _renderTopLeftGrid (props) { - const { - fixedColumnCount, - fixedRowCount - } = props + _renderTopLeftGrid(props) { + const {fixedColumnCount, fixedRowCount} = props; if (!fixedColumnCount || !fixedRowCount) { - return null + return null; } return ( - ) + ); } - _renderTopRightGrid (props) { + _renderTopRightGrid(props) { const { columnCount, + enableFixedRowScroll, fixedColumnCount, fixedRowCount, - scrollLeft - } = props + scrollLeft, + hideTopRightGridScrollbar, + } = props; + const {showHorizontalScrollbar, scrollbarSize} = this.state; if (!fixedRowCount) { - return null + return null; } - return ( + 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 + _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 (index === rowCount - fixedRowCount) { - return SCROLLBAR_SIZE_BUFFER + if (showVerticalScrollbar && index === rowCount - fixedRowCount) { + return scrollbarSize; } return typeof rowHeight === 'function' - ? rowHeight({ index: index + fixedRowCount }) - : rowHeight - } + ? rowHeight({index: index + fixedRowCount}) + : rowHeight; + }; - _topLeftGridRef (ref) { - this._topLeftGrid = ref - } + _topLeftGridRef = ref => { + this._topLeftGrid = ref; + }; - _topRightGridRef (ref) { - this._topRightGrid = ref - } + _topRightGridRef = ref => { + this._topRightGrid = ref; + }; } + +polyfill(MultiGrid); + +export default MultiGrid; diff --git a/source/MultiGrid/index.js b/source/MultiGrid/index.js index afd25cbe9..0e20bb3b5 100644 --- a/source/MultiGrid/index.js +++ b/source/MultiGrid/index.js @@ -1,3 +1,5 @@ /** @flow */ -export default from './MultiGrid' -export MultiGrid from './MultiGrid' +import MultiGrid from './MultiGrid'; + +export default MultiGrid; +export {MultiGrid}; diff --git a/source/ScrollSync/ScrollSync.example.js b/source/ScrollSync/ScrollSync.example.js index b6f857a50..f24c872c3 100644 --- a/source/ScrollSync/ScrollSync.example.js +++ b/source/ScrollSync/ScrollSync.example.js @@ -1,21 +1,25 @@ /** @flow */ -import React, { PureComponent } from 'react' -import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' -import AutoSizer from '../AutoSizer' -import Grid from '../Grid' -import ScrollSync from './ScrollSync' -import cn from 'classnames' -import styles from './ScrollSync.example.css' -import scrollbarSize from 'dom-helpers/util/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 PureComponent { - constructor (props, context) { - super(props, context) +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, @@ -24,15 +28,15 @@ export default class GridExample extends PureComponent { overscanColumnCount: 0, overscanRowCount: 5, rowHeight: 40, - rowCount: 100 - } + rowCount: 100, + }; - this._renderBodyCell = this._renderBodyCell.bind(this) - this._renderHeaderCell = this._renderHeaderCell.bind(this) - this._renderLeftSideCell = this._renderLeftSideCell.bind(this) + this._renderBodyCell = this._renderBodyCell.bind(this); + this._renderHeaderCell = this._renderHeaderCell.bind(this); + this._renderLeftSideCell = this._renderLeftSideCell.bind(this); } - render () { + render() { const { columnCount, columnWidth, @@ -40,37 +44,60 @@ export default class GridExample extends PureComponent { overscanColumnCount, overscanRowCount, rowHeight, - rowCount - } = this.state + rowCount, + } = this.state; return ( - High order component that simplifies the process of synchronizing scrolling between two or more virtualized components. + 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. + 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) + {({ + 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' + 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 (
@@ -81,9 +108,8 @@ export default class GridExample extends PureComponent { left: 0, top: 0, color: leftColor, - backgroundColor: `rgb(${topBackgroundColor.r},${topBackgroundColor.g},${topBackgroundColor.b})` - }} - > + backgroundColor: `rgb(${topBackgroundColor.r},${topBackgroundColor.g},${topBackgroundColor.b})`, + }}> + backgroundColor: `rgb(${leftBackgroundColor.r},${leftBackgroundColor.g},${leftBackgroundColor.b})`, + }}>
- {({ width }) => ( + {({width}) => (
-
+
+ width, + }}>
- ) + ); }} - ) + ); } - _renderBodyCell ({ columnIndex, key, rowIndex, style }) { + _renderBodyCell({columnIndex, key, rowIndex, style}) { if (columnIndex < 1) { - return + return; } - return this._renderLeftSideCell({ columnIndex, key, rowIndex, style }) + return this._renderLeftSideCell({columnIndex, key, rowIndex, style}); } - _renderHeaderCell ({ columnIndex, key, rowIndex, style }) { + _renderHeaderCell({columnIndex, key, rowIndex, style}) { if (columnIndex < 1) { - return + return; } - return this._renderLeftHeaderCell({ columnIndex, key, rowIndex, style }) + return this._renderLeftHeaderCell({columnIndex, key, rowIndex, style}); } - _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 = cn(rowClass, styles.cell) + _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 +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 +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) + 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 } + return {r, g, b}; } diff --git a/source/ScrollSync/ScrollSync.jest.js b/source/ScrollSync/ScrollSync.jest.js index 22518d9a4..f987a2f10 100644 --- a/source/ScrollSync/ScrollSync.jest.js +++ b/source/ScrollSync/ScrollSync.jest.js @@ -1,9 +1,16 @@ -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import ScrollSync from './ScrollSync' +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 }) { +function ChildComponent({ + clientHeight, + clientWidth, + scrollHeight, + scrollLeft, + scrollTop, + scrollWidth, +}) { return (
{`clientHeight:${clientHeight}`} @@ -13,14 +20,21 @@ function ChildComponent ({ clientHeight, clientWidth, scrollHeight, 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 }) => ( + {({ + clientHeight, + clientWidth, + scrollHeight, + scrollLeft, + scrollTop, + scrollWidth, + }) => ( { scrollWidth={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') - }) + , + ); + 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 + let onScroll; const component = render( - {(params) => { - onScroll = params.onScroll - return + {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') - }) -}) + scrollWidth: 500, + }); + expect(findDOMNode(component).textContent).toContain('clientHeight:400'); + expect(findDOMNode(component).textContent).toContain('clientWidth:200'); + expect(findDOMNode(component).textContent).toContain('scrollHeight:1000'); + expect(findDOMNode(component).textContent).toContain('scrollLeft:50'); + expect(findDOMNode(component).textContent).toContain('scrollTop:100'); + expect(findDOMNode(component).textContent).toContain('scrollWidth:500'); + }); +}); diff --git a/source/ScrollSync/ScrollSync.js b/source/ScrollSync/ScrollSync.js index 9495fa1a3..93e030699 100644 --- a/source/ScrollSync/ScrollSync.js +++ b/source/ScrollSync/ScrollSync.js @@ -1,20 +1,21 @@ -import { PropTypes, PureComponent } from 'react' +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 PureComponent { +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 + children: PropTypes.func.isRequired, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); this.state = { clientHeight: 0, @@ -22,15 +23,22 @@ export default class ScrollSync extends PureComponent { scrollHeight: 0, scrollLeft: 0, scrollTop: 0, - scrollWidth: 0 - } + scrollWidth: 0, + }; - this._onScroll = this._onScroll.bind(this) + this._onScroll = this._onScroll.bind(this); } - render () { - const { children } = this.props - const { clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth } = this.state + render() { + const {children} = this.props; + const { + clientHeight, + clientWidth, + scrollHeight, + scrollLeft, + scrollTop, + scrollWidth, + } = this.state; return children({ clientHeight, @@ -39,11 +47,25 @@ export default class ScrollSync extends PureComponent { scrollHeight, scrollLeft, scrollTop, - scrollWidth - }) + scrollWidth, + }); } - _onScroll ({ clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth }) { - this.setState({ clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth }) + _onScroll({ + clientHeight, + clientWidth, + scrollHeight, + scrollLeft, + scrollTop, + scrollWidth, + }) { + this.setState({ + clientHeight, + clientWidth, + scrollHeight, + scrollLeft, + scrollTop, + scrollWidth, + }); } } diff --git a/source/ScrollSync/index.js b/source/ScrollSync/index.js index 43c0923bf..02cf7039c 100644 --- a/source/ScrollSync/index.js +++ b/source/ScrollSync/index.js @@ -1,3 +1,5 @@ /** @flow */ -export default from './ScrollSync' -export ScrollSync from './ScrollSync' +import ScrollSync from './ScrollSync'; + +export default ScrollSync; +export {ScrollSync}; diff --git a/source/Table/Column.jest.js b/source/Table/Column.jest.js index 61df6127f..f448c6922 100644 --- a/source/Table/Column.jest.js +++ b/source/Table/Column.jest.js @@ -1,62 +1,112 @@ -import Immutable from 'immutable' -import defaultCellDataGetter from './defaultCellDataGetter' -import defaultCellRenderer from './defaultCellRenderer' +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 - }) + 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) - }) + 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) - }) - }) + 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') - }) + 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('') - }) - }) -}) + expect( + defaultCellRenderer({ + cellData: null, + dataKey: 'baz', + rowData, + rowIndex: 0, + }), + ).toEqual(''); + expect( + defaultCellRenderer({ + cellData: undefined, + dataKey: 'baz', + rowData, + rowIndex: 0, + }), + ).toEqual(''); + }); + }); + + describe('defaultHeaderRenderer', () => { + it('should render a value for specified attributes', () => { + expect( + defaultHeaderRenderer({ + dataKey: 'foo', + label: 'squirrel', + })[0].props.children, + ).toEqual('squirrel'); + + const label =
Rabbit
; + expect( + defaultHeaderRenderer({ + dataKey: 'bar', + label: label, + })[0].props.children, + ).toEqual(label); + }); + + it('should render empty string for null or missing attributes', () => { + expect( + defaultHeaderRenderer({ + dataKey: 'foo', + label: null, + })[0].props.children, + ).toBeNull(); + expect( + defaultHeaderRenderer({ + dataKey: 'bar', + label: undefined, + })[0].props.children, + ).toBeUndefined(); + }); + }); +}); diff --git a/source/Table/Column.js b/source/Table/Column.js index d18bf0d23..6c39d122a 100644 --- a/source/Table/Column.js +++ b/source/Table/Column.js @@ -1,13 +1,15 @@ /** @flow */ -import { Component, PropTypes } from 'react' -import defaultHeaderRenderer from './defaultHeaderRenderer' -import defaultCellRenderer from './defaultCellRenderer' -import defaultCellDataGetter from './defaultCellDataGetter' +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 Component { +export default class Column extends React.Component { static propTypes = { /** Optional aria-label value to set on the column header */ 'aria-label': PropTypes.string, @@ -30,9 +32,15 @@ export default class Column extends Component { /** Optional additional data passed to this column's :cellDataGetter */ columnData: PropTypes.object, - /** Uniquely identifies the row-data attribute correspnding to this cell */ + /** 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, @@ -47,12 +55,18 @@ export default class Column extends Component { /** * Optional callback responsible for rendering a column header contents. - * ({ columnData: object, dataKey: string, disableSort: boolean, label: string, sortBy: string, sortDirection: string }): PropTypes.node + * ({ 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.string, + label: PropTypes.node, /** Maximum width of column; this property will only be used if :flexGrow is > 0. */ maxWidth: PropTypes.number, @@ -64,15 +78,16 @@ export default class Column extends Component { 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 + width: PropTypes.number.isRequired, }; static defaultProps = { cellDataGetter: defaultCellDataGetter, cellRenderer: defaultCellRenderer, + defaultSortDirection: SortDirection.ASC, flexGrow: 0, flexShrink: 1, headerRenderer: defaultHeaderRenderer, - style: {} + style: {}, }; } diff --git a/source/Table/SortDirection.js b/source/Table/SortDirection.js index 7fff4048a..485285c18 100644 --- a/source/Table/SortDirection.js +++ b/source/Table/SortDirection.js @@ -9,7 +9,7 @@ const SortDirection = { * Sort items in descending order. * This means arranging from the highest value to the lowest (e.g. z-a, 9-0). */ - DESC: 'DESC' -} + DESC: 'DESC', +}; -export default SortDirection +export default SortDirection; diff --git a/source/Table/SortIndicator.js b/source/Table/SortIndicator.js index 3dc386003..fa0cd351c 100644 --- a/source/Table/SortIndicator.js +++ b/source/Table/SortIndicator.js @@ -1,32 +1,31 @@ -import React, { PropTypes } from 'react' -import cn from 'classnames' -import SortDirection from './SortDirection' +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 = cn('ReactVirtualized__Table__sortableHeaderIcon', { - 'ReactVirtualized__Table__sortableHeaderIcon--ASC': sortDirection === SortDirection.ASC, - 'ReactVirtualized__Table__sortableHeaderIcon--DESC': sortDirection === SortDirection.DESC - }) +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 - ? - : - } - + + {sortDirection === SortDirection.ASC ? ( + + ) : ( + + )} + - ) + ); } SortIndicator.propTypes = { - sortDirection: PropTypes.oneOf([SortDirection.ASC, SortDirection.DESC]) -} + sortDirection: PropTypes.oneOf([SortDirection.ASC, SortDirection.DESC]), +}; diff --git a/source/Table/Table.example.js b/source/Table/Table.example.js index 1eddad914..e5f83475e 100644 --- a/source/Table/Table.example.js +++ b/source/Table/Table.example.js @@ -1,22 +1,31 @@ /** @flow */ -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } 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 PureComponent { +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 + list: PropTypes.instanceOf(Immutable.List).isRequired, }; - constructor (props, context) { - super(props, context) + constructor(props, context) { + super(props, context); + + const sortBy = 'index'; + const sortDirection = SortDirection.ASC; + const sortedList = this._sortList({sortBy, sortDirection}); this.state = { disableHeader: false, @@ -27,21 +36,22 @@ export default class TableExample extends PureComponent { rowHeight: 40, rowCount: 1000, scrollToIndex: undefined, - sortBy: 'index', - sortDirection: SortDirection.ASC, - useDynamicRowHeight: false - } + 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) + 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 () { + render() { const { disableHeader, headerHeight, @@ -53,67 +63,64 @@ export default class TableExample extends PureComponent { scrollToIndex, sortBy, sortDirection, - useDynamicRowHeight - } = this.state - - const { list } = this.context - const sortedList = this._isSortEnabled() - ? list - .sortBy(item => item[sortBy]) - .update(list => - sortDirection === SortDirection.DESC - ? list.reverse() - : list - ) - : list - - const rowGetter = ({ index }) => this._getDatum(sortedList, index) + 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. + 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. @@ -121,50 +128,64 @@ export default class TableExample extends PureComponent { this.setState({ height: parseInt(event.target.value, 10) || 1 })} + label="List height" + name="height" + onChange={event => + this.setState({height: parseInt(event.target.value, 10) || 1}) + } value={height} /> this.setState({ rowHeight: parseInt(event.target.value, 10) || 1 })} + label="Row height" + name="rowHeight" + onChange={event => + this.setState({ + rowHeight: parseInt(event.target.value, 10) || 1, + }) + } value={rowHeight} /> this.setState({ headerHeight: parseInt(event.target.value, 10) || 1 })} + label="Header height" + name="headerHeight" + onChange={event => + this.setState({ + headerHeight: parseInt(event.target.value, 10) || 1, + }) + } value={headerHeight} /> this.setState({ overscanRowCount: parseInt(event.target.value, 10) || 0 })} + label="Overscan" + name="overscanRowCount" + onChange={event => + this.setState({ + overscanRowCount: parseInt(event.target.value, 10) || 0, + }) + } value={overscanRowCount} />
- {({ width }) => ( + {({width}) => ( - {!hideIndexRow && + width={width}> + {!hideIndexRow && ( rowData.index - } - dataKey='index' + label="Index" + cellDataGetter={({rowData}) => rowData.index} + dataKey="index" disableSort={!this._isSortEnabled()} width={60} /> - } + )} cellData - } + cellRenderer={({cellData}) => cellData} flexGrow={1} />
@@ -214,84 +230,86 @@ export default class TableExample extends PureComponent {
- ) + ); } - _getDatum (list, index) { - return list.get(index % list.size) + _getDatum(list, index) { + return list.get(index % list.size); } - _getRowHeight ({ index }) { - const { list } = this.context + _getRowHeight({index}) { + const {list} = this.context; - return this._getDatum(list, index).size + return this._getDatum(list, index).size; } - _headerRenderer ({ - columnData, - dataKey, - disableSort, - label, - sortBy, - sortDirection - }) { + _headerRenderer({dataKey, sortBy, sortDirection}) { return (
Full Name - {sortBy === dataKey && - - } + {sortBy === dataKey && }
- ) + ); } - _isSortEnabled () { - const { list } = this.context - const { rowCount } = this.state + _isSortEnabled() { + const {list} = this.context; + const {rowCount} = this.state; - return rowCount <= list.size + return rowCount <= list.size; } - _noRowsRenderer () { - return ( -
- No rows -
- ) + _noRowsRenderer() { + return
No rows
; } - _onRowCountChange (event) { - const rowCount = parseInt(event.target.value, 10) || 0 + _onRowCountChange(event) { + const rowCount = parseInt(event.target.value, 10) || 0; - this.setState({ rowCount }) + this.setState({rowCount}); } - _onScrollToRowChange (event) { - const { rowCount } = this.state - let scrollToIndex = Math.min(rowCount - 1, parseInt(event.target.value, 10)) + _onScrollToRowChange(event) { + const {rowCount} = this.state; + let scrollToIndex = Math.min( + rowCount - 1, + parseInt(event.target.value, 10), + ); if (isNaN(scrollToIndex)) { - scrollToIndex = undefined + scrollToIndex = undefined; } - this.setState({ scrollToIndex }) + this.setState({scrollToIndex}); } - _rowClassName ({ index }) { + _rowClassName({index}) { if (index < 0) { - return styles.headerRow + return styles.headerRow; } else { - return index % 2 === 0 ? styles.evenRow : styles.oddRow + return index % 2 === 0 ? styles.evenRow : styles.oddRow; } } - _sort ({ sortBy, sortDirection }) { - this.setState({ sortBy, sortDirection }) + _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) { + _updateUseDynamicRowHeight(value) { this.setState({ - useDynamicRowHeight: value - }) + useDynamicRowHeight: value, + }); } } diff --git a/source/Table/Table.jest.js b/source/Table/Table.jest.js index 366ae38b6..544759fdc 100644 --- a/source/Table/Table.jest.js +++ b/source/Table/Table.jest.js @@ -1,42 +1,55 @@ -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import { Simulate } from 'react-addons-test-utils' -import Immutable from 'immutable' -import Column from './Column' -import Table from './Table' -import SortDirection from './SortDirection' +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 = [] + const array = []; for (var i = 0; i < 100; i++) { array.push({ id: i, name: `Name ${i}`, - email: `user-${i}@treasure-data.com` - }) + email: `user-${i}@treasure-data.com`, + }); } - const list = Immutable.fromJS(array) + const list = Immutable.fromJS(array); // Works with an Immutable List of Maps - function immutableRowGetter ({ index }) { - return list.get(index) + function immutableRowGetter({index}) { + return list.get(index); } // Works with an Array of Objects - function vanillaRowGetter ({ index }) { - return array[index] + function vanillaRowGetter({index}) { + return array[index]; } - function getMarkup ({ + // 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 }, + columnData = {data: 123}, + columnID, columnStyle, + columnHeaderStyle, disableSort = false, headerRenderer, maxWidth, minWidth, + defaultSortDirection, + label, ...flexTableProps } = {}) { return ( @@ -44,26 +57,29 @@ describe('Table', () => { headerHeight={20} height={100} overscanRowCount={0} + overscanIndicesGetter={overscanIndicesGetter} rowCount={list.size} rowGetter={immutableRowGetter} rowHeight={10} width={100} - {...flexTableProps} - > + {...flexTableProps}> { {null} {undefined} - ) + ); } - beforeEach(() => jest.resetModules()) + 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) - }) + 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) - }) + 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) - }) - }) + 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) - }) + 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) - }) - }) + 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] + const useImmutable = [true, false]; useImmutable.forEach(useImmutable => { it('should render the correct number of rows', () => { - const rendered = findDOMNode(render(getMarkup({ - rowGetter: useImmutable ? immutableRowGetter : vanillaRowGetter - }))) + 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) - }) + 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') - }) + 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) + 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')) - }) - }) - }) + 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') + 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 })) - }) - }) + 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) - }) - }) + 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._rowSizeAndPositionManager.getTotalSize()).toEqual(150) - rendered.measureAllRows() - expect(rendered.Grid._rowSizeAndPositionManager.getTotalSize()).toEqual(200) - }) - }) + 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() + 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) + expect(indices[0]).toEqual(0); + expect(indices[indices.length - 1]).toEqual(7); - indices.splice(0) - component.recomputeRowHeights(4) + indices.splice(0); + component.recomputeRowHeights(4); - expect(indices[0]).toEqual(4) - expect(indices[indices.length - 1]).toEqual(7) - }) - }) + 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 ({ cellData, columnData, dataKey, rowData, rowIndex }) { - return `${rowIndex}${marker}` + 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') - }) - }) + 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: ({ columnData, dataKey, rowData }) => `Custom ${dataKey} for row ${rowData.get('id')}` - }))) - const nameColumns = rendered.querySelectorAll('.ReactVirtualized__Table__rowColumn:first-of-type') + 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}`) - }) - }) + expect(nameColumn.textContent).toEqual(`Custom name for row ${index}`); + }); + }); it('should use a custom cellRenderer if specified', () => { - const rendered = findDOMNode(render(getMarkup({ - cellRenderer: ({ cellData, columnData, dataKey, rowData, rowIndex }) => `Custom ${cellData}` - }))) - const nameColumns = rendered.querySelectorAll('.ReactVirtualized__Table__rowColumn:first-of-type') + 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')}`) - }) - }) + 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: ({ cellData, columnData, dataKey, rowData, rowIndex }) => 'Custom' - }))) - const nameColumn = rendered.querySelector('.ReactVirtualized__Table__rowColumn:first-of-type') - expect(nameColumn.getAttribute('title')).toContain('Custom') - }) + 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: ({ cellData, columnData, dataKey, rowData, rowIndex }) =>
Custom
- }))) - const nameColumn = rendered.querySelector('.ReactVirtualized__Table__rowColumn:first-of-type') - expect(nameColumn.getAttribute('title')).toEqual(null) - }) - }) + 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') + const rendered = findDOMNode(render(getMarkup())); + const nameColumn = rendered.querySelectorAll( + '.ReactVirtualized__Table__headerColumn:first-of-type', + ); - expect(nameColumn.className || '').not.toContain('ReactVirtualized__Table__sortableHeaderColumn') - }) + 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 - }) + 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 - }) + 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] + 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) - }) - }) + 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] + 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) - }) - }) + 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) - }) + 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) - }) - }) + 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) - }) - }) + 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) - }) + 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: (params) => '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) - }) + 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: (params) => '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') - }) - }) + 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!') - }) + 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('') - }) - }) + 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') - }) + 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') - }) - }) + 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]) - }) - }) + 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]) - }) - }) + 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 - }))) + 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]) - }) - }) + 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, index) => { - expect(row.className).toContain(staticClassName) - }) - }) + 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') + 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') + expect(row.className).toContain('even'); + expect(row.className).not.toContain('odd'); } else { - expect(row.className).toContain('odd') - expect(row.className).not.toContain('even') + 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) - }) + 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 + 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) - }) + 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 + 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) - }) + 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) - }) - }) + 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) - }) + 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) - }) - }) + 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() - }) + 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) - }) + 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') - }) + 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') - }) + 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' } - const headerStyle = { backgroundColor: 'blue' } - const rowStyle = { backgroundColor: 'green' } - const style = { backgroundColor: 'orange' } - const node = findDOMNode(render(getMarkup({ - columnStyle, - headerStyle, - rowStyle, - style - }))) - expect(node.querySelector('.ReactVirtualized__Table__rowColumn').style.backgroundColor).toEqual('red') - expect(node.querySelector('.ReactVirtualized__Table__headerColumn').style.backgroundColor).toEqual('blue') - expect(node.querySelector('.ReactVirtualized__Table__row').style.backgroundColor).toEqual('green') - expect(node.style.backgroundColor).toEqual('orange') - }) + 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') + 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') + expect(row.style.backgroundColor).toEqual('red'); } else { - expect(row.style.backgroundColor).toEqual('green') + 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') - }) - }) + 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', () => { - let overscanStartIndex, overscanStopIndex, startIndex, stopIndex - render(getMarkup({ - onRowsRendered: params => ({ overscanStartIndex, overscanStopIndex, startIndex, stopIndex } = params) - })) - expect(overscanStartIndex).toEqual(startIndex) - expect(overscanStopIndex).toEqual(stopIndex) - }) + 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', () => { - let overscanStartIndex, overscanStopIndex, startIndex, stopIndex - render(getMarkup({ - onRowsRendered: params => ({ overscanStartIndex, overscanStopIndex, startIndex, stopIndex } = params), - overscanRowCount: 10, - scrollToIndex: 30 - })) - expect(overscanStartIndex).toEqual(23) - expect(startIndex).toEqual(23) - expect(stopIndex).toEqual(30) - expect(overscanStopIndex).toEqual(40) - }) - - it('should not overscan beyond the start of the list', () => { - let overscanStartIndex, overscanStopIndex, startIndex, stopIndex - render(getMarkup({ - onRowsRendered: params => ({ overscanStartIndex, overscanStopIndex, startIndex, stopIndex } = params), - overscanRowCount: 10 - })) - expect(overscanStartIndex).toEqual(0) - expect(startIndex).toEqual(0) - expect(stopIndex).toEqual(7) - expect(overscanStopIndex).toEqual(17) - }) - - it('should not overscan beyond the end of the list', () => { - let overscanStartIndex, overscanStopIndex, startIndex, stopIndex - render(getMarkup({ - onRowsRendered: params => ({ overscanStartIndex, overscanStopIndex, startIndex, stopIndex } = params), - overscanRowCount: 10, - rowCount: 15 - })) - expect(overscanStartIndex).toEqual(0) - expect(startIndex).toEqual(0) - expect(stopIndex).toEqual(7) - expect(overscanStopIndex).toEqual(14) - }) - }) + 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 - }]) - }) + 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 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) + 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 - }) - }) - }) + scrollTop: 100, + }); + }); + }); describe('a11y properties', () => { it('should set aria role on the table', () => { - const node = findDOMNode(render(getMarkup())) - expect(node.getAttribute('role')).toEqual('grid') - }) + 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') - }) + 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') - }) + 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') - }) + 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) - }) + 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) - }) + 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.getAttribute('role')).toEqual('rowheader') - expect(row.tabIndex).toEqual(0) - }) + 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.getAttribute('role')).toEqual(null) - expect(row.tabIndex).toEqual(-1) - }) - }) + 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) - }) + 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) - }) - }) + 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' + let headerRendererCalled = false; + let cellRendererCalled = false; + function headerRenderer() { + headerRendererCalled = true; + return 'foo'; } - function cellRenderer () { - cellRendererCalled = 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) - }) + 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' + let headerRendererCalled = false; + let cellRendererCalled = false; + function headerRenderer() { + headerRendererCalled = true; + return 'foo'; } - function cellRenderer () { - cellRendererCalled = true - return 'foo' + function cellRenderer() { + cellRendererCalled = true; + return 'foo'; } const initialProperties = { autoHeight: false, @@ -1002,8 +1519,8 @@ describe('Table', () => { scrollTop: 0, sortBy: 'name', sortDirection: SortDirection.ASC, - tabIndex: null - } + tabIndex: null, + }; const changedProperties = { autoHeight: true, estimatedRowSize: 10, @@ -1014,31 +1531,36 @@ describe('Table', () => { scrollTop: 1, sortBy: 'email', sortDirection: SortDirection.DESC, - tabIndex: 1 - } + 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) - }) - }) - }) + 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') - }) + 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() - }) -}) + const cellRenderer = jest.fn().mockReturnValue(null); + findDOMNode(render(getMarkup({cellRenderer}))); + expect(cellRenderer.mock.calls[0][0].parent).not.toBeUndefined(); + }); +}); diff --git a/source/Table/Table.js b/source/Table/Table.js index 4c0f903f4..9f87b7a52 100644 --- a/source/Table/Table.js +++ b/source/Table/Table.js @@ -1,21 +1,29 @@ /** @flow */ -import cn from 'classnames' -import Column from './Column' -import React, { PropTypes, PureComponent } from 'react' -import { findDOMNode } from 'react-dom' -import Grid from '../Grid' -import defaultRowRenderer from './defaultRowRenderer' -import defaultHeaderRowRenderer from './defaultHeaderRowRenderer' -import SortDirection from './SortDirection' + +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 PureComponent { +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 @@ -23,11 +31,12 @@ export default class Table extends PureComponent { autoHeight: PropTypes.bool, /** One or more Columns describing the data displayed in this row */ - children: (props, propName, componentName) => { - const children = React.Children.toArray(props.children) + children: props => { + const children = React.Children.toArray(props.children); for (let i = 0; i < children.length; i++) { - if (children[i].type !== Column) { - return new Error('Table only accepts children of type Column') + const childType = children[i].type; + if (childType !== Column && !(childType.prototype instanceof Column)) { + return new Error('Table only accepts children of type Column'); } } }, @@ -79,9 +88,15 @@ export default class Table extends PureComponent { noRowsRenderer: PropTypes.func, /** - * Optional callback when a column's header is clicked. - * ({ columnData: any, dataKey: string }): void - */ + * 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, /** @@ -108,6 +123,12 @@ export default class Table extends PureComponent { */ 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 @@ -121,6 +142,9 @@ export default class Table extends PureComponent { */ 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. @@ -144,7 +168,8 @@ export default class Table extends PureComponent { * 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, + rowHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.func]) + .isRequired, /** Number of rows in table. */ rowCount: PropTypes.number.isRequired, @@ -167,10 +192,12 @@ export default class Table extends PureComponent { rowRenderer: PropTypes.func, /** Optional custom inline style to attach to table rows. */ - rowStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired, + rowStyle: PropTypes.oneOfType([PropTypes.object, PropTypes.func]) + .isRequired, /** See Grid#scrollToAlignment */ - scrollToAlignment: PropTypes.oneOf(['auto', 'end', 'start', 'center']).isRequired, + scrollToAlignment: PropTypes.oneOf(['auto', 'end', 'start', 'center']) + .isRequired, /** Row index to ensure visible (by forcefully scrolling if necessary) */ scrollToIndex: PropTypes.number.isRequired, @@ -180,7 +207,12 @@ export default class Table extends PureComponent { /** * Sort function to be called if a sortable header is clicked. - * ({ sortBy: string, sortDirection: SortDirection }): void + * Should implement the following interface: ({ + * defaultSortDirection: 'ASC' | 'DESC', + * event: MouseEvent, + * sortBy: string, + * sortDirection: SortDirection + * }): void */ sort: PropTypes.func, @@ -197,7 +229,7 @@ export default class Table extends PureComponent { tabIndex: PropTypes.number, /** Width of list */ - width: PropTypes.number.isRequired + width: PropTypes.number.isRequired, }; static defaultProps = { @@ -208,62 +240,123 @@ export default class Table extends PureComponent { noRowsRenderer: () => null, onRowsRendered: () => null, onScroll: () => null, + overscanIndicesGetter: accessibilityOverscanIndicesGetter, overscanRowCount: 10, rowRenderer: defaultRowRenderer, headerRowRenderer: defaultHeaderRowRenderer, rowStyle: {}, scrollToAlignment: 'auto', scrollToIndex: -1, - style: {} + style: {}, }; - constructor (props) { - super(props) + constructor(props) { + super(props); this.state = { - scrollbarWidth: 0 + 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, + }); - 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) + return scrollTop; + } + return 0; } - forceUpdateGrid () { - this.Grid.forceUpdate() + /** CellMeasurer compatibility */ + invalidateCellSizeAfterRender({columnIndex, rowIndex}: CellPosition) { + if (this.Grid) { + this.Grid.invalidateCellSizeAfterRender({ + rowIndex, + columnIndex, + }); + } } /** See Grid#measureAllCells */ - measureAllRows () { - this.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) { - this.Grid.recomputeGridSize({ - rowIndex: index - }) + 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) { - this.Grid.scrollToCell({ - columnIndex: 0, - rowIndex: index - }) + 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() + componentDidMount() { + this._setScrollbarWidth(); } - componentDidUpdate () { - this._setScrollbarWidth() + componentDidUpdate() { + this._setScrollbarWidth(); } - render () { + render() { const { children, className, @@ -279,54 +372,65 @@ export default class Table extends PureComponent { rowStyle, scrollToIndex, style, - width - } = this.props - const { scrollbarWidth } = this.state + width, + } = this.props; + const {scrollbarWidth} = this.state; - const availableRowsHeight = disableHeader ? height : height - headerHeight + const availableRowsHeight = disableHeader ? height : height - headerHeight; - const rowClass = typeof rowClassName === 'function' ? rowClassName({ index: -1 }) : rowClassName - const rowStyleObject = typeof rowStyle === 'function' ? rowStyle({ index: -1 }) : rowStyle + 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 = [] + this._cachedColumnStyles = []; React.Children.toArray(children).forEach((column, index) => { - const flexStyles = this._getFlexStyleForColumn(column, column.props.style) + const flexStyles = this._getFlexStyleForColumn( + column, + column.props.style || Column.defaultProps.style, + ); this._cachedColumnStyles[index] = { + overflow: 'hidden', ...flexStyles, - overflow: 'hidden' - } - }) + }; + }); // 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 && ( + role="grid" + style={style}> + {!disableHeader && headerRowRenderer({ - className: cn('ReactVirtualized__Table__headerRow', rowClass), + className: clsx('ReactVirtualized__Table__headerRow', rowClass), columns: this._getHeaderColumns(), style: { - ...rowStyleObject, height: headerHeight, overflow: 'hidden', paddingRight: scrollbarWidth, - width: width - } - }) - )} + width: width, + ...rowStyleObject, + }, + })}
- ) + ); } - _createColumn ({ - column, - columnIndex, - isScrolling, - parent, - rowData, - rowIndex - }) { + _createColumn({column, columnIndex, isScrolling, parent, rowData, rowIndex}) { + const {onColumnClick} = this.props; const { cellDataGetter, cellRenderer, className, columnData, - dataKey - } = column.props + dataKey, + id, + } = column.props; + + const cellData = cellDataGetter({columnData, dataKey, rowData}); + const renderedCell = cellRenderer({ + cellData, + columnData, + columnIndex, + dataKey, + isScrolling, + parent, + rowData, + rowIndex, + }); - const cellData = cellDataGetter({ columnData, dataKey, rowData }) - const renderedCell = cellRenderer({ cellData, columnData, dataKey, isScrolling, parent, rowData, rowIndex }) + const onClick = event => { + onColumnClick && onColumnClick({columnData, dataKey, event}); + }; - const style = this._cachedColumnStyles[columnIndex] + const style = this._cachedColumnStyles[columnIndex]; - const title = typeof renderedCell === 'string' - ? renderedCell - : null + 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 (
+ title={title}> {renderedCell}
- ) + ); } - _createHeader ({ column, index }) { - const { headerClassName, headerStyle, onHeaderClick, sort, sortBy, sortDirection } = this.props - const { dataKey, disableSort, headerRenderer, label, columnData } = column.props - const sortEnabled = !disableSort && sort + _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 = cn( + const classNames = clsx( 'ReactVirtualized__Table__headerColumn', headerClassName, column.props.headerClassName, { - 'ReactVirtualized__Table__sortableHeaderColumn': sortEnabled - } - ) - const style = this._getFlexStyleForColumn(column, headerStyle) + ReactVirtualized__Table__sortableHeaderColumn: sortEnabled, + }, + ); + const style = this._getFlexStyleForColumn(column, { + ...headerStyle, + ...column.props.headerStyle, + }); const renderedHeader = headerRenderer({ columnData, @@ -406,95 +540,119 @@ export default class Table extends PureComponent { disableSort, label, sortBy, - sortDirection - }) + sortDirection, + }); - const a11yProps = {} + 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 newSortDirection = sortBy !== dataKey || sortDirection === SortDirection.DESC - ? SortDirection.ASC - : SortDirection.DESC - - const onClick = (event) => { - sortEnabled && sort({ - sortBy: dataKey, - sortDirection: newSortDirection - }) - onHeaderClick && onHeaderClick({ columnData, dataKey, event }) - } + const isFirstTimeSort = sortBy !== dataKey; - const onKeyDown = (event) => { + // 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) + onClick(event); } - } + }; + + headerAriaLabel = column.props['aria-label'] || label || dataKey; + headerAriaSort = 'none'; + headerTabIndex = 0; + headerOnClick = onClick; + headerOnKeyDown = onKeyDown; + } - a11yProps['aria-label'] = column.props['aria-label'] || label || dataKey - a11yProps.role = 'rowheader' - a11yProps.tabIndex = 0 - a11yProps.onClick = onClick - a11yProps.onKeyDown = 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 (
+ tabIndex={headerTabIndex}> {renderedHeader}
- ) + ); } - _createRow ({ - rowIndex: index, - isScrolling, - key, - parent, - style - }) { + _createRow({rowIndex: index, isScrolling, key, parent, style}) { const { children, onRowClick, onRowDoubleClick, + onRowRightClick, onRowMouseOver, onRowMouseOut, rowClassName, rowGetter, rowRenderer, - rowStyle - } = this.props + rowStyle, + } = this.props; - const { scrollbarWidth } = this.state + 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 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 = cn('ReactVirtualized__Table__row', rowClass) + (column, columnIndex) => + this._createColumn({ + column, + columnIndex, + isScrolling, + parent, + rowData, + rowIndex: index, + scrollbarWidth, + }), + ); + + const className = clsx('ReactVirtualized__Table__row', rowClass); const flattenedStyle = { ...style, - ...rowStyleObject, height: this._getRowHeight(index), overflow: 'hidden', - paddingRight: scrollbarWidth - } + paddingRight: scrollbarWidth, + ...rowStyleObject, + }; return rowRenderer({ className, @@ -504,81 +662,86 @@ export default class Table extends PureComponent { key, onRowClick, onRowDoubleClick, + onRowRightClick, onRowMouseOver, onRowMouseOut, rowData, - style: flattenedStyle - }) + 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` + _getFlexStyleForColumn(column, customStyle = {}) { + const flexValue = `${column.props.flexGrow} ${column.props.flexShrink} ${column.props.width}px`; const style = { ...customStyle, flex: flexValue, msFlex: flexValue, - WebkitFlex: flexValue - } + WebkitFlex: flexValue, + }; if (column.props.maxWidth) { - style.maxWidth = column.props.maxWidth + style.maxWidth = column.props.maxWidth; } if (column.props.minWidth) { - style.minWidth = column.props.minWidth + style.minWidth = column.props.minWidth; } - return style + return style; } - _getHeaderColumns () { - const { children, disableHeader } = this.props - const items = disableHeader ? [] : React.Children.toArray(children) + _getHeaderColumns() { + const {children, disableHeader} = this.props; + const items = disableHeader ? [] : React.Children.toArray(children); - return items.map((column, index) => - this._createHeader({ column, index }) - ) + return items.map((column, index) => this._createHeader({column, index})); } - _getRowHeight (rowIndex) { - const { rowHeight } = this.props + _getRowHeight(rowIndex) { + const {rowHeight} = this.props; return typeof rowHeight === 'function' - ? rowHeight({ index: rowIndex }) - : rowHeight + ? rowHeight({index: rowIndex}) + : rowHeight; } - _onScroll ({ clientHeight, scrollHeight, scrollTop }) { - const { onScroll } = this.props + _onScroll({clientHeight, scrollHeight, scrollTop}) { + const {onScroll} = this.props; - onScroll({ clientHeight, scrollHeight, scrollTop }) + onScroll({clientHeight, scrollHeight, scrollTop}); } - _onSectionRendered ({ rowOverscanStartIndex, rowOverscanStopIndex, rowStartIndex, rowStopIndex }) { - const { onRowsRendered } = this.props + _onSectionRendered({ + rowOverscanStartIndex, + rowOverscanStopIndex, + rowStartIndex, + rowStopIndex, + }) { + const {onRowsRendered} = this.props; onRowsRendered({ overscanStartIndex: rowOverscanStartIndex, overscanStopIndex: rowOverscanStopIndex, startIndex: rowStartIndex, - stopIndex: rowStopIndex - }) + stopIndex: rowStopIndex, + }); + } + + _setRef(ref) { + this.Grid = ref; } - _setRef (ref) { - this.Grid = ref + _setGridElementRef(ref) { + this.GridElement = ref; } - _setScrollbarWidth () { - const Grid = findDOMNode(this.Grid) - const clientWidth = Grid.clientWidth || 0 - const offsetWidth = Grid.offsetWidth || 0 - const scrollbarWidth = offsetWidth - clientWidth + _setScrollbarWidth() { + const scrollbarWidth = this.getScrollbarWidth(); - this.setState({ scrollbarWidth }) + this.setState({scrollbarWidth}); } } diff --git a/source/Table/createMultiSort.jest.js b/source/Table/createMultiSort.jest.js new file mode 100644 index 000000000..9a7f14660 --- /dev/null +++ b/source/Table/createMultiSort.jest.js @@ -0,0 +1,200 @@ +import createMultiSort from './createMultiSort'; + +describe('createMultiSort', () => { + function simulate( + sort, + dataKey, + eventModifier = '', + defaultSortDirection = 'ASC', + ) { + sort({ + defaultSortDirection, + event: { + ctrlKey: eventModifier === 'control', + metaKey: eventModifier === 'meta', + shiftKey: eventModifier === 'shift', + }, + sortBy: dataKey, + }); + } + + it('errors if the user did not specify a sort callback', () => { + expect(createMultiSort).toThrow(); + }); + + it('sets the correct default values', () => { + const multiSort = createMultiSort(jest.fn(), { + defaultSortBy: ['a', 'b'], + defaultSortDirection: { + a: 'ASC', + b: 'DESC', + }, + }); + expect(multiSort.sortBy).toEqual(['a', 'b']); + expect(multiSort.sortDirection.a).toBe('ASC'); + expect(multiSort.sortDirection.b).toBe('DESC'); + }); + + it('sets the correct default sparse values', () => { + const multiSort = createMultiSort(jest.fn(), { + defaultSortBy: ['a', 'b'], + }); + expect(multiSort.sortBy).toEqual(['a', 'b']); + expect(multiSort.sortDirection.a).toBe('ASC'); + expect(multiSort.sortDirection.b).toBe('ASC'); + }); + + describe('on click', () => { + it('sets the correct default value for a field', () => { + const multiSort = createMultiSort(jest.fn()); + + simulate(multiSort.sort, 'a'); + expect(multiSort.sortBy).toEqual(['a']); + expect(multiSort.sortDirection.a).toBe('ASC'); + + simulate(multiSort.sort, 'b', '', 'DESC'); + expect(multiSort.sortBy).toEqual(['b']); + expect(multiSort.sortDirection.b).toBe('DESC'); + }); + + it('toggles a field value', () => { + const multiSort = createMultiSort(jest.fn()); + + simulate(multiSort.sort, 'a'); + expect(multiSort.sortBy).toEqual(['a']); + expect(multiSort.sortDirection.a).toBe('ASC'); + + simulate(multiSort.sort, 'a'); + expect(multiSort.sortBy).toEqual(['a']); + expect(multiSort.sortDirection.a).toBe('DESC'); + + simulate(multiSort.sort, 'b', '', 'DESC'); + expect(multiSort.sortBy).toEqual(['b']); + expect(multiSort.sortDirection.b).toBe('DESC'); + + simulate(multiSort.sort, 'b', '', 'DESC'); + expect(multiSort.sortBy).toEqual(['b']); + expect(multiSort.sortDirection.b).toBe('ASC'); + }); + + it('resets sort-by fields', () => { + const multiSort = createMultiSort(jest.fn(), { + defaultSortBy: ['a', 'b'], + }); + expect(multiSort.sortBy).toEqual(['a', 'b']); + + simulate(multiSort.sort, 'a'); + expect(multiSort.sortBy).toEqual(['a']); + }); + + it('resets sort-direction fields', () => { + const multiSort = createMultiSort(jest.fn(), { + defaultSortBy: ['a', 'b'], + defaultSortDirection: { + a: 'DESC', + b: 'ASC', + }, + }); + expect(multiSort.sortBy).toEqual(['a', 'b']); + expect(multiSort.sortDirection.a).toEqual('DESC'); + expect(multiSort.sortDirection.b).toEqual('ASC'); + + simulate(multiSort.sort, 'a'); + expect(multiSort.sortBy).toEqual(['a']); + expect(multiSort.sortDirection.a).toEqual('ASC'); + expect(multiSort.sortDirection.b).toEqual(undefined); + + simulate(multiSort.sort, 'b'); + expect(multiSort.sortBy).toEqual(['b']); + expect(multiSort.sortDirection.a).toEqual(undefined); + expect(multiSort.sortDirection.b).toEqual('ASC'); + }); + }); + + describe('on shift click', () => { + it('appends a field to the sort by list', () => { + const multiSort = createMultiSort(jest.fn()); + + simulate(multiSort.sort, 'a'); + expect(multiSort.sortBy).toEqual(['a']); + expect(multiSort.sortDirection.a).toBe('ASC'); + + simulate(multiSort.sort, 'b', 'shift'); + expect(multiSort.sortBy).toEqual(['a', 'b']); + expect(multiSort.sortDirection.a).toBe('ASC'); + expect(multiSort.sortDirection.b).toBe('ASC'); + }); + + it('toggles an appended field value', () => { + const multiSort = createMultiSort(jest.fn()); + + simulate(multiSort.sort, 'a'); + expect(multiSort.sortBy).toEqual(['a']); + expect(multiSort.sortDirection.a).toBe('ASC'); + + simulate(multiSort.sort, 'b', 'shift'); + expect(multiSort.sortBy).toEqual(['a', 'b']); + expect(multiSort.sortDirection.a).toBe('ASC'); + expect(multiSort.sortDirection.b).toBe('ASC'); + + simulate(multiSort.sort, 'a', 'shift'); + expect(multiSort.sortBy).toEqual(['a', 'b']); + expect(multiSort.sortDirection.a).toBe('DESC'); + expect(multiSort.sortDirection.b).toBe('ASC'); + + simulate(multiSort.sort, 'a', 'shift'); + expect(multiSort.sortBy).toEqual(['a', 'b']); + expect(multiSort.sortDirection.a).toBe('ASC'); + expect(multiSort.sortDirection.b).toBe('ASC'); + }); + + it('able to shift+click more than once', () => { + const multiSort = createMultiSort(jest.fn()); + + simulate(multiSort.sort, 'a'); + expect(multiSort.sortBy).toEqual(['a']); + expect(multiSort.sortDirection.a).toBe('ASC'); + + simulate(multiSort.sort, 'b', 'shift'); + expect(multiSort.sortBy).toEqual(['a', 'b']); + expect(multiSort.sortDirection.a).toBe('ASC'); + expect(multiSort.sortDirection.b).toBe('ASC'); + + simulate(multiSort.sort, 'b'); + expect(multiSort.sortBy).toEqual(['b']); + expect(multiSort.sortDirection.b).toBe('DESC'); + + simulate(multiSort.sort, 'a', 'shift'); + expect(multiSort.sortBy).toEqual(['b', 'a']); + expect(multiSort.sortDirection.a).toBe('ASC'); + expect(multiSort.sortDirection.b).toBe('DESC'); + }); + }); + + ['control', 'meta'].forEach(modifier => { + describe(`${modifier} click`, () => { + it('removes a field from the sort by list', () => { + const multiSort = createMultiSort(jest.fn(), { + defaultSortBy: ['a', 'b'], + }); + expect(multiSort.sortBy).toEqual(['a', 'b']); + + simulate(multiSort.sort, 'a', modifier); + expect(multiSort.sortBy).toEqual(['b']); + + simulate(multiSort.sort, 'b', modifier); + expect(multiSort.sortBy).toEqual([]); + }); + + it('ignores fields not in the list on control click', () => { + const multiSort = createMultiSort(jest.fn(), { + defaultSortBy: ['a', 'b'], + }); + expect(multiSort.sortBy).toEqual(['a', 'b']); + + simulate(multiSort.sort, 'c', modifier); + expect(multiSort.sortBy).toEqual(['a', 'b']); + }); + }); + }); +}); diff --git a/source/Table/createMultiSort.js b/source/Table/createMultiSort.js new file mode 100644 index 000000000..689557f7b --- /dev/null +++ b/source/Table/createMultiSort.js @@ -0,0 +1,109 @@ +/** @flow */ + +type SortDirection = 'ASC' | 'DESC'; + +type SortParams = { + defaultSortDirection: SortDirection, + event: MouseEvent, + sortBy: string, +}; + +type SortDirectionMap = {[string]: SortDirection}; + +type MultiSortOptions = { + defaultSortBy: ?Array, + defaultSortDirection: ?SortDirectionMap, +}; + +type MultiSortReturn = { + /** + * Sort property to be passed to the `Table` component. + * This function updates `sortBy` and `sortDirection` values. + */ + sort: (params: SortParams) => void, + + /** + * Specifies the fields currently responsible for sorting data, + * In order of importance. + */ + sortBy: Array, + + /** + * Specifies the direction a specific field is being sorted in. + */ + sortDirection: SortDirectionMap, +}; + +export default function createMultiSort( + sortCallback: Function, + {defaultSortBy, defaultSortDirection = {}}: MultiSortOptions = {}, +): MultiSortReturn { + if (!sortCallback) { + throw Error(`Required parameter "sortCallback" not specified`); + } + + const sortBy = defaultSortBy || []; + const sortDirection = {}; + + sortBy.forEach(dataKey => { + sortDirection[dataKey] = + defaultSortDirection[dataKey] !== undefined + ? defaultSortDirection[dataKey] + : 'ASC'; + }); + + function sort({ + defaultSortDirection, + event, + sortBy: dataKey, + }: SortParams): void { + if (event.shiftKey) { + // Shift + click appends a column to existing criteria + if (sortDirection[dataKey] !== undefined) { + sortDirection[dataKey] = + sortDirection[dataKey] === 'ASC' ? 'DESC' : 'ASC'; + } else { + sortDirection[dataKey] = defaultSortDirection; + sortBy.push(dataKey); + } + } else if (event.ctrlKey || event.metaKey) { + // Control + click removes column from sort (if pressent) + const index = sortBy.indexOf(dataKey); + if (index >= 0) { + sortBy.splice(index, 1); + delete sortDirection[dataKey]; + } + } else { + // Clear sortBy array of all non-selected keys + sortBy.length = 0; + sortBy.push(dataKey); + + // Clear sortDirection object of all non-selected keys + const sortDirectionKeys = Object.keys(sortDirection); + sortDirectionKeys.forEach(key => { + if (key !== dataKey) delete sortDirection[key]; + }); + + // If key is already selected, reverse sort direction. + // Else, set sort direction to default direction. + if (sortDirection[dataKey] !== undefined) { + sortDirection[dataKey] = + sortDirection[dataKey] === 'ASC' ? 'DESC' : 'ASC'; + } else { + sortDirection[dataKey] = defaultSortDirection; + } + } + + // Notify application code + sortCallback({ + sortBy, + sortDirection, + }); + } + + return { + sort, + sortBy, + sortDirection, + }; +} diff --git a/source/Table/defaultCellDataGetter.js b/source/Table/defaultCellDataGetter.js index 582346c6a..e08a72ef0 100644 --- a/source/Table/defaultCellDataGetter.js +++ b/source/Table/defaultCellDataGetter.js @@ -1,19 +1,18 @@ /** @flow */ -import type { CellDataGetterParams } from './types' +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 ({ - columnData, +export default function defaultCellDataGetter({ dataKey, - rowData + rowData, }: CellDataGetterParams) { if (typeof rowData.get === 'function') { - return rowData.get(dataKey) + return rowData.get(dataKey); } else { - return rowData[dataKey] + return rowData[dataKey]; } } diff --git a/source/Table/defaultCellRenderer.js b/source/Table/defaultCellRenderer.js index 34409da61..626794752 100644 --- a/source/Table/defaultCellRenderer.js +++ b/source/Table/defaultCellRenderer.js @@ -1,20 +1,16 @@ /** @flow */ -import type { CellRendererParams } from './types' +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 ({ +export default function defaultCellRenderer({ cellData, - cellDataKey, - columnData, - rowData, - rowIndex }: CellRendererParams): string { if (cellData == null) { - return '' + return ''; } else { - return String(cellData) + return String(cellData); } } diff --git a/source/Table/defaultHeaderRenderer.js b/source/Table/defaultHeaderRenderer.js index b995b858b..5ae186955 100644 --- a/source/Table/defaultHeaderRenderer.js +++ b/source/Table/defaultHeaderRenderer.js @@ -1,38 +1,32 @@ /** @flow */ -import React from 'react' -import SortIndicator from './SortIndicator' -import type { HeaderRendererParams } from './types' +import * as React from 'react'; +import SortIndicator from './SortIndicator'; +import type {HeaderRendererParams} from './types'; /** * Default table header renderer. */ -export default function defaultHeaderRenderer ({ - columnData, +export default function defaultHeaderRenderer({ dataKey, - disableSort, label, sortBy, - sortDirection + sortDirection, }: HeaderRendererParams) { - const showSortIndicator = sortBy === dataKey + const showSortIndicator = sortBy === dataKey; const children = [ + className="ReactVirtualized__Table__headerTruncatedText" + key="label" + title={typeof label === 'string' ? label : null}> {label} - - ] + , + ]; if (showSortIndicator) { children.push( - - ) + , + ); } - return children + return children; } diff --git a/source/Table/defaultHeaderRowRenderer.js b/source/Table/defaultHeaderRowRenderer.js index b58b95cd1..a44d82cbc 100644 --- a/source/Table/defaultHeaderRowRenderer.js +++ b/source/Table/defaultHeaderRowRenderer.js @@ -1,17 +1,15 @@ /** @flow */ -import React from 'react' -import type { HeaderRowRendererParams } from './types' +import * as React from 'react'; +import type {HeaderRowRendererParams} from './types'; -export default function defaultHeaderRowRenderer ({ +export default function defaultHeaderRowRenderer({ className, columns, - style + style, }: HeaderRowRendererParams) { - return
- {columns} -
+ return ( +
+ {columns} +
+ ); } diff --git a/source/Table/defaultRowRenderer.js b/source/Table/defaultRowRenderer.js index f1f3491fe..836c0ff39 100644 --- a/source/Table/defaultRowRenderer.js +++ b/source/Table/defaultRowRenderer.js @@ -1,45 +1,51 @@ /** @flow */ -import React from 'react' -import type { RowRendererParams } from './types' +import * as React from 'react'; +import type {RowRendererParams} from './types'; /** * Default row renderer for Table. */ -export default function defaultRowRenderer ({ +export default function defaultRowRenderer({ className, columns, index, - isScrolling, key, onRowClick, onRowDoubleClick, - onRowMouseOver, onRowMouseOut, + onRowMouseOver, + onRowRightClick, rowData, - style + style, }: RowRendererParams) { - const a11yProps = {} + const a11yProps = {'aria-rowindex': index + 1}; if ( onRowClick || onRowDoubleClick || + onRowMouseOut || onRowMouseOver || - onRowMouseOut + onRowRightClick ) { - a11yProps['aria-label'] = 'row' - a11yProps.tabIndex = 0 + a11yProps['aria-label'] = 'row'; + a11yProps.tabIndex = 0; if (onRowClick) { - a11yProps.onClick = (event) => onRowClick({ event, index, rowData }) + a11yProps.onClick = event => onRowClick({event, index, rowData}); } if (onRowDoubleClick) { - a11yProps.onDoubleClick = (event) => onRowDoubleClick({ event, index, rowData }) + a11yProps.onDoubleClick = event => + onRowDoubleClick({event, index, rowData}); } if (onRowMouseOut) { - a11yProps.onMouseOut = (event) => onRowMouseOut({ event, index, rowData }) + a11yProps.onMouseOut = event => onRowMouseOut({event, index, rowData}); } if (onRowMouseOver) { - a11yProps.onMouseOver = (event) => onRowMouseOver({ event, index, rowData }) + a11yProps.onMouseOver = event => onRowMouseOver({event, index, rowData}); + } + if (onRowRightClick) { + a11yProps.onContextMenu = event => + onRowRightClick({event, index, rowData}); } } @@ -48,10 +54,9 @@ export default function defaultRowRenderer ({ {...a11yProps} className={className} key={key} - role='row' - style={style} - > + role="row" + style={style}> {columns}
- ) + ); } diff --git a/source/Table/index.js b/source/Table/index.js index 45dcca9b6..c4a5dc920 100644 --- a/source/Table/index.js +++ b/source/Table/index.js @@ -1,11 +1,25 @@ /* @flow */ -export default from './Table' -export defaultCellDataGetter from './defaultCellDataGetter' -export defaultCellRenderer from './defaultCellRenderer' -export defaultHeaderRowRenderer from './defaultHeaderRowRenderer.js' -export defaultHeaderRenderer from './defaultHeaderRenderer' -export defaultRowRenderer from './defaultRowRenderer' -export Table from './Table' -export Column from './Column' -export SortDirection from './SortDirection' -export SortIndicator from './SortIndicator' +import createMultiSort from './createMultiSort'; +import defaultCellDataGetter from './defaultCellDataGetter'; +import defaultCellRenderer from './defaultCellRenderer'; +import defaultHeaderRowRenderer from './defaultHeaderRowRenderer.js'; +import defaultHeaderRenderer from './defaultHeaderRenderer'; +import defaultRowRenderer from './defaultRowRenderer'; +import Column from './Column'; +import SortDirection from './SortDirection'; +import SortIndicator from './SortIndicator'; +import Table from './Table'; + +export default Table; +export { + createMultiSort, + defaultCellDataGetter, + defaultCellRenderer, + defaultHeaderRowRenderer, + defaultHeaderRenderer, + defaultRowRenderer, + Column, + SortDirection, + SortIndicator, + Table, +}; diff --git a/source/Table/types.js b/source/Table/types.js index 279d624cd..5e4a5c907 100644 --- a/source/Table/types.js +++ b/source/Table/types.js @@ -2,7 +2,7 @@ export type CellDataGetterParams = { columnData: ?any, dataKey: string, - rowData: any + rowData: any, }; export type CellRendererParams = { @@ -10,22 +10,22 @@ export type CellRendererParams = { columnData: ?any, dataKey: string, rowData: any, - rowIndex: number + rowIndex: number, }; export type HeaderRowRendererParams = { className: string, columns: Array, - style: any + style: any, }; export type HeaderRendererParams = { columnData: ?any, dataKey: string, disableSort: ?boolean, - label: ?string, + label: ?any, sortBy: ?string, - sortDirection: ?string + sortDirection: ?string, }; export type RowRendererParams = { @@ -38,5 +38,6 @@ export type RowRendererParams = { onRowMouseOver: ?Function, onRowMouseOut: ?Function, rowData: any, - style: any + style: any, + key: string, }; diff --git a/source/TestUtils.js b/source/TestUtils.js index 1ce28eb2c..a258531e4 100644 --- a/source/TestUtils.js +++ b/source/TestUtils.js @@ -1,33 +1,33 @@ -import ReactDOM from 'react-dom' +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) { +export function render(markup) { if (!render._mountNode) { - render._mountNode = document.createElement('div') + render._mountNode = document.createElement('div'); // Unless we attach the mount-node to body, getBoundingClientRect() won't work - document.body.appendChild(render._mountNode) + document.body.appendChild(render._mountNode); - afterEach(render.unmount) + afterEach(render.unmount); } - return ReactDOM.render(markup, render._mountNode) + 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 () { +render.unmount = function() { if (render._mountNode) { - ReactDOM.unmountComponentAtNode(render._mountNode) + ReactDOM.unmountComponentAtNode(render._mountNode); - document.body.removeChild(render._mountNode) + document.body.removeChild(render._mountNode); - render._mountNode = null + render._mountNode = null; } -} +}; diff --git a/source/WindowScroller/WindowScroller.e2e.js b/source/WindowScroller/WindowScroller.e2e.js new file mode 100644 index 000000000..767ba2e05 --- /dev/null +++ b/source/WindowScroller/WindowScroller.e2e.js @@ -0,0 +1,165 @@ +/** + * @jest-environment jest-environment-puppeteer + */ + +const bootstrap = async () => { + const page = await global.browser.newPage(); + const scripts = [ + './node_modules/react/umd/react.development.js', + './node_modules/react-dom/umd/react-dom.development.js', + './dist/umd/react-virtualized.js', + ]; + + for (const path of scripts) { + await page.addScriptTag({path}); + } + + return page; +}; + +const renderWindowScroller = ({scrollElement}) => { + const {render} = window.ReactDOM; + const {createElement} = window.React; + const {WindowScroller} = window.ReactVirtualized; + + const container = document.createElement('div'); + container.id = 'container'; + container.style.margin = '100px'; + container.style.padding = '50px'; + document.body.appendChild(container); + document.body.style.margin = 0; + + if (scrollElement === 'container') { + container.style.width = '100%'; + container.style.height = '100%'; + container.style.overflow = 'auto'; + } + + render( + createElement( + WindowScroller, + { + scrollElement: scrollElement === 'container' ? container : window, + onScroll: window.scrollFn, + onResize: window.resizeFn, + }, + () => createElement('div', {style: {width: 2000, height: 3000}}), + ), + container, + ); +}; + +const delay = time => new Promise(resolve => setTimeout(resolve, time)); + +test('save position after resize and then scroll in window', async () => { + const page = await bootstrap(); + const scrollFn = jest.fn(); + const resizeFn = jest.fn(); + await page.exposeFunction('scrollFn', scrollFn); + await page.exposeFunction('resizeFn', resizeFn); + + await page.setViewport({width: 400, height: 600}); + await page.evaluate(renderWindowScroller, {scrollElement: 'window'}); + + // scroll more than viewport + await page.evaluate(() => window.scrollTo(610, 830)); + await delay(100); + // resize a bit container/window + await page.setViewport({width: 300, height: 500}); + await delay(100); + // scroll again + await page.evaluate(() => window.scrollTo(620, 840)); + await delay(100); + + await page.close(); + + expect(scrollFn.mock.calls).toEqual([ + [{scrollLeft: 610 - 150, scrollTop: 830 - 150}], + [{scrollLeft: 620 - 150, scrollTop: 840 - 150}], + ]); + expect(resizeFn.mock.calls).toEqual([[{width: 300, height: 500}]]); +}); + +test('save position after resize and then scroll in container', async () => { + const page = await bootstrap(); + const scrollFn = jest.fn(); + const resizeFn = jest.fn(); + await page.exposeFunction('scrollFn', scrollFn); + await page.exposeFunction('resizeFn', resizeFn); + + await page.setViewport({width: 400, height: 600}); + await page.evaluate(renderWindowScroller, {scrollElement: 'container'}); + + // scroll more than viewport + await page.$eval('#container', el => el.scrollTo(610, 830)); + await delay(100); + // resize a bit container/window + await page.setViewport({width: 300, height: 500}); + await delay(100); + // scroll again + await page.$eval('#container', el => el.scrollTo(620, 840)); + await delay(100); + + await page.close(); + + expect(scrollFn.mock.calls).toEqual([ + [{scrollLeft: 610 - 50, scrollTop: 830 - 50}], + [{scrollLeft: 620 - 50, scrollTop: 840 - 50}], + ]); + expect(resizeFn.mock.calls).toEqual([ + [{width: 500, height: 700}], + [{width: 400, height: 600}], + ]); +}); + +test('react on container resize without window changing', async () => { + const page = await bootstrap(); + const resizeFn = jest.fn(); + await page.exposeFunction('resizeFn', resizeFn); + + await page.evaluate(() => { + const {render} = window.ReactDOM; + const {createElement} = window.React; + const {WindowScroller} = window.ReactVirtualized; + + const wrapper = document.createElement('div'); + wrapper.id = 'wrapper'; + Object.assign(wrapper.style, { + width: '1000px', + height: '800px', + display: 'flex', + }); + const container = document.createElement('div'); + Object.assign(container.style, { + flex: '1', + }); + wrapper.appendChild(container); + document.body.style.margin = 0; + document.body.appendChild(wrapper); + + render( + createElement( + WindowScroller, + {scrollElement: container, onResize: window.resizeFn}, + () => null, + ), + container, + ); + }); + + await delay(100); + + await page.$eval('#wrapper', el => { + el.style.width = '500px'; + el.style.height = '700px'; + }); + + await delay(100); + + await page.close(); + + expect(resizeFn.mock.calls).toEqual([ + [{width: 1000, height: 800}], + [{width: 500, height: 700}], + ]); +}); diff --git a/source/WindowScroller/WindowScroller.example.js b/source/WindowScroller/WindowScroller.example.js index 521847761..e8831531e 100644 --- a/source/WindowScroller/WindowScroller.example.js +++ b/source/WindowScroller/WindowScroller.example.js @@ -1,136 +1,177 @@ -/** @flow */ -import cn from 'classnames' -import Immutable from 'immutable' -import React, { PropTypes, PureComponent } from 'react' -import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' -import WindowScroller from './WindowScroller' -import List from '../List' -import AutoSizer from '../AutoSizer' -import styles from './WindowScroller.example.css' - -export default class WindowScrollerExample extends PureComponent { +// @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 = { - list: PropTypes.instanceOf(Immutable.List).isRequired, customElement: PropTypes.any, isScrollingCustomElement: PropTypes.bool.isRequired, - setScrollingCustomElement: PropTypes.func - } - - constructor (props) { - super(props) + list: PropTypes.instanceOf(Immutable.List).isRequired, + setScrollingCustomElement: PropTypes.func, + }; - this.state = { - showHeaderText: true - } + state = { + scrollToIndex: -1, + showHeaderText: true, + }; - this._hideHeader = this._hideHeader.bind(this) - this._rowRenderer = this._rowRenderer.bind(this) - this._onCheckboxChange = this._onCheckboxChange.bind(this) - this._setRef = this._setRef.bind(this) - } + _windowScroller: ?WindowScroller; - render () { - const { list, isScrollingCustomElement, customElement } = this.context - const { showHeaderText } = this.state + 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 + This component decorates List, Table, or + any other component and manages the window scroll to scroll through + the list )} {showHeaderText && ( - + )} - -
- - {({ height, isScrolling, scrollTop }) => ( + + + + + + {({height, isScrolling, registerChild, onChildScroll, scrollTop}) => ( +
- {({ width }) => ( - this._rowRenderer({ index, isScrolling, isVisible, key, style })} - scrollTop={scrollTop} - width={width} - /> + {({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 - }, () => { - this._windowScroller.updatePosition() - }) - } - - _rowRenderer ({ index, isScrolling, isVisible, key, style }) { - const { list } = this.context - const row = list.get(index) - const className = cn(styles.row, { + _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 - }) + isVisible: isVisible, + }); return ( -
+
{row.name}
- ) - } - - _setRef (windowScroller) { - this._windowScroller = windowScroller - } + ); + }; + + _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; + } - _onCheckboxChange (event) { - this.context.setScrollingCustomElement(event.target.checked) - } + setTimeout(() => { + this.setState({scrollToIndex}); + }, 0); + }; } diff --git a/source/WindowScroller/WindowScroller.header-resize.e2e.js b/source/WindowScroller/WindowScroller.header-resize.e2e.js new file mode 100644 index 000000000..23531309a --- /dev/null +++ b/source/WindowScroller/WindowScroller.header-resize.e2e.js @@ -0,0 +1,206 @@ +/** + * @jest-environment jest-environment-puppeteer + */ + +const bootstrap = async () => { + const page = await global.browser.newPage(); + const scripts = [ + './node_modules/react/umd/react.development.js', + './node_modules/react-dom/umd/react-dom.development.js', + './dist/umd/react-virtualized.js', + ]; + + for (const path of scripts) { + await page.addScriptTag({path}); + } + + return page; +}; + +const renderWindowScroller = updateScrollTopOnUpdatePosition => { + const {render} = window.ReactDOM; + const {createElement, useState, useEffect} = window.React; + const {WindowScroller} = window.ReactVirtualized; + + const container = document.createElement('div'); + container.id = 'container'; + document.body.appendChild(container); + document.body.style.margin = 0; + + function Header({height}) { + return createElement('div', {style: {height, backgroundColor: 'red'}}); + } + + function App() { + const [height, setHeight] = useState(100); + window.setHeaderHeight = setHeight; + useEffect(() => () => (window.setHeaderHeight = null)); + + return createElement( + 'div', + {}, + createElement(Header, {height}), + createElement( + WindowScroller, + { + updateScrollTopOnUpdatePosition, + ref: windowScroller => { + window.windowScroller = windowScroller; + }, + onScroll: window.scrollFn, + onResize: window.resizeFn, + }, + ({width, scrollTop}) => { + console.log({scrollTop}); + window.windowScrollerScrollTop = scrollTop; + return createElement('div', { + style: { + width, + height: 3000, + backgroundColor: 'yellow', + }, + }); + }, + ), + ); + } + + render( + createElement( + 'div', + {'data-test-id': 'main-container'}, + createElement(App, {}), + ), + container, + ); +}; + +jest.setTimeout(1200000); + +const delay = time => new Promise(resolve => setTimeout(resolve, time)); + +test('will react to header height updates if notified through updatePosition', async () => { + const page = await bootstrap(); + const scrollFn = jest.fn(); + const resizeFn = jest.fn(); + await page.exposeFunction('scrollFn', scrollFn); + await page.exposeFunction('resizeFn', resizeFn); + + await page.setViewport({width: 400, height: 600}); + await page.evaluate(renderWindowScroller, true); + + const el = await page.$('[data-test-id="main-container"]'); + expect(el).not.toBeNull(); + + await page.evaluate(() => window.scrollTo(0, 200)); + await delay(500); + + { + const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); + expect(scrollTop).toEqual(100); + } + await delay(500); + + // Update the header height + await page.evaluate(() => { + console.log('change header height'); + window.setHeaderHeight(200); + }); + await delay(500); + + await page.evaluate(() => { + console.log('update position'); + window.windowScroller.updatePosition(); + }); + await delay(500); + + // Despite header updates, we'd expect the scrollTop to be the same. + { + const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); + expect(scrollTop).toEqual(100); + } +}); + +test('will NOT react to header height updates if notified through updatePosition if `updateScrollTopOnUpdatePosition` is false', async () => { + const page = await bootstrap(); + const scrollFn = jest.fn(); + const resizeFn = jest.fn(); + await page.exposeFunction('scrollFn', scrollFn); + await page.exposeFunction('resizeFn', resizeFn); + + await page.setViewport({width: 400, height: 600}); + await page.evaluate(renderWindowScroller, false); + + const el = await page.$('[data-test-id="main-container"]'); + expect(el).not.toBeNull(); + + await page.evaluate(() => window.scrollTo(0, 200)); + await delay(500); + + { + const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); + expect(scrollTop).toEqual(100); + } + await delay(500); + + // Update the header height + await page.evaluate(() => { + console.log('change header height'); + window.setHeaderHeight(200); + }); + await delay(500); + + await page.evaluate(() => { + console.log('update position'); + window.windowScroller.updatePosition(); + }); + await delay(500); + + // Despite header updates, we'd expect the scrollTop to be the same. + // As the fix is off, this will fail. + const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); + expect(() => { + expect(scrollTop).toEqual(100); + }).toThrow(); +}); + +test('will properly process scroll events after header height updates', async () => { + const page = await bootstrap(); + const scrollFn = jest.fn(); + const resizeFn = jest.fn(); + await page.exposeFunction('scrollFn', scrollFn); + await page.exposeFunction('resizeFn', resizeFn); + + await page.setViewport({width: 400, height: 600}); + await page.evaluate(renderWindowScroller, true); + + const el = await page.$('[data-test-id="main-container"]'); + expect(el).not.toBeNull(); + + await page.evaluate(() => window.scrollTo(0, 200)); + await delay(500); + + { + const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); + expect(scrollTop).toEqual(100); + } + await delay(500); + + // Update the header height + await page.evaluate(() => { + window.setHeaderHeight(200); + }); + await delay(500); + + await page.evaluate(() => { + window.windowScroller.updatePosition(); + }); + await delay(500); + // This is only 50px under the first position + await page.evaluate(() => window.scrollTo(0, 350)); + + { + const scrollTop = await page.evaluate(() => window.windowScrollerScrollTop); + expect(scrollTop).toEqual(150); + } +}); diff --git a/source/WindowScroller/WindowScroller.jest.js b/source/WindowScroller/WindowScroller.jest.js index d7654fe6c..e3c54cf93 100644 --- a/source/WindowScroller/WindowScroller.jest.js +++ b/source/WindowScroller/WindowScroller.jest.js @@ -1,54 +1,39 @@ -/* global Element */ - -import React from 'react' -import { findDOMNode } from 'react-dom' -import { render } from '../TestUtils' -import { IS_SCROLLING_TIMEOUT } from './utils/onScroll' -import WindowScroller from './WindowScroller' - -function ChildComponent ({ scrollTop, isScrolling, height }) { - return ( -
{`scrollTop:${scrollTop}, isScrolling:${isScrolling}, height:${height}`}
- ) -} +import * as React from 'react'; +import {findDOMNode} from 'react-dom'; +import {render} from '../TestUtils'; +import WindowScroller, {IS_SCROLLING_TIMEOUT} from './WindowScroller'; -function mockGetBoundingClientRectForHeader ({ +function mockGetBoundingClientRectForHeader({ documentOffset = 0, - height + 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 - })) + top: height, + left: width, + })); document.documentElement.getBoundingClientRect = jest.fn(() => ({ - top: documentOffset - })) + top: documentOffset, + left: documentOffset, + })); } -function getMarkup ({ - headerElements, - documentOffset, - ...props -} = {}) { +function getMarkup({headerElements, documentOffset, renderFn, ...props} = {}) { const windowScroller = ( - {({ height, isScrolling, scrollTop }) => ( - - )} + {params =>
{renderFn && renderFn(params)}
}
- ) + ); // JSDome doesn't implement a working getBoundingClientRect() // But WindowScroller requires it mockGetBoundingClientRectForHeader({ documentOffset, - height: headerElements ? headerElements.props.style.height : 0 - }) + height: headerElements ? headerElements.props.style.height : 0, + width: headerElements ? headerElements.props.style.width : 0, + }); if (headerElements) { return ( @@ -56,229 +41,436 @@ function getMarkup ({ {headerElements} {windowScroller}
- ) + ); } else { - return windowScroller + return windowScroller; } } -function simulateWindowScroll ({ - scrollY = 0 -}) { - document.body.style.height = '10000px' - window.scrollY = scrollY - document.dispatchEvent(new window.Event('scroll', { bubbles: true })) - document.body.style.height = '' +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 -}) { - window.innerHeight = height - document.dispatchEvent(new window.Event('resize', { bubbles: true })) +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.innerHeight = 500 - }) + 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 property to be defined on :_positionFromTop', () => { - const component = render(getMarkup()) - const rendered = findDOMNode(component) - const top = rendered.getBoundingClientRect().top - expect(component._positionFromTop).toEqual(top) - }) + 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 property to be defined on :_positionFromTop if documentElement is scrolled', () => { - render.unmount() + 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 = rendered.getBoundingClientRect().top - expect(component._positionFromTop).toEqual(top + 100) + 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 - }) + delete document.documentElement.getBoundingClientRect; + }); it('inherits the window height and passes it to child component', () => { - const component = render(getMarkup()) - const rendered = findDOMNode(component) - - expect(component.state.height).toEqual(window.innerHeight) - expect(component.state.height).toEqual(500) - expect(rendered.textContent).toContain('height:500') - }) - - it('should restore pointerEvents on body after IS_SCROLLING_TIMEOUT', async (done) => { - 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)) - expect(document.body.style.pointerEvents).toEqual('all') - done() - }) + 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') - }) + 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 done => { - const onScrollCalls = [] - render(getMarkup({ - onScroll: params => onScrollCalls.push(params) - })) + it('should trigger callback when window scrolls', async () => { + const onScroll = jest.fn(); + render(getMarkup({onScroll})); - simulateWindowScroll({ scrollY: 5000 }) + simulateWindowScroll({scrollY: 5000}); // Allow scrolling timeout to complete so that the component computes state - await new Promise(resolve => setTimeout(resolve, 150)) + await new Promise(resolve => setTimeout(resolve, 150)); - expect(onScrollCalls.length).toEqual(1) - expect(onScrollCalls[0]).toEqual({ - scrollTop: 5000 - }) + expect(onScroll).toHaveBeenCalledWith({ + scrollLeft: 0, + scrollTop: 5000, + }); - done() - }) - - it('should update :scrollTop when window is scrolled', async done => { - const component = render(getMarkup()) - const rendered = findDOMNode(component) - - // Initial load of the component should have 0 scrollTop - expect(rendered.textContent).toContain('scrollTop:0') - - simulateWindowScroll({ scrollY: 5000 }) + simulateWindowScroll({ + scrollX: 2500, + 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(rendered.textContent).toContain(`scrollTop:${componentScrollTop}`) - - done() - }) + await new Promise(resolve => setTimeout(resolve, 150)); - it('should specify :isScrolling when scrolling and reset after scrolling', async (done) => { - const component = render(getMarkup()) - const rendered = findDOMNode(component) + expect(onScroll).toHaveBeenCalledWith({ + scrollLeft: 2500, + scrollTop: 5000, + }); + }); - simulateWindowScroll({ scrollY: 5000 }) + it('should update :scrollTop when window is scrolled', async () => { + const renderFn = jest.fn(); + const component = render(getMarkup({renderFn})); - expect(rendered.textContent).toContain('isScrolling:true') - - await new Promise(resolve => setTimeout(resolve, 250)) + // Initial load of the component should have 0 scrollTop + expect(renderFn).lastCalledWith( + expect.objectContaining({ + scrollTop: 0, + }), + ); - expect(rendered.textContent).toContain('isScrolling:false') + simulateWindowScroll({scrollY: 5000}); - done() - }) - }) + // 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 when window resizes', () => { - const onResizeCalls = [] - render(getMarkup({ - onResize: params => onResizeCalls.push(params) - })) + it('should trigger callback on init and when window resizes', () => { + const resizeFn = jest.fn(); + render(getMarkup({onResize: resizeFn})); - simulateWindowResize({ height: 1000 }) + simulateWindowResize({height: 1000, width: 1024}); - expect(onResizeCalls.length).toEqual(1) - expect(onResizeCalls[0]).toEqual({ - height: 1000 - }) - }) + expect(resizeFn).toHaveBeenCalledTimes(1); + expect(resizeFn).lastCalledWith({height: 1000, width: 1024}); + }); it('should update height when window resizes', () => { - const component = render(getMarkup()) - const rendered = findDOMNode(component) + 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(rendered.textContent).toContain('height:500') - - simulateWindowResize({ height: 1000 }) - - expect(component.state.height).toEqual(window.innerHeight) - expect(component.state.height).toEqual(1000) - expect(rendered.textContent).toContain('height:1000') - }) - }) + 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 + let windowScroller; - render(getMarkup({ - headerElements:
, - ref: (ref) => { - windowScroller = ref - } - })) + render( + getMarkup({ + headerElements:
, + ref: ref => { + windowScroller = ref; + }, + }), + ); - expect(windowScroller._positionFromTop).toBe(100) - }) + expect(windowScroller._positionFromTop).toBe(100); + }); it('should recalculate the offset from the top when the window resizes', () => { - let windowScroller + let windowScroller; - render(getMarkup({ - headerElements: , - ref: (ref) => { - windowScroller = ref - } - })) + render( + getMarkup({ + headerElements: