├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .importjs.js ├── .npmrc ├── .overcommit.yml ├── .projections.json ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── INTHEWILD.md ├── LICENSE ├── README.md ├── index.d.ts ├── jest.config.js ├── karma.conf.js ├── package.json ├── react-waypoint-demo.gif ├── rollup.config.js ├── src ├── computeOffsetPixels.js ├── constants.js ├── debugLog.js ├── ensureRefIsUsedByChild.js ├── getCurrentPosition.js ├── isDOMElement.js ├── onNextTick.js ├── parseOffsetAsPercentage.js ├── parseOffsetAsPixels.js ├── resolveScrollableAncestorProp.js └── waypoint.jsx ├── test ├── browser │ ├── .eslintrc.js │ └── waypoint_test.jsx ├── node │ ├── .eslintrc.js │ ├── onNextTick.test.js │ ├── resolveScrollableAncestorProp.test.js │ └── waypoint.test.jsx ├── performance-test.html └── performance-test.jsx ├── tests.webpack.js └── webpack.config.performance-test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [["airbnb", { 5 | "looseClasses": true, 6 | "modules": true, 7 | "runtimeHelpersUseESModules": false, 8 | "runtimeVersion": "7.12.5", 9 | "transformRuntime": true 10 | }]] 11 | }, 12 | 13 | "cjs": { 14 | "presets": [["airbnb", { 15 | "looseClasses": true, 16 | "modules": false, 17 | "runtimeHelpersUseESModules": false, 18 | "runtimeVersion": "7.12.5", 19 | "transformRuntime": true 20 | }]] 21 | }, 22 | 23 | "es": { 24 | "presets": [["airbnb", { 25 | "looseClasses": true, 26 | "modules": false, 27 | "runtimeHelpersUseESModules": true, 28 | "runtimeVersion": "7.12.5", 29 | "transformRuntime": true 30 | }]] 31 | }, 32 | 33 | "development": { 34 | "presets": [["airbnb", { 35 | "looseClasses": true, 36 | "modules": false, 37 | "runtimeVersion": "7.12.5", 38 | "transformRuntime": true 39 | }]] 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | es 3 | cjs 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'eslint-config-airbnb', 3 | parserOptions: { 4 | ecmaVersion: 2018, 5 | sourceType: 'module', 6 | ecmaFeatures: { 7 | jsx: true, 8 | }, 9 | }, 10 | rules: { 11 | 'no-restricted-globals': 'off', 12 | 'no-plusplus': 'off', 13 | 'no-underscore-dangle': 'off', 14 | }, 15 | env: { 16 | browser: true, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | es 3 | cjs 4 | node_modules 5 | npm-debug.log 6 | .idea/ 7 | package-lock.json 8 | -------------------------------------------------------------------------------- /.importjs.js: -------------------------------------------------------------------------------- 1 | // This config file controls how import-js behaves 2 | // https://github.com/Galooshi/import-js 3 | module.exports = { 4 | importDevDependencies: true, 5 | environments: ['node', 'browser'], 6 | declarationKeyword: 'import', 7 | }; 8 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | git-tag-version=false 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.overcommit.yml: -------------------------------------------------------------------------------- 1 | CommitMsg: 2 | HardTabs: 3 | enabled: true 4 | 5 | PreCommit: 6 | ALL: 7 | exclude: 8 | - 'node_modules/**/*' 9 | 10 | EsLint: 11 | enabled: true 12 | required_executable: './node_modules/.bin/eslint' 13 | include: 14 | - '**/*.js' 15 | - '**/*.jsx' 16 | 17 | HardTabs: 18 | enabled: true 19 | description: 'Checking for hard tabs' 20 | 21 | JsonSyntax: 22 | enabled: true 23 | 24 | MergeConflicts: 25 | enabled: true 26 | 27 | TrailingWhitespace: 28 | enabled: true 29 | 30 | YamlSyntax: 31 | enabled: true 32 | -------------------------------------------------------------------------------- /.projections.json: -------------------------------------------------------------------------------- 1 | { 2 | "src/*.js": { 3 | "alternate": "test/{}_test.js", 4 | "type": "source" 5 | }, 6 | "src/*.jsx": { 7 | "alternate": "test/{}_test.jsx", 8 | "type": "source" 9 | }, 10 | "test/*_test.js": { 11 | "alternate": "src/{}.js", 12 | "type": "test" 13 | }, 14 | "test/*_test.jsx": { 15 | "alternate": "src/{}.jsx", 16 | "type": "test" 17 | } 18 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | dist: bionic 3 | services: 4 | - xvfb 5 | sudo: false 6 | node_js: 7 | - node 8 | - lts/* 9 | cache: 10 | npm: false 11 | env: 12 | - REACT_VERSION=15.3 13 | - REACT_VERSION=16.0 14 | - REACT_VERSION=17.0 15 | - REACT_VERSION=18.0 16 | before_script: 17 | - npm install react-dom@^$REACT_VERSION react@^$REACT_VERSION react-test-renderer@^$REACT_VERSION 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 10.3.0 2 | 3 | - Allow React v18 in `package.json`. 4 | 5 | ## 10.2.0 6 | 7 | - Add `children` prop to TypeScript definitions for `Waypoint` to support React v18. 8 | 9 | ## 10.1.0 10 | 11 | - Classes are now compiled in loose mode, which should improve runtime performance. 12 | - Babel helpers now import from `@babel/runtime` instead of being inlined, which reduces bundle size. 13 | 14 | ## 10.0.0 15 | 16 | - [Breaking] Remove `Waypoint.getWindow()` 17 | - Support React 17 18 | 19 | ## 9.0.3 20 | 21 | - Make waypoints work with `overflow: overlay` 22 | 23 | ## 9.0.2 24 | 25 | - Remove StrictMode warnings 26 | 27 | ## 9.0.1 28 | 29 | - Fix export in TypeScript types (#298) 30 | - Update babel 6 -> 7, rollup 0 -> 1 (#301) 31 | 32 | ## 9.0.0 33 | 34 | - [Breaking] Require React 15.3+ 35 | - [Breaking] Make Waypoint a named export instead of default export 36 | - Change constants into named exports for better minification 37 | - Fix removing propTypes in production builds 38 | - Ensure that children is valid only in dev 39 | - Fix isForwardRef call 40 | 41 | ## 8.1.0 42 | 43 | - Improve support for refs (#278) 44 | - Don't include `.babelrc` in published npm package (#270) 45 | 46 | ## 8.0.3 47 | 48 | - Defer `handleScroll` in `componentDidUpdate` ([#265](https://github.com/civiccc/react-waypoint/pull/265)) 49 | - Extend `React.PureComponent` instead of `React.Component` when available ([#264](https://github.com/civiccc/react-waypoint/pull/264)) 50 | 51 | ## 8.0.2 52 | 53 | - Allow consolidated-events ^2.0.0 ([#256](https://github.com/civiccc/react-waypoint/pull/256)) 54 | - Add message to better understand logged error message in tests ([#255](https://github.com/civiccc/react-waypoint/pull/255)) 55 | 56 | ## 8.0.1 57 | 58 | - Fix default export error in typescript definition file 59 | 60 | ## 8.0.0 61 | 62 | - Add es module entry point in package.json 63 | - Type Waypoint class properties as static 64 | - Let proptypes be removable in consumers production bundles 65 | 66 | ## 7.3.4 67 | 68 | - Fix second arg to Typescript component definition 69 | 70 | ## 7.3.3 71 | 72 | - Add second arg to Typescript component definition 73 | 74 | ## 7.3.2 75 | 76 | - Fix typescript definition 77 | 78 | ## 7.3.1 79 | 80 | - Make es module opt in (via `import Waypoint from 'react-waypoint/es'`) 81 | 82 | ## 7.3.0 83 | 84 | - Build with rollup. 85 | - Add an ES module build. 86 | 87 | ## 7.2.0 88 | 89 | - Allow React 16 as a peerDependency. 90 | - Remove scrollableParent prop check error. 91 | 92 | ## 7.1.0 93 | 94 | - Add support for using composite components as child (#208) 95 | 96 | ## 7.0.4 97 | 98 | - Update consolidated-events from 1.0.1 to 1.1.0. 99 | 100 | ## 7.0.3 101 | 102 | - Fix bug in onNextTick. 103 | 104 | ## 7.0.2 105 | 106 | - Fix bug if waypoint updates before being initialized. 107 | 108 | ## 7.0.1 109 | 110 | - Improve startup time by consolidating `setTimeout`s and deferring work until 111 | the initial timeout happens. 112 | 113 | ## 7.0.0 114 | 115 | - Move `prop-types` to a regular dependency 116 | - Assume `window` as scrollable ancestor when `` has `overflow: auto|scroll` 117 | - Restrict lower bound of React to v0.14.9 118 | 119 | ## 6.0.0 120 | 121 | - Add `prop-types` as a peer dependency to remove deprecation warnings when 122 | running on React 15.5 123 | 124 | ## 5.3.1 125 | 126 | - Remove the `prop-types` peer dependency. This was an accidental breaking 127 | change that will instead be released as 6.0.0. 128 | 129 | ## 5.3.0 130 | 131 | - Remove deprecation warnings when running on React 15.5 132 | - Add React 14 to Travis test suite. 133 | 134 | ## 5.2.1 135 | 136 | - [Fix] Avoid unnecessary clearTimeout when unmounting. 137 | 138 | ## 5.2.0 139 | 140 | - [New] scrollableAncestor prop can now accept "window" as a string. This should 141 | help with server rendering. 142 | - Debug code is now minified out in production build. 143 | 144 | ## 5.1.0 145 | 146 | - Waypoint can now accept children. 147 | 148 | ## 5.0.3 149 | 150 | - Clear initial timeout when unmounting component. 151 | 152 | ## 5.0.2 153 | - Revert ES6 typescript definition. 154 | 155 | ## 5.0.1 156 | - Fix typescript definition to support ES6 imports 157 | 158 | ## 5.0.0 159 | 160 | - [Breaking] Remove `throttleHandler` 161 | - Add typescript definitions file 162 | 163 | ## 4.1.0 164 | 165 | - Add `horizontal` prop. Use it to make the waypoint trigger on horizontal scrolling. 166 | 167 | ## 4.0.4 168 | 169 | - Delay initial calling of handleScroll when mounting. 170 | 171 | ## 4.0.3 172 | 173 | - Extract event listener code to consolidated-events package. 174 | 175 | ## 4.0.2 176 | 177 | - Prevent event listeners from leaking. 178 | 179 | ## 4.0.1 180 | 181 | - Fix error when a waypoint unmounts another waypoint as part of handling a 182 | (scroll/resize) event. 183 | 184 | ## 4.0.0 185 | 186 | - [Breaking] Use passive event listeners in browsers that support them. This 187 | will break any Waypoint event handler that was calling 188 | `event.preventDefault()`. 189 | - Initialize fewer event listeners. 190 | 191 | ## 3.1.3 192 | 193 | - Avoid warnings from React about calling PropTypes directly (#119). 194 | 195 | ## 3.1.2 196 | 197 | This version contains a fix for errors of the following kind: 198 | 199 | ``` 200 | Unable to get property 'getBoundingClientRect' of undefined or null reference 201 | ``` 202 | 203 | ## 3.1.1 204 | 205 | - Fix passing props to super class, to make react-waypoint compatible with [preact](https://github.com/developit/preact) (thanks @kamotos!) 206 | 207 | ## 3.1.0 208 | 209 | New properties have been added to the `onEnter`/`onLeave`/`onPositionChange` 210 | callbacks: 211 | 212 | - `waypointTop` - the waypoint's distance to the top of the viewport. 213 | - `viewportTop` - the distance from the scrollable ancestor to the viewport top. 214 | - `viewportBottom` - the distance from the bottom of the scrollable ancestor to 215 | the viewport top. 216 | 217 | ## 3.0.0 218 | 219 | - Change `threshold` to `bottomOffset` and `topOffset` 220 | - Add `throttleHandler` prop to allow scrolling to be throttled 221 | 222 | ## 2.0.3 223 | 224 | - Added `debug` prop 225 | 226 | ## 2.0.2 227 | 228 | - Improved position calculation 229 | 230 | ## 2.0.1 231 | 232 | - Add React 15 support 233 | 234 | ## 2.0.0 235 | 236 | - Breaking: Unify arguments passed to callbacks 237 | - Add `displayName` 238 | 239 | ## 1.3.1 240 | 241 | - Handle invisible waypoint parents 242 | - Add `onPositionChange` 243 | 244 | ## 1.3.0 245 | 246 | - Rename `scrollableParent` prop to `scrollableAncestor` 247 | 248 | ## 1.2.3 249 | 250 | - Simplify `getWindow` usage 251 | - Allow any `scrollableParent` 252 | 253 | ## 1.2.2 254 | 255 | - Add `fireOnRapidScroll` prop 256 | 257 | ## 1.2.1 258 | 259 | - Make bundled waypoint.js easier to import 260 | 261 | ## 1.2.0 262 | 263 | - Upgrade Babel from 5 to 6 264 | - Convert from CommonJS to ES2015 modules 265 | - Convert from React.createClass to ES2015 class 266 | - Remove bower support 267 | 268 | ## 1.1.3 269 | 270 | - Calculate proper offset when or has a margin 271 | 272 | ## 1.1.2 273 | 274 | - Fix built version 275 | 276 | ## 1.1.1 277 | 278 | - Add statics for edge argument used by `onEnter` and `onLeave` 279 | - Prevent scroll handler from blowing up if the component is not mounted at the 280 | time of execution 281 | 282 | ## 1.1.0 283 | 284 | - Add second parameter to `onEnter` and `onLeave` callbacks to indicate 285 | from which direction the waypoint entered _from_ and _to_ respectively 286 | 287 | ## 1.0.6 288 | 289 | - Prevent duplicate onError/onLeave callbacks 290 | 291 | ## 1.0.5 292 | 293 | - Prevent error when `` has a scrollable overflow styling 294 | 295 | ## 1.0.4 296 | 297 | - Bump `react` dependency to 0.14 and add `react-dom` to `peerDependencies` 298 | 299 | ## 1.0.3 300 | 301 | - Replace `this.getDOMNode()` with `React.findDOMNode(this)` 302 | - Improve support for scrolling very quickly 303 | 304 | ## 1.0.2 305 | 306 | - Add event object and scope to onEnter/onLeave calls 307 | - Allow React 0.14.0-beta peerDependency 308 | - Always remove window resize event listener 309 | 310 | ## 1.0.1 311 | 312 | - Ignore more files for bower and npm packages 313 | - Commit the built version for bower package 314 | 315 | ## 1.0.0 316 | 317 | - Add 'jsx' syntax to the unbuilt version of the component, and build into 318 | 'build/ReactWaypoint.js' with webpack. 319 | - Fix corner case where scrollable parent is not the window and the window 320 | resize should trigger a Waypoint callback. 321 | 322 | ## 0.3.0 323 | 324 | - Fix Waypoints with the window element as their scrollable parent (Firefox 325 | only) 326 | 327 | ## 0.2.0 328 | 329 | - Fix Waypoints with the window element as their scrollable parent 330 | - Change default threshold from 0.1 to 0 331 | - Guard against undefined scrollable parent when unmounting 332 | 333 | ## 0.1.0 334 | 335 | - Initial release 336 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | We love pull requests. Here's a quick guide: 2 | 3 | 1. Fork the repo. 4 | 2. Run the tests. We only take pull requests with passing tests, and it's great 5 | to know that you have a clean slate: `npm install && npm test`. 6 | 3. Add a test for your change. Only refactoring and documentation changes 7 | require no new tests. If you are adding functionality or fixing a bug, we 8 | need a test! 9 | 4. Make the test pass. 10 | 5. Push to your fork and submit a pull request. 11 | 12 | ## Testing performance 13 | 14 | To test scroll performance when having multiple waypoints on a page, run `npm run 15 | performance-test:watch`, then open `test/performance-test.html`. Scroll around 16 | and use your regular performance profiling tools to see the effects of your 17 | changes. 18 | 19 | ## Publishing a new version 20 | 21 | 1. Add list of changes to CHANGELOG.md. Do not commit them yet. 22 | 2. Run `npm version major`, `npm version minor`, or `npm 23 | version patch`. 24 | 25 | This will handle the rest of the process for you, including running tests, 26 | cleaning out the previous build, building the package, bumping the version, 27 | committing the changes you've made to CHANGELOG.md, tagging the version, pushing 28 | the changes to GitHub, pushing the tags to GitHub, and publishing the new 29 | version on npm. 30 | 31 | ## Code of conduct 32 | 33 | This project adheres to the [Open Code of Conduct][code-of-conduct]. By 34 | participating, you are expected to honor this code. 35 | 36 | [code-of-conduct]: https://github.com/civiccc/code-of-conduct 37 | -------------------------------------------------------------------------------- /INTHEWILD.md: -------------------------------------------------------------------------------- 1 | Please use [pull requests](https://github.com/civiccc/react-waypoint/pull/new/master) to add your organization and/or project to this document! 2 | 3 | # Organizations 4 | 5 | - [Airbnb](https://github.com/airbnb) 6 | - [Brigade](https://github.com/brigade) 7 | - [Domain Group](https://github.com/DomainGroupOSS) 8 | - [DoorDash](https://github.com/doordash) 9 | - [HousingAnywhere](https://github.com/housinganywhere) 10 | - [Matter](https://github.com/matter-app) 11 | - [Netflix](https://github.com/Netflix) 12 | - [Remix](https://github.com/remix) 13 | - [StarNow](https://github.com/starnow) 14 | - [Yorango](https://github.com/Yorango) 15 | - [Wanderpaths](https://github.com/wanderpaths) 16 | 17 | # Projects 18 | 19 | - [Happo](https://github.com/Galooshi/happo) 20 | - [react-ideal-image](https://github.com/stereobooster/react-ideal-image) 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Brigade 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Waypoint 2 | 3 | [![npm version](https://badge.fury.io/js/react-waypoint.svg)](http://badge.fury.io/js/react-waypoint) 4 | [![Build Status](https://travis-ci.org/civiccc/react-waypoint.svg?branch=master)](https://travis-ci.org/civiccc/react-waypoint) 5 | 6 | A React component to execute a function whenever you scroll to an element. Works 7 | in all containers that can scroll, including the window. 8 | 9 | React Waypoint can be used to build features like lazy loading content, infinite 10 | scroll, scrollspies, or docking elements to the viewport on scroll. 11 | 12 | Inspired by [Waypoints][waypoints], except this little library grooves the 13 | [React][react] way. 14 | 15 | ## Demo 16 | ![Demo of React Waypoint in action](https://raw.github.com/civiccc/react-waypoint/master/react-waypoint-demo.gif) 17 | 18 | [View demo page][demo-page] 19 | 20 | [waypoints]: https://github.com/imakewebthings/waypoints 21 | [react]: https://github.com/facebook/react 22 | [demo-page]: https://civiccc.github.io/react-waypoint/ 23 | 24 | ## Installation 25 | 26 | ### npm 27 | 28 | ```bash 29 | npm install react-waypoint --save 30 | ``` 31 | 32 | ### yarn 33 | 34 | ```bash 35 | yarn add react-waypoint 36 | ``` 37 | 38 | ## Usage 39 | 40 | ```jsx 41 | import { Waypoint } from 'react-waypoint'; 42 | 43 | 47 | ``` 48 | 49 | A waypoint normally fires `onEnter` and `onLeave` as you are scrolling, but it 50 | can fire because of other events too: 51 | 52 | - When the window is resized 53 | - When it is mounted (fires `onEnter` if it's visible on the page) 54 | - When it is updated/re-rendered by its parent 55 | 56 | Callbacks will only fire if the new position changed from the last known 57 | position. Sometimes it's useful to have a waypoint that fires `onEnter` every 58 | time it is updated as long as it stays visible (e.g. for infinite scroll). You 59 | can then use a `key` prop to control when a waypoint is reused vs. re-created. 60 | 61 | ```jsx 62 | 66 | ``` 67 | 68 | Alternatively, you can also use an `onPositionChange` event to just get 69 | notified when the waypoint's position (e.g. inside the viewport, above or 70 | below) has changed. 71 | 72 | ```jsx 73 | 76 | ``` 77 | 78 | Waypoints can take a child, allowing you to track when a section of content 79 | enters or leaves the viewport. For details, see [Children](#children), below. 80 | 81 | ```jsx 82 | 83 |
84 | Some content here 85 |
86 |
87 | ``` 88 | 89 | ### Example: [JSFiddle Example][jsfiddle-example] 90 | 91 | [jsfiddle-example]: http://jsfiddle.net/L4z5wcx0/7/ 92 | 93 | ## Prop types 94 | 95 | ```jsx 96 | propTypes: { 97 | 98 | /** 99 | * Function called when waypoint enters viewport 100 | */ 101 | onEnter: PropTypes.func, 102 | 103 | /** 104 | * Function called when waypoint leaves viewport 105 | */ 106 | onLeave: PropTypes.func, 107 | 108 | /** 109 | * Function called when waypoint position changes 110 | */ 111 | onPositionChange: PropTypes.func, 112 | 113 | /** 114 | * Whether to activate on horizontal scrolling instead of vertical 115 | */ 116 | horizontal: PropTypes.bool, 117 | 118 | /** 119 | * `topOffset` can either be a number, in which case its a distance from the 120 | * top of the container in pixels, or a string value. Valid string values are 121 | * of the form "20px", which is parsed as pixels, or "20%", which is parsed 122 | * as a percentage of the height of the containing element. 123 | * For instance, if you pass "-20%", and the containing element is 100px tall, 124 | * then the waypoint will be triggered when it has been scrolled 20px beyond 125 | * the top of the containing element. 126 | */ 127 | topOffset: PropTypes.oneOfType([ 128 | PropTypes.string, 129 | PropTypes.number, 130 | ]), 131 | 132 | /** 133 | * `bottomOffset` can either be a number, in which case its a distance from the 134 | * bottom of the container in pixels, or a string value. Valid string values are 135 | * of the form "20px", which is parsed as pixels, or "20%", which is parsed 136 | * as a percentage of the height of the containing element. 137 | * For instance, if you pass "20%", and the containing element is 100px tall, 138 | * then the waypoint will be triggered when it has been scrolled 20px beyond 139 | * the bottom of the containing element. 140 | * 141 | * Similar to `topOffset`, but for the bottom of the container. 142 | */ 143 | bottomOffset: PropTypes.oneOfType([ 144 | PropTypes.string, 145 | PropTypes.number, 146 | ]), 147 | 148 | /** 149 | * Scrollable Ancestor - A custom ancestor to determine if the 150 | * target is visible in it. This is useful in cases where 151 | * you do not want the immediate scrollable ancestor to be 152 | * the container. For example, when your target is in a div 153 | * that has overflow auto but you are detecting onEnter based 154 | * on the window. 155 | * 156 | * This should typically be a reference to a DOM node, but it will also work 157 | * to pass it the string "window" if you are using server rendering. 158 | */ 159 | scrollableAncestor: PropTypes.any, 160 | 161 | /** 162 | * fireOnRapidScroll - if the onEnter/onLeave events are to be fired 163 | * on rapid scrolling. This has no effect on onPositionChange -- it will 164 | * fire anyway. 165 | */ 166 | fireOnRapidScroll: PropTypes.bool, 167 | 168 | /** 169 | * Use this prop to get debug information in the console log. This slows 170 | * things down significantly, so it should only be used during development. 171 | */ 172 | debug: PropTypes.bool, 173 | }, 174 | ``` 175 | 176 | All callbacks (`onEnter`/`onLeave`/`onPositionChange`) receive an object as the 177 | only argument. That object has the following properties: 178 | 179 | - `currentPosition` - the position that the waypoint has at the moment. One 180 | of `Waypoint.below`, `Waypoint.above`, `Waypoint.inside`, 181 | and `Waypoint.invisible`. 182 | - `previousPosition` - the position that the waypoint had before. Also one 183 | of `Waypoint.below`, `Waypoint.above`, `Waypoint.inside`, 184 | and `Waypoint.invisible`. 185 | 186 | In most cases, the above two properties should be enough. In some cases 187 | though, you might find these additional properties useful: 188 | 189 | - `event` - the native [scroll 190 | event](https://developer.mozilla.org/en-US/docs/Web/Events/scroll) that 191 | triggered the callback. May be missing if the callback wasn't triggered 192 | as the result of a scroll. 193 | - `waypointTop` - the waypoint's distance to the top of the viewport. 194 | - `viewportTop` - the distance from the scrollable ancestor to the 195 | viewport top. 196 | - `viewportBottom` - the distance from the bottom of the scrollable 197 | ancestor to the viewport top. 198 | 199 | If you use [es6 object 200 | destructuring](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment), 201 | this means that you can use waypoints in the following way: 202 | 203 | ```jsx 204 | { 205 | // do something useful! 206 | }} 207 | /> 208 | ``` 209 | 210 | If you are more familiar with plain old js functions, you'll do something like 211 | this: 212 | 213 | ```jsx 214 | 219 | ``` 220 | 221 | ## Offsets and Boundaries 222 | 223 | Two of the Waypoint props are `topOffset` and `bottomOffset`. To appreciate 224 | what these can do for you, it will help to have an understanding of the 225 | "boundaries" used by this library. The boundaries of React Waypoint are the top 226 | and bottom of the element containing your scrollable content ([although this element 227 | can be configured](#containing-elements-and-scrollableancestor)). When a 228 | waypoint is within these boundaries, it is considered to be "inside." When a 229 | waypoint passes beyond these boundaries, then it is "outside." The `onEnter` and 230 | `onLeave` props are called as an element transitions from being inside to 231 | outside, or vice versa. 232 | 233 | The `topOffset` and `bottomOffset` properties can adjust the placement of these 234 | boundaries. By default, the offset is `'0px'`. If you specify a positive value, 235 | then the boundaries will be pushed inward, toward the center of the page. If 236 | you specify a negative value for an offset, then the boundary will be pushed 237 | outward from the center of the page. 238 | 239 | Here is an illustration of offsets and boundaries. The black box is the 240 | [`scrollableAncestor`](#containing-elements-and-scrollableancestor). The pink 241 | lines represent the location of the boundaries. The offsets that determine 242 | the boundaries are in light pink. 243 | 244 | ![](https://cloud.githubusercontent.com/assets/2322305/16939123/5be12454-4d33-11e6-86b6-ad431da93bf2.png) 245 | 246 | #### Horizontal Scrolling Offsets and Boundaries 247 | 248 | By default, waypoints listen to vertical scrolling. If you want to switch to 249 | horizontal scrolling instead, use the `horizontal` prop. For simplicity's sake, 250 | all other props and callbacks do not change. Instead, `topOffset` and 251 | `bottomOffset` (among other directional variables) will mean the offset from 252 | the left and the offset from the right, respectively, and work exactly as they 253 | did before, just calculated in the horizontal direction. 254 | 255 | #### Example Usage 256 | 257 | Positive values of the offset props are useful when you have an element that 258 | overlays your scrollable area. For instance, if your app has a `50px` fixed 259 | header, then you may want to specify `topOffset='50px'`, so that the 260 | `onEnter` callback is called when waypoints scroll into view from beneath the 261 | header. 262 | 263 | Negative values of the offset prop could be useful for lazy loading. Imagine if 264 | you had a lot of large images on a long page, but you didn't want to load them 265 | all at once. You can use React Waypoint to receive a callback whenever an image 266 | is a certain distance from the bottom of the page. For instance, by specifying 267 | `bottomOffset='-200px'`, then your `onEnter` callback would be called when 268 | the waypoint comes closer than 200 pixels from the bottom edge of the page. By 269 | placing a waypoint near each image, you could dynamically load them. 270 | 271 | There are likely many more use cases for the offsets: be creative! Also, keep in 272 | mind that there are _two_ boundaries, so there are always _two_ positions when 273 | the `onLeave` and `onEnter` callback will be called. By using the arguments 274 | passed to the callbacks, you can determine whether the waypoint has crossed the 275 | top boundary or the bottom boundary. 276 | 277 | ## Children 278 | 279 | If you don't pass a child into your Waypoint, then you can think of the 280 | waypoint as a line across the page. Whenever that line crosses a 281 | [boundary](#offsets-and-boundaries), then the `onEnter` or `onLeave` callbacks 282 | will be called. 283 | 284 | If you do pass a child, it can be a single DOM component (e.g. `
`) or a 285 | composite component (e.g. ``). 286 | 287 | Waypoint needs a DOM node to compute its boundaries. When you pass a DOM 288 | component to Waypoint, it handles getting a reference to the DOM node through 289 | the `ref` prop automatically. 290 | 291 | If you pass a composite component, you can wrap it with `React.forwardRef` (requires `react@^16.3.0`) 292 | and have the `ref` prop being handled automatically for you, like this: 293 | 294 | ```jsx 295 | class Block extends React.Component { 296 | render() { 297 | return
Hello
298 | } 299 | } 300 | 301 | const BlockWithRef = React.forwardRef((props, ref) => { 302 | return 303 | }) 304 | 305 | const App = () => ( 306 | 307 | 308 | 309 | ) 310 | ``` 311 | 312 | If you can't do that because you are using older version of React then 313 | you need to make use of the `innerRef` prop passed by Waypoint to your component. 314 | Simply pass it through as the `ref` of a DOM component and you're all set. Like in 315 | this example: 316 | 317 | ```jsx 318 | class Block extends React.Component { 319 | render() { 320 | return
Hello
321 | } 322 | } 323 | Block.propTypes = { 324 | innerRef: PropTypes.func.isRequired, 325 | } 326 | 327 | const App = () => ( 328 | 329 | 330 | 331 | ) 332 | ``` 333 | 334 | The `onEnter` callback will be called when *any* part of the child is visible 335 | in the viewport. The `onLeave` callback will be called when *all* of the child 336 | has exited the viewport. 337 | 338 | (Note that this is measured only on a single axis. What this means is that for a 339 | Waypoint within a vertically scrolling parent, it could be off of the screen 340 | horizontally yet still fire an onEnter event, because it is within the vertical 341 | boundaries). 342 | 343 | Deciding whether to pass a child or not will depend on your use case. One 344 | example of when passing a child is useful is for a scrollspy 345 | (like [Bootstrap's](https://bootstrapdocs.com/v3.3.6/docs/javascript/#scrollspy)). 346 | Imagine if you want to fire a waypoint when a particularly long piece of content 347 | is visible onscreen. When the page loads, it is conceivable that both the top 348 | and bottom of this piece of content could lie outside of the boundaries, 349 | because the content is taller than the viewport. If you didn't pass a child, 350 | and instead put the waypoint above or below the content, then you will not 351 | receive an `onEnter` callback (nor any other callback from this library). 352 | Instead, passing this long content as a child of the Waypoint would fire the `onEnter` 353 | callback when the page loads. 354 | 355 | ## Containing elements and `scrollableAncestor` 356 | 357 | React Waypoint positions its [boundaries](#offsets-and-boundaries) based on the 358 | first scrollable ancestor of the Waypoint. 359 | 360 | If that algorithm doesn't work for your use case, then you might find the 361 | `scrollableAncestor` prop useful. It allows you to specify what the scrollable 362 | ancestor is. Pass a reference to a DOM node as that prop, and the Waypoint will 363 | use the scroll position of *that* node, rather than its first scrollable 364 | ancestor. 365 | 366 | This can also be the string "window", which can be useful if you are using 367 | server rendering. 368 | 369 | #### Example Usage 370 | 371 | Sometimes, waypoints that are deeply nested in the DOM tree may need to track 372 | the scroll position of the page as a whole. If you want to be sure that no other 373 | scrollable ancestor is used (since, once again, the first scrollable ancestor is 374 | what the library will use by default), then you can explicitly set the 375 | `scrollableAncestor` to be the `window` to ensure that no other element is used. 376 | 377 | This might look something like: 378 | 379 | ```jsx 380 | 385 | ``` 386 | 387 | ## Troubleshooting 388 | 389 | If your waypoint isn't working the way you expect it to, there are a few ways 390 | you can debug your setup. 391 | 392 | OPTION 1: Add the `debug={true}` prop to your waypoint. When you do, you'll see console 393 | logs informing you about the internals of the waypoint. 394 | 395 | OPTION 2: Clone and modify the project locally. 396 | - clone this repo 397 | - add `console.log` or breakpoints where you think it would be useful. 398 | - `npm link` in the react-waypoint repo. 399 | - `npm link react-waypoint` in your project. 400 | - if needed rebuild react-waypoint module: `npm run build-npm` 401 | 402 | ## Limitations 403 | 404 | In this component we make a few assumptions that we believe are generally safe, 405 | but in some situations might present limitations. 406 | 407 | - We determine the scrollable-ness of a node by inspecting its computed 408 | overflow-y or overflow property and nothing else. This could mean that a 409 | container with this style that does not actually currently scroll will be 410 | considered when performing visibility calculations. 411 | - We assume that waypoints are rendered within at most one scrollable container. 412 | If you render a waypoint in multiple nested scrollable containers, the 413 | visibility calculations will likely not be accurate. 414 | - We also base the visibility calculations on the scroll position of the 415 | scrollable container (or `window` if no scrollable container is found). This 416 | means that if your scrollable container has a height that is greater than the 417 | window, it might trigger `onEnter` unexpectedly. 418 | 419 | ## In the wild 420 | 421 | [Organizations and projects using `react-waypoint`](INTHEWILD.md). 422 | 423 | ## Credits 424 | 425 | Credit to [trotzig][trotzig-github] and [lencioni][lencioni-github] for writing 426 | this component, and [Brigade][brigade-home] for open sourcing it. 427 | 428 | Thanks to the creator of the original Waypoints library, 429 | [imakewebthings][imakewebthings-github]. 430 | 431 | [lencioni-github]: https://github.com/lencioni 432 | [trotzig-github]: https://github.com/trotzig 433 | [brigade-home]: https://www.brigade.com/ 434 | [imakewebthings-github]: https://github.com/imakewebthings 435 | 436 | ## License 437 | 438 | [MIT][mit-license] 439 | 440 | [mit-license]: ./LICENSE 441 | 442 | --- 443 | 444 | _Make sure to check out other popular open-source tools from the 445 | [Brigade][civiccc-github] team: [dock], [overcommit], [haml-lint], and [scss-lint]._ 446 | 447 | [civiccc-github]: https://github.com/civiccc 448 | [dock]: https://github.com/civiccc/dock 449 | [overcommit]: https://github.com/sds/overcommit 450 | [haml-lint]: https://github.com/sds/haml-lint 451 | [scss-lint]: https://github.com/sds/scss-lint 452 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export declare class Waypoint extends React.Component { 4 | static above: string; 5 | static below: string; 6 | static inside: string; 7 | static invisible: string; 8 | } 9 | 10 | declare namespace Waypoint { 11 | interface CallbackArgs { 12 | /* 13 | * The position that the waypoint has at the moment. 14 | * One of Waypoint.below, Waypoint.above, Waypoint.inside, and Waypoint.invisible. 15 | */ 16 | currentPosition: string; 17 | 18 | /* 19 | * The position that the waypoint had before. 20 | * One of Waypoint.below, Waypoint.above, Waypoint.inside, and Waypoint.invisible. 21 | */ 22 | previousPosition: string; 23 | 24 | /* 25 | * The native scroll event that triggered the callback. 26 | * May be missing if the callback wasn't triggered as the result of a scroll 27 | */ 28 | event?: Event; 29 | 30 | /* 31 | * The waypoint's distance to the top of the viewport. 32 | */ 33 | waypointTop: number; 34 | 35 | /* 36 | * The distance from the scrollable ancestor to the viewport top. 37 | */ 38 | viewportTop: number; 39 | 40 | /* 41 | * The distance from the bottom of the scrollable ancestor to the viewport top. 42 | */ 43 | viewportBottom: number; 44 | } 45 | 46 | interface WaypointProps { 47 | /** 48 | * Function called when waypoint enters viewport 49 | * @param {CallbackArgs} args 50 | */ 51 | onEnter?: (args: CallbackArgs) => void; 52 | 53 | /** 54 | * Function called when waypoint leaves viewport 55 | * @param {CallbackArgs} args 56 | */ 57 | onLeave?: (args: CallbackArgs) => void; 58 | 59 | /** 60 | * Function called when waypoint position changes 61 | * @param {CallbackArgs} args 62 | */ 63 | onPositionChange?: (args: CallbackArgs) => void; 64 | 65 | /** 66 | * Whether to activate on horizontal scrolling instead of vertical 67 | */ 68 | horizontal?: boolean; 69 | 70 | /** 71 | * `topOffset` can either be a number, in which case its a distance from the 72 | * top of the container in pixels, or a string value. Valid string values are 73 | * of the form "20px", which is parsed as pixels, or "20%", which is parsed 74 | * as a percentage of the height of the containing element. 75 | * For instance, if you pass "-20%", and the containing element is 100px tall, 76 | * then the waypoint will be triggered when it has been scrolled 20px beyond 77 | * the top of the containing element. 78 | */ 79 | topOffset?: string|number; 80 | 81 | /** 82 | * `bottomOffset` can either be a number, in which case its a distance from the 83 | * bottom of the container in pixels, or a string value. Valid string values are 84 | * of the form "20px", which is parsed as pixels, or "20%", which is parsed 85 | * as a percentage of the height of the containing element. 86 | * For instance, if you pass "20%", and the containing element is 100px tall, 87 | * then the waypoint will be triggered when it has been scrolled 20px beyond 88 | * the bottom of the containing element. 89 | * 90 | * Similar to `topOffset`, but for the bottom of the container. 91 | */ 92 | bottomOffset?: string|number; 93 | 94 | /** 95 | * A custom ancestor to determine if the target is visible in it. 96 | * This is useful in cases where you do not want the immediate scrollable 97 | * ancestor to be the container. For example, when your target is in a div 98 | * that has overflow auto but you are detecting onEnter based on the window. 99 | */ 100 | scrollableAncestor?: any; 101 | 102 | /** 103 | * If the onEnter/onLeave events are to be fired on rapid scrolling. 104 | * This has no effect on onPositionChange -- it will fire anyway. 105 | */ 106 | fireOnRapidScroll?: boolean; 107 | 108 | /** 109 | * Use this prop to get debug information in the console log. This slows 110 | * things down significantly, so it should only be used during development. 111 | */ 112 | debug?: boolean; 113 | 114 | /** 115 | * Since React 18 Children are no longer implied, therefore we specify them here 116 | */ 117 | children?: React.ReactNode; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | }; 4 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | 3 | module.exports = (config) => { 4 | config.set({ 5 | 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: ['jasmine'], 12 | 13 | // list of files / patterns to load in the browser 14 | files: [ 15 | 'tests.webpack.js', 16 | ], 17 | 18 | // list of files to exclude 19 | exclude: [ 20 | ], 21 | 22 | // preprocess matching files before serving them to the browser 23 | // available preprocessors: 24 | // https://npmjs.org/browse/keyword/karma-preprocessor 25 | preprocessors: { 26 | 'tests.webpack.js': ['webpack'], 27 | }, 28 | 29 | webpack: { 30 | mode: 'development', 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.jsx?$/, 35 | exclude: /node_modules/, 36 | use: { 37 | loader: 'babel-loader', 38 | options: { 39 | cacheDirectory: true, 40 | }, 41 | }, 42 | }, 43 | ], 44 | }, 45 | resolve: { 46 | extensions: ['.js', '.jsx', '.json'], 47 | }, 48 | }, 49 | 50 | webpackMiddleware: { 51 | noInfo: true, 52 | }, 53 | 54 | // test results reporter to use 55 | // possible values: 'dots', 'progress' 56 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 57 | reporters: ['progress'], 58 | 59 | // web server port 60 | port: 9876, 61 | 62 | // enable / disable colors in the output (reporters and logs) 63 | colors: true, 64 | 65 | // level of logging 66 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || 67 | // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 68 | logLevel: config.LOG_INFO, 69 | 70 | // enable / disable watching file and executing tests whenever any file 71 | // changes 72 | autoWatch: true, 73 | 74 | // start these browsers 75 | // available browser launchers: 76 | // https://npmjs.org/browse/keyword/karma-launcher 77 | browsers: process.env.CONTINUOUS_INTEGRATION === 'true' 78 | ? ['Firefox'] : ['Chrome'], 79 | 80 | // if true, Karma captures browsers, runs the tests and exits 81 | singleRun: true, 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-waypoint", 3 | "version": "10.3.0", 4 | "description": "A React component to execute a function whenever you scroll to an element.", 5 | "main": "cjs/index.js", 6 | "module": "es/index.js", 7 | "types": "index.d.ts", 8 | "files": [ 9 | "cjs", 10 | "es", 11 | "index.d.ts" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/civiccc/react-waypoint.git" 16 | }, 17 | "homepage": "https://github.com/civiccc/react-waypoint", 18 | "bugs": "https://github.com/civiccc/react-waypoint/issues", 19 | "scripts": { 20 | "build": "npm run clean && rollup -c", 21 | "check-changelog": "expr $(git status --porcelain 2>/dev/null| grep \"^\\s*M.*CHANGELOG.md\" | wc -l) >/dev/null || (echo 'Please edit CHANGELOG.md' && exit 1)", 22 | "check-only-changelog-changed": "(expr $(git status --porcelain 2>/dev/null| grep -v \"CHANGELOG.md\" | wc -l) >/dev/null && echo 'Only CHANGELOG.md may have uncommitted changes' && exit 1) || exit 0", 23 | "clean": "rimraf es cjs", 24 | "lint": "eslint . --ext .js,.jsx", 25 | "postversion": "git commit package.json CHANGELOG.md -m \"Version $npm_package_version\" && npm run tag && git push && git push --tags && npm publish", 26 | "prepublish": "in-publish && safe-publish-latest && npm run build || not-in-publish", 27 | "pretest": "npm run --silent lint", 28 | "preversion": "npm run check-changelog && npm run check-only-changelog-changed", 29 | "tag": "git tag v$npm_package_version", 30 | "test": "npm run test:browser && npm run test:node", 31 | "test:node": "jest 'test/node/.*.js'", 32 | "test:browser": "karma start --single-run", 33 | "test:browser:watch": "karma start --no-single-run", 34 | "performance-test:watch": "webpack --watch --config webpack.config.performance-test.js" 35 | }, 36 | "author": "Brigade Engineering", 37 | "license": "MIT", 38 | "peerDependencies": { 39 | "react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "^7.0.0", 43 | "@babel/core": "^7.0.0", 44 | "@rollup/plugin-babel": "^5.2.1", 45 | "@types/react": "^16.14.5", 46 | "babel-loader": "^8.0.0", 47 | "babel-preset-airbnb": "^5.0.0", 48 | "eslint": "^7.12.0", 49 | "eslint-config-airbnb": "^18.2.0", 50 | "eslint-plugin-import": "^2.22.0", 51 | "eslint-plugin-jest": "^24.1.3", 52 | "eslint-plugin-jsx-a11y": "^6.3.1", 53 | "eslint-plugin-react": "^7.21.2", 54 | "in-publish": "^2.0.0", 55 | "jasmine-core": "^2.99.1", 56 | "jest": "^26.6.3", 57 | "karma": "^6.0.2", 58 | "karma-chrome-launcher": "^3.1.0", 59 | "karma-cli": "^2.0.0", 60 | "karma-firefox-launcher": "^2.1.0", 61 | "karma-jasmine": "^1.1.2", 62 | "karma-webpack": "^4.0.2", 63 | "loose-envify": "^1.4.0", 64 | "react": "^16.0.0 || ^17.0.0 || ^18.0.0", 65 | "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0", 66 | "react-test-renderer": "^16.0.0 || ^17.0.0 || ^18.0.0", 67 | "rimraf": "^3.0.2", 68 | "rollup": "^2.33.2", 69 | "safe-publish-latest": "^1.1.1", 70 | "webpack": "^4.46.0", 71 | "webpack-cli": "^4.4.0" 72 | }, 73 | "keywords": [ 74 | "react", 75 | "component", 76 | "react-component", 77 | "scroll", 78 | "onscroll", 79 | "scrollspy" 80 | ], 81 | "dependencies": { 82 | "@babel/runtime": "^7.12.5", 83 | "consolidated-events": "^1.1.0 || ^2.0.0", 84 | "prop-types": "^15.0.0", 85 | "react-is": "^17.0.1 || ^18.0.0" 86 | }, 87 | "browserify": { 88 | "transform": [ 89 | "loose-envify" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /react-waypoint-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/civiccc/react-waypoint/0905ac5a073131147c96dd0694bd6f1b6ee8bc97/react-waypoint-demo.gif -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import pkg from './package.json'; 3 | 4 | const depsSet = new Set([ 5 | ...Object.keys(pkg.dependencies), 6 | ...Object.keys(pkg.devDependencies), 7 | ]); 8 | 9 | /** 10 | * @param {'es' | 'cjs'} format 11 | */ 12 | function makeBuild(format) { 13 | return { 14 | input: 'src/waypoint.jsx', 15 | 16 | external: (id) => { 17 | if (id.startsWith('.') || id.startsWith('/')) { 18 | return false; 19 | } 20 | 21 | const packageName = id.startsWith('@') 22 | ? id.split('/').slice(0, 2).join('/') 23 | : id.split('/')[0]; 24 | 25 | return depsSet.has(packageName); 26 | }, 27 | 28 | output: [{ file: format === 'es' ? pkg.module : pkg.main, format }], 29 | 30 | plugins: [ 31 | babel({ 32 | babelHelpers: 'runtime', 33 | envName: format, 34 | exclude: ['node_modules/**'], 35 | }), 36 | ], 37 | }; 38 | } 39 | 40 | export default [makeBuild('es'), makeBuild('cjs')]; 41 | -------------------------------------------------------------------------------- /src/computeOffsetPixels.js: -------------------------------------------------------------------------------- 1 | import parseOffsetAsPercentage from './parseOffsetAsPercentage'; 2 | import parseOffsetAsPixels from './parseOffsetAsPixels'; 3 | 4 | /** 5 | * @param {string|number} offset 6 | * @param {number} contextHeight 7 | * @return {number} A number representing `offset` converted into pixels. 8 | */ 9 | export default function computeOffsetPixels(offset, contextHeight) { 10 | const pixelOffset = parseOffsetAsPixels(offset); 11 | 12 | if (typeof pixelOffset === 'number') { 13 | return pixelOffset; 14 | } 15 | 16 | const percentOffset = parseOffsetAsPercentage(offset); 17 | if (typeof percentOffset === 'number') { 18 | return percentOffset * contextHeight; 19 | } 20 | 21 | return undefined; 22 | } 23 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const ABOVE = 'above'; 2 | export const INSIDE = 'inside'; 3 | export const BELOW = 'below'; 4 | export const INVISIBLE = 'invisible'; 5 | -------------------------------------------------------------------------------- /src/debugLog.js: -------------------------------------------------------------------------------- 1 | export default function debugLog(...args) { 2 | if (process.env.NODE_ENV !== 'production') { 3 | console.log(...args); // eslint-disable-line no-console 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/ensureRefIsUsedByChild.js: -------------------------------------------------------------------------------- 1 | import isDOMElement from './isDOMElement'; 2 | 3 | export const errorMessage = ' needs a DOM element to compute boundaries. The child you passed is neither a ' 4 | + 'DOM element (e.g.
) nor does it use the innerRef prop.\n\n' 5 | + 'See https://goo.gl/LrBNgw for more info.'; 6 | 7 | /** 8 | * Raise an error if "children" is not a DOM Element and there is no ref provided to Waypoint 9 | * 10 | * @param {?React.element} children 11 | * @param {?HTMLElement} ref 12 | * @return {undefined} 13 | */ 14 | export default function ensureRefIsProvidedByChild(children, ref) { 15 | if (children && !isDOMElement(children) && !ref) { 16 | throw new Error(errorMessage); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/getCurrentPosition.js: -------------------------------------------------------------------------------- 1 | import { 2 | INVISIBLE, INSIDE, BELOW, ABOVE, 3 | } from './constants'; 4 | 5 | /** 6 | * @param {object} bounds An object with bounds data for the waypoint and 7 | * scrollable parent 8 | * @return {string} The current position of the waypoint in relation to the 9 | * visible portion of the scrollable parent. One of the constants `ABOVE`, 10 | * `BELOW`, `INSIDE` or `INVISIBLE`. 11 | */ 12 | export default function getCurrentPosition(bounds) { 13 | if (bounds.viewportBottom - bounds.viewportTop === 0) { 14 | return INVISIBLE; 15 | } 16 | 17 | // top is within the viewport 18 | if (bounds.viewportTop <= bounds.waypointTop 19 | && bounds.waypointTop <= bounds.viewportBottom) { 20 | return INSIDE; 21 | } 22 | 23 | // bottom is within the viewport 24 | if (bounds.viewportTop <= bounds.waypointBottom 25 | && bounds.waypointBottom <= bounds.viewportBottom) { 26 | return INSIDE; 27 | } 28 | 29 | // top is above the viewport and bottom is below the viewport 30 | if (bounds.waypointTop <= bounds.viewportTop 31 | && bounds.viewportBottom <= bounds.waypointBottom) { 32 | return INSIDE; 33 | } 34 | 35 | if (bounds.viewportBottom < bounds.waypointTop) { 36 | return BELOW; 37 | } 38 | 39 | if (bounds.waypointTop < bounds.viewportTop) { 40 | return ABOVE; 41 | } 42 | 43 | return INVISIBLE; 44 | } 45 | -------------------------------------------------------------------------------- /src/isDOMElement.js: -------------------------------------------------------------------------------- 1 | /** 2 | * When an element's type is a string, it represents a DOM node with that tag name 3 | * https://facebook.github.io/react/blog/2015/12/18/react-components-elements-and-instances.html#dom-elements 4 | * 5 | * @param {React.element} Component 6 | * @return {bool} Whether the component is a DOM Element 7 | */ 8 | export default function isDOMElement(Component) { 9 | return (typeof Component.type === 'string'); 10 | } 11 | -------------------------------------------------------------------------------- /src/onNextTick.js: -------------------------------------------------------------------------------- 1 | let timeout; 2 | const timeoutQueue = []; 3 | 4 | export default function onNextTick(cb) { 5 | timeoutQueue.push(cb); 6 | 7 | if (!timeout) { 8 | timeout = setTimeout(() => { 9 | timeout = null; 10 | 11 | // Drain the timeoutQueue 12 | let item; 13 | // eslint-disable-next-line no-cond-assign 14 | while (item = timeoutQueue.shift()) { 15 | item(); 16 | } 17 | }, 0); 18 | } 19 | 20 | let isSubscribed = true; 21 | 22 | return function unsubscribe() { 23 | if (!isSubscribed) { 24 | return; 25 | } 26 | 27 | isSubscribed = false; 28 | 29 | const index = timeoutQueue.indexOf(cb); 30 | if (index === -1) { 31 | return; 32 | } 33 | 34 | timeoutQueue.splice(index, 1); 35 | 36 | if (!timeoutQueue.length && timeout) { 37 | clearTimeout(timeout); 38 | timeout = null; 39 | } 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/parseOffsetAsPercentage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Attempts to parse the offset provided as a prop as a percentage. For 3 | * instance, if the component has been provided with the string "20%" as 4 | * a value of one of the offset props. If the value matches, then it returns 5 | * a numeric version of the prop. For instance, "20%" would become `0.2`. 6 | * If `str` isn't a percentage, then `undefined` will be returned. 7 | * 8 | * @param {string} str The value of an offset prop to be converted to a 9 | * number. 10 | * @return {number|undefined} The numeric version of `str`. Undefined if `str` 11 | * was not a percentage. 12 | */ 13 | export default function parseOffsetAsPercentage(str) { 14 | if (str.slice(-1) === '%') { 15 | return parseFloat(str.slice(0, -1)) / 100; 16 | } 17 | 18 | return undefined; 19 | } 20 | -------------------------------------------------------------------------------- /src/parseOffsetAsPixels.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Attempts to parse the offset provided as a prop as a pixel value. If 3 | * parsing fails, then `undefined` is returned. Three examples of values that 4 | * will be successfully parsed are: 5 | * `20` 6 | * "20px" 7 | * "20" 8 | * 9 | * @param {string|number} str A string of the form "{number}" or "{number}px", 10 | * or just a number. 11 | * @return {number|undefined} The numeric version of `str`. Undefined if `str` 12 | * was neither a number nor string ending in "px". 13 | */ 14 | export default function parseOffsetAsPixels(str) { 15 | if (!isNaN(parseFloat(str)) && isFinite(str)) { 16 | return parseFloat(str); 17 | } if (str.slice(-2) === 'px') { 18 | return parseFloat(str.slice(0, -2)); 19 | } 20 | 21 | return undefined; 22 | } 23 | -------------------------------------------------------------------------------- /src/resolveScrollableAncestorProp.js: -------------------------------------------------------------------------------- 1 | export default function resolveScrollableAncestorProp(scrollableAncestor) { 2 | // When Waypoint is rendered on the server, `window` is not available. 3 | // To make Waypoint easier to work with, we allow this to be specified in 4 | // string form and safely convert to `window` here. 5 | if (scrollableAncestor === 'window') { 6 | return global.window; 7 | } 8 | 9 | return scrollableAncestor; 10 | } 11 | -------------------------------------------------------------------------------- /src/waypoint.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | 3 | import { addEventListener } from 'consolidated-events'; 4 | import PropTypes from 'prop-types'; 5 | import React from 'react'; 6 | import { isForwardRef } from 'react-is'; 7 | 8 | import computeOffsetPixels from './computeOffsetPixels'; 9 | import { 10 | INVISIBLE, INSIDE, BELOW, ABOVE, 11 | } from './constants'; 12 | import debugLog from './debugLog'; 13 | import ensureRefIsUsedByChild from './ensureRefIsUsedByChild'; 14 | import isDOMElement from './isDOMElement'; 15 | import getCurrentPosition from './getCurrentPosition'; 16 | import onNextTick from './onNextTick'; 17 | import resolveScrollableAncestorProp from './resolveScrollableAncestorProp'; 18 | 19 | const hasWindow = typeof window !== 'undefined'; 20 | 21 | const defaultProps = { 22 | debug: false, 23 | scrollableAncestor: undefined, 24 | children: undefined, 25 | topOffset: '0px', 26 | bottomOffset: '0px', 27 | horizontal: false, 28 | onEnter() { }, 29 | onLeave() { }, 30 | onPositionChange() { }, 31 | fireOnRapidScroll: true, 32 | }; 33 | 34 | // Calls a function when you scroll to the element. 35 | export class Waypoint extends React.PureComponent { 36 | constructor(props) { 37 | super(props); 38 | 39 | this.refElement = (e) => { 40 | this._ref = e; 41 | }; 42 | } 43 | 44 | componentDidMount() { 45 | if (!hasWindow) { 46 | return; 47 | } 48 | 49 | // this._ref may occasionally not be set at this time. To help ensure that 50 | // this works smoothly and to avoid layout thrashing, we want to delay the 51 | // initial execution until the next tick. 52 | this.cancelOnNextTick = onNextTick(() => { 53 | this.cancelOnNextTick = null; 54 | const { children, debug } = this.props; 55 | 56 | // Berofe doing anything, we want to check that this._ref is avaliable in Waypoint 57 | ensureRefIsUsedByChild(children, this._ref); 58 | 59 | this._handleScroll = this._handleScroll.bind(this); 60 | this.scrollableAncestor = this._findScrollableAncestor(); 61 | 62 | if (process.env.NODE_ENV !== 'production' && debug) { 63 | debugLog('scrollableAncestor', this.scrollableAncestor); 64 | } 65 | 66 | this.scrollEventListenerUnsubscribe = addEventListener( 67 | this.scrollableAncestor, 68 | 'scroll', 69 | this._handleScroll, 70 | { passive: true }, 71 | ); 72 | 73 | this.resizeEventListenerUnsubscribe = addEventListener( 74 | window, 75 | 'resize', 76 | this._handleScroll, 77 | { passive: true }, 78 | ); 79 | 80 | this._handleScroll(null); 81 | }); 82 | } 83 | 84 | componentDidUpdate() { 85 | if (!hasWindow) { 86 | return; 87 | } 88 | 89 | if (!this.scrollableAncestor) { 90 | // The Waypoint has not yet initialized. 91 | return; 92 | } 93 | 94 | // The element may have moved, so we need to recompute its position on the 95 | // page. This happens via handleScroll in a way that forces layout to be 96 | // computed. 97 | // 98 | // We want this to be deferred to avoid forcing layout during render, which 99 | // causes layout thrashing. And, if we already have this work enqueued, we 100 | // can just wait for that to happen instead of enqueueing again. 101 | if (this.cancelOnNextTick) { 102 | return; 103 | } 104 | 105 | this.cancelOnNextTick = onNextTick(() => { 106 | this.cancelOnNextTick = null; 107 | this._handleScroll(null); 108 | }); 109 | } 110 | 111 | componentWillUnmount() { 112 | if (!hasWindow) { 113 | return; 114 | } 115 | 116 | if (this.scrollEventListenerUnsubscribe) { 117 | this.scrollEventListenerUnsubscribe(); 118 | } 119 | if (this.resizeEventListenerUnsubscribe) { 120 | this.resizeEventListenerUnsubscribe(); 121 | } 122 | 123 | if (this.cancelOnNextTick) { 124 | this.cancelOnNextTick(); 125 | } 126 | } 127 | 128 | /** 129 | * Traverses up the DOM to find an ancestor container which has an overflow 130 | * style that allows for scrolling. 131 | * 132 | * @return {Object} the closest ancestor element with an overflow style that 133 | * allows for scrolling. If none is found, the `window` object is returned 134 | * as a fallback. 135 | */ 136 | _findScrollableAncestor() { 137 | const { 138 | horizontal, 139 | scrollableAncestor, 140 | } = this.props; 141 | 142 | if (scrollableAncestor) { 143 | return resolveScrollableAncestorProp(scrollableAncestor); 144 | } 145 | 146 | let node = this._ref; 147 | 148 | while (node.parentNode) { 149 | node = node.parentNode; 150 | 151 | if (node === document.body) { 152 | // We've reached all the way to the root node. 153 | return window; 154 | } 155 | 156 | const style = window.getComputedStyle(node); 157 | const overflowDirec = horizontal 158 | ? style.getPropertyValue('overflow-x') 159 | : style.getPropertyValue('overflow-y'); 160 | const overflow = overflowDirec || style.getPropertyValue('overflow'); 161 | 162 | if (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay') { 163 | return node; 164 | } 165 | } 166 | 167 | // A scrollable ancestor element was not found, which means that we need to 168 | // do stuff on window. 169 | return window; 170 | } 171 | 172 | /** 173 | * @param {Object} event the native scroll event coming from the scrollable 174 | * ancestor, or resize event coming from the window. Will be undefined if 175 | * called by a React lifecyle method 176 | */ 177 | _handleScroll(event) { 178 | if (!this._ref) { 179 | // There's a chance we end up here after the component has been unmounted. 180 | return; 181 | } 182 | 183 | const bounds = this._getBounds(); 184 | const currentPosition = getCurrentPosition(bounds); 185 | const previousPosition = this._previousPosition; 186 | const { 187 | debug, 188 | onPositionChange, 189 | onEnter, 190 | onLeave, 191 | fireOnRapidScroll, 192 | } = this.props; 193 | 194 | if (process.env.NODE_ENV !== 'production' && debug) { 195 | debugLog('currentPosition', currentPosition); 196 | debugLog('previousPosition', previousPosition); 197 | } 198 | 199 | // Save previous position as early as possible to prevent cycles 200 | this._previousPosition = currentPosition; 201 | 202 | if (previousPosition === currentPosition) { 203 | // No change since last trigger 204 | return; 205 | } 206 | 207 | const callbackArg = { 208 | currentPosition, 209 | previousPosition, 210 | event, 211 | waypointTop: bounds.waypointTop, 212 | waypointBottom: bounds.waypointBottom, 213 | viewportTop: bounds.viewportTop, 214 | viewportBottom: bounds.viewportBottom, 215 | }; 216 | onPositionChange.call(this, callbackArg); 217 | 218 | if (currentPosition === INSIDE) { 219 | onEnter.call(this, callbackArg); 220 | } else if (previousPosition === INSIDE) { 221 | onLeave.call(this, callbackArg); 222 | } 223 | 224 | const isRapidScrollDown = previousPosition === BELOW 225 | && currentPosition === ABOVE; 226 | const isRapidScrollUp = previousPosition === ABOVE 227 | && currentPosition === BELOW; 228 | 229 | if (fireOnRapidScroll && (isRapidScrollDown || isRapidScrollUp)) { 230 | // If the scroll event isn't fired often enough to occur while the 231 | // waypoint was visible, we trigger both callbacks anyway. 232 | onEnter.call(this, { 233 | currentPosition: INSIDE, 234 | previousPosition, 235 | event, 236 | waypointTop: bounds.waypointTop, 237 | waypointBottom: bounds.waypointBottom, 238 | viewportTop: bounds.viewportTop, 239 | viewportBottom: bounds.viewportBottom, 240 | }); 241 | onLeave.call(this, { 242 | currentPosition, 243 | previousPosition: INSIDE, 244 | event, 245 | waypointTop: bounds.waypointTop, 246 | waypointBottom: bounds.waypointBottom, 247 | viewportTop: bounds.viewportTop, 248 | viewportBottom: bounds.viewportBottom, 249 | }); 250 | } 251 | } 252 | 253 | _getBounds() { 254 | const { horizontal, debug } = this.props; 255 | const { 256 | left, top, right, bottom, 257 | } = this._ref.getBoundingClientRect(); 258 | const waypointTop = horizontal ? left : top; 259 | const waypointBottom = horizontal ? right : bottom; 260 | 261 | let contextHeight; 262 | let contextScrollTop; 263 | if (this.scrollableAncestor === window) { 264 | contextHeight = horizontal ? window.innerWidth : window.innerHeight; 265 | contextScrollTop = 0; 266 | } else { 267 | contextHeight = horizontal ? this.scrollableAncestor.offsetWidth 268 | : this.scrollableAncestor.offsetHeight; 269 | contextScrollTop = horizontal 270 | ? this.scrollableAncestor.getBoundingClientRect().left 271 | : this.scrollableAncestor.getBoundingClientRect().top; 272 | } 273 | 274 | if (process.env.NODE_ENV !== 'production' && debug) { 275 | debugLog('waypoint top', waypointTop); 276 | debugLog('waypoint bottom', waypointBottom); 277 | debugLog('scrollableAncestor height', contextHeight); 278 | debugLog('scrollableAncestor scrollTop', contextScrollTop); 279 | } 280 | 281 | const { bottomOffset, topOffset } = this.props; 282 | const topOffsetPx = computeOffsetPixels(topOffset, contextHeight); 283 | const bottomOffsetPx = computeOffsetPixels(bottomOffset, contextHeight); 284 | const contextBottom = contextScrollTop + contextHeight; 285 | 286 | return { 287 | waypointTop, 288 | waypointBottom, 289 | viewportTop: contextScrollTop + topOffsetPx, 290 | viewportBottom: contextBottom - bottomOffsetPx, 291 | }; 292 | } 293 | 294 | /** 295 | * @return {Object} 296 | */ 297 | render() { 298 | const { children } = this.props; 299 | 300 | if (!children) { 301 | // We need an element that we can locate in the DOM to determine where it is 302 | // rendered relative to the top of its context. 303 | return ; 304 | } 305 | 306 | if (isDOMElement(children) || isForwardRef(children)) { 307 | const ref = (node) => { 308 | this.refElement(node); 309 | if (children.ref) { 310 | if (typeof children.ref === 'function') { 311 | children.ref(node); 312 | } else { 313 | children.ref.current = node; 314 | } 315 | } 316 | }; 317 | 318 | return React.cloneElement(children, { ref }); 319 | } 320 | 321 | return React.cloneElement(children, { innerRef: this.refElement }); 322 | } 323 | } 324 | 325 | if (process.env.NODE_ENV !== 'production') { 326 | Waypoint.propTypes = { 327 | children: PropTypes.element, 328 | debug: PropTypes.bool, 329 | onEnter: PropTypes.func, 330 | onLeave: PropTypes.func, 331 | onPositionChange: PropTypes.func, 332 | fireOnRapidScroll: PropTypes.bool, 333 | // eslint-disable-next-line react/forbid-prop-types 334 | scrollableAncestor: PropTypes.any, 335 | horizontal: PropTypes.bool, 336 | 337 | // `topOffset` can either be a number, in which case its a distance from the 338 | // top of the container in pixels, or a string value. Valid string values are 339 | // of the form "20px", which is parsed as pixels, or "20%", which is parsed 340 | // as a percentage of the height of the containing element. 341 | // For instance, if you pass "-20%", and the containing element is 100px tall, 342 | // then the waypoint will be triggered when it has been scrolled 20px beyond 343 | // the top of the containing element. 344 | topOffset: PropTypes.oneOfType([ 345 | PropTypes.string, 346 | PropTypes.number, 347 | ]), 348 | 349 | // `bottomOffset` can either be a number, in which case its a distance from the 350 | // bottom of the container in pixels, or a string value. Valid string values are 351 | // of the form "20px", which is parsed as pixels, or "20%", which is parsed 352 | // as a percentage of the height of the containing element. 353 | // For instance, if you pass "20%", and the containing element is 100px tall, 354 | // then the waypoint will be triggered when it has been scrolled 20px beyond 355 | // the bottom of the containing element. 356 | // Similar to `topOffset`, but for the bottom of the container. 357 | bottomOffset: PropTypes.oneOfType([ 358 | PropTypes.string, 359 | PropTypes.number, 360 | ]), 361 | }; 362 | } 363 | 364 | Waypoint.above = ABOVE; 365 | Waypoint.below = BELOW; 366 | Waypoint.inside = INSIDE; 367 | Waypoint.invisible = INVISIBLE; 368 | Waypoint.defaultProps = defaultProps; 369 | Waypoint.displayName = 'Waypoint'; 370 | -------------------------------------------------------------------------------- /test/browser/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globals: { 3 | afterEach: false, 4 | beforeEach: false, 5 | describe: false, 6 | expect: false, 7 | it: false, 8 | jasmine: false, 9 | spyOn: false, 10 | xit: false, 11 | }, 12 | 13 | rules: { 14 | 'max-nested-callbacks': [2, 4], 15 | 'react/prop-types': 0, 16 | 'max-classes-per-file': 0, 17 | 'react/jsx-props-no-spreading': 0, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /test/browser/waypoint_test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-multi-comp, react/no-render-return-value, react/no-find-dom-node */ 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import { Waypoint } from '../../src/waypoint'; 5 | 6 | import { errorMessage as refNotUsedErrorMessage } from '../../src/ensureRefIsUsedByChild'; 7 | 8 | let div; 9 | 10 | function renderAttached(component) { 11 | div = document.createElement('div'); 12 | document.body.appendChild(div); 13 | const renderedComponent = ReactDOM.render(component, div); 14 | return renderedComponent; 15 | } 16 | 17 | function scrollNodeTo(node, scrollTop) { 18 | if (node === window) { 19 | window.scroll(0, scrollTop); 20 | } else { 21 | // eslint-disable-next-line no-param-reassign 22 | node.scrollTop = scrollTop; 23 | } 24 | const event = document.createEvent('Event'); 25 | event.initEvent('scroll', false, false); 26 | node.dispatchEvent(event); 27 | } 28 | 29 | describe('', () => { 30 | let props; 31 | let margin; 32 | let parentHeight; 33 | let parentStyle; 34 | let topSpacerHeight; 35 | let bottomSpacerHeight; 36 | let subject; 37 | 38 | beforeEach(() => { 39 | jasmine.clock().install(); 40 | spyOn(console, 'log'); 41 | props = { 42 | onEnter: jasmine.createSpy('onEnter'), 43 | onLeave: jasmine.createSpy('onLeave'), 44 | onPositionChange: jasmine.createSpy('onPositionChange'), 45 | }; 46 | 47 | margin = 10; 48 | parentHeight = 100; 49 | 50 | parentStyle = { 51 | height: parentHeight, 52 | overflow: 'auto', 53 | position: 'relative', 54 | width: 100, 55 | margin, // Normalize the space above the viewport. 56 | }; 57 | 58 | topSpacerHeight = 0; 59 | bottomSpacerHeight = 0; 60 | 61 | subject = () => { 62 | const el = renderAttached( 63 |
64 |
65 | 66 |
67 |
, 68 | ); 69 | 70 | jasmine.clock().tick(1); 71 | return el; 72 | }; 73 | }); 74 | 75 | afterEach(() => { 76 | if (div) { 77 | ReactDOM.unmountComponentAtNode(div); 78 | } 79 | scrollNodeTo(window, 0); 80 | jasmine.clock().uninstall(); 81 | }); 82 | 83 | it('logs to the console when called with debug = true', () => { 84 | props.debug = true; 85 | subject(); 86 | expect(console.log).toHaveBeenCalled(); // eslint-disable-line no-console 87 | }); 88 | 89 | describe('when the Waypoint is visible on mount', () => { 90 | beforeEach(() => { 91 | topSpacerHeight = 90; 92 | bottomSpacerHeight = 200; 93 | subject(); 94 | }); 95 | 96 | it('does not log to the console', () => { 97 | // eslint-disable-next-line no-console 98 | expect(console.log).not.toHaveBeenCalled(); 99 | }); 100 | 101 | it('calls the onEnter handler', () => { 102 | expect(props.onEnter).toHaveBeenCalledWith({ 103 | currentPosition: Waypoint.inside, 104 | previousPosition: undefined, 105 | event: null, 106 | waypointTop: margin + topSpacerHeight, 107 | waypointBottom: margin + topSpacerHeight, 108 | viewportTop: margin, 109 | viewportBottom: margin + parentHeight, 110 | }); 111 | }); 112 | 113 | it('calls the onPositionChange handler', () => { 114 | expect(props.onPositionChange) 115 | .toHaveBeenCalledWith({ 116 | currentPosition: Waypoint.inside, 117 | previousPosition: undefined, 118 | event: null, 119 | waypointTop: margin + topSpacerHeight, 120 | waypointBottom: margin + topSpacerHeight, 121 | viewportTop: margin, 122 | viewportBottom: margin + parentHeight, 123 | }); 124 | }); 125 | 126 | it('does not call the onLeave handler', () => { 127 | expect(props.onLeave).not.toHaveBeenCalled(); 128 | }); 129 | }); 130 | 131 | describe('when the Waypoint is visible on mount and has topOffset < -100%', () => { 132 | beforeEach(() => { 133 | props.topOffset = '-200%'; 134 | 135 | topSpacerHeight = 90; 136 | bottomSpacerHeight = 200; 137 | subject(); 138 | }); 139 | 140 | it('calls the onEnter handler', () => { 141 | expect(props.onEnter).toHaveBeenCalledWith({ 142 | currentPosition: Waypoint.inside, 143 | previousPosition: undefined, 144 | event: null, 145 | waypointTop: margin + topSpacerHeight, 146 | waypointBottom: margin + topSpacerHeight, 147 | viewportTop: margin - (parentHeight * 2), 148 | viewportBottom: margin + parentHeight, 149 | }); 150 | }); 151 | 152 | it('calls the onPositionChange handler', () => { 153 | expect(props.onPositionChange) 154 | .toHaveBeenCalledWith({ 155 | currentPosition: Waypoint.inside, 156 | previousPosition: undefined, 157 | event: null, 158 | waypointTop: margin + topSpacerHeight, 159 | waypointBottom: margin + topSpacerHeight, 160 | viewportTop: margin - (parentHeight * 2), 161 | viewportBottom: margin + parentHeight, 162 | }); 163 | }); 164 | 165 | it('does not call the onLeave handler', () => { 166 | expect(props.onLeave).not.toHaveBeenCalled(); 167 | }); 168 | }); 169 | 170 | describe('when the Waypoint is visible on mount and has bottomOffset < -100%', () => { 171 | beforeEach(() => { 172 | props.bottomOffset = '-200%'; 173 | 174 | topSpacerHeight = 90; 175 | bottomSpacerHeight = 200; 176 | subject(); 177 | }); 178 | 179 | it('calls the onEnter handler', () => { 180 | expect(props.onEnter).toHaveBeenCalledWith({ 181 | currentPosition: Waypoint.inside, 182 | previousPosition: undefined, 183 | event: null, 184 | waypointTop: margin + topSpacerHeight, 185 | waypointBottom: margin + topSpacerHeight, 186 | viewportTop: margin, 187 | viewportBottom: margin + (parentHeight * 3), 188 | }); 189 | }); 190 | 191 | it('calls the onPositionChange handler', () => { 192 | expect(props.onPositionChange) 193 | .toHaveBeenCalledWith({ 194 | currentPosition: Waypoint.inside, 195 | previousPosition: undefined, 196 | event: null, 197 | waypointTop: margin + topSpacerHeight, 198 | waypointBottom: margin + topSpacerHeight, 199 | viewportTop: margin, 200 | viewportBottom: margin + (parentHeight * 3), 201 | }); 202 | }); 203 | 204 | it('does not call the onLeave handler', () => { 205 | expect(props.onLeave).not.toHaveBeenCalled(); 206 | }); 207 | }); 208 | 209 | describe('when the Waypoint is visible on mount and offsets < -100%', () => { 210 | beforeEach(() => { 211 | props.topOffset = '-200%'; 212 | props.bottomOffset = '-200%'; 213 | 214 | topSpacerHeight = 90; 215 | bottomSpacerHeight = 200; 216 | subject(); 217 | }); 218 | 219 | it('calls the onEnter handler', () => { 220 | expect(props.onEnter).toHaveBeenCalledWith({ 221 | currentPosition: Waypoint.inside, 222 | previousPosition: undefined, 223 | event: null, 224 | waypointTop: margin + topSpacerHeight, 225 | waypointBottom: margin + topSpacerHeight, 226 | viewportTop: margin - (parentHeight * 2), 227 | viewportBottom: margin + (parentHeight * 3), 228 | }); 229 | }); 230 | 231 | it('calls the onPositionChange handler', () => { 232 | expect(props.onPositionChange) 233 | .toHaveBeenCalledWith({ 234 | currentPosition: Waypoint.inside, 235 | previousPosition: undefined, 236 | event: null, 237 | waypointTop: margin + topSpacerHeight, 238 | waypointBottom: margin + topSpacerHeight, 239 | viewportTop: margin - (parentHeight * 2), 240 | viewportBottom: margin + (parentHeight * 3), 241 | }); 242 | }); 243 | 244 | it('does not call the onLeave handler', () => { 245 | expect(props.onLeave).not.toHaveBeenCalled(); 246 | }); 247 | }); 248 | 249 | describe('when scrolling while the waypoint is visible', () => { 250 | let parentComponent; 251 | let scrollable; 252 | 253 | beforeEach(() => { 254 | topSpacerHeight = 90; 255 | bottomSpacerHeight = 200; 256 | parentComponent = subject(); 257 | scrollable = parentComponent; 258 | scrollNodeTo(scrollable, topSpacerHeight / 2); 259 | }); 260 | 261 | it('does not call the onEnter handler again', () => { 262 | expect(props.onEnter.calls.count()).toBe(1); 263 | }); 264 | 265 | it('does not call the onLeave handler', () => { 266 | expect(props.onLeave).not.toHaveBeenCalled(); 267 | }); 268 | 269 | it('does not call the onPositionChange handler again', () => { 270 | expect(props.onPositionChange.calls.count()).toBe(1); 271 | }); 272 | }); 273 | 274 | describe('when scrolling past the waypoint while it is visible', () => { 275 | let parentComponent; 276 | let scrollable; 277 | 278 | beforeEach(() => { 279 | topSpacerHeight = 90; 280 | bottomSpacerHeight = 200; 281 | parentComponent = subject(); 282 | scrollable = parentComponent; 283 | scrollNodeTo(scrollable, topSpacerHeight + 10); 284 | }); 285 | 286 | it('the onLeave handler is called', () => { 287 | expect(props.onLeave) 288 | .toHaveBeenCalledWith({ 289 | currentPosition: Waypoint.above, 290 | previousPosition: Waypoint.inside, 291 | event: jasmine.any(Event), 292 | waypointTop: margin - 10, 293 | waypointBottom: margin - 10, 294 | viewportTop: margin, 295 | viewportBottom: margin + parentHeight, 296 | }); 297 | }); 298 | 299 | it('does not call the onEnter handler', () => { 300 | expect(props.onEnter.calls.count()).toBe(1); 301 | }); 302 | 303 | it('the onPositionChange is called', () => { 304 | expect(props.onPositionChange) 305 | .toHaveBeenCalledWith({ 306 | currentPosition: Waypoint.above, 307 | previousPosition: Waypoint.inside, 308 | event: jasmine.any(Event), 309 | waypointTop: margin - 10, 310 | waypointBottom: margin - 10, 311 | viewportTop: margin, 312 | viewportBottom: margin + parentHeight, 313 | }); 314 | }); 315 | }); 316 | 317 | describe('when the Waypoint is below the bottom', () => { 318 | beforeEach(() => { 319 | topSpacerHeight = 200; 320 | 321 | // The bottom spacer needs to be tall enough to force the Waypoint to exit 322 | // the viewport when scrolled all the way down. 323 | bottomSpacerHeight = 3000; 324 | }); 325 | 326 | it('does not call the onEnter handler on mount', () => { 327 | subject(); 328 | expect(props.onEnter).not.toHaveBeenCalled(); 329 | }); 330 | 331 | it('does not call the onLeave handler on mount', () => { 332 | subject(); 333 | expect(props.onLeave).not.toHaveBeenCalled(); 334 | }); 335 | 336 | it('calls the onPositionChange handler', () => { 337 | subject(); 338 | expect(props.onPositionChange) 339 | .toHaveBeenCalledWith({ 340 | currentPosition: Waypoint.below, 341 | previousPosition: undefined, 342 | event: null, 343 | waypointTop: margin + topSpacerHeight, 344 | waypointBottom: margin + topSpacerHeight, 345 | viewportTop: margin, 346 | viewportBottom: margin + parentHeight, 347 | }); 348 | }); 349 | 350 | describe('with children', () => { 351 | let childrenHeight; 352 | 353 | beforeEach(() => { 354 | childrenHeight = 80; 355 | props.children = ( 356 |
357 |
358 |
359 |
360 | ); 361 | }); 362 | 363 | it('calls the onEnter handler when scrolling down far enough', () => { 364 | const component = subject(); 365 | props.onPositionChange.calls.reset(); 366 | scrollNodeTo(component, 100); 367 | 368 | expect(props.onEnter) 369 | .toHaveBeenCalledWith({ 370 | currentPosition: Waypoint.inside, 371 | previousPosition: Waypoint.below, 372 | event: jasmine.any(Event), 373 | waypointTop: margin + topSpacerHeight - 100, 374 | waypointBottom: margin + topSpacerHeight - 100 + childrenHeight, 375 | viewportTop: margin, 376 | viewportBottom: margin + parentHeight, 377 | }); 378 | }); 379 | }); 380 | 381 | describe('when scrolling down just below the threshold', () => { 382 | let component; 383 | 384 | beforeEach(() => { 385 | component = subject(); 386 | props.onPositionChange.calls.reset(); 387 | scrollNodeTo(component, 99); 388 | }); 389 | 390 | it('does not call the onEnter handler', () => { 391 | expect(props.onEnter).not.toHaveBeenCalled(); 392 | }); 393 | 394 | it('does not call the onLeave handler', () => { 395 | expect(props.onLeave).not.toHaveBeenCalled(); 396 | }); 397 | 398 | it('does not call the onPositionChange handler', () => { 399 | expect(props.onPositionChange).not.toHaveBeenCalled(); 400 | }); 401 | }); 402 | 403 | it('calls the onEnter handler when scrolling down past the threshold', () => { 404 | scrollNodeTo(subject(), 100); 405 | 406 | expect(props.onEnter) 407 | .toHaveBeenCalledWith({ 408 | currentPosition: Waypoint.inside, 409 | previousPosition: Waypoint.below, 410 | event: jasmine.any(Event), 411 | waypointTop: margin + topSpacerHeight - 100, 412 | waypointBottom: margin + topSpacerHeight - 100, 413 | viewportTop: margin, 414 | viewportBottom: margin + parentHeight, 415 | }); 416 | }); 417 | 418 | it('calls the onPositionChange handler when scrolling down past the threshold', () => { 419 | scrollNodeTo(subject(), 100); 420 | 421 | expect(props.onPositionChange) 422 | .toHaveBeenCalledWith({ 423 | currentPosition: Waypoint.inside, 424 | previousPosition: Waypoint.below, 425 | event: jasmine.any(Event), 426 | waypointTop: margin + topSpacerHeight - 100, 427 | waypointBottom: margin + topSpacerHeight - 100, 428 | viewportTop: margin, 429 | viewportBottom: margin + parentHeight, 430 | }); 431 | }); 432 | 433 | it('does not call the onLeave handler when scrolling down past the threshold', () => { 434 | scrollNodeTo(subject(), 100); 435 | expect(props.onLeave).not.toHaveBeenCalled(); 436 | }); 437 | 438 | describe('when `fireOnRapidScroll` is disabled', () => { 439 | beforeEach(() => { 440 | props.fireOnRapidScroll = false; 441 | }); 442 | 443 | it('calls the onEnter handler when scrolling down past the threshold', () => { 444 | scrollNodeTo(subject(), 100); 445 | 446 | expect(props.onEnter) 447 | .toHaveBeenCalledWith({ 448 | currentPosition: Waypoint.inside, 449 | previousPosition: Waypoint.below, 450 | event: jasmine.any(Event), 451 | waypointTop: margin + topSpacerHeight - 100, 452 | waypointBottom: margin + topSpacerHeight - 100, 453 | viewportTop: margin, 454 | viewportBottom: margin + parentHeight, 455 | }); 456 | }); 457 | 458 | it('calls the onPositionChange handler when scrolling down past the threshold', () => { 459 | scrollNodeTo(subject(), 100); 460 | 461 | expect(props.onPositionChange) 462 | .toHaveBeenCalledWith({ 463 | currentPosition: Waypoint.inside, 464 | previousPosition: Waypoint.below, 465 | event: jasmine.any(Event), 466 | waypointTop: margin + topSpacerHeight - 100, 467 | waypointBottom: margin + topSpacerHeight - 100, 468 | viewportTop: margin, 469 | viewportBottom: margin + parentHeight, 470 | }); 471 | }); 472 | 473 | it('does not call the onLeave handler when scrolling down past the threshold', () => { 474 | scrollNodeTo(subject(), 100); 475 | 476 | expect(props.onLeave).not.toHaveBeenCalled(); 477 | }); 478 | }); 479 | 480 | describe('when scrolling quickly past the waypoint', () => { 481 | let scrollQuicklyPast; 482 | let component; 483 | 484 | // If you scroll really fast, we might not get a scroll event when the 485 | // waypoint is in view. We will get a scroll event before going into view 486 | // though, and one after. We want to treat this as if the waypoint was 487 | // visible for a brief moment, and so we fire both onEnter and onLeave. 488 | beforeEach(() => { 489 | scrollQuicklyPast = () => { 490 | component = subject(); 491 | props.onPositionChange.calls.reset(); 492 | scrollNodeTo(component, topSpacerHeight + bottomSpacerHeight); 493 | }; 494 | }); 495 | 496 | it('calls the onEnter handler', () => { 497 | scrollQuicklyPast(); 498 | expect(props.onEnter) 499 | .toHaveBeenCalledWith({ 500 | currentPosition: Waypoint.inside, 501 | previousPosition: Waypoint.below, 502 | event: jasmine.any(Event), 503 | waypointTop: margin - bottomSpacerHeight + parentHeight, 504 | waypointBottom: margin - bottomSpacerHeight + parentHeight, 505 | viewportTop: margin, 506 | viewportBottom: margin + parentHeight, 507 | }); 508 | }); 509 | 510 | it('calls the onLeave handler', () => { 511 | scrollQuicklyPast(); 512 | expect(props.onLeave) 513 | .toHaveBeenCalledWith({ 514 | currentPosition: Waypoint.above, 515 | previousPosition: Waypoint.inside, 516 | event: jasmine.any(Event), 517 | waypointTop: margin - bottomSpacerHeight + parentHeight, 518 | waypointBottom: margin - bottomSpacerHeight + parentHeight, 519 | viewportTop: margin, 520 | viewportBottom: margin + parentHeight, 521 | }); 522 | }); 523 | 524 | it('calls the onPositionChange handler', () => { 525 | scrollQuicklyPast(); 526 | expect(props.onPositionChange) 527 | .toHaveBeenCalledWith({ 528 | currentPosition: Waypoint.above, 529 | previousPosition: Waypoint.below, 530 | event: jasmine.any(Event), 531 | waypointTop: margin - bottomSpacerHeight + parentHeight, 532 | waypointBottom: margin - bottomSpacerHeight + parentHeight, 533 | viewportTop: margin, 534 | viewportBottom: margin + parentHeight, 535 | }); 536 | }); 537 | 538 | describe('when `fireOnRapidScroll` is disabled', () => { 539 | beforeEach(() => { 540 | props.fireOnRapidScroll = false; 541 | }); 542 | 543 | it('does not call the onEnter handler', () => { 544 | scrollQuicklyPast(); 545 | expect(props.onEnter).not.toHaveBeenCalled(); 546 | }); 547 | 548 | it('does not call the onLeave handler', () => { 549 | scrollQuicklyPast(); 550 | expect(props.onLeave).not.toHaveBeenCalled(); 551 | }); 552 | 553 | it('calls the onPositionChange handler', () => { 554 | scrollQuicklyPast(); 555 | expect(props.onPositionChange) 556 | .toHaveBeenCalledWith({ 557 | currentPosition: Waypoint.above, 558 | previousPosition: Waypoint.below, 559 | event: jasmine.any(Event), 560 | waypointTop: margin - bottomSpacerHeight + parentHeight, 561 | waypointBottom: margin - bottomSpacerHeight + parentHeight, 562 | viewportTop: margin, 563 | viewportBottom: margin + parentHeight, 564 | }); 565 | }); 566 | }); 567 | }); 568 | 569 | describe('with a non-zero topOffset', () => { 570 | describe('and the topOffset is passed as a percentage', () => { 571 | beforeEach(() => { 572 | props.topOffset = '-10%'; 573 | }); 574 | 575 | it('calls the onLeave handler when scrolling down past the bottom offset', () => { 576 | const component = subject(); 577 | props.onPositionChange.calls.reset(); 578 | scrollNodeTo(component, 211); 579 | 580 | expect(props.onLeave) 581 | .toHaveBeenCalledWith({ 582 | currentPosition: Waypoint.above, 583 | previousPosition: Waypoint.inside, 584 | event: jasmine.any(Event), 585 | waypointTop: margin + topSpacerHeight - 211, 586 | waypointBottom: margin + topSpacerHeight - 211, 587 | viewportTop: margin + parentHeight * -0.1, 588 | viewportBottom: margin + parentHeight, 589 | }); 590 | }); 591 | }); 592 | }); 593 | 594 | describe('with a non-zero bottomOffset', () => { 595 | describe('and the bottomOffset is passed as a percentage', () => { 596 | beforeEach(() => { 597 | props.bottomOffset = '-10%'; 598 | }); 599 | 600 | it('does not call the onEnter handler when scrolling down near the bottom offset', () => { 601 | const component = subject(); 602 | props.onPositionChange.calls.reset(); 603 | scrollNodeTo(component, 89); 604 | 605 | expect(props.onEnter).not.toHaveBeenCalled(); 606 | }); 607 | 608 | it('does not call the onLeave handler when scrolling down near the bottom offset', () => { 609 | const component = subject(); 610 | props.onPositionChange.calls.reset(); 611 | scrollNodeTo(component, 89); 612 | 613 | expect(props.onLeave).not.toHaveBeenCalled(); 614 | }); 615 | 616 | it('does not call onPositionChange handler when scrolling down near bottom offset', () => { 617 | const component = subject(); 618 | props.onPositionChange.calls.reset(); 619 | scrollNodeTo(component, 89); 620 | 621 | expect(props.onPositionChange).not.toHaveBeenCalled(); 622 | }); 623 | 624 | it('calls the onEnter handler when scrolling down past the bottom offset', () => { 625 | const component = subject(); 626 | props.onPositionChange.calls.reset(); 627 | scrollNodeTo(component, 90); 628 | 629 | expect(props.onEnter) 630 | .toHaveBeenCalledWith({ 631 | currentPosition: Waypoint.inside, 632 | previousPosition: Waypoint.below, 633 | event: jasmine.any(Event), 634 | waypointTop: margin + topSpacerHeight - 90, 635 | waypointBottom: margin + topSpacerHeight - 90, 636 | viewportTop: margin, 637 | viewportBottom: margin + Math.floor(parentHeight * 1.1), 638 | }); 639 | }); 640 | 641 | it('does not call the onLeave handler when scrolling down past the bottom offset', () => { 642 | const component = subject(); 643 | props.onPositionChange.calls.reset(); 644 | scrollNodeTo(component, 90); 645 | 646 | expect(props.onLeave).not.toHaveBeenCalled(); 647 | }); 648 | 649 | it('calls the onPositionChange handler when scrolling down past the bottom offset', () => { 650 | const component = subject(); 651 | props.onPositionChange.calls.reset(); 652 | scrollNodeTo(component, 90); 653 | 654 | expect(props.onPositionChange) 655 | .toHaveBeenCalledWith({ 656 | currentPosition: Waypoint.inside, 657 | previousPosition: Waypoint.below, 658 | event: jasmine.any(Event), 659 | waypointTop: margin + topSpacerHeight - 90, 660 | waypointBottom: margin + topSpacerHeight - 90, 661 | viewportTop: margin, 662 | viewportBottom: margin + Math.floor(parentHeight * 1.1), 663 | }); 664 | }); 665 | }); 666 | 667 | describe('and the bottom offset is passed as a numeric string', () => { 668 | beforeEach(() => { 669 | props.bottomOffset = '-10'; 670 | }); 671 | 672 | it('does not call the onEnter handler when scrolling down near the bottom offset', () => { 673 | const component = subject(); 674 | props.onPositionChange.calls.reset(); 675 | scrollNodeTo(component, 89); 676 | 677 | expect(props.onEnter).not.toHaveBeenCalled(); 678 | }); 679 | 680 | it('does not call the onLeave handler when scrolling down near the bottom offset', () => { 681 | const component = subject(); 682 | props.onPositionChange.calls.reset(); 683 | scrollNodeTo(component, 89); 684 | 685 | expect(props.onLeave).not.toHaveBeenCalled(); 686 | }); 687 | 688 | it('does not call onPositionChange handler when scrolling down near bottom offset', () => { 689 | const component = subject(); 690 | props.onPositionChange.calls.reset(); 691 | scrollNodeTo(component, 89); 692 | 693 | expect(props.onPositionChange).not.toHaveBeenCalled(); 694 | }); 695 | 696 | it('calls the onEnter handler when scrolling down past the bottom offset', () => { 697 | const component = subject(); 698 | props.onPositionChange.calls.reset(); 699 | scrollNodeTo(component, 90); 700 | 701 | expect(props.onEnter) 702 | .toHaveBeenCalledWith({ 703 | currentPosition: Waypoint.inside, 704 | previousPosition: Waypoint.below, 705 | event: jasmine.any(Event), 706 | waypointTop: margin + topSpacerHeight - 90, 707 | waypointBottom: margin + topSpacerHeight - 90, 708 | viewportTop: margin, 709 | viewportBottom: margin + parentHeight + 10, 710 | }); 711 | }); 712 | 713 | it('does not call the onLeave handler when scrolling down past the bottom offset', () => { 714 | const component = subject(); 715 | props.onPositionChange.calls.reset(); 716 | scrollNodeTo(component, 90); 717 | 718 | expect(props.onLeave).not.toHaveBeenCalled(); 719 | }); 720 | 721 | it('calls the onPositionChange handler when scrolling down past the bottom offset', () => { 722 | const component = subject(); 723 | props.onPositionChange.calls.reset(); 724 | scrollNodeTo(component, 90); 725 | 726 | expect(props.onPositionChange) 727 | .toHaveBeenCalledWith({ 728 | currentPosition: Waypoint.inside, 729 | previousPosition: Waypoint.below, 730 | event: jasmine.any(Event), 731 | waypointTop: margin + topSpacerHeight - 90, 732 | waypointBottom: margin + topSpacerHeight - 90, 733 | viewportTop: margin, 734 | viewportBottom: margin + parentHeight + 10, 735 | }); 736 | }); 737 | }); 738 | 739 | describe('and the bottom offset is passed as a pixel string', () => { 740 | beforeEach(() => { 741 | props.bottomOffset = '-10px'; 742 | }); 743 | 744 | it('does not call the onEnter handler when scrolling down near the bottom offset', () => { 745 | const component = subject(); 746 | props.onPositionChange.calls.reset(); 747 | scrollNodeTo(component, 89); 748 | 749 | expect(props.onEnter).not.toHaveBeenCalled(); 750 | }); 751 | 752 | it('does not call the onLeave handler when scrolling down near the bottom offset', () => { 753 | const component = subject(); 754 | props.onPositionChange.calls.reset(); 755 | scrollNodeTo(component, 89); 756 | 757 | expect(props.onLeave).not.toHaveBeenCalled(); 758 | }); 759 | 760 | it('does not call onPositionChange handler when scrolling down near bottom offset', () => { 761 | const component = subject(); 762 | props.onPositionChange.calls.reset(); 763 | scrollNodeTo(component, 89); 764 | 765 | expect(props.onPositionChange).not.toHaveBeenCalled(); 766 | }); 767 | 768 | it('calls the onEnter handler when scrolling down past the bottom offset', () => { 769 | const component = subject(); 770 | props.onPositionChange.calls.reset(); 771 | scrollNodeTo(component, 90); 772 | 773 | expect(props.onEnter) 774 | .toHaveBeenCalledWith({ 775 | currentPosition: Waypoint.inside, 776 | previousPosition: Waypoint.below, 777 | event: jasmine.any(Event), 778 | waypointTop: margin + topSpacerHeight - 90, 779 | waypointBottom: margin + topSpacerHeight - 90, 780 | viewportTop: margin, 781 | viewportBottom: margin + parentHeight + 10, 782 | }); 783 | }); 784 | 785 | it('does not call the onLeave handler when scrolling down past the bottom offset', () => { 786 | const component = subject(); 787 | props.onPositionChange.calls.reset(); 788 | scrollNodeTo(component, 90); 789 | 790 | expect(props.onLeave).not.toHaveBeenCalled(); 791 | }); 792 | 793 | it('calls the onPositionChange handler when scrolling down past the bottom offset', () => { 794 | const component = subject(); 795 | props.onPositionChange.calls.reset(); 796 | scrollNodeTo(component, 90); 797 | 798 | expect(props.onPositionChange) 799 | .toHaveBeenCalledWith({ 800 | currentPosition: Waypoint.inside, 801 | previousPosition: Waypoint.below, 802 | event: jasmine.any(Event), 803 | waypointTop: margin + topSpacerHeight - 90, 804 | waypointBottom: margin + topSpacerHeight - 90, 805 | viewportTop: margin, 806 | viewportBottom: margin + parentHeight + 10, 807 | }); 808 | }); 809 | }); 810 | 811 | describe('and the bottom offset is passed as a number', () => { 812 | beforeEach(() => { 813 | props.bottomOffset = -10; 814 | }); 815 | 816 | it('does not call the onEnter handler when scrolling down near the bottom offset', () => { 817 | const component = subject(); 818 | props.onPositionChange.calls.reset(); 819 | scrollNodeTo(component, 89); 820 | 821 | expect(props.onEnter).not.toHaveBeenCalled(); 822 | }); 823 | 824 | it('does not call the onLeave handler when scrolling down near the bottom offset', () => { 825 | const component = subject(); 826 | props.onPositionChange.calls.reset(); 827 | scrollNodeTo(component, 89); 828 | 829 | expect(props.onLeave).not.toHaveBeenCalled(); 830 | }); 831 | 832 | it('does not call onPositionChange handler when scrolling down near bottom offset', () => { 833 | const component = subject(); 834 | props.onPositionChange.calls.reset(); 835 | scrollNodeTo(component, 89); 836 | 837 | expect(props.onPositionChange).not.toHaveBeenCalled(); 838 | }); 839 | 840 | it('calls the onEnter handler when scrolling down past the bottom offset', () => { 841 | const component = subject(); 842 | props.onPositionChange.calls.reset(); 843 | scrollNodeTo(component, 90); 844 | 845 | expect(props.onEnter) 846 | .toHaveBeenCalledWith({ 847 | currentPosition: Waypoint.inside, 848 | previousPosition: Waypoint.below, 849 | event: jasmine.any(Event), 850 | waypointTop: margin + topSpacerHeight - 90, 851 | waypointBottom: margin + topSpacerHeight - 90, 852 | viewportTop: margin, 853 | viewportBottom: margin + parentHeight + 10, 854 | }); 855 | }); 856 | 857 | it('does not call the onLeave handler when scrolling down past the bottom offset', () => { 858 | const component = subject(); 859 | props.onPositionChange.calls.reset(); 860 | scrollNodeTo(component, 90); 861 | 862 | expect(props.onLeave).not.toHaveBeenCalled(); 863 | }); 864 | 865 | it('calls onPositionChange handler when scrolling down past bottom offset', () => { 866 | const component = subject(); 867 | props.onPositionChange.calls.reset(); 868 | scrollNodeTo(component, 90); 869 | 870 | expect(props.onPositionChange) 871 | .toHaveBeenCalledWith({ 872 | currentPosition: Waypoint.inside, 873 | previousPosition: Waypoint.below, 874 | event: jasmine.any(Event), 875 | waypointTop: margin + topSpacerHeight - 90, 876 | waypointBottom: margin + topSpacerHeight - 90, 877 | viewportTop: margin, 878 | viewportBottom: margin + parentHeight + 10, 879 | }); 880 | }); 881 | }); 882 | }); 883 | }); 884 | 885 | describe('when the Waypoint has children', () => { 886 | it('does not throw with a DOM Element as a child', () => { 887 | props.children =
; 888 | expect(subject).not.toThrow(); 889 | }); 890 | 891 | it('does not throw with a Stateful Component as a child', () => { 892 | class StatefulComponent extends React.Component { 893 | render() { 894 | const { innerRef } = this.props; 895 | return
; 896 | } 897 | } 898 | 899 | props.children = ; 900 | expect(subject).not.toThrow(); 901 | }); 902 | 903 | it('errors when a Stateful Component does not provide ref to Waypoint', () => { 904 | // eslint-disable-next-line react/prefer-stateless-function 905 | class StatefulComponent extends React.Component { 906 | render() { 907 | return
; 908 | } 909 | } 910 | 911 | props.children = ; 912 | expect(subject).toThrowError(refNotUsedErrorMessage); 913 | }); 914 | 915 | it('does not throw with a Stateless Component as a child', () => { 916 | const StatelessComponent = ({ innerRef }) =>
; 917 | 918 | props.children = ; 919 | expect(subject).not.toThrow(); 920 | }); 921 | 922 | it('errors when a Stateless Component does not provide ref to Waypoint', () => { 923 | const StatelessComponent = () =>
; 924 | 925 | props.children = ; 926 | expect(subject).toThrowError(refNotUsedErrorMessage); 927 | }); 928 | }); 929 | 930 | describe('when the Waypoint has children and is above the top', () => { 931 | let childrenHeight; 932 | let scrollable; 933 | 934 | beforeEach(() => { 935 | topSpacerHeight = 200; 936 | bottomSpacerHeight = 200; 937 | childrenHeight = 100; 938 | props.children =
; 939 | scrollable = subject(); 940 | 941 | // Because of how we detect when a Waypoint is scrolled past without any 942 | // scroll event fired when it was visible, we need to reset callback 943 | // spies. 944 | scrollNodeTo(scrollable, 400); 945 | props.onEnter.calls.reset(); 946 | props.onLeave.calls.reset(); 947 | scrollNodeTo(scrollable, 400); 948 | }); 949 | 950 | it('does not call the onEnter handler', () => { 951 | expect(props.onEnter).not.toHaveBeenCalled(); 952 | }); 953 | 954 | it('does not call the onLeave handler', () => { 955 | expect(props.onLeave).not.toHaveBeenCalled(); 956 | }); 957 | 958 | it('calls the onEnter handler when scrolled back up just past the bottom', () => { 959 | scrollNodeTo(scrollable, topSpacerHeight + 50); 960 | 961 | expect(props.onEnter) 962 | .toHaveBeenCalledWith({ 963 | currentPosition: Waypoint.inside, 964 | previousPosition: Waypoint.above, 965 | event: jasmine.any(Event), 966 | waypointTop: -40, 967 | waypointBottom: -40 + childrenHeight, 968 | viewportTop: margin, 969 | viewportBottom: margin + parentHeight, 970 | }); 971 | }); 972 | 973 | it('does not call the onLeave handler when scrolled back up just past the bottom', () => { 974 | scrollNodeTo(scrollable, topSpacerHeight + 50); 975 | 976 | expect(props.onLeave).not.toHaveBeenCalled(); 977 | }); 978 | }); 979 | 980 | describe('when the Waypoint is above the top', () => { 981 | let scrollable; 982 | 983 | beforeEach(() => { 984 | topSpacerHeight = 200; 985 | bottomSpacerHeight = 200; 986 | scrollable = subject(); 987 | 988 | // Because of how we detect when a Waypoint is scrolled past without any 989 | // scroll event fired when it was visible, we need to reset callback 990 | // spies. 991 | scrollNodeTo(scrollable, 400); 992 | props.onEnter.calls.reset(); 993 | props.onLeave.calls.reset(); 994 | props.onPositionChange.calls.reset(); 995 | scrollNodeTo(scrollable, 400); 996 | }); 997 | 998 | it('does not call the onEnter handler', () => { 999 | expect(props.onEnter).not.toHaveBeenCalled(); 1000 | }); 1001 | 1002 | it('does not call the onLeave handler', () => { 1003 | expect(props.onLeave).not.toHaveBeenCalled(); 1004 | }); 1005 | 1006 | it('does not call the onPositionChange handler', () => { 1007 | expect(props.onPositionChange).not.toHaveBeenCalled(); 1008 | }); 1009 | 1010 | it('does not call the onEnter handler when scrolling up not past the threshold', () => { 1011 | scrollNodeTo(scrollable, 201); 1012 | 1013 | expect(props.onEnter).not.toHaveBeenCalled(); 1014 | }); 1015 | 1016 | it('does not call the onLeave handler when scrolling up not past the threshold', () => { 1017 | scrollNodeTo(scrollable, 201); 1018 | 1019 | expect(props.onLeave).not.toHaveBeenCalled(); 1020 | }); 1021 | 1022 | it('does not call onPositionChange handler when scrolling up not past the threshold', () => { 1023 | scrollNodeTo(scrollable, 201); 1024 | 1025 | expect(props.onPositionChange).not.toHaveBeenCalled(); 1026 | }); 1027 | 1028 | describe('when scrolling up past the threshold', () => { 1029 | beforeEach(() => { 1030 | scrollNodeTo(scrollable, 200); 1031 | }); 1032 | 1033 | it('calls the onEnter handler', () => { 1034 | expect(props.onEnter) 1035 | .toHaveBeenCalledWith({ 1036 | currentPosition: Waypoint.inside, 1037 | previousPosition: Waypoint.above, 1038 | event: jasmine.any(Event), 1039 | waypointTop: margin + topSpacerHeight - 200, 1040 | waypointBottom: margin + topSpacerHeight - 200, 1041 | viewportTop: margin, 1042 | viewportBottom: margin + parentHeight, 1043 | }); 1044 | }); 1045 | 1046 | it('does not call the onLeave handler', () => { 1047 | expect(props.onLeave).not.toHaveBeenCalled(); 1048 | }); 1049 | 1050 | it('calls the onPositionChange handler', () => { 1051 | expect(props.onPositionChange) 1052 | .toHaveBeenCalledWith({ 1053 | currentPosition: Waypoint.inside, 1054 | previousPosition: Waypoint.above, 1055 | event: jasmine.any(Event), 1056 | waypointTop: margin + topSpacerHeight - 200, 1057 | waypointBottom: margin + topSpacerHeight - 200, 1058 | viewportTop: margin, 1059 | viewportBottom: margin + parentHeight, 1060 | }); 1061 | }); 1062 | 1063 | it('calls the onLeave handler when scrolling up past the waypoint', () => { 1064 | scrollNodeTo(scrollable, 99); 1065 | 1066 | expect(props.onLeave) 1067 | .toHaveBeenCalledWith({ 1068 | currentPosition: Waypoint.below, 1069 | previousPosition: Waypoint.inside, 1070 | event: jasmine.any(Event), 1071 | waypointTop: margin + topSpacerHeight - 99, 1072 | waypointBottom: margin + topSpacerHeight - 99, 1073 | viewportTop: margin, 1074 | viewportBottom: margin + parentHeight, 1075 | }); 1076 | }); 1077 | 1078 | it('does not call the onEnter handler again when scrolling up past the waypoint', () => { 1079 | scrollNodeTo(scrollable, 99); 1080 | 1081 | expect(props.onEnter.calls.count()).toBe(1); 1082 | }); 1083 | 1084 | it('calls the onPositionChange handler when scrolling up past the waypoint', () => { 1085 | scrollNodeTo(scrollable, 99); 1086 | 1087 | expect(props.onPositionChange) 1088 | .toHaveBeenCalledWith({ 1089 | currentPosition: Waypoint.below, 1090 | previousPosition: Waypoint.inside, 1091 | event: jasmine.any(Event), 1092 | waypointTop: margin + topSpacerHeight - 99, 1093 | waypointBottom: margin + topSpacerHeight - 99, 1094 | viewportTop: margin, 1095 | viewportBottom: margin + parentHeight, 1096 | }); 1097 | }); 1098 | }); 1099 | 1100 | describe('when scrolling up quickly past the waypoint', () => { 1101 | // If you scroll really fast, we might not get a scroll event when the 1102 | // waypoint is in view. We will get a scroll event before going into view 1103 | // though, and one after. We want to treat this as if the waypoint was 1104 | // visible for a brief moment, and so we fire both onEnter and onLeave. 1105 | beforeEach(() => { 1106 | scrollNodeTo(scrollable, 0); 1107 | }); 1108 | 1109 | it('calls the onEnter handler', () => { 1110 | expect(props.onEnter) 1111 | .toHaveBeenCalledWith({ 1112 | currentPosition: Waypoint.inside, 1113 | previousPosition: Waypoint.above, 1114 | event: jasmine.any(Event), 1115 | waypointTop: margin + topSpacerHeight, 1116 | waypointBottom: margin + topSpacerHeight, 1117 | viewportTop: margin, 1118 | viewportBottom: margin + parentHeight, 1119 | }); 1120 | }); 1121 | 1122 | it('calls the onLeave handler', () => { 1123 | expect(props.onLeave) 1124 | .toHaveBeenCalledWith({ 1125 | currentPosition: Waypoint.below, 1126 | previousPosition: Waypoint.inside, 1127 | event: jasmine.any(Event), 1128 | waypointTop: margin + topSpacerHeight, 1129 | waypointBottom: margin + topSpacerHeight, 1130 | viewportTop: margin, 1131 | viewportBottom: margin + parentHeight, 1132 | }); 1133 | }); 1134 | 1135 | it('calls the onPositionChange handler', () => { 1136 | expect(props.onPositionChange) 1137 | .toHaveBeenCalledWith({ 1138 | currentPosition: Waypoint.below, 1139 | previousPosition: Waypoint.above, 1140 | event: jasmine.any(Event), 1141 | waypointTop: margin + topSpacerHeight, 1142 | waypointBottom: margin + topSpacerHeight, 1143 | viewportTop: margin, 1144 | viewportBottom: margin + parentHeight, 1145 | }); 1146 | }); 1147 | }); 1148 | }); 1149 | 1150 | describe('when the scrollable parent is not displayed', () => { 1151 | it('calls the onLeave handler', () => { 1152 | const component = subject(); 1153 | const node = ReactDOM.findDOMNode(component); 1154 | node.style.display = 'none'; 1155 | scrollNodeTo(component, 0); 1156 | expect(props.onLeave) 1157 | .toHaveBeenCalledWith({ 1158 | currentPosition: Waypoint.invisible, 1159 | previousPosition: Waypoint.inside, 1160 | event: jasmine.any(Event), 1161 | waypointTop: 0, 1162 | waypointBottom: 0, 1163 | viewportTop: 0, 1164 | viewportBottom: 0, 1165 | }); 1166 | }); 1167 | }); 1168 | 1169 | describe('when the window is the scrollable parent', () => { 1170 | beforeEach(() => { 1171 | // Make the normal parent non-scrollable 1172 | parentStyle.height = 'auto'; 1173 | parentStyle.overflow = 'visible'; 1174 | 1175 | // This is only here to try and confuse the _findScrollableAncestor code. 1176 | document.body.style.overflow = 'auto'; 1177 | 1178 | // Make the spacers large enough to make the Waypoint render off-screen 1179 | topSpacerHeight = window.innerHeight + 1000; 1180 | bottomSpacerHeight = 1000; 1181 | }); 1182 | 1183 | afterEach(() => { 1184 | // Reset body style 1185 | document.body.style.overflow = ''; 1186 | }); 1187 | 1188 | it('does not fire the onEnter handler on mount', () => { 1189 | subject(); 1190 | expect(props.onEnter).not.toHaveBeenCalled(); 1191 | }); 1192 | 1193 | it('fires the onPositionChange handler on mount', () => { 1194 | subject(); 1195 | expect(props.onPositionChange) 1196 | .toHaveBeenCalledWith({ 1197 | currentPosition: Waypoint.below, 1198 | previousPosition: undefined, 1199 | event: null, 1200 | waypointTop: margin + topSpacerHeight, 1201 | waypointBottom: margin + topSpacerHeight, 1202 | viewportTop: 0, 1203 | viewportBottom: window.innerHeight, 1204 | }); 1205 | }); 1206 | 1207 | it('fires the onEnter handler when the Waypoint is in view', () => { 1208 | subject(); 1209 | scrollNodeTo(window, topSpacerHeight - window.innerHeight / 2); 1210 | 1211 | expect(props.onEnter) 1212 | .toHaveBeenCalledWith({ 1213 | currentPosition: Waypoint.inside, 1214 | previousPosition: Waypoint.below, 1215 | event: jasmine.any(Event), 1216 | waypointTop: margin + Math.ceil(window.innerHeight / 2), 1217 | waypointBottom: margin + Math.ceil(window.innerHeight / 2), 1218 | viewportTop: 0, 1219 | viewportBottom: window.innerHeight, 1220 | }); 1221 | }); 1222 | 1223 | it('fires the onPositionChange handler when the Waypoint is in view', () => { 1224 | subject(); 1225 | scrollNodeTo(window, topSpacerHeight - window.innerHeight / 2); 1226 | 1227 | expect(props.onPositionChange) 1228 | .toHaveBeenCalledWith({ 1229 | currentPosition: Waypoint.inside, 1230 | previousPosition: Waypoint.below, 1231 | event: jasmine.any(Event), 1232 | waypointTop: margin + Math.ceil(window.innerHeight / 2), 1233 | waypointBottom: margin + Math.ceil(window.innerHeight / 2), 1234 | viewportTop: 0, 1235 | viewportBottom: window.innerHeight, 1236 | }); 1237 | }); 1238 | }); 1239 | 1240 | it('does not throw an error when the is the scrollable parent', () => { 1241 | // Give the an overflow style 1242 | document.documentElement.style.overflow = 'auto'; 1243 | 1244 | // Make the normal parent non-scrollable 1245 | parentStyle.height = 'auto'; 1246 | parentStyle.overflow = 'visible'; 1247 | 1248 | expect(subject).not.toThrow(); 1249 | 1250 | delete document.documentElement.style.overflow; 1251 | }); 1252 | 1253 | describe('when the waypoint is updated in the onEnter callback', () => { 1254 | beforeEach(() => { 1255 | class Wrapper extends React.Component { 1256 | render() { 1257 | const doOnEnter = () => { 1258 | const { onEnter } = this.props; 1259 | onEnter(); 1260 | this.forceUpdate(); 1261 | }; 1262 | 1263 | return ( 1264 |
1265 | 1266 |
1267 | ); 1268 | } 1269 | } 1270 | 1271 | subject = () => renderAttached(); 1272 | }); 1273 | 1274 | it('only calls onEnter once', (done) => { 1275 | subject(); 1276 | 1277 | setTimeout(() => { 1278 | scrollNodeTo(window, window.innerHeight); 1279 | expect(props.onEnter.calls.count()).toBe(1); 1280 | done(); 1281 | }, 0); 1282 | 1283 | jasmine.clock().tick(5000); 1284 | }); 1285 | }); 1286 | 1287 | describe('when the itself has a margin', () => { 1288 | beforeEach(() => { 1289 | // document.body.style.marginTop = '0px'; 1290 | document.body.style.marginTop = '20px'; 1291 | document.body.style.position = 'relative'; 1292 | // topSpacerHeight = 20; 1293 | 1294 | // Make the spacers large enough to make the Waypoint render off-screen 1295 | bottomSpacerHeight = window.innerHeight + 1000; 1296 | 1297 | // Make the normal parent non-scrollable 1298 | parentStyle = {}; 1299 | 1300 | subject(); 1301 | }); 1302 | 1303 | afterEach(() => { 1304 | document.body.style.marginTop = ''; 1305 | document.body.style.position = ''; 1306 | }); 1307 | 1308 | it('calls the onEnter handler', () => { 1309 | expect(props.onEnter) 1310 | .toHaveBeenCalledWith({ 1311 | currentPosition: Waypoint.inside, 1312 | previousPosition: undefined, 1313 | event: null, 1314 | waypointTop: 20 + topSpacerHeight, 1315 | waypointBottom: 20 + topSpacerHeight, 1316 | viewportTop: 0, 1317 | viewportBottom: window.innerHeight, 1318 | }); 1319 | }); 1320 | 1321 | it('does not call the onLeave handler', () => { 1322 | expect(props.onLeave).not.toHaveBeenCalled(); 1323 | }); 1324 | 1325 | it('calls the onPositionChange handler', () => { 1326 | expect(props.onPositionChange) 1327 | .toHaveBeenCalledWith({ 1328 | currentPosition: Waypoint.inside, 1329 | previousPosition: undefined, 1330 | event: null, 1331 | waypointTop: 20 + topSpacerHeight, 1332 | waypointBottom: 20 + topSpacerHeight, 1333 | viewportTop: 0, 1334 | viewportBottom: window.innerHeight, 1335 | }); 1336 | }); 1337 | 1338 | describe('when scrolling while the waypoint is visible', () => { 1339 | beforeEach(() => { 1340 | props.onPositionChange.calls.reset(); 1341 | scrollNodeTo(window, 10); 1342 | }); 1343 | 1344 | it('does not call the onEnter handler again', () => { 1345 | expect(props.onEnter.calls.count()).toBe(1); 1346 | }); 1347 | 1348 | it('does not call the onLeave handler', () => { 1349 | expect(props.onLeave).not.toHaveBeenCalled(); 1350 | }); 1351 | 1352 | it('does not call the onPositionChange handler', () => { 1353 | expect(props.onPositionChange).not.toHaveBeenCalled(); 1354 | }); 1355 | 1356 | it('the onLeave handler is called when scrolling past the waypoint', () => { 1357 | scrollNodeTo(window, 25); 1358 | 1359 | expect(props.onLeave) 1360 | .toHaveBeenCalledWith({ 1361 | currentPosition: Waypoint.above, 1362 | previousPosition: Waypoint.inside, 1363 | event: jasmine.any(Event), 1364 | waypointTop: 20 + topSpacerHeight - 25, 1365 | waypointBottom: 20 + topSpacerHeight - 25, 1366 | viewportTop: 0, 1367 | viewportBottom: window.innerHeight, 1368 | }); 1369 | }); 1370 | 1371 | it('does not call the onEnter handler when scrolling past the waypoint', () => { 1372 | scrollNodeTo(window, 25); 1373 | 1374 | expect(props.onEnter.calls.count()).toBe(1); 1375 | }); 1376 | 1377 | it('the onPositionChange handler is called when scrolling past the waypoint', () => { 1378 | scrollNodeTo(window, 25); 1379 | 1380 | expect(props.onPositionChange) 1381 | .toHaveBeenCalledWith({ 1382 | currentPosition: Waypoint.above, 1383 | previousPosition: Waypoint.inside, 1384 | event: jasmine.any(Event), 1385 | waypointTop: 20 + topSpacerHeight - 25, 1386 | waypointBottom: 20 + topSpacerHeight - 25, 1387 | viewportTop: 0, 1388 | viewportBottom: window.innerHeight, 1389 | }); 1390 | }); 1391 | }); 1392 | }); 1393 | }); 1394 | 1395 | // smoke tests for horizontal scrolling 1396 | function scrollNodeToHorizontal(node, scrollLeft) { 1397 | if (node === window) { 1398 | window.scroll(scrollLeft, 0); 1399 | } else { 1400 | // eslint-disable-next-line no-param-reassign 1401 | node.scrollLeft = scrollLeft; 1402 | } 1403 | const event = document.createEvent('Event'); 1404 | event.initEvent('scroll', false, false); 1405 | node.dispatchEvent(event); 1406 | } 1407 | 1408 | describe(' Horizontal', () => { 1409 | let props; 1410 | let margin; 1411 | let parentWidth; 1412 | let parentStyle; 1413 | let leftSpacerWidth; 1414 | let rightSpacerWidth; 1415 | let subject; 1416 | 1417 | beforeEach(() => { 1418 | jasmine.clock().install(); 1419 | document.body.style.margin = 'auto'; // should be no horizontal margin 1420 | 1421 | props = { 1422 | onEnter: jasmine.createSpy('onEnter'), 1423 | onLeave: jasmine.createSpy('onLeave'), 1424 | horizontal: true, 1425 | }; 1426 | 1427 | margin = 10; 1428 | parentWidth = 100; 1429 | 1430 | parentStyle = { 1431 | height: 100, 1432 | overflow: 'auto', 1433 | whiteSpace: 'nowrap', 1434 | width: parentWidth, 1435 | margin, // Normalize the space left of the viewport. 1436 | }; 1437 | 1438 | leftSpacerWidth = 0; 1439 | rightSpacerWidth = 0; 1440 | 1441 | subject = () => { 1442 | const el = renderAttached( 1443 |
1444 |
1445 | 1446 |
1447 |
, 1448 | ); 1449 | 1450 | jasmine.clock().tick(1); 1451 | return el; 1452 | }; 1453 | }); 1454 | 1455 | afterEach(() => { 1456 | if (div) { 1457 | ReactDOM.unmountComponentAtNode(div); 1458 | } 1459 | scrollNodeToHorizontal(window, 0); 1460 | jasmine.clock().uninstall(); 1461 | }); 1462 | 1463 | describe('when a div is the scrollable ancestor', () => { 1464 | it('calls the onEnter handler when the Waypoint is visible on mount', () => { 1465 | subject(); 1466 | 1467 | expect(props.onEnter).toHaveBeenCalledWith({ 1468 | currentPosition: Waypoint.inside, 1469 | previousPosition: undefined, 1470 | event: null, 1471 | waypointTop: margin + leftSpacerWidth, 1472 | waypointBottom: margin + leftSpacerWidth, 1473 | viewportTop: margin, 1474 | viewportBottom: margin + parentWidth, 1475 | }); 1476 | }); 1477 | 1478 | it('does not call the onEnter handler when the Waypoint is not visible on mount', () => { 1479 | leftSpacerWidth = 300; 1480 | subject(); 1481 | expect(props.onEnter).not.toHaveBeenCalled(); 1482 | }); 1483 | }); 1484 | 1485 | describe('when the window is the scrollable ancestor', () => { 1486 | beforeEach(() => { 1487 | delete parentStyle.overflow; 1488 | delete parentStyle.width; 1489 | }); 1490 | 1491 | it('calls the onEnter handler when the Waypoint is visible on mount', () => { 1492 | subject(); 1493 | expect(props.onEnter).toHaveBeenCalled(); 1494 | }); 1495 | 1496 | describe('when the Waypoint is not visible on mount', () => { 1497 | beforeEach(() => { 1498 | leftSpacerWidth = window.innerWidth * 2; 1499 | subject(); 1500 | }); 1501 | 1502 | it('does not call the onEnter handler', () => { 1503 | expect(props.onEnter).not.toHaveBeenCalled(); 1504 | }); 1505 | 1506 | describe('when scrolled sideways to make the waypoint visible', () => { 1507 | beforeEach(() => { 1508 | scrollNodeToHorizontal(window, window.innerWidth + 100); 1509 | }); 1510 | 1511 | it('calls the onEnter handler', () => { 1512 | expect(props.onEnter).toHaveBeenCalled(); 1513 | }); 1514 | 1515 | it('does not call the onLeave handler', () => { 1516 | expect(props.onLeave).not.toHaveBeenCalled(); 1517 | }); 1518 | 1519 | it('does not call the onEnter handler when scrolled back to initial position', () => { 1520 | props.onEnter.calls.reset(); 1521 | scrollNodeToHorizontal(window, 0); 1522 | 1523 | expect(props.onEnter).not.toHaveBeenCalled(); 1524 | }); 1525 | 1526 | it('calls the onLeave handler when scrolled back to initial position', () => { 1527 | props.onEnter.calls.reset(); 1528 | scrollNodeToHorizontal(window, 0); 1529 | 1530 | expect(props.onLeave).toHaveBeenCalled(); 1531 | }); 1532 | }); 1533 | }); 1534 | }); 1535 | }); 1536 | -------------------------------------------------------------------------------- /test/node/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '../../.eslintrc.js', 4 | 'plugin:jest/recommended', 5 | 'plugin:jest/style', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /test/node/onNextTick.test.js: -------------------------------------------------------------------------------- 1 | import onNextTick from '../../src/onNextTick'; 2 | 3 | describe('onNextTick()', () => { 4 | beforeEach(() => { 5 | jest.useFakeTimers(); 6 | }); 7 | 8 | afterEach(() => { 9 | jest.clearAllTimers(); 10 | }); 11 | 12 | it('does not call callbacks immediately', () => { 13 | const called = []; 14 | 15 | onNextTick(() => { 16 | called.push(0); 17 | }); 18 | 19 | onNextTick(() => { 20 | called.push(1); 21 | }); 22 | 23 | onNextTick(() => { 24 | called.push(2); 25 | }); 26 | 27 | expect(called).toEqual([]); 28 | 29 | jest.advanceTimersByTime(1); 30 | }); 31 | 32 | it('calls callbacks in order', () => { 33 | const called = []; 34 | 35 | onNextTick(() => { 36 | called.push(0); 37 | }); 38 | 39 | onNextTick(() => { 40 | called.push(1); 41 | }); 42 | 43 | onNextTick(() => { 44 | called.push(2); 45 | }); 46 | 47 | jest.advanceTimersByTime(1); 48 | 49 | expect(called).toEqual([0, 1, 2]); 50 | }); 51 | 52 | it('does not call callbacks that have been unsubscribed', () => { 53 | const called = []; 54 | 55 | onNextTick(() => { 56 | called.push(0); 57 | }); 58 | 59 | const unsub = onNextTick(() => { 60 | called.push(1); 61 | }); 62 | 63 | onNextTick(() => { 64 | called.push(2); 65 | }); 66 | 67 | unsub(); 68 | 69 | jest.advanceTimersByTime(1); 70 | 71 | expect(called).toEqual([0, 2]); 72 | }); 73 | 74 | it('does nothing if unsubscribe is called multiple times', () => { 75 | const called = []; 76 | 77 | onNextTick(() => { 78 | called.push(0); 79 | }); 80 | 81 | const unsub = onNextTick(() => { 82 | called.push(1); 83 | }); 84 | 85 | onNextTick(() => { 86 | called.push(2); 87 | }); 88 | 89 | unsub(); 90 | unsub(); 91 | unsub(); 92 | 93 | jest.advanceTimersByTime(1); 94 | 95 | expect(called).toEqual([0, 2]); 96 | }); 97 | 98 | it('does nothing when unsubscribing a callback that has already been called', () => { 99 | const called = []; 100 | 101 | onNextTick(() => { 102 | called.push(0); 103 | }); 104 | 105 | const unsub = onNextTick(() => { 106 | called.push(1); 107 | }); 108 | 109 | onNextTick(() => { 110 | called.push(2); 111 | }); 112 | 113 | jest.advanceTimersByTime(1); 114 | 115 | expect(called).toEqual([0, 1, 2]); 116 | 117 | onNextTick(() => { 118 | called.push(3); 119 | }); 120 | 121 | onNextTick(() => { 122 | called.push(4); 123 | }); 124 | 125 | onNextTick(() => { 126 | called.push(5); 127 | }); 128 | 129 | unsub(); 130 | 131 | jest.advanceTimersByTime(1); 132 | 133 | expect(called).toEqual([0, 1, 2, 3, 4, 5]); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /test/node/resolveScrollableAncestorProp.test.js: -------------------------------------------------------------------------------- 1 | import resolveScrollableAncestorProp from '../../src/resolveScrollableAncestorProp'; 2 | 3 | describe('resolveScrollableAncestorProp()', () => { 4 | it('converts "window" into `global.window`', () => { 5 | global.window = {}; 6 | 7 | expect(resolveScrollableAncestorProp('window')).toEqual(global.window); 8 | }); 9 | 10 | it('passes other values through', () => { 11 | expect(resolveScrollableAncestorProp('foo')).toEqual('foo'); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/node/waypoint.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | 4 | import { Waypoint } from '../../src/waypoint'; 5 | 6 | describe('', () => { 7 | it('does not throw an error when in an environment without window', () => { 8 | expect(typeof window).toBe('undefined'); 9 | expect(() => { 10 | renderer.create(); 11 | }).not.toThrow(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /test/performance-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 |

Performance test page

17 |

18 | This page has hundreds of waypoints rendered, allowing you to test 19 | performance - scroll jank, repaints, reflows etc. 20 |

21 |
22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/performance-test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len, react/no-multi-comp, react/jsx-no-bind */ 2 | import React, { Component } from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | import { Waypoint } from '../src/waypoint'; 6 | 7 | const WAYPOINT_COUNT = 1000; 8 | 9 | function Foo() { 10 | return ( 11 |
12 | 19 | 20 | 39 | 40 | Birds 41 |
42 | ); 43 | } 44 | 45 | class PerformanceTest extends Component { 46 | constructor() { 47 | super(); 48 | this.state = {}; 49 | } 50 | 51 | handleEnter(index) { 52 | this.setState({ 53 | [`active-${index}`]: true, 54 | }); 55 | } 56 | 57 | handleLeave(index) { 58 | this.setState({ 59 | [`active-${index}`]: false, 60 | }); 61 | } 62 | 63 | render() { 64 | const elements = []; 65 | for (let i = 0; i < WAYPOINT_COUNT; i++) { 66 | // eslint-disable-next-line react/destructuring-assignment 67 | const isActive = this.state[`active-${i}`]; 68 | 69 | elements.push( 70 |
71 |

72 | Container 73 | {i} 74 |

75 | {isActive && } 76 | {!isActive && ( 77 |
84 | )} 85 | 89 |
, 90 | ); 91 | } 92 | return
{elements}
; 93 | } 94 | } 95 | 96 | ReactDOM.render(, document.getElementById('app')); 97 | -------------------------------------------------------------------------------- /tests.webpack.js: -------------------------------------------------------------------------------- 1 | const context = require.context('./test/browser', true, /_test\.jsx?$/); 2 | context.keys().forEach(context); 3 | -------------------------------------------------------------------------------- /webpack.config.performance-test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | // This config file is used to create a webpack bundle that we use on 4 | // test/performance-test.html to profile the performance footprint of the 5 | // component. 6 | module.exports = { 7 | mode: 'production', 8 | entry: path.join(__dirname, 'test/performance-test.jsx'), 9 | output: { 10 | path: path.join(__dirname, 'build'), 11 | filename: 'performance-test.js', 12 | }, 13 | resolve: { 14 | extensions: ['.js', '.jsx', '.json'], 15 | }, 16 | devtool: 'source-map', 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.jsx?/, 21 | exclude: /node_modules/, 22 | loader: 'babel-loader', 23 | }, 24 | ], 25 | }, 26 | }; 27 | --------------------------------------------------------------------------------