├── .nvmrc ├── src ├── index.js ├── virtual-container.js └── __tests__ │ └── virtual-container.test.js ├── tools ├── .eslintrc ├── utils.js └── scripts │ └── build.js ├── .babelrc ├── rollup-min.config.js ├── .gitignore ├── .travis.yml ├── wallaby.js ├── rollup.config.js ├── LICENSE ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 8 2 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './virtual-container' 2 | -------------------------------------------------------------------------------- /tools/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "no-console": 0, 4 | "import/no-extraneous-dependencies": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "stage-3", 5 | "react" 6 | ], 7 | "plugins": ["transform-class-properties"] 8 | } 9 | -------------------------------------------------------------------------------- /tools/utils.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process') 2 | const appRootDir = require('app-root-dir') 3 | 4 | function exec(command) { 5 | execSync(command, { stdio: 'inherit', cwd: appRootDir.get() }) 6 | } 7 | 8 | module.exports = { 9 | exec, 10 | } 11 | -------------------------------------------------------------------------------- /rollup-min.config.js: -------------------------------------------------------------------------------- 1 | const { uglify } = require('rollup-plugin-uglify') 2 | const packageJson = require('./package.json') 3 | 4 | const baseConfig = require('./rollup.config.js') 5 | 6 | baseConfig.plugins.push(uglify()) 7 | baseConfig.output.file = `dist/${packageJson.name}.min.js` 8 | 9 | module.exports = baseConfig 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Dependencies 6 | node_modules 7 | 8 | # Debug log from npm 9 | npm-debug.log 10 | 11 | # Jest 12 | coverage 13 | 14 | # Build output 15 | dist 16 | 17 | # Flow 18 | flow-coverage 19 | flow-typed 20 | 21 | # Yarn 22 | yarn-error.log 23 | 24 | # Temp directories 25 | temp 26 | 27 | # IDEs 28 | .vscode 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | yarn: true 5 | directories: 6 | - node_modules 7 | node_js: 8 | - '8' 9 | script: 10 | # Unfortunately flow falls over when a dep exists in peer deps and others. :( 11 | # @see https://github.com/flowtype/flow-typed/issues/528 12 | #- yarn run flow:defs 13 | - npm run test 14 | after_success: 15 | # Deploy code coverage report to codecov.io 16 | - npm run test:coverage:deploy 17 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | process.env.NODE_ENV = 'test' 5 | 6 | const babelConfigContents = fs.readFileSync(path.join(__dirname, '.babelrc')) 7 | const babelConfig = JSON.parse(babelConfigContents) 8 | 9 | module.exports = wallaby => ({ 10 | files: ['src/**/*.js', { pattern: 'src/**/*.test.js', ignore: true }], 11 | tests: ['src/**/*.test.js'], 12 | testFramework: 'jest', 13 | env: { 14 | type: 'node', 15 | runner: 'node', 16 | }, 17 | compilers: { 18 | 'src/**/*.js': wallaby.compilers.babel(babelConfig), 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /tools/scripts/build.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require('fs') 2 | const { inInstall } = require('in-publish') 3 | const prettyBytes = require('pretty-bytes') 4 | const gzipSize = require('gzip-size') 5 | const { pipe } = require('ramda') 6 | const { exec } = require('../utils') 7 | const packageJson = require('../../package.json') 8 | 9 | if (inInstall()) { 10 | process.exit(0) 11 | } 12 | 13 | const nodeEnv = Object.assign({}, process.env, { 14 | NODE_ENV: 'production', 15 | }) 16 | 17 | exec('npx rollup -c rollup-min.config.js', nodeEnv) 18 | exec('npx rollup -c rollup.config.js', nodeEnv) 19 | 20 | function fileGZipSize(path) { 21 | return pipe(readFileSync, gzipSize.sync, prettyBytes)(path) 22 | } 23 | 24 | console.log( 25 | `\ngzipped, the build is ${fileGZipSize(`dist/${packageJson.name}.min.js`)}`, 26 | ) 27 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const flow = require('rollup-plugin-flow') 2 | const babel = require('rollup-plugin-babel') 3 | const changeCase = require('change-case') 4 | const packageJson = require('./package.json') 5 | 6 | process.env.BABEL_ENV = 'production' 7 | 8 | module.exports = { 9 | external: ['react', 'react-waypoint', 'prop-types'], 10 | input: 'src/index.js', 11 | output: { 12 | file: `dist/${packageJson.name}.js`, 13 | format: 'cjs', 14 | sourcemap: true, 15 | name: changeCase 16 | .titleCase(packageJson.name.replace(/-/g, ' ')) 17 | .replace(/ /g, ''), 18 | }, 19 | plugins: [ 20 | flow({ all: true }), 21 | babel({ 22 | babelrc: false, 23 | exclude: 'node_modules/**', 24 | presets: [['env', { modules: false }], 'stage-3', 'react'], 25 | plugins: ['external-helpers', 'transform-class-properties'], 26 | }), 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Sean Matheson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-virtual-container", 3 | "version": "0.3.1", 4 | "description": "Optimise your React apps by only rendering your components when they are in proximity to the viewport.", 5 | "license": "MIT", 6 | "main": "dist/react-virtual-container.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/ctrlplusb/react-virtual-container.git" 13 | }, 14 | "homepage": "https://github.com/ctrlplusb/react-virtual-container#readme", 15 | "author": "Sean Matheson ", 16 | "keywords": [ 17 | "library", 18 | "react", 19 | "virtual", 20 | "windowing", 21 | "render props" 22 | ], 23 | "scripts": { 24 | "build": "node ./tools/scripts/build.js", 25 | "clean": "rimraf ./dist && rimraf ./coverage", 26 | "lint": "eslint src", 27 | "precommit": "lint-staged && npm run test", 28 | "prepublish": "npm run build", 29 | "test": "jest", 30 | "test:coverage": "npm run test -- --coverage", 31 | "test:coverage:deploy": "npm run test:coverage && codecov" 32 | }, 33 | "peerDependencies": { 34 | "prop-types": "^15.0.0", 35 | "react": "^16.2.0" 36 | }, 37 | "dependencies": { 38 | "react-waypoint": "^8.0.3" 39 | }, 40 | "devDependencies": { 41 | "app-root-dir": "^1.0.2", 42 | "babel-cli": "^6.26.0", 43 | "babel-core": "^6.26.3", 44 | "babel-eslint": "^8.2.6", 45 | "babel-jest": "^23.4.0", 46 | "babel-loader": "^7.1.5", 47 | "babel-plugin-external-helpers": "^6.22.0", 48 | "babel-plugin-transform-class-properties": "^6.24.1", 49 | "babel-polyfill": "^6.26.0", 50 | "babel-preset-env": "^1.7.0", 51 | "babel-preset-react": "^6.24.1", 52 | "babel-preset-stage-3": "^6.24.1", 53 | "babel-register": "^6.26.0", 54 | "change-case": "^3.0.2", 55 | "codecov": "^3.0.4", 56 | "cross-env": "^5.2.0", 57 | "enzyme": "^3.3.0", 58 | "enzyme-adapter-react-16": "^1.1.1", 59 | "enzyme-to-json": "^3.3.4", 60 | "eslint": "^4.9.0", 61 | "eslint-config-airbnb": "^17.0.0", 62 | "eslint-config-prettier": "^2.6.0", 63 | "eslint-plugin-import": "^2.13.0", 64 | "eslint-plugin-jsx-a11y": "^6.1.0", 65 | "eslint-plugin-react": "^7.10.0", 66 | "gzip-size": "^5.0.0", 67 | "husky": "^0.14.3", 68 | "in-publish": "^2.0.0", 69 | "jest": "^23.4.0", 70 | "lint-staged": "^7.2.0", 71 | "prettier": "^1.13.7", 72 | "prettier-eslint": "^8.8.2", 73 | "pretty-bytes": "^5.1.0", 74 | "ramda": "^0.25.0", 75 | "react": "^16.4.1", 76 | "react-dom": "^16.4.1", 77 | "readline-sync": "^1.4.7", 78 | "rimraf": "^2.6.2", 79 | "rollup": "^0.62.0", 80 | "rollup-plugin-babel": "^3.0.7", 81 | "rollup-plugin-flow": "^1.1.1", 82 | "rollup-plugin-uglify": "^4.0.0", 83 | "sinon": "^6.1.3" 84 | }, 85 | "jest": { 86 | "collectCoverageFrom": [ 87 | "src/**/*.{js,jsx}" 88 | ], 89 | "testPathIgnorePatterns": [ 90 | "/(commonjs|coverage|node_modules|tools|umd)/" 91 | ] 92 | }, 93 | "eslintConfig": { 94 | "parser": "babel-eslint", 95 | "root": true, 96 | "env": { 97 | "browser": true, 98 | "es6": true, 99 | "jest": true, 100 | "node": true 101 | }, 102 | "extends": [ 103 | "airbnb", 104 | "prettier", 105 | "prettier/react" 106 | ], 107 | "rules": { 108 | "array-callback-return": 0, 109 | "camelcase": 0, 110 | "import/prefer-default-export": 0, 111 | "import/no-extraneous-dependencies": 0, 112 | "no-underscore-dangle": 0, 113 | "no-nested-ternary": 0, 114 | "react/no-array-index-key": 0, 115 | "react/react-in-jsx-scope": 0, 116 | "react/forbid-prop-types": 0, 117 | "react/jsx-filename-extension": 0, 118 | "react/sort-comp": 0 119 | } 120 | }, 121 | "eslintIgnoreConfig": [ 122 | "node_modules/", 123 | "coverage/", 124 | "dist/" 125 | ], 126 | "prettier": { 127 | "semi": false, 128 | "singleQuote": true, 129 | "trailingComma": "all", 130 | "parser": "flow" 131 | }, 132 | "lint-staged": { 133 | "*.js": [ 134 | "prettier --write \"src/**/*.js\"", 135 | "git add" 136 | ] 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/virtual-container.js: -------------------------------------------------------------------------------- 1 | import React, { createElement, Component, Fragment } from 'react' 2 | import PropTypes from 'prop-types' 3 | import Waypoint from 'react-waypoint' 4 | 5 | const defaultStyle = { position: 'relative' } 6 | 7 | export default class VirtualContainer extends Component { 8 | static propTypes = { 9 | children: PropTypes.func, 10 | className: PropTypes.string, 11 | el: PropTypes.string, 12 | inAndOut: PropTypes.bool, 13 | onChange: PropTypes.func, 14 | offsetBottom: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 15 | offsetTop: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 16 | optimistic: PropTypes.bool, 17 | placeholder: PropTypes.func, 18 | render: PropTypes.func, 19 | scrollableAncestor: PropTypes.any, 20 | style: PropTypes.object, 21 | } 22 | 23 | static defaultProps = { 24 | children: undefined, 25 | className: undefined, 26 | el: 'div', 27 | inAndOut: false, 28 | offsetBottom: '50vh', 29 | offsetTop: '50vh', 30 | optimistic: false, 31 | onChange: undefined, 32 | placeholder: undefined, 33 | render: undefined, 34 | scrollableAncestor: undefined, 35 | style: undefined, 36 | } 37 | 38 | constructor(props) { 39 | super(props) 40 | this.state = { 41 | virtualized: !props.optimistic, 42 | } 43 | this.initialized = false 44 | } 45 | 46 | componentDidMount() { 47 | if (process.env.NODE_ENV === 'development') { 48 | if (this.el && getComputedStyle(this.el).position == null) { 49 | // eslint-disable-next-line no-console 50 | console.warn( 51 | 'You must provide a "position" style to your VirtualContainer element', 52 | ) 53 | } 54 | } 55 | } 56 | 57 | componentWillUnmount() { 58 | this.unmounted = true 59 | } 60 | 61 | handleWaypointChange = type => ({ currentPosition, previousPosition }) => { 62 | if (this.unmounted) { 63 | return 64 | } 65 | 66 | const waypointIn = type === 'bottom' ? Waypoint.below : Waypoint.above 67 | const waypointOut = type === 'bottom' ? Waypoint.above : Waypoint.below 68 | 69 | const startsNotVisible = 70 | previousPosition == null && currentPosition === waypointOut 71 | const startsVisible = 72 | previousPosition == null && 73 | (currentPosition === waypointIn || currentPosition === Waypoint.inside) 74 | const isNowVisible = 75 | previousPosition === waypointOut && 76 | (currentPosition === waypointIn || currentPosition === Waypoint.inside) 77 | const isNowNotVisible = 78 | previousPosition === Waypoint.inside && currentPosition === waypointOut 79 | 80 | const virtualized = 81 | startsNotVisible || isNowNotVisible 82 | ? true 83 | : startsVisible || isNowVisible 84 | ? false 85 | : this.state.virtualized 86 | 87 | if (this.initialized === false) { 88 | this[type] = virtualized 89 | if ( 90 | typeof this.top !== 'undefined' && 91 | typeof this.bottom !== 'undefined' 92 | ) { 93 | this.initialized = true 94 | this.updateVirtualization(this.top || this.bottom) 95 | } 96 | } else { 97 | this.updateVirtualization(virtualized) 98 | } 99 | } 100 | 101 | updateVirtualization = virtualized => { 102 | if (this.state.virtualized !== virtualized) { 103 | this.setState({ 104 | virtualized, 105 | }) 106 | if (this.props.onChange) { 107 | this.props.onChange(virtualized) 108 | } 109 | } 110 | } 111 | 112 | onBottomWaypointChange = this.handleWaypointChange('bottom') 113 | 114 | onTopWaypointChange = this.handleWaypointChange('top') 115 | 116 | refCb = el => { 117 | if (el == null) { 118 | return 119 | } 120 | this.el = el 121 | } 122 | 123 | render() { 124 | const { 125 | children, 126 | className, 127 | el, 128 | inAndOut, 129 | optimistic, 130 | offsetTop, 131 | offsetBottom, 132 | placeholder: Placeholder, 133 | scrollableAncestor, 134 | style, 135 | render, 136 | ...passThroughProps 137 | } = this.props 138 | const { virtualized } = this.state 139 | const resolvedRender = children || render 140 | if (!resolvedRender) { 141 | throw new Error('Must provide a children or render function') 142 | } 143 | 144 | const stopTracking = this.initialized && !inAndOut 145 | 146 | return createElement( 147 | el, 148 | { 149 | ...passThroughProps, 150 | className, 151 | style: style != null ? style : className ? undefined : defaultStyle, 152 | ref: this.refCb, 153 | }, 154 | 155 | {!virtualized || stopTracking ? ( 156 | resolvedRender() 157 | ) : Placeholder ? ( 158 | 159 | ) : null} 160 | {stopTracking ? null : ( 161 | 165 | 166 | 167 | )} 168 | {stopTracking ? null : ( 169 | 173 | 174 | 175 | )} 176 | , 177 | ) 178 | } 179 | } 180 | 181 | const WaypointTarget = ({ innerRef, offsetBottom, offsetTop, top }) => ( 182 |
194 |   195 |
196 | ) 197 | 198 | WaypointTarget.propTypes = { 199 | innerRef: PropTypes.any, 200 | offsetBottom: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 201 | offsetTop: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 202 | top: PropTypes.bool, 203 | } 204 | 205 | WaypointTarget.defaultProps = { 206 | innerRef: undefined, 207 | offsetBottom: 0, 208 | offsetTop: 0, 209 | top: false, 210 | } 211 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-virtual-container 2 | 3 | Optimise your React apps by only rendering your components when they are in proximity to the viewport. 4 | 5 | 6 | [![npm](https://img.shields.io/npm/v/react-virtual-container.svg?style=flat-square)](http://npm.im/react-virtual-container) 7 | [![MIT License](https://img.shields.io/npm/l/react-virtual-container.svg?style=flat-square)](http://opensource.org/licenses/MIT) 8 | [![Travis](https://img.shields.io/travis/ctrlplusb/react-virtual-container.svg?style=flat-square)](https://travis-ci.org/ctrlplusb/react-virtual-container) 9 | [![Codecov](https://img.shields.io/codecov/c/github/ctrlplusb/react-virtual-container.svg?style=flat-square)](https://codecov.io/github/ctrlplusb/react-virtual-container) 10 | 11 | ## Table of Contents 12 | 13 | - [Introduction](#introduction) 14 | - [Installation](#installation) 15 | - [Example](#example) 16 | - [Example - Out Again](#example---out-again) 17 | - [Usage](#usage) 18 | - [Configuration](#configuration) 19 | - [Tips and Tricks](#tips-and-tricks) 20 | - [Usage with Styled Components or Emotion](#usage-with-styled-components-or-emotion) 21 | 22 | ## Introduction 23 | 24 | This library provides you with the ability to create a "virtual container", where it's children will only get rendered if the "virtual container" is within a given proximity of the viewport. This provides you with a nice mechanism by which to lazy load images or "heavy" components. 25 | 26 | ## Installation 27 | 28 | ```bash 29 | yarn add react-virtual-container 30 | ``` 31 | 32 | Or, if you prefer npm: 33 | 34 | ```bash 35 | npm install react-virtual-container 36 | ``` 37 | 38 | ## Example 39 | 40 | In the example below you will note two virtual containers. As the viewport moves down the page it triggers each virtual container causing the associated component to render and replace their placeholder. 41 | 42 |

43 | 44 |

45 | 46 | ## Example - Out Again 47 | 48 | In this example we expand on the behaviour of our virtual containers by setting the `inAndOut` prop. With this prop enabled as the viewport moves away from the virtual containers the components will again be replaced by their placeholders. This can be useful for cases where the components being virtualised consume a heavy amount of CPU/memory resources. 49 | 50 |

51 | 52 |

53 | 54 | ### Usage 55 | 56 | This library follows the "render prop" pattern, allowing you to specify a `render` or `children` prop. The "render prop" will be called and have its result rendered when the virtual container is within proximity of the viewport. 57 | 58 | By default the virtual container needs to be within "50vh" proximity of the viewport. This is configurable - see [configuration](#configuration). You could set it to a pixel value or any other standard CSS unit value. 59 | 60 | In addition to the "render prop" a useful prop is `placeholder`; it allows you to provide a component which will be rendered by the virtual container until it is within proximity of the viewport. This allows you to avoid layout that jumps when the virtual container activates. The `placeholder` prop is optional, however, we do recommend using it where you expect content jumping to occur. 61 | 62 | ```jsx 63 | import React from 'react' 64 | import VirtualContainer from 'react-virtual-container' 65 | 66 | const Placeholder = () =>
🙈
67 | 68 | export default function MyVirtualisedComponent() { 69 | return ( 70 | 71 | {() =>
🐵
} 72 |
73 | ) 74 | } 75 | ``` 76 | 77 | ### Configuration 78 | 79 | The virtual container component accepts the following props: 80 | 81 | - `children` (_PropTypes.func_) 82 | 83 | The "render prop" responsible for returning the elements you wish to render should the virtual container come within proximity of the viewport. You can alternatively use the `render` prop. 84 | 85 | - `className` (_PropTypes.string_, optional) 86 | 87 | A class to apply to the virtual container element. 88 | 89 | > Note: when providing a style for your virtual container element you MUST ensure that you have a "position" value set. This is because we use a set of absolutely positioned elements by which to track the proximity. 90 | 91 | - `el` (_PropTypes.string_, *default*: "div") 92 | 93 | The type of element that the virtual container should render as. 94 | 95 | > Your render prop results will render as children to this element. 96 | 97 | - `inAndOut` (_PropTypes.bool_, *default*: false) 98 | 99 | If you enable this prop, then your component will be removed (or replaced with the `placeholder` if one was defined) when the viewport moves out of proximity to the virtual container. 100 | 101 | This can be especially useful for component that heavily use CPU/memory resources. 102 | 103 | - `offsetBottom` (_PropTypes.oneOfType([PropTypes.string, PropTypes.number])_, *default*: "50vh") 104 | 105 | The proximity value that will trigger rendering of your "render prop" when the virtual container is within the specified distance relative to the bottom of the view port. 106 | 107 | - `offsetTop` (_PropTypes.oneOfType([PropTypes.string, PropTypes.number])_, *default*: "50vh") 108 | 109 | The proximity value that will trigger rendering of your "render prop" when the virtual container is within the specified distance relative to the top of the view port. 110 | 111 | - `onChange` (_PropTypes.func_) 112 | 113 | If provided, this callback function will be called any time the virtualisation value changes. It recieves a single boolean parameter, being `true` when virtualisation is active, and `false` when it is not. 114 | 115 | - `optimistic` (_PropTypes.bool_, *default*: false) 116 | 117 | If you set this then the placeholder will be rendered before the "render prop" whilst we determine if the virtual container is within proximity of the viewport. You should almost never use this. 118 | 119 | - `placeholder` (_PropTypes.func_) 120 | 121 | A placeholder component/function that will be rendered when the virtual container is not within proximity of the viewport. Useful to avoid your page jumping with new content being inserted (especially when scrolling from the bottom of the page up). 122 | 123 | - `render` (_PropTypes.func_) 124 | 125 | The "render prop" responsible for returning the elements you wish to render should the virtual container come within proximity of the viewport. You can alternatively use the `children` prop. 126 | 127 | - `scrollableAncestor` (_PropTypes.any_) 128 | 129 | When tracking proximity we use scroll positioning. Depending on the structure of your DOM you may want to explicitly define the element by which your scrolling is bound. For example this could be a value of "window" or a direct reference to a DOM node. 130 | 131 | - `style` (_PropTypes.object_) 132 | 133 | A style to provide to the virtual container element. 134 | 135 | > Note: when providing a style for your virtual container element you MUST ensure that you have a "position" value set. This is because we use a set of absolutely positioned elements by which to track the proximity. 136 | 137 | ### Tips and Tricks 138 | 139 | Below are some useful tips for using this library effectively within your app. 140 | 141 | #### Usage with Styled Components or Emotion 142 | 143 | The `VirtualContainer` component produces an actual DOM element - a `div` by default, although this is configurable via the `el` prop. 144 | 145 | What if you want to style the element via Styled Components or Emotion, two very popular CSS-in-JS libraries. 146 | 147 | You can completely do so by wrapping the `VirtualContainer` with the style function. Styled Components / Emotion will then pass down a `className` to the `VirtualContainer`, which it supports as a prop. The `className` will be applied to the element. 148 | 149 | We will update the example from earlier to do so. 150 | 151 | ```jsx 152 | import styled from 'react-emotion' 153 | import VirtualContainer from 'react-virtual-container' 154 | 155 | const StyledVirtualContainer = styled(VirtualContainer)` 156 | position: relative; 157 | height: 100px; 158 | background-color: pink; 159 | ` 160 | 161 | export default function MyVirtualisedComponent() { 162 | return ( 163 | // 👇 you can still pass down configuration! woot! 164 | 165 | {() =>
🐵
} 166 |
167 | ) 168 | } 169 | ``` 170 | 171 | Awesome! This is a pretty powerful technique, and can aid in avoiding having to use a `placeholder`. 172 | 173 | > Note: when providing your own styled, please make sure you set a "position" style on your component. This is because internally we have some relatively positioned elements which are rendered as children in order to tracking of viewport proximity. -------------------------------------------------------------------------------- /src/__tests__/virtual-container.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/accessible-emoji */ 2 | 3 | import React from 'react' 4 | import enzyme, { mount as enzymeMount } from 'enzyme' 5 | import Adapter from 'enzyme-adapter-react-16' 6 | import Waypoint from 'react-waypoint' 7 | import VirtualContainer from '../' 8 | 9 | enzyme.configure({ adapter: new Adapter() }) 10 | 11 | const waypointStates = { 12 | top: { 13 | initialVisible: { 14 | previousPosition: null, 15 | currentPosition: Waypoint.above, 16 | }, 17 | initialVisibleInside: { 18 | previousPosition: null, 19 | currentPosition: Waypoint.inside, 20 | }, 21 | initialNotVisible: { 22 | previousPosition: null, 23 | currentPosition: Waypoint.below, 24 | }, 25 | becomesVisible: { 26 | previousPosition: Waypoint.below, 27 | currentPosition: Waypoint.inside, 28 | }, 29 | becomesNotVisible: { 30 | previousPosition: Waypoint.inside, 31 | currentPosition: Waypoint.below, 32 | }, 33 | }, 34 | bottom: { 35 | initialVisible: { 36 | previousPosition: null, 37 | currentPosition: Waypoint.below, 38 | }, 39 | initialVisibleInside: { 40 | previousPosition: null, 41 | currentPosition: Waypoint.inside, 42 | }, 43 | initialNotVisible: { 44 | previousPosition: null, 45 | currentPosition: Waypoint.above, 46 | }, 47 | becomesVisible: { 48 | previousPosition: Waypoint.above, 49 | currentPosition: Waypoint.inside, 50 | }, 51 | becomesNotVisible: { 52 | previousPosition: Waypoint.inside, 53 | currentPosition: Waypoint.above, 54 | }, 55 | }, 56 | } 57 | 58 | describe('VirtualContainer', () => { 59 | const Placeholder = () =>
🙈
60 | const Actual = () =>
🐵
61 | const defaultProps = { 62 | render: Actual, 63 | } 64 | const mount = (props = {}) => { 65 | const wrapper = enzymeMount( 66 | , 67 | ) 68 | const topWaypoint = wrapper.find(Waypoint).at(0) 69 | const bottomWaypoint = wrapper.find(Waypoint).at(1) 70 | const changeTop = topWaypoint.prop('onPositionChange') 71 | const changeBottom = bottomWaypoint.prop('onPositionChange') 72 | return { 73 | wrapper, 74 | topWaypoint, 75 | bottomWaypoint, 76 | changeTop, 77 | changeBottom, 78 | } 79 | } 80 | it('should initially render the placeholder if one was provided', () => { 81 | const { wrapper } = mount({ placeholder: Placeholder }) 82 | expect(wrapper.containsMatchingElement()).toBe(true) 83 | }) 84 | describe('initial waypoint firing', () => { 85 | describe('with placeholder', () => { 86 | it('only one waypoint has fired - show placeholder', () => { 87 | const { wrapper, changeTop } = mount({ placeholder: Placeholder }) 88 | changeTop(waypointStates.top.initialVisible) 89 | wrapper.update() 90 | expect(wrapper.containsMatchingElement()).toBe(true) 91 | }) 92 | it('both waypoints fire, top visible, bottom not visible - show placeholder', () => { 93 | const { wrapper, changeTop, changeBottom } = mount({ 94 | placeholder: Placeholder, 95 | }) 96 | changeTop(waypointStates.top.initialVisible) 97 | changeBottom(waypointStates.bottom.initialNotVisible) 98 | wrapper.update() 99 | expect(wrapper.containsMatchingElement()).toBe(true) 100 | }) 101 | it('both waypoints fire, top not visible, bottom visible - show placeholder', () => { 102 | const { wrapper, changeTop, changeBottom } = mount({ 103 | placeholder: Placeholder, 104 | }) 105 | changeTop(waypointStates.top.initialNotVisible) 106 | changeBottom(waypointStates.bottom.initialVisible) 107 | wrapper.update() 108 | expect(wrapper.containsMatchingElement()).toBe(true) 109 | }) 110 | it('both waypoints fire, top visible, bottom visible - show actual', () => { 111 | const { wrapper, changeTop, changeBottom } = mount({ 112 | placeholder: Placeholder, 113 | }) 114 | changeTop(waypointStates.top.initialVisible) 115 | changeBottom(waypointStates.bottom.initialVisible) 116 | wrapper.update() 117 | expect(wrapper.containsMatchingElement(Actual())).toBe(true) 118 | }) 119 | }) 120 | describe('without placeholder', () => { 121 | it('only one waypoint has fired - show nothing', () => { 122 | const { wrapper, changeTop } = mount() 123 | changeTop(waypointStates.top.initialVisible) 124 | wrapper.update() 125 | expect(wrapper.containsMatchingElement()).toBe(false) 126 | expect(wrapper.containsMatchingElement(Actual())).toBe(false) 127 | }) 128 | it('both waypoints fire, top visible, bottom not visible - show nothing', () => { 129 | const { wrapper, changeTop, changeBottom } = mount() 130 | changeTop(waypointStates.top.initialVisible) 131 | changeBottom(waypointStates.bottom.initialNotVisible) 132 | wrapper.update() 133 | expect(wrapper.containsMatchingElement()).toBe(false) 134 | expect(wrapper.containsMatchingElement(Actual())).toBe(false) 135 | }) 136 | it('both waypoints fire, top not visible, bottom visible - show nothing', () => { 137 | const { wrapper, changeTop, changeBottom } = mount() 138 | changeTop(waypointStates.top.initialNotVisible) 139 | changeBottom(waypointStates.bottom.initialVisible) 140 | wrapper.update() 141 | expect(wrapper.containsMatchingElement()).toBe(false) 142 | expect(wrapper.containsMatchingElement(Actual())).toBe(false) 143 | }) 144 | it('both waypoints fire, top visible, bottom visible - show actual', () => { 145 | const { wrapper, changeTop, changeBottom } = mount() 146 | changeTop(waypointStates.top.initialVisible) 147 | changeBottom(waypointStates.bottom.initialVisible) 148 | wrapper.update() 149 | expect(wrapper.containsMatchingElement(Actual())).toBe(true) 150 | }) 151 | }) 152 | }) 153 | describe('waypoints updating', () => { 154 | describe('with placeholder', () => { 155 | describe('top visible, bottom not visible', () => { 156 | it('bottom waypoint becomes visible', () => { 157 | const { wrapper, changeTop, changeBottom } = mount({ 158 | placeholder: Placeholder, 159 | }) 160 | changeTop(waypointStates.top.initialVisible) 161 | changeBottom(waypointStates.bottom.initialNotVisible) 162 | wrapper.update() 163 | // Change: 164 | changeBottom(waypointStates.bottom.becomesVisible) 165 | wrapper.update() 166 | expect(wrapper.containsMatchingElement(Actual())).toBe(true) 167 | }) 168 | }) 169 | describe('top not visible, bottom visible', () => { 170 | it('top waypoint becomes visible', () => { 171 | const { wrapper, changeTop, changeBottom } = mount({ 172 | placeholder: Placeholder, 173 | }) 174 | changeTop(waypointStates.top.initialNotVisible) 175 | wrapper.update() 176 | changeBottom(waypointStates.bottom.initialVisible) 177 | wrapper.update() 178 | // Change: 179 | changeTop(waypointStates.top.becomesVisible) 180 | wrapper.update() 181 | expect(wrapper.containsMatchingElement(Actual())).toBe(true) 182 | }) 183 | }) 184 | describe('inAndOut, top visible, bottom visible', () => { 185 | it('bottom waypoint becomes not visible', () => { 186 | const { wrapper, changeTop, changeBottom } = mount({ 187 | placeholder: Placeholder, 188 | inAndOut: true, 189 | }) 190 | changeTop(waypointStates.top.initialVisible) 191 | changeBottom(waypointStates.bottom.initialVisible) 192 | wrapper.update() 193 | // Change 194 | changeBottom(waypointStates.bottom.becomesNotVisible) 195 | wrapper.update() 196 | expect(wrapper.containsMatchingElement()).toBe(true) 197 | }) 198 | it('top waypoint becomes not visible', () => { 199 | const { wrapper, changeTop, changeBottom } = mount({ 200 | placeholder: Placeholder, 201 | inAndOut: true, 202 | }) 203 | changeTop(waypointStates.top.initialVisible) 204 | changeBottom(waypointStates.bottom.initialVisible) 205 | wrapper.update() 206 | // Change: 207 | changeTop(waypointStates.top.becomesNotVisible) 208 | wrapper.update() 209 | expect(wrapper.containsMatchingElement()).toBe(true) 210 | }) 211 | }) 212 | }) 213 | describe('without placeholder', () => { 214 | it('bottom waypoint becomes visible', () => { 215 | const { wrapper, changeTop, changeBottom } = mount() 216 | changeTop(waypointStates.top.initialVisible) 217 | changeBottom(waypointStates.bottom.initialNotVisible) 218 | wrapper.update() 219 | // Change: 220 | changeBottom(waypointStates.bottom.becomesVisible) 221 | wrapper.update() 222 | expect(wrapper.containsMatchingElement(Actual())).toBe(true) 223 | }) 224 | describe('top not visible, bottom visible', () => { 225 | it('top waypoint becomes visible', () => { 226 | const { wrapper, changeTop, changeBottom } = mount() 227 | changeTop(waypointStates.top.initialNotVisible) 228 | wrapper.update() 229 | changeBottom(waypointStates.bottom.initialVisible) 230 | wrapper.update() 231 | changeTop(waypointStates.top.becomesVisible) 232 | wrapper.update() 233 | expect(wrapper.containsMatchingElement(Actual())).toBe(true) 234 | }) 235 | }) 236 | describe('inAndOut, top visible, bottom visible', () => { 237 | it('bottom waypoint becomes not visible', () => { 238 | const { wrapper, changeTop, changeBottom } = mount({ 239 | inAndOut: true, 240 | }) 241 | changeTop(waypointStates.top.initialVisible) 242 | changeBottom(waypointStates.bottom.initialVisible) 243 | wrapper.update() 244 | // Change 245 | changeBottom(waypointStates.bottom.becomesNotVisible) 246 | wrapper.update() 247 | expect(wrapper.containsMatchingElement()).toBe(false) 248 | expect(wrapper.containsMatchingElement(Actual())).toBe(false) 249 | }) 250 | it('top waypoint becomes not visible', () => { 251 | const { wrapper, changeTop, changeBottom } = mount({ 252 | inAndOut: true, 253 | }) 254 | changeTop(waypointStates.top.initialVisible) 255 | changeBottom(waypointStates.bottom.initialVisible) 256 | wrapper.update() 257 | // Change: 258 | changeTop(waypointStates.top.becomesNotVisible) 259 | wrapper.update() 260 | expect(wrapper.containsMatchingElement()).toBe(false) 261 | expect(wrapper.containsMatchingElement(Actual())).toBe(false) 262 | }) 263 | }) 264 | }) 265 | }) 266 | it('should apply the default top offset', () => { 267 | const { wrapper } = mount() 268 | expect( 269 | wrapper 270 | .find(Waypoint) 271 | .at(0) 272 | .children() 273 | .first() 274 | .prop('offsetTop'), 275 | ).toBe('50vh') 276 | }) 277 | it('should apply the default bottom offset', () => { 278 | const { wrapper } = mount() 279 | expect( 280 | wrapper 281 | .find(Waypoint) 282 | .at(1) 283 | .children() 284 | .first() 285 | .prop('offsetBottom'), 286 | ).toBe('50vh') 287 | }) 288 | it('should apply the custom top offset', () => { 289 | const { wrapper } = mount({ offsetTop: '20vh' }) 290 | expect( 291 | wrapper 292 | .find(Waypoint) 293 | .at(0) 294 | .children() 295 | .first() 296 | .prop('offsetTop'), 297 | ).toBe('20vh') 298 | }) 299 | it('should apply the custom bottom offset', () => { 300 | const { wrapper } = mount({ offsetBottom: '40vh' }) 301 | expect( 302 | wrapper 303 | .find(Waypoint) 304 | .at(1) 305 | .children() 306 | .first() 307 | .prop('offsetBottom'), 308 | ).toBe('40vh') 309 | }) 310 | it('should assign the scrollableAncestor', () => { 311 | const { wrapper } = mount({ scrollableAncestor: 'window' }) 312 | expect( 313 | wrapper 314 | .find(Waypoint) 315 | .at(0) 316 | .prop('scrollableAncestor'), 317 | ).toBe('window') 318 | expect( 319 | wrapper 320 | .find(Waypoint) 321 | .at(1) 322 | .prop('scrollableAncestor'), 323 | ).toBe('window') 324 | }) 325 | it('should render the default element', () => { 326 | const { wrapper } = mount({ scrollableAncestor: 'window' }) 327 | expect( 328 | wrapper 329 | .children() 330 | .first() 331 | .type(), 332 | ).toBe('div') 333 | }) 334 | it('should render the custom element', () => { 335 | const { wrapper } = mount({ el: 'section' }) 336 | expect( 337 | wrapper 338 | .children() 339 | .first() 340 | .type(), 341 | ).toBe('section') 342 | }) 343 | it('should render the actual element when optimistic is set', () => { 344 | const { wrapper } = mount({ optimistic: true }) 345 | expect(wrapper.containsMatchingElement(Actual())).toBe(true) 346 | }) 347 | it('should render the default style when no className or style provided', () => { 348 | const { wrapper } = mount() 349 | expect( 350 | wrapper 351 | .children() 352 | .first() 353 | .prop('style'), 354 | ).toEqual({ position: 'relative' }) 355 | }) 356 | it('should not render the default style when a className is provided', () => { 357 | const { wrapper } = mount({ className: 'foo' }) 358 | expect( 359 | wrapper 360 | .children() 361 | .first() 362 | .prop('style'), 363 | ).toBeUndefined() 364 | }) 365 | it('should render a custom style', () => { 366 | const { wrapper } = mount({ style: { position: 'absolute' } }) 367 | expect( 368 | wrapper 369 | .children() 370 | .first() 371 | .prop('style'), 372 | ).toEqual({ position: 'absolute' }) 373 | }) 374 | it('should pass down any addition props to the el', () => { 375 | const additionalProps = { 376 | foo: 'bar', 377 | baz: 'qux', 378 | } 379 | const { wrapper } = mount(additionalProps) 380 | expect( 381 | wrapper 382 | .children() 383 | .first() 384 | .props(), 385 | ).toMatchObject(additionalProps) 386 | }) 387 | it('calls onChange when the virtualisation changes', () => { 388 | const onChangeSpy = jest.fn() 389 | const { wrapper, changeTop, changeBottom } = mount({ 390 | onChange: onChangeSpy, 391 | }) 392 | changeTop(waypointStates.top.initialVisible) 393 | changeBottom(waypointStates.bottom.initialNotVisible) 394 | expect(onChangeSpy).toHaveBeenCalledTimes(0) 395 | changeBottom(waypointStates.bottom.becomesVisible) 396 | wrapper.update() 397 | expect(onChangeSpy).toHaveBeenCalledTimes(1) 398 | changeBottom(waypointStates.bottom.becomesNotVisible) 399 | wrapper.update() 400 | expect(onChangeSpy).toHaveBeenCalledTimes(2) 401 | }) 402 | }) 403 | --------------------------------------------------------------------------------