├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── publish-gh-pages ├── example-umd ├── index.html └── main.js ├── example ├── index.html └── main.js ├── index.d.ts ├── karma.conf.js ├── lib └── is-visible-with-offset.js ├── package-lock.json ├── package.json ├── testconf.js ├── tests └── visibility-sensor-spec.jsx ├── visibility-sensor.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-proposal-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | tests/bundle.js 3 | dist 4 | example/dist 5 | .idea/ 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: trusty 3 | language: node_js 4 | node_js: 5 | - 8 6 | 7 | before_install: 8 | - export CHROME_BIN=/usr/bin/google-chrome 9 | - export DISPLAY=:99.0 10 | - sh -e /etc/init.d/xvfb start 11 | - sudo apt-get update 12 | - sudo apt-get install -y libappindicator1 fonts-liberation 13 | - wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb 14 | - sudo dpkg -i google-chrome*.deb 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 5.1.1 4 | 5 | - Upgrade outdated dependencies to resolve vulnerabilities ([#162](https://github.com/joshwnj/react-visibility-sensor/pull/162)) 6 | 7 | ## 5.1.0 8 | 9 | - Add TypeScript definition ([#153](https://github.com/joshwnj/react-visibility-sensor/pull/153)) 10 | 11 | ## 5.0.0 12 | 13 | - Update to ES6 style React and replaced Browserify with Webpack ([#123](https://github.com/joshwnj/react-visibility-sensor/pull/123)) 14 | - Update code to the latest version of react, remove useless params/function ([#115](https://github.com/joshwnj/react-visibility-sensor/pull/115)) 15 | 16 | ## 4.1.0 17 | 18 | - Update lifecycle method for React 16.3 ([#119](https://github.com/joshwnj/react-visibility-sensor/pull/119)) 19 | 20 | ## 4.0.0 21 | 22 | - Upgrade outdated deps and node version ([#127](https://github.com/joshwnj/react-visibility-sensor/pull/127)) 23 | 24 | ## 3.14.0 25 | 26 | - re-register node in componentDidUpdate if children diffs ([#103](https://github.com/joshwnj/react-visibility-sensor/pull/103)) 27 | 28 | ## 3.13.0 29 | 30 | - Check if the component has size and is not hidden ([#114](https://github.com/joshwnj/react-visibility-sensor/pull/114)) 31 | 32 | ## 3.12.0 33 | 34 | - round down viewport values ([#116](https://github.com/joshwnj/react-visibility-sensor/pull/116)) 35 | 36 | ## 3.11.0 37 | 38 | - add react 16 as a peer dep ([#94](https://github.com/joshwnj/react-visibility-sensor/pull/94)) 39 | 40 | ## 3.10.1 41 | 42 | - prevent unnecessary rerendering ([#85](https://github.com/joshwnj/react-visibility-sensor/pull/85)) 43 | 44 | ## 3.10.0 45 | 46 | - allow passing a children function that takes state and chooses what to render from it ([#76](https://github.com/joshwnj/react-visibility-sensor/pull/76#pullrequestreview-33850456)) 47 | 48 | ## 3.9.0 49 | 50 | - Migrated deprecated React.PropTypes and React.createClass ([#73](https://github.com/joshwnj/react-visibility-sensor/pull/73)) 51 | 52 | ## 3.8.0 53 | 54 | - Improving offset and adding resize listener ([#69](https://github.com/joshwnj/react-visibility-sensor/pull/69)) 55 | 56 | ## 3.7.0 57 | 58 | - added `offset` prop ([#64](https://github.com/joshwnj/react-visibility-sensor/pull/64)) 59 | 60 | ## 3.6.2 61 | 62 | - fixed a problem where `.debounceCheck` is not cleared properly ([#62](https://github.com/joshwnj/react-visibility-sensor/pull/62)) 63 | 64 | ## 3.6.1 65 | 66 | - fixed typo from `delay` to `scrollDelay` ([#59](https://github.com/joshwnj/react-visibility-sensor/pull/59)) 67 | 68 | ## 3.6.0 69 | 70 | - added support for "scrollCheck" as well as the default "intervalCheck" ([#54](https://github.com/joshwnj/react-visibility-sensor/pull/54)) 71 | 72 | ## 3.5.0 73 | 74 | - simpler logic for `partialVisible` ([#41](https://github.com/joshwnj/react-visibility-sensor/pull/41)) 75 | 76 | ## 3.4.0 77 | 78 | - `partialVisibility` prop can now either be a `boolean` (any edge can be visible) or a string of `top|right|bottom|left` to indicate which edge determines visibility ([#42](https://github.com/joshwnj/react-visibility-sensor/pull/42/files)) 79 | 80 | ## 3.3.0 81 | 82 | - Mark partially visible when center is visible ([#40](https://github.com/joshwnj/react-visibility-sensor/pull/40)) 83 | 84 | ## 3.2.1 85 | 86 | - Fixed error case where component can be null ([#38](https://github.com/joshwnj/react-visibility-sensor/pull/38)) 87 | 88 | ## 3.2.0 89 | 90 | - Added `minTopValue` and `delayedCall` props ([#30](https://github.com/joshwnj/react-visibility-sensor/pull/30)) 91 | 92 | ## 3.1.1 93 | 94 | - Removed dist file from git (as suggested in #18) 95 | - Added `npm run build`, which is also run on prepublish 96 | - updated the build script so browserify produces a standalone umd script 97 | - added `example-umd` to show how to use it with plain ` 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /example-umd/main.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | class Example extends React.Component { 4 | constructor(props) { 5 | super(props); 6 | this.state = { 7 | msg: "" 8 | }; 9 | 10 | this.onChange = this.onChange.bind(this); 11 | } 12 | 13 | onChange(isVisible) { 14 | this.setState({ 15 | msg: "Element is now " + (isVisible ? "visible" : "hidden") 16 | }); 17 | } 18 | 19 | render() { 20 | return React.createElement( 21 | "div", 22 | null, 23 | React.createElement("p", { className: "msg" }, this.state.msg), 24 | React.createElement("div", { className: "before" }), 25 | React.createElement( 26 | VisibilitySensor, 27 | { 28 | containment: this.props.containment, 29 | onChange: this.onChange, 30 | minTopValue: this.props.minTopValue, 31 | partialVisibility: this.props.partialVisibility 32 | }, 33 | React.createElement("div", { className: "sensor" }) 34 | ), 35 | React.createElement("div", { className: "after" }) 36 | ); 37 | } 38 | } 39 | 40 | ReactDOM.render( 41 | React.createElement(Example), 42 | document.getElementById("example") 43 | ); 44 | 45 | var container = document.getElementById("example-container"); 46 | var elem = container.querySelector(".inner"); 47 | container.scrollTop = 320; 48 | container.scrollLeft = 320; 49 | ReactDOM.render( 50 | React.createElement(Example, { 51 | containment: container, 52 | minTopValue: 10, 53 | partialVisibility: true 54 | }), 55 | elem 56 | ); 57 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 68 | 69 | 70 |
71 |

In this example, the first element's visibility will change as you scroll the page. The second element uses the `containment` prop to tell whether it is within the visible part of the container. Scroll around to see it change.

72 |

Note that the "Contained Element" in this example is also using the `partialVisibility` prop. So it will be considered "visible" if even part of it is within the viewport.

73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import ReactDOM from "react-dom"; 5 | import VisibilitySensor from "../visibility-sensor"; 6 | 7 | class Example extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.state = { 12 | msg: "" 13 | }; 14 | } 15 | 16 | onChange = isVisible => { 17 | this.setState({ 18 | msg: "Element is now " + (isVisible ? "visible" : "hidden") 19 | }); 20 | }; 21 | 22 | render() { 23 | return ( 24 |
25 |

{this.state.msg}

26 |
27 | 37 |
38 | 39 |
40 |
41 | ); 42 | } 43 | } 44 | 45 | ReactDOM.render( 46 | React.createElement(Example), 47 | document.getElementById("example") 48 | ); 49 | 50 | var container = document.getElementById("example-container"); 51 | var elem = container.querySelector(".inner"); 52 | 53 | container.scrollTop = 320; 54 | container.scrollLeft = 320; 55 | 56 | ReactDOM.render( 57 | React.createElement(Example, { 58 | containment: container, 59 | minTopValue: 10, 60 | partialVisibility: true 61 | }), 62 | elem 63 | ); 64 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react-visibility-sensor" { 2 | import * as React from "react"; 3 | 4 | interface Shape { 5 | top?: number; 6 | left?: number; 7 | bottom?: number; 8 | right?: number; 9 | } 10 | 11 | interface Props { 12 | onChange?: (isVisible: boolean) => void; 13 | active?: boolean; 14 | partialVisibility?: boolean; 15 | offset?: Shape; 16 | minTopValue?: number; 17 | intervalCheck?: boolean; 18 | intervalDelay?: number; 19 | scrollCheck?: boolean; 20 | scrollDelay?: number; 21 | scrollThrottle?: number; 22 | resizeCheck?: boolean; 23 | resizeDelay?: number; 24 | resizeThrottle?: number; 25 | containment?: any; 26 | delayedCall?: boolean; 27 | children?: 28 | | React.ReactNode 29 | | (( 30 | args: { isVisible: boolean; visibilityRect?: Shape } 31 | ) => React.ReactNode); 32 | } 33 | 34 | const ReactVisibilitySensor: React.StatelessComponent; 35 | 36 | export default ReactVisibilitySensor; 37 | } 38 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Thu Sep 18 2014 10:13:19 GMT+1000 (EST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | // base path that will be used to resolve all patterns (eg. files, exclude) 7 | basePath: "", 8 | 9 | // frameworks to use 10 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 11 | frameworks: ["mocha"], 12 | 13 | // list of files / patterns to load in the browser 14 | files: ["./tests/bundle.js"], 15 | 16 | // list of files to exclude 17 | exclude: [], 18 | 19 | // test results reporter to use 20 | // possible values: 'dots', 'progress' 21 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 22 | reporters: ["progress"], 23 | 24 | // web server port 25 | port: 9876, 26 | 27 | // enable / disable colors in the output (reporters and logs) 28 | colors: true, 29 | 30 | // level of logging 31 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 32 | logLevel: config.LOG_INFO, 33 | 34 | // enable / disable watching file and executing tests whenever any file changes 35 | autoWatch: false, 36 | 37 | // start these browsers 38 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 39 | browsers: [process.env.TRAVIS === "true" ? "Chrome_travis_ci" : "Chrome"], 40 | 41 | customLaunchers: { 42 | Chrome_travis_ci: { 43 | base: "Chrome", 44 | flags: ["--no-sandbox"] 45 | } 46 | }, 47 | 48 | // Continuous Integration mode 49 | // if true, Karma captures browsers, runs the tests and exits 50 | singleRun: false 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /lib/is-visible-with-offset.js: -------------------------------------------------------------------------------- 1 | // Tell whether the rect is visible, given an offset 2 | // 3 | // return: boolean 4 | module.exports = function (offset, rect, containmentRect) { 5 | var offsetDir = offset.direction; 6 | var offsetVal = offset.value; 7 | 8 | // Rules for checking different kind of offsets. In example if the element is 9 | // 90px below viewport and offsetTop is 100, it is considered visible. 10 | switch (offsetDir) { 11 | case 'top': 12 | return ((containmentRect.top + offsetVal) < rect.top) && 13 | (containmentRect.bottom > rect.bottom) && 14 | (containmentRect.left < rect.left) && 15 | (containmentRect.right > rect.right); 16 | 17 | case 'left': 18 | return ((containmentRect.left + offsetVal) < rect.left) && 19 | (containmentRect.bottom > rect.bottom) && 20 | (containmentRect.top < rect.top) && 21 | (containmentRect.right > rect.right); 22 | 23 | case 'bottom': 24 | return ((containmentRect.bottom - offsetVal) > rect.bottom) && 25 | (containmentRect.left < rect.left) && 26 | (containmentRect.right > rect.right) && 27 | (containmentRect.top < rect.top); 28 | 29 | case 'right': 30 | return ((containmentRect.right - offsetVal) > rect.right) && 31 | (containmentRect.left < rect.left) && 32 | (containmentRect.top < rect.top) && 33 | (containmentRect.bottom > rect.bottom); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-visibility-sensor", 3 | "version": "5.1.1", 4 | "description": "Sensor component for React that notifies you when it goes in or out of the window viewport.", 5 | "main": "dist/visibility-sensor.js", 6 | "scripts": { 7 | "clean": "rm -rf dist && mkdir dist", 8 | "prebuild": "npm run clean", 9 | "build": "webpack --env=production", 10 | "build-example": "rm -rf example/dist && webpack --env=example", 11 | "build-test": "webpack --env=test", 12 | "publish-gh-pages": "npm run build-example && ./bin/publish-gh-pages", 13 | "test": "npm run build-test && karma start --single-run", 14 | "prepublishOnly": "npm run build" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "react-component", 19 | "visibility" 20 | ], 21 | "author": "joshwnj", 22 | "license": "MIT", 23 | "peerDependencies": { 24 | "react": ">=16.0.0", 25 | "react-dom": ">=16.0.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.5.5", 29 | "@babel/plugin-proposal-class-properties": "^7.5.5", 30 | "@babel/preset-env": "^7.5.5", 31 | "@babel/preset-react": "^7.0.0", 32 | "babel-loader": "^8.0.6", 33 | "gh-pages": "^2.0.1", 34 | "husky": "^3.0.1", 35 | "karma": "^4.2.0", 36 | "karma-chrome-launcher": "^3.0.0", 37 | "karma-mocha": "^1.3.0", 38 | "karma-phantomjs-launcher": "^1.0.4", 39 | "lint-staged": "^9.2.1", 40 | "mocha": "^6.2.0", 41 | "prettier": "1.18.2", 42 | "react": "^16.4.2", 43 | "react-dom": "^16.4.2", 44 | "uglify-js": "^3.6.0", 45 | "uglifyjs-webpack-plugin": "^2.1.3", 46 | "webpack": "^4.37.0", 47 | "webpack-cli": "^3.3.6" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "https://github.com/joshwnj/react-visibility-sensor.git" 52 | }, 53 | "dependencies": { 54 | "prop-types": "^15.7.2" 55 | }, 56 | "husky": { 57 | "hooks": { 58 | "pre-commit": "lint-staged" 59 | } 60 | }, 61 | "lint-staged": { 62 | "*.{js,json,css,md}": [ 63 | "prettier --write", 64 | "git add" 65 | ] 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /testconf.js: -------------------------------------------------------------------------------- 1 | // karma.conf.js 2 | module.exports = function(config) { 3 | config.set({ 4 | frameworks: ["mocha"], 5 | preprocessors: { 6 | "**/*.jsx": ["jsx"] 7 | }, 8 | browsers: ["Chrome"] 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /tests/visibility-sensor-spec.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import assert from "assert"; 4 | import VisibilitySensor from "../visibility-sensor"; 5 | 6 | describe("VisibilitySensor", function() { 7 | var node; 8 | 9 | beforeEach(function() { 10 | node = document.createElement("div"); 11 | document.body.appendChild(node); 12 | }); 13 | 14 | afterEach(function() { 15 | ReactDOM.unmountComponentAtNode(node); 16 | document.body.removeChild(node); 17 | }); 18 | 19 | it("should notify of changes to visibility when parent moves", function( 20 | done 21 | ) { 22 | var firstTime = true; 23 | var onChange = function(isVisible) { 24 | // by default we expect the sensor to be visible 25 | if (firstTime) { 26 | firstTime = false; 27 | assert.equal(isVisible, true, "Component starts out visible"); 28 | node.setAttribute( 29 | "style", 30 | "position:absolute; width:100px; left:-101px" 31 | ); 32 | } else { 33 | // after moving the sensor it should be not visible anymore 34 | assert.equal( 35 | isVisible, 36 | false, 37 | "Component has moved out of the visible viewport" 38 | ); 39 | done(); 40 | } 41 | }; 42 | 43 | var element = ( 44 | 45 |
46 | 47 | ); 48 | 49 | ReactDOM.render(element, node); 50 | }); 51 | 52 | it("should notify of changes to visibility when user scrolls", function( 53 | done 54 | ) { 55 | var firstTime = true; 56 | var onChange = function(isVisible) { 57 | // by default we expect the sensor to be visible 58 | if (firstTime) { 59 | firstTime = false; 60 | assert.equal(isVisible, true, "Component starts out visible"); 61 | 62 | window.scrollTo(0, 1000); 63 | } else { 64 | // after moving the sensor it should be not visible anymore 65 | assert.equal( 66 | isVisible, 67 | false, 68 | "Component has moved out of the visible viewport" 69 | ); 70 | done(); 71 | } 72 | }; 73 | 74 | var element = ( 75 |
76 | 82 |
83 | 84 |
85 | ); 86 | 87 | ReactDOM.render(element, node); 88 | }); 89 | 90 | it("should notify of changes to visibility when child moves", function(done) { 91 | var firstTime = true; 92 | var initialStyle = { 93 | height: "10px", 94 | width: "10px" 95 | }; 96 | var onChange = function(isVisible) { 97 | // by default we expect the sensor to be visible 98 | if (firstTime) { 99 | firstTime = false; 100 | assert.equal(isVisible, true, "Component starts out visible"); 101 | const style = { 102 | position: "absolute", 103 | width: 100, 104 | left: -101 105 | }; 106 | ReactDOM.render(getElement(style), node); 107 | } else { 108 | // after moving the sensor it should be not visible anymore 109 | assert.equal( 110 | isVisible, 111 | false, 112 | "Component has moved out of the visible viewport" 113 | ); 114 | done(); 115 | } 116 | }; 117 | 118 | // set interval must be one in order for this to work 119 | function getElement(style) { 120 | return ( 121 | 122 |
123 | 124 | ); 125 | } 126 | 127 | ReactDOM.render(getElement(initialStyle), node); 128 | }); 129 | 130 | it("should notify of changes to visibility", function(done) { 131 | var onChange = function(isVisible) { 132 | assert.equal(isVisible, true, "Component starts out visible"); 133 | done(); 134 | }; 135 | 136 | var element = ( 137 | 138 |
139 | 140 | ); 141 | 142 | ReactDOM.render(element, node); 143 | }); 144 | 145 | it("should not notify when deactivated", function(done) { 146 | var wasCallbackCalled = false; 147 | var onChange = function(isVisible) { 148 | wasCallbackCalled = true; 149 | }; 150 | 151 | setTimeout(function() { 152 | assert(!wasCallbackCalled, "onChange callback should not be called"); 153 | done(); 154 | }, 20); 155 | 156 | var element = ( 157 | 158 | ); 159 | 160 | ReactDOM.render(element, node); 161 | }); 162 | 163 | it("should clear interval and debounceCheck when deactivated", function() { 164 | var onChange = function() {}; 165 | 166 | var element1 = ( 167 | 173 | ); 174 | 175 | var element2 = ( 176 | 182 | ); 183 | 184 | var component1 = ReactDOM.render(element1, node); 185 | assert(component1.interval, "interval should be set"); 186 | assert(component1.debounceCheck, "debounceCheck should be set"); 187 | assert( 188 | component1.debounceCheck.scroll, 189 | "debounceCheck.scroll should be set" 190 | ); 191 | assert( 192 | component1.debounceCheck.resize, 193 | "debounceCheck.scroll should be set" 194 | ); 195 | 196 | var component2 = ReactDOM.render(element2, node); 197 | assert(!component2.interval, "interval should not be set"); 198 | assert(!component2.debounceCheck, "debounceCheck should not be set"); 199 | }); 200 | 201 | it("should work when using offset prop and moving to outside of offset area", function( 202 | done 203 | ) { 204 | var firstTime = true; 205 | node.setAttribute("style", "position:absolute; top:51px"); 206 | var onChange = function(isVisible) { 207 | if (firstTime) { 208 | firstTime = false; 209 | assert.equal(isVisible, true, "Component starts out visible"); 210 | node.setAttribute("style", "position:absolute; top:49px"); 211 | } else { 212 | assert.equal( 213 | isVisible, 214 | false, 215 | "Component has moved out of offset area" 216 | ); 217 | done(); 218 | } 219 | }; 220 | 221 | var element = ( 222 | 227 |
228 | 229 | ); 230 | 231 | ReactDOM.render(element, node); 232 | }); 233 | 234 | it("should be backwards-compatible with old offset config", function(done) { 235 | var firstTime = true; 236 | node.setAttribute("style", "position:absolute; top:51px"); 237 | var onChange = function(isVisible) { 238 | if (firstTime) { 239 | firstTime = false; 240 | assert.equal(isVisible, true, "Component starts out visible"); 241 | node.setAttribute("style", "position:absolute; top:49px"); 242 | } else { 243 | assert.equal( 244 | isVisible, 245 | false, 246 | "Component has moved out of offset area" 247 | ); 248 | done(); 249 | } 250 | }; 251 | 252 | var element = ( 253 | 258 |
259 | 260 | ); 261 | 262 | ReactDOM.render(element, node); 263 | }); 264 | 265 | it("should work when using offset prop and moving to inside of offset area", function( 266 | done 267 | ) { 268 | var firstTime = true; 269 | node.setAttribute("style", "position:absolute; top:49px"); 270 | var onChange = function(isVisible) { 271 | if (firstTime) { 272 | firstTime = false; 273 | assert.equal(isVisible, false, "Component starts out invisible"); 274 | node.setAttribute("style", "position:absolute; top:51px"); 275 | } else { 276 | assert.equal( 277 | isVisible, 278 | true, 279 | "Component has moved inside of offset area" 280 | ); 281 | done(); 282 | } 283 | }; 284 | 285 | var element = ( 286 | 291 |
292 | 293 | ); 294 | 295 | ReactDOM.render(element, node); 296 | }); 297 | 298 | it("should work when using negative offset prop and moving to outside of viewport", function( 299 | done 300 | ) { 301 | var firstTime = true; 302 | node.setAttribute("style", "position:absolute; top:-49px"); 303 | var onChange = function(isVisible) { 304 | if (firstTime) { 305 | firstTime = false; 306 | assert.equal(isVisible, true, "Component starts out visible"); 307 | node.setAttribute("style", "position:absolute; top:-51px"); 308 | } else { 309 | assert.equal( 310 | isVisible, 311 | false, 312 | "Component has moved outside of viewport and visible area" 313 | ); 314 | done(); 315 | } 316 | }; 317 | 318 | var element = ( 319 | 324 |
325 | 326 | ); 327 | 328 | ReactDOM.render(element, node); 329 | }); 330 | 331 | it("should call child function with state", function(done) { 332 | var wasChildrenCalled = false; 333 | var children = function(props) { 334 | wasChildrenCalled = true; 335 | assert( 336 | "isVisible" in props, 337 | "children should be called with isVisible prop" 338 | ); 339 | assert( 340 | "visibilityRect" in props, 341 | "children should be called with visibilityRect prop" 342 | ); 343 | return
; 344 | }; 345 | 346 | setTimeout(function() { 347 | assert(wasChildrenCalled, "children should be called"); 348 | done(); 349 | }, 200); 350 | 351 | var element = {children}; 352 | 353 | ReactDOM.render(element, node); 354 | }); 355 | 356 | it("should not return visible if it has no size", function(done) { 357 | var firstTime = true; 358 | var onChange = function(isVisible) { 359 | if (firstTime) { 360 | assert.equal(isVisible, false, "Component is not visible"); 361 | done(); 362 | } 363 | }; 364 | 365 | var element = ( 366 | 367 |
368 | 369 | ); 370 | 371 | ReactDOM.render(element, node); 372 | }); 373 | 374 | it("should not return visible if the sensor is hidden", function(done) { 375 | var firstTime = true; 376 | var onChange = function(isVisible) { 377 | if (firstTime) { 378 | assert.equal(isVisible, false, "Component is not visible"); 379 | done(); 380 | } 381 | }; 382 | 383 | var element = ( 384 |
385 | 386 |
387 | 388 |
389 | ); 390 | 391 | ReactDOM.render(element, node); 392 | }); 393 | }); 394 | -------------------------------------------------------------------------------- /visibility-sensor.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import ReactDOM from "react-dom"; 5 | import PropTypes from "prop-types"; 6 | import isVisibleWithOffset from "./lib/is-visible-with-offset"; 7 | 8 | function normalizeRect(rect) { 9 | if (rect.width === undefined) { 10 | rect.width = rect.right - rect.left; 11 | } 12 | 13 | if (rect.height === undefined) { 14 | rect.height = rect.bottom - rect.top; 15 | } 16 | 17 | return rect; 18 | } 19 | 20 | export default class VisibilitySensor extends React.Component { 21 | static defaultProps = { 22 | active: true, 23 | partialVisibility: false, 24 | minTopValue: 0, 25 | scrollCheck: false, 26 | scrollDelay: 250, 27 | scrollThrottle: -1, 28 | resizeCheck: false, 29 | resizeDelay: 250, 30 | resizeThrottle: -1, 31 | intervalCheck: true, 32 | intervalDelay: 100, 33 | delayedCall: false, 34 | offset: {}, 35 | containment: null, 36 | children: 37 | }; 38 | 39 | static propTypes = { 40 | onChange: PropTypes.func, 41 | active: PropTypes.bool, 42 | partialVisibility: PropTypes.oneOfType([ 43 | PropTypes.bool, 44 | PropTypes.oneOf(["top", "right", "bottom", "left"]) 45 | ]), 46 | delayedCall: PropTypes.bool, 47 | offset: PropTypes.oneOfType([ 48 | PropTypes.shape({ 49 | top: PropTypes.number, 50 | left: PropTypes.number, 51 | bottom: PropTypes.number, 52 | right: PropTypes.number 53 | }), 54 | // deprecated offset property 55 | PropTypes.shape({ 56 | direction: PropTypes.oneOf(["top", "right", "bottom", "left"]), 57 | value: PropTypes.number 58 | }) 59 | ]), 60 | scrollCheck: PropTypes.bool, 61 | scrollDelay: PropTypes.number, 62 | scrollThrottle: PropTypes.number, 63 | resizeCheck: PropTypes.bool, 64 | resizeDelay: PropTypes.number, 65 | resizeThrottle: PropTypes.number, 66 | intervalCheck: PropTypes.bool, 67 | intervalDelay: PropTypes.number, 68 | containment: 69 | typeof window !== "undefined" 70 | ? PropTypes.instanceOf(window.Element) 71 | : PropTypes.any, 72 | children: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), 73 | minTopValue: PropTypes.number 74 | }; 75 | 76 | constructor(props) { 77 | super(props); 78 | 79 | this.state = { 80 | isVisible: null, 81 | visibilityRect: {} 82 | }; 83 | } 84 | 85 | componentDidMount() { 86 | this.node = ReactDOM.findDOMNode(this); 87 | if (this.props.active) { 88 | this.startWatching(); 89 | } 90 | } 91 | 92 | componentWillUnmount() { 93 | this.stopWatching(); 94 | } 95 | 96 | componentDidUpdate(prevProps) { 97 | // re-register node in componentDidUpdate if children diffs [#103] 98 | this.node = ReactDOM.findDOMNode(this); 99 | 100 | if (this.props.active && !prevProps.active) { 101 | this.setState({ 102 | isVisible: null, 103 | visibilityRect: {} 104 | }); 105 | 106 | this.startWatching(); 107 | } else if (!this.props.active) { 108 | this.stopWatching(); 109 | } 110 | } 111 | 112 | getContainer = () => { 113 | return this.props.containment || window; 114 | }; 115 | 116 | addEventListener = (target, event, delay, throttle) => { 117 | if (!this.debounceCheck) { 118 | this.debounceCheck = {}; 119 | } 120 | 121 | let timeout; 122 | let func; 123 | 124 | const later = () => { 125 | timeout = null; 126 | this.check(); 127 | }; 128 | 129 | if (throttle > -1) { 130 | func = () => { 131 | if (!timeout) { 132 | timeout = setTimeout(later, throttle || 0); 133 | } 134 | }; 135 | } else { 136 | func = () => { 137 | clearTimeout(timeout); 138 | timeout = setTimeout(later, delay || 0); 139 | }; 140 | } 141 | 142 | const info = { 143 | target: target, 144 | fn: func, 145 | getLastTimeout: () => { 146 | return timeout; 147 | } 148 | }; 149 | 150 | target.addEventListener(event, info.fn); 151 | this.debounceCheck[event] = info; 152 | }; 153 | 154 | startWatching = () => { 155 | if (this.debounceCheck || this.interval) { 156 | return; 157 | } 158 | 159 | if (this.props.intervalCheck) { 160 | this.interval = setInterval(this.check, this.props.intervalDelay); 161 | } 162 | 163 | if (this.props.scrollCheck) { 164 | this.addEventListener( 165 | this.getContainer(), 166 | "scroll", 167 | this.props.scrollDelay, 168 | this.props.scrollThrottle 169 | ); 170 | } 171 | 172 | if (this.props.resizeCheck) { 173 | this.addEventListener( 174 | window, 175 | "resize", 176 | this.props.resizeDelay, 177 | this.props.resizeThrottle 178 | ); 179 | } 180 | 181 | // if dont need delayed call, check on load ( before the first interval fires ) 182 | !this.props.delayedCall && this.check(); 183 | }; 184 | 185 | stopWatching = () => { 186 | if (this.debounceCheck) { 187 | // clean up event listeners and their debounce callers 188 | for (let debounceEvent in this.debounceCheck) { 189 | if (this.debounceCheck.hasOwnProperty(debounceEvent)) { 190 | const debounceInfo = this.debounceCheck[debounceEvent]; 191 | 192 | clearTimeout(debounceInfo.getLastTimeout()); 193 | debounceInfo.target.removeEventListener( 194 | debounceEvent, 195 | debounceInfo.fn 196 | ); 197 | 198 | this.debounceCheck[debounceEvent] = null; 199 | } 200 | } 201 | } 202 | this.debounceCheck = null; 203 | 204 | if (this.interval) { 205 | this.interval = clearInterval(this.interval); 206 | } 207 | }; 208 | 209 | roundRectDown(rect) { 210 | return { 211 | top: Math.floor(rect.top), 212 | left: Math.floor(rect.left), 213 | bottom: Math.floor(rect.bottom), 214 | right: Math.floor(rect.right) 215 | }; 216 | } 217 | 218 | /** 219 | * Check if the element is within the visible viewport 220 | */ 221 | check = () => { 222 | const el = this.node; 223 | let rect; 224 | let containmentRect; 225 | 226 | // if the component has rendered to null, dont update visibility 227 | if (!el) { 228 | return this.state; 229 | } 230 | 231 | rect = normalizeRect(this.roundRectDown(el.getBoundingClientRect())); 232 | 233 | if (this.props.containment) { 234 | const containmentDOMRect = this.props.containment.getBoundingClientRect(); 235 | containmentRect = { 236 | top: containmentDOMRect.top, 237 | left: containmentDOMRect.left, 238 | bottom: containmentDOMRect.bottom, 239 | right: containmentDOMRect.right 240 | }; 241 | } else { 242 | containmentRect = { 243 | top: 0, 244 | left: 0, 245 | bottom: window.innerHeight || document.documentElement.clientHeight, 246 | right: window.innerWidth || document.documentElement.clientWidth 247 | }; 248 | } 249 | 250 | // Check if visibility is wanted via offset? 251 | const offset = this.props.offset || {}; 252 | const hasValidOffset = typeof offset === "object"; 253 | 254 | if (hasValidOffset) { 255 | containmentRect.top += offset.top || 0; 256 | containmentRect.left += offset.left || 0; 257 | containmentRect.bottom -= offset.bottom || 0; 258 | containmentRect.right -= offset.right || 0; 259 | } 260 | 261 | const visibilityRect = { 262 | top: rect.top >= containmentRect.top, 263 | left: rect.left >= containmentRect.left, 264 | bottom: rect.bottom <= containmentRect.bottom, 265 | right: rect.right <= containmentRect.right 266 | }; 267 | 268 | // https://github.com/joshwnj/react-visibility-sensor/pull/114 269 | const hasSize = rect.height > 0 && rect.width > 0; 270 | 271 | let isVisible = 272 | hasSize && 273 | visibilityRect.top && 274 | visibilityRect.left && 275 | visibilityRect.bottom && 276 | visibilityRect.right; 277 | 278 | // check for partial visibility 279 | if (hasSize && this.props.partialVisibility) { 280 | let partialVisible = 281 | rect.top <= containmentRect.bottom && 282 | rect.bottom >= containmentRect.top && 283 | rect.left <= containmentRect.right && 284 | rect.right >= containmentRect.left; 285 | 286 | // account for partial visibility on a single edge 287 | if (typeof this.props.partialVisibility === "string") { 288 | partialVisible = visibilityRect[this.props.partialVisibility]; 289 | } 290 | 291 | // if we have minimum top visibility set by props, lets check, if it meets the passed value 292 | // so if for instance element is at least 200px in viewport, then show it. 293 | isVisible = this.props.minTopValue 294 | ? partialVisible && 295 | rect.top <= containmentRect.bottom - this.props.minTopValue 296 | : partialVisible; 297 | } 298 | 299 | // Deprecated options for calculating offset. 300 | if ( 301 | typeof offset.direction === "string" && 302 | typeof offset.value === "number" 303 | ) { 304 | console.warn( 305 | "[notice] offset.direction and offset.value have been deprecated. They still work for now, but will be removed in next major version. Please upgrade to the new syntax: { %s: %d }", 306 | offset.direction, 307 | offset.value 308 | ); 309 | 310 | isVisible = isVisibleWithOffset(offset, rect, containmentRect); 311 | } 312 | 313 | let state = this.state; 314 | // notify the parent when the value changes 315 | if (this.state.isVisible !== isVisible) { 316 | state = { 317 | isVisible: isVisible, 318 | visibilityRect: visibilityRect 319 | }; 320 | this.setState(state); 321 | if (this.props.onChange) this.props.onChange(isVisible); 322 | } 323 | 324 | return state; 325 | }; 326 | 327 | render() { 328 | if (this.props.children instanceof Function) { 329 | return this.props.children({ 330 | isVisible: this.state.isVisible, 331 | visibilityRect: this.state.visibilityRect 332 | }); 333 | } 334 | return React.Children.only(this.props.children); 335 | } 336 | } 337 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const UglifyJsPlugin = require("uglifyjs-webpack-plugin"); 3 | 4 | module.exports = env => { 5 | let entry; 6 | let output; 7 | 8 | let externals = { 9 | react: { 10 | commonjs: "react", 11 | commonjs2: "react", 12 | amd: "React", 13 | root: "React" 14 | }, 15 | "react-dom": { 16 | commonjs: "react-dom", 17 | commonjs2: "react-dom", 18 | amd: "ReactDOM", 19 | root: "ReactDOM" 20 | } 21 | }; 22 | 23 | if (env === "production") { 24 | entry = { 25 | "visibility-sensor": "./visibility-sensor.js", 26 | "visibility-sensor.min": "./visibility-sensor.js" 27 | }; 28 | 29 | output = { 30 | path: path.resolve(__dirname, "dist"), 31 | filename: "[name].js", 32 | library: "react-visibility-sensor", 33 | libraryTarget: "umd", 34 | globalObject: "this" 35 | }; 36 | } 37 | 38 | if (env === "test") { 39 | entry = { 40 | bundle: "./tests/visibility-sensor-spec.jsx" 41 | }; 42 | 43 | output = { 44 | path: path.resolve(__dirname, "tests"), 45 | filename: "[name].js" 46 | }; 47 | 48 | // we want React, ReactDOM included in the test bundle 49 | externals = {}; 50 | } 51 | 52 | if (env === "example") { 53 | entry = { 54 | bundle: "./example/main.js" 55 | }; 56 | 57 | output = { 58 | path: path.resolve(__dirname, "example/dist"), 59 | filename: "[name].js" 60 | }; 61 | 62 | // we want React, ReactDOM included in the example bundle 63 | externals = {}; 64 | } 65 | 66 | return { 67 | mode: "production", 68 | entry: entry, 69 | output: output, 70 | optimization: { 71 | minimizer: [ 72 | new UglifyJsPlugin({ 73 | test: /\.min.js($|\?)/i 74 | }) 75 | ] 76 | }, 77 | module: { 78 | rules: [ 79 | { 80 | test: /\.jsx?$/, 81 | exclude: /node_modules/, 82 | use: { 83 | loader: "babel-loader" 84 | } 85 | } 86 | ] 87 | }, 88 | resolve: { 89 | alias: { 90 | react: path.resolve(__dirname, "./node_modules/react"), 91 | "react-dom": path.resolve(__dirname, "./node_modules/react-dom") 92 | }, 93 | extensions: [".js", ".jsx"] 94 | }, 95 | externals: externals 96 | }; 97 | }; 98 | --------------------------------------------------------------------------------