├── src ├── noop.js ├── throttle.js ├── index.js └── index.test.js ├── .travis.yml ├── .prettierrc ├── bower.json ├── LICENSE ├── .gitignore ├── package.json ├── README.md ├── test.html └── index.html /src/noop.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Fallback function 3 | * @method noop 4 | * @returns {undefined} 5 | */ 6 | export default () => {} 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "10" 5 | 6 | branches: 7 | only: 8 | - master 9 | 10 | cache: yarn 11 | 12 | script: yarn test 13 | 14 | after_success: 15 | - yarn build 16 | - yarn release 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "proseWrap": "preserve", 10 | "quoteProps": "as-needed", 11 | "requirePragma": false, 12 | "semi": false, 13 | "singleQuote": true, 14 | "tabWidth": 2, 15 | "trailingComma": "none", 16 | "useTabs": false 17 | } 18 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "huntjs", 3 | "description": "Minimal library to observe nodes entering and leaving the viewport", 4 | "main": "dist/hunt.js", 5 | "authors": [ 6 | "Jeremias Menichelli" 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "hunt", 11 | "scroll", 12 | "dom", 13 | "element" 14 | ], 15 | "ignore": [ 16 | "src", 17 | "index.html", 18 | "**/.*" 19 | ], 20 | "homepage": "https://github.com/jeremenichelli/hunt", 21 | "moduleType": [ 22 | "globals" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/throttle.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_THROTTLE_INTERVAL = 100 2 | 3 | /** 4 | * Throttles method execution to prevent some performance bottlenecks 5 | * @param {Function} fn method to throttle 6 | * @param {Number} interval milliseconds for the method to be delayed 7 | */ 8 | function throttle(fn, interval = DEFAULT_THROTTLE_INTERVAL) { 9 | let inThrottle 10 | let lastFunc 11 | let lastRan 12 | 13 | return function() { 14 | if (inThrottle === true) { 15 | clearTimeout(lastFunc) 16 | lastFunc = setTimeout(function() { 17 | if (Date.now() - lastRan >= interval) { 18 | fn.apply(this, arguments) 19 | lastRan = Date.now() 20 | } 21 | }, interval - (Date.now() - lastRan)) 22 | } else { 23 | fn.apply(this, arguments) 24 | lastRan = Date.now() 25 | inThrottle = true 26 | } 27 | } 28 | } 29 | 30 | export default throttle 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jeremias Menichelli 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # package distribution folder 9 | dist 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "huntjs", 3 | "version": "0.0.0", 4 | "description": "Minimal library to observe nodes entering and leaving the viewport", 5 | "main": "dist/hunt.js", 6 | "browser": "dist/hunt.umd.js", 7 | "module": "dist/hunt.esm.js", 8 | "scripts": { 9 | "format": "prettier ./**/*.js --write", 10 | "test": "ava --verbose", 11 | "prebuild": "npm test", 12 | "start": "microbundle watch -i ./src/index.js --name=Hunt", 13 | "build": "microbundle -i ./src/index.js --name=Hunt --external none", 14 | "release": "travis-deploy-once 'semantic-release'" 15 | }, 16 | "files": [ 17 | "dist", 18 | "README.md", 19 | "LICENSE" 20 | ], 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/jeremenichelli/hunt.git" 24 | }, 25 | "keywords": [ 26 | "hunt", 27 | "scroll", 28 | "intersection", 29 | "observer", 30 | "viewport", 31 | "dom", 32 | "element" 33 | ], 34 | "author": "Jeremias Menichelli", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/jeremenichelli/hunt/issues" 38 | }, 39 | "homepage": "https://github.com/jeremenichelli/hunt#readme", 40 | "devDependencies": { 41 | "@commitlint/cli": "^7.6.1", 42 | "@commitlint/config-conventional": "^7.6.0", 43 | "ava": "^2.0.0", 44 | "esm": "^3.2.25", 45 | "husky": "^2.3.0", 46 | "lint-staged": "^8.1.7", 47 | "lodash.clone": "^4.5.0", 48 | "microbundle": "^0.11.0", 49 | "prettier": "^1.17.1", 50 | "semantic-release": "^15.9.9", 51 | "sinon": "^7.3.2", 52 | "travis-deploy-once": "^5.0.3" 53 | }, 54 | "ava": { 55 | "require": [ 56 | "esm" 57 | ] 58 | }, 59 | "lint-staged": { 60 | "./**/*.js": [ 61 | "prettier --write", 62 | "git add" 63 | ] 64 | }, 65 | "commitlint": { 66 | "extends": [ 67 | "@commitlint/config-conventional" 68 | ] 69 | }, 70 | "husky": { 71 | "hooks": { 72 | "pre-commit": "lint-staged && yarn test", 73 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hunt 2 | 3 | [![Build Status](https://travis-ci.org/jeremenichelli/hunt.svg)](https://travis-ci.org/jeremenichelli/hunt) 4 | 5 | 👻 Minimal library to observe nodes entering and leaving the viewport. 6 | 7 | _Be sure to also check the **Intersection Observer API**, a native solution which works in modern browsers, if you want to know more I wrote an [introduction article](//jeremenichelli.io/2016/04/quick-introduction-to-the-intersection-observer-api) explaining how it works._ 8 | 9 | ## Install 10 | 11 | ```sh 12 | # npm 13 | npm i huntjs --save 14 | 15 | # yarn 16 | yarn add huntjs 17 | ``` 18 | 19 | Or include it as a script with `//unpkg.com/huntjs/dist/hunt.umd.js` as source. 20 | 21 | ## Usage 22 | 23 | The package exposes an observer that receives a `Node`, `NodeList` or `Array` as a first argument and an object as a second argument with the desired set of options. 24 | 25 | ```js 26 | import Hunt from 'huntjs'; 27 | 28 | // lazy loading images using dataset and hunt 29 | const lazyImages = document.querySelectorAll('img.lazy'); 30 | 31 | let observer = new Hunt(lazyImages, { 32 | enter: (image) => image.src = image.dataset.src 33 | }); 34 | ``` 35 | 36 | _Check this example working [here](//jeremenichelli.github.io/hunt)_ 37 | 38 | By default the observer will stop _hunting_ elements when they enter and then leave the viewport. 39 | 40 | ## Config options 41 | 42 | These are the properties you can set as a configuration: 43 | 44 | - `enter`, _function_ called when the element becomes visible. 45 | - `leave`, _function_ method called when the element leaves completely the viewport. 46 | 47 | _Both callbacks will receive the element which triggered the action as argument._ 48 | 49 | - `persist`, _boolean_ and `false` by default which indicates if the targets should still be observed after they entered and left the viewport. When this option is `true` enter and leave methods will be called each time an element state changes. Recommended for constant animations triggered by scroll events. 50 | - `offset`, _number_ that defines a number of pixels ahead of the element's state change, `0` being the default value. Good if you want to start loading an image before the user reaches to it. 51 | - `throttleInterval`, _number_ interval use for event throttling. A lower number will mean elements are detected in view quicker, but may degrade performace. A higher value will mean elements are detected in view slower, but may improve performance. The default value is `100`, is recommended not to modify this. 52 | 53 | ### Custom configuration over dataset 54 | 55 | If you need exceptions over config for one or more elements, `data-hunt-offset` and `data-hunt-persist` attributes can be used. These custom values will override the ones you passed to the observer. 56 | 57 | ```html 58 |
63 |
64 | ``` 65 | 66 | _**JSON.parse** is used on these values at runtime, make sure to pass safe content._ 67 | 68 | ## Contributing 69 | 70 | To contribute [Node.js](//nodejs.org) and [yarn](//yarnpkg.com) are required. 71 | 72 | Before commit make sure to follow [conventional commits](//www.conventionalcommits.org) specification and check all tests pass by running `yarn test`. 73 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | hunt | stress test 7 | 8 | 9 | 60 | 61 | 62 |
63 |
64 |

hunt

65 |

Minimal library to observe nodes entering and leaving the viewport. Library written by Jeremias Menichelli and contributors.

67 |

Stress test observing multiple elements being animated as they become visible.

68 |
69 |
70 |
71 |
72 |
73 | 74 | 75 | 87 | 88 | 89 | 90 | 91 | 92 | 103 | 104 | 105 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | hunt | lazy loading image example 10 | 11 | 12 | 13 | 72 | 73 | 74 |
75 |
76 |

hunt

77 |

Minimal library to observe nodes entering and leaving the viewport. Library written by Jeremias Menichelli and contributors.

78 |

Example of code lazy loading images when they become visible.

79 |
80 |
81 |
82 |
83 |
import Hunt from 'huntjs';
 84 | 
 85 | // lazy loading images using dataset and hunt
 86 | const lazyImages = document.querySelectorAll('img.lazy');
 87 | 
 88 | let observer = new Hunt(lazyImages, {
 89 |   enter: (image) => image.src = image.dataset.src
 90 | });
91 |
92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 |
103 |
104 |
105 | 106 | 112 | 113 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import noop from './noop' 2 | import throttle from './throttle' 3 | 4 | /** 5 | * Constructor for element that should be hunted 6 | * @constructor Hunted 7 | * @param {Node} element 8 | * @param {Object} config 9 | */ 10 | class Hunted { 11 | constructor(element, config) { 12 | this.element = element 13 | 14 | // instantiate element as not visible 15 | this.visible = false 16 | 17 | // extend properties from config or fallback to prototype values 18 | for (var prop in config) { 19 | if (Object.hasOwnProperty.call(config, prop)) { 20 | this[prop] = config[prop] 21 | } 22 | } 23 | 24 | // replace options with dataset if present 25 | if (typeof element.dataset !== 'undefined') { 26 | if (typeof element.dataset.huntPersist !== 'undefined') { 27 | try { 28 | this.persist = JSON.parse(element.dataset.huntPersist) 29 | } catch (e) {} 30 | } 31 | if (typeof element.dataset.huntOffset !== 'undefined') { 32 | try { 33 | this.offset = JSON.parse(element.dataset.huntOffset) 34 | } catch (e) {} 35 | } 36 | } 37 | } 38 | } 39 | 40 | // protoype values 41 | Hunted.prototype.offset = 0 42 | Hunted.prototype.persist = false 43 | Hunted.prototype.enter = noop 44 | Hunted.prototype.leave = noop 45 | 46 | /** 47 | * Creates and initializes observer 48 | * @constructor Hunt 49 | * @param {Node|NodeList|Array} target 50 | * @param {Object} options 51 | */ 52 | class Hunt { 53 | constructor(target, options) { 54 | // sanity check for first argument 55 | const isValidTarget = 56 | (target && target.nodeType === 1) || typeof target.length === 'number' 57 | if (!isValidTarget) { 58 | throw new TypeError( 59 | 'hunt: observer first argument should be a node or a list of nodes' 60 | ) 61 | } 62 | // sanity check for second argument 63 | if (typeof options !== 'object') { 64 | throw new TypeError('hunt: observer second argument should be an object') 65 | } 66 | 67 | // turn target to array 68 | if (target.nodeType === 1) { 69 | this.__huntedElements__ = [new Hunted(target, options)] 70 | } else { 71 | const targetArray = [].slice.call(target) 72 | this.__huntedElements__ = targetArray.map((t) => new Hunted(t, options)) 73 | } 74 | 75 | // hoist viewport metrics 76 | this.__viewportHeight__ = window.innerHeight 77 | 78 | // connect observer and pass in throttle interval 79 | this.__connect__(options.throttleInterval) 80 | } 81 | 82 | /** 83 | * Assign throttled actions and add listeners 84 | * @param {Number} throttleInterval 85 | * @method __connect__ 86 | * @memberof Hunt 87 | */ 88 | __connect__(throttleInterval) { 89 | // throttle actions 90 | this.__throttledHuntElements__ = throttle( 91 | this.__huntElements__.bind(this), 92 | throttleInterval 93 | ) 94 | this.__throttledUpdateMetrics__ = throttle( 95 | this.__updateMetrics__.bind(this), 96 | throttleInterval 97 | ) 98 | 99 | // add listeners 100 | window.addEventListener('scroll', this.__throttledHuntElements__) 101 | window.addEventListener('resize', this.__throttledUpdateMetrics__) 102 | 103 | // run first check 104 | this.__huntElements__() 105 | } 106 | 107 | /** 108 | * Checks if hunted elements are visible and apply callbacks 109 | * @method __huntElements__ 110 | * @memberof Hunt 111 | */ 112 | __huntElements__() { 113 | let position = this.__huntedElements__.length 114 | 115 | while (position) { 116 | --position 117 | const hunted = this.__huntedElements__[position] 118 | const rect = hunted.element.getBoundingClientRect() 119 | const isOnViewport = 120 | rect.top - hunted.offset < this.__viewportHeight__ && 121 | rect.top >= -(rect.height + hunted.offset) 122 | 123 | /* 124 | * trigger (enter) event if element comes from a non visible state and the scrolled 125 | * viewport has reached the visible range of the element without exceeding it 126 | */ 127 | if (hunted.visible === false && isOnViewport === true) { 128 | hunted.enter.call(null, hunted.element) 129 | hunted.visible = true 130 | // when the leave callback method is not set and hunting should not persist remove element 131 | if (hunted.leave === noop && hunted.persist !== true) { 132 | this.__huntedElements__.splice(position, 1) 133 | 134 | // end observer activity when there are no more elements 135 | if (this.__huntedElements__.length === 0) { 136 | this.disconnect() 137 | } 138 | } 139 | } 140 | 141 | /* 142 | * trigger (leave) event if element comes from a visible state 143 | * and it's out of the visible range its bottom or top limit 144 | */ 145 | if (hunted.visible === true && isOnViewport === false) { 146 | hunted.leave.call(null, hunted.element) 147 | hunted.visible = false 148 | // when hunting should not persist remove element 149 | if (hunted.persist !== true) { 150 | this.__huntedElements__.splice(position, 1) 151 | 152 | // end observer activity when there are no more elements 153 | if (this.__huntedElements__.length === 0) { 154 | this.disconnect() 155 | } 156 | } 157 | } 158 | } 159 | } 160 | 161 | /** 162 | * Update viewport tracked height and runs a check 163 | * @method __updateMetrics__ 164 | * @memberof Hunt 165 | */ 166 | __updateMetrics__() { 167 | this.__viewportHeight__ = window.innerHeight 168 | this.__huntElements__() 169 | } 170 | 171 | /** 172 | * Remove listeners and stops observing elements 173 | * @method disconnect 174 | * @memberof Hunt 175 | */ 176 | disconnect() { 177 | // remove listeners 178 | window.removeEventListener('scroll', this.__throttledHuntElements__) 179 | window.removeEventListener('resize', this.__throttledUpdateMetrics__) 180 | } 181 | 182 | /** 183 | * __huntElements__ public alias 184 | * @method trigger 185 | * @memberof Hunt 186 | */ 187 | trigger() { 188 | this.__huntElements__() 189 | } 190 | } 191 | 192 | export default Hunt 193 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import Hunt from '.' 2 | import test from 'ava' 3 | import sinon from 'sinon' 4 | import clone from 'lodash.clone' 5 | 6 | // mock window object 7 | const window = { 8 | innerHeight: 0, 9 | addEventListener() {}, 10 | removeEventListener() {} 11 | } 12 | 13 | test.beforeEach(() => { 14 | global.window = clone(window) 15 | }) 16 | 17 | test('calls enter and leave hooks correctly and persists', (t) => { 18 | // alter window dimensions 19 | global.window.innerHeight = 550 20 | 21 | // fake rects 22 | let rects = { 23 | x: 352, 24 | y: 1040, 25 | width: 350, 26 | height: 350, 27 | top: 1040, 28 | right: 702, 29 | bottom: 1390, 30 | left: 352 31 | } 32 | 33 | // mock element outside the viewport 34 | const mockedElement = { 35 | nodeType: 1, 36 | getBoundingClientRect() { 37 | return rects 38 | } 39 | } 40 | 41 | const enterSpy = sinon.spy() 42 | const leaveSpy = sinon.spy() 43 | const options = { 44 | enter: enterSpy, 45 | leave: leaveSpy, 46 | persist: true 47 | } 48 | 49 | const observer = new Hunt(mockedElement, options) 50 | t.is(enterSpy.callCount, 0) 51 | t.is(leaveSpy.callCount, 0) 52 | 53 | // modify rects for viewport visibility and trigger observer 54 | rects = { 55 | x: 352, 56 | y: 97, 57 | width: 350, 58 | height: 350, 59 | top: 97, 60 | right: 702, 61 | bottom: 447, 62 | left: 352 63 | } 64 | observer.trigger() 65 | 66 | t.is(enterSpy.callCount, 1) 67 | t.is(leaveSpy.callCount, 0) 68 | 69 | // modify rects for viewport leave and trigger observer 70 | rects = { 71 | x: 352, 72 | y: 1040, 73 | width: 350, 74 | height: 350, 75 | top: 1040, 76 | right: 702, 77 | bottom: 1390, 78 | left: 352 79 | } 80 | observer.trigger() 81 | 82 | t.is(enterSpy.callCount, 1) 83 | t.is(leaveSpy.callCount, 1) 84 | 85 | // modify rects for viewport visibility and trigger observer 86 | rects = { 87 | x: 352, 88 | y: 97, 89 | width: 350, 90 | height: 350, 91 | top: 97, 92 | right: 702, 93 | bottom: 447, 94 | left: 352 95 | } 96 | observer.trigger() 97 | 98 | t.is(enterSpy.callCount, 2) 99 | t.is(leaveSpy.callCount, 1) 100 | }) 101 | 102 | test('disconnects when persist is false', (t) => { 103 | // alter window dimensions 104 | global.window.innerHeight = 550 105 | 106 | // fake rects 107 | let rects = { 108 | x: 352, 109 | y: 1040, 110 | width: 350, 111 | height: 350, 112 | top: 1040, 113 | right: 702, 114 | bottom: 1390, 115 | left: 352 116 | } 117 | 118 | // mock element outside the viewport 119 | const mockedElement = { 120 | nodeType: 1, 121 | getBoundingClientRect() { 122 | return rects 123 | } 124 | } 125 | 126 | const disconnectSpy = sinon.spy() 127 | const enterSpy = sinon.spy() 128 | const leaveSpy = sinon.spy() 129 | const options = { 130 | enter: enterSpy, 131 | leave: leaveSpy 132 | } 133 | 134 | const observer = new Hunt(mockedElement, options) 135 | observer.disconnect = disconnectSpy 136 | t.is(enterSpy.callCount, 0) 137 | t.is(leaveSpy.callCount, 0) 138 | t.is(disconnectSpy.callCount, 0) 139 | 140 | // modify rects for viewport visibility and trigger observer 141 | rects = { 142 | x: 352, 143 | y: 97, 144 | width: 350, 145 | height: 350, 146 | top: 97, 147 | right: 702, 148 | bottom: 447, 149 | left: 352 150 | } 151 | observer.trigger() 152 | 153 | t.is(enterSpy.callCount, 1) 154 | t.is(leaveSpy.callCount, 0) 155 | t.is(disconnectSpy.callCount, 0) 156 | 157 | // modify rects for viewport leave and trigger observer 158 | rects = { 159 | x: 352, 160 | y: 1040, 161 | width: 350, 162 | height: 350, 163 | top: 1040, 164 | right: 702, 165 | bottom: 1390, 166 | left: 352 167 | } 168 | observer.trigger() 169 | 170 | t.is(enterSpy.callCount, 1) 171 | t.is(leaveSpy.callCount, 1) 172 | t.is(disconnectSpy.callCount, 1) 173 | }) 174 | 175 | test('accepts collection of elements', (t) => { 176 | // alter window dimensions 177 | global.window.innerHeight = 550 178 | 179 | // fake rects 180 | let firstElementRect = { 181 | x: 352, 182 | y: 1040, 183 | width: 350, 184 | height: 350, 185 | top: 1040, 186 | right: 702, 187 | bottom: 1390, 188 | left: 352 189 | } 190 | let secondElementRect = { 191 | x: 737, 192 | y: 1429, 193 | width: 350, 194 | height: 350, 195 | top: 1429, 196 | right: 1087, 197 | bottom: 1779, 198 | left: 737 199 | } 200 | 201 | // mock element outside the viewport 202 | const firstMockedElement = { 203 | getBoundingClientRect() { 204 | return firstElementRect 205 | } 206 | } 207 | const secondMockedElement = { 208 | getBoundingClientRect() { 209 | return secondElementRect 210 | } 211 | } 212 | 213 | const enterSpy = sinon.spy() 214 | const leaveSpy = sinon.spy() 215 | const options = { 216 | enter: enterSpy, 217 | leave: leaveSpy, 218 | persist: true 219 | } 220 | 221 | const observer = new Hunt([firstMockedElement, secondMockedElement], options) 222 | 223 | // modify rects for viewport visibility and trigger observer 224 | firstElementRect = { 225 | x: 352, 226 | y: -90, 227 | width: 350, 228 | height: 350, 229 | top: -90, 230 | right: 702, 231 | bottom: 259, 232 | left: 352 233 | } 234 | secondElementRect = { 235 | x: 737, 236 | y: 298, 237 | width: 350, 238 | height: 350, 239 | top: 298, 240 | right: 1087, 241 | bottom: 648, 242 | left: 737 243 | } 244 | observer.trigger() 245 | 246 | // hunt does reverse crawling on elements when they get to the viewport 247 | t.deepEqual(enterSpy.getCall(1).args[0], firstMockedElement) 248 | t.deepEqual(enterSpy.getCall(0).args[0], secondMockedElement) 249 | 250 | // modify rects for viewport leave and trigger observer 251 | firstElementRect = { 252 | x: 352, 253 | y: 1040, 254 | width: 350, 255 | height: 350, 256 | top: 1040, 257 | right: 702, 258 | bottom: 1390, 259 | left: 352 260 | } 261 | secondElementRect = { 262 | x: 737, 263 | y: 1429, 264 | width: 350, 265 | height: 350, 266 | top: 1429, 267 | right: 1087, 268 | bottom: 1779, 269 | left: 737 270 | } 271 | observer.trigger() 272 | 273 | // hunt does reverse crawling on elements when they get to the viewport 274 | t.deepEqual(leaveSpy.getCall(1).args[0], firstMockedElement) 275 | t.deepEqual(leaveSpy.getCall(0).args[0], secondMockedElement) 276 | }) 277 | 278 | test('adds event listeners on instantiation and removes them on disconnect', (t) => { 279 | global.window.addEventListener = sinon.spy() 280 | global.window.removeEventListener = sinon.spy() 281 | 282 | // fake rects 283 | let rects = { 284 | x: 352, 285 | y: 1040, 286 | width: 350, 287 | height: 350, 288 | top: 1040, 289 | right: 702, 290 | bottom: 1390, 291 | left: 352 292 | } 293 | 294 | // mock element outside the viewport 295 | const mockedElement = { 296 | nodeType: 1, 297 | getBoundingClientRect() { 298 | return rects 299 | } 300 | } 301 | 302 | const observer = new Hunt(mockedElement, {}) 303 | 304 | t.is(global.window.addEventListener.getCall(0).args[0], 'scroll') 305 | t.is(global.window.addEventListener.getCall(1).args[0], 'resize') 306 | 307 | observer.disconnect() 308 | 309 | t.is(global.window.removeEventListener.getCall(0).args[0], 'scroll') 310 | t.is(global.window.removeEventListener.getCall(1).args[0], 'resize') 311 | }) 312 | 313 | test('throws when call with wrong arguments', (t) => { 314 | // called with an invalid target 315 | t.throws( 316 | () => new Hunt({}), 317 | 'hunt: observer first argument should be a node or a list of nodes' 318 | ) 319 | 320 | // called with an invalid target 321 | t.throws( 322 | () => new Hunt({ nodeType: 1 }), 323 | 'hunt: observer second argument should be an object' 324 | ) 325 | }) 326 | --------------------------------------------------------------------------------