├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE.md ├── README.md ├── examples ├── example.css ├── example.html ├── example.js ├── example2.css ├── example2.html └── example2.js ├── package-lock.json ├── package.json ├── rollup ├── bundle.js ├── helpers.js ├── index.js ├── options.js └── watch.js ├── src ├── base.js ├── constants.js ├── emitter.js ├── entry.js └── helpers │ ├── dom.js │ └── mix.js └── test ├── e2e ├── .eslintrc.yaml ├── constants.js ├── drag.test.js ├── helpers.js ├── pages │ ├── drag.css │ ├── drag.html │ ├── drag.js │ ├── scroll.html │ └── scroll.js └── scroll.test.js └── unit ├── .eslintrc.yaml ├── helpers.js └── instance.test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | # space indentation 10 | indent_style = space 11 | indent_size = 4 12 | 13 | # tabs 2 spaces for makefiles 14 | [Makefile] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "extends": ["jwalker", "prettier"], 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module" 11 | }, 12 | "reportUnusedDisableDirectives": true, 13 | "rules": { 14 | "default-case": "off", 15 | "no-prototype-builtins": "off", 16 | "unicorn/prevent-abbreviations": "off", 17 | "unicorn/prefer-query-selector": "off", 18 | "security/detect-non-literal-fs-filename": "off", 19 | "security/detect-object-injection": "off", 20 | "sonarjs/no-duplicate-string": "off" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [14.x, 16.x] 16 | 17 | steps: 18 | - name: Setup Chrome 19 | uses: browser-actions/setup-chrome@latest 20 | 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: "npm" 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npm run test:unit 30 | 31 | - name: Run TestCafe tests on headless Chrome 32 | uses: DevExpress/testcafe-action@latest 33 | with: 34 | args: "chrome:headless test/e2e/*.test.js" 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | 12 | # Dependency directories 13 | node_modules 14 | 15 | dist 16 | 17 | # Optional npm cache directory 18 | .npm 19 | 20 | # eslint 21 | .eslintcache 22 | 23 | # vscode 24 | .vscode/snipsnap.code-snippets -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 4 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | 3 | language: node_js 4 | node_js: 5 | - '14' 6 | 7 | cache: 8 | bundler: true 9 | directories: 10 | - node_modules 11 | 12 | addons: 13 | chrome: stable 14 | 15 | services: 16 | - xvfb 17 | 18 | before_install: 19 | - npm i -g npm@latest 20 | 21 | branches: 22 | only: 23 | - master 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [100, 120], 3 | "editor.formatOnSave": true, 4 | "editor.formatOnPaste": false, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": true 7 | }, 8 | "[typescript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[javascript]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[json]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "eslint.validate": ["javascript"] 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jonatas Walker 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 | # scroll-watcher 2 | 3 |

4 | 5 | build status 6 | 7 | 8 | npm version 10 | 11 | 12 | license 14 | 15 | 16 | dependency status 18 | 19 | 20 | devDependency status 21 | 22 |

23 | 24 | A lightweight, blazing fast, [rAF](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame) based, scroll watcher. 25 | 26 | A more up-to-date approach to this **_scrolling watchers_** stuff. Slightly inspired by [scrollMonitor](https://github.com/stutrek/scrollMonitor). 27 | 28 | ### Demos 29 | 30 | → [Scrolling, Moving and Recalculating](https://jonataswalker.github.io/scroll-watcher/examples/example.html) 31 | 32 | → [Static Website Menu](https://jonataswalker.github.io/scroll-watcher/examples/example2.html) 33 | 34 | ## How to use it? 35 | 36 | ##### → CDN Hosted - [jsDelivr](http://www.jsdelivr.com/projects/scroll-watcher) 37 | 38 | ```HTML 39 | 40 | ``` 41 | 42 | ##### → Self hosted 43 | 44 | Download [latest release](https://github.com/jonataswalker/scroll-watcher/releases/latest). 45 | 46 | ##### → [NPM](https://www.npmjs.com/package/scroll-watcher) 47 | 48 | ```shell 49 | npm install scroll-watcher 50 | ``` 51 | 52 | ##### Instantiate and watch for a specific (or a list) DOM element 53 | 54 | ```javascript 55 | var scroll = new ScrollWatcher(); 56 | scroll 57 | .watch("my-element") 58 | .on("enter", function(evt) { 59 | console.log("I'm partially inside viewport"); 60 | }) 61 | .on("enter:full", function(evt) { 62 | console.log("I'm entirely within the viewport"); 63 | }) 64 | .on("exit:partial", function(evt) { 65 | console.log("I'm partially out of viewport"); 66 | }) 67 | .on("exit", function(evt) { 68 | console.log("I'm out of viewport"); 69 | }); 70 | ``` 71 | 72 | ##### Make some decision when page is loaded (or reloaded) 73 | 74 | ```javascript 75 | watcher.on("page:load", function(evt) { 76 | // maybe trigger a scroll? 77 | window.setTimeout(() => window.scrollBy(0, 1), 20); 78 | }); 79 | ``` 80 | 81 | ## Instance Methods 82 | 83 | ### watch(target[, offset]) 84 | 85 | - `target` - `{String|Element}` String or DOM node. 86 | - `offset` - `{Number|Object|undefined}` (optional) Element offset. 87 | 88 | ###### Returns: 89 | 90 | - Methods 91 | - `on/once/off` - common events 92 | - `update` - updates the location of the element in relation to the document 93 | - Properties 94 | - `target` - DOM node being watched 95 | 96 | #### windowAtBottom([offset]) 97 | 98 | - `offset` - `{Number|undefined}` (optional) How far to offset. 99 | 100 | #### windowAtTop([offset]) 101 | 102 | - `offset` - `{Number|undefined}` (optional) How far to offset. 103 | 104 | ## Instance Events - `on/once/off` 105 | 106 | You can simply watch for scrolling action: 107 | 108 | ```javascript 109 | var watcher = new ScrollWatcher(); 110 | watcher.on("scrolling", function(evt) { 111 | console.log(evt); 112 | }); 113 | 114 | // or just once 115 | watcher.once("scrolling", function(evt) { 116 | console.log(evt); 117 | }); 118 | 119 | // and turn it off (later) 120 | watcher.off("scrolling"); 121 | ``` 122 | 123 | ## License 124 | 125 | [MIT](https://github.com/jonataswalker/scroll-watcher/blob/master/LICENSE.md) 126 | -------------------------------------------------------------------------------- /examples/example.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 1.2em/1.3 'Raleway', sans-serif; 3 | color: #222; 4 | font-weight: 400; 5 | padding: 20px 40px; 6 | background: linear-gradient(#efefef, #999) fixed; 7 | } 8 | h1 { 9 | font-size: 150%; 10 | text-align: center; 11 | margin: 10px 0; 12 | font-weight: 700; 13 | } 14 | h3 { 15 | font-weight: 700; 16 | font-size: 1.125em; 17 | } 18 | h5 { 19 | font-size: 0.8em; 20 | } 21 | .move { 22 | position: absolute; 23 | top: 100px; 24 | left: 100px; 25 | width: 140px; 26 | height: 80px; 27 | border-radius: 100%; 28 | color: #fff; 29 | background-color: #29e; 30 | cursor: pointer; 31 | } 32 | .inner { 33 | position: relative; 34 | top: 50%; 35 | transform: translateY(-50%); 36 | text-align: center; 37 | } 38 | .enter { 39 | background-color: #849a6f; 40 | } 41 | .partial-exit { 42 | background-color: #f40; 43 | } 44 | .fully-enter { 45 | background-color: #4E9A06; 46 | } 47 | .move2 { 48 | top: 300px; 49 | left: 200px; 50 | } 51 | .move3 { 52 | top: 500px; 53 | left: 300px; 54 | } 55 | .move4 { 56 | top: 700px; 57 | left: 400px; 58 | } 59 | .move5 { 60 | top: 900px; 61 | left: 500px; 62 | } 63 | .move6 { 64 | top: 1100px; 65 | left: 600px; 66 | } 67 | .move7 { 68 | top: 1300px; 69 | left: 700px; 70 | } 71 | a.link { 72 | text-decoration: none; 73 | color: inherit; 74 | border-bottom: 1px dotted #333; 75 | } 76 | a.link:hover { 77 | border-bottom: 1px solid #333; 78 | } -------------------------------------------------------------------------------- /examples/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | scroll-watcher — Scrolling, Moving and Recalculating 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

13 | scroll-watcher.js 14 | — Scrolling, Moving and Recalculating 15 |

16 |
17 |
18 |

Move me

19 |
20 |
21 |
22 |
23 |
24 |

Move me

25 |
26 |
27 |
28 |
29 |
30 |

Move me

31 |
32 |
33 |
34 |
35 |
36 |

Move me

37 |
38 |
39 |
40 |
41 |
42 |

Move me

43 |
44 |
45 |
46 |
47 |
48 |

Move me

49 |
50 |
51 |
52 |
53 |
54 |

Move me

55 |
56 |
57 |
58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /examples/example.js: -------------------------------------------------------------------------------- 1 | /* global interact, ScrollWatcher */ 2 | const watcher = new ScrollWatcher(); 3 | const targets = document.getElementsByClassName('move'); 4 | 5 | let firstChild; 6 | 7 | Array.prototype.forEach.call(targets, (each) => { 8 | const rect = watcher 9 | .watch(each) 10 | .on('enter', (evt) => { 11 | firstChild = evt.target.firstElementChild; 12 | evt.target.classList.add('enter'); 13 | evt.target.classList.remove('partial-exit'); 14 | firstChild.lastElementChild.textContent = 'entered'; 15 | }) 16 | .on('exit', () => {}) 17 | .on('enter:full', (evt) => { 18 | firstChild = evt.target.firstElementChild; 19 | evt.target.classList.add('fully-enter'); 20 | firstChild.lastElementChild.textContent = 'fully entered'; 21 | }) 22 | .on('exit:partial', (evt) => { 23 | firstChild = evt.target.firstElementChild; 24 | evt.target.classList.add('partial-exit'); 25 | evt.target.classList.remove('fully-enter'); 26 | firstChild.lastElementChild.textContent = 'partial exited'; 27 | }); 28 | 29 | interact(each).draggable({ 30 | inertia: true, 31 | 32 | onmove(event) { 33 | const { target, dx, dy } = event; 34 | const x = (Number.parseFloat(target.dataset.x) || 0) + dx; 35 | const y = (Number.parseFloat(target.dataset.y) || 0) + dy; 36 | 37 | target.style.transform = `translate(${x}px, ${y}px)`; 38 | target.dataset.x = x; 39 | target.dataset.y = y; 40 | }, 41 | 42 | onend() { 43 | rect.target.firstElementChild.lastElementChild.textContent = ''; 44 | rect.target.classList.remove('enter', 'fully-enter', 'partial-exit'); 45 | rect.update(); 46 | }, 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /examples/example2.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Raleway', sans-serif; 3 | padding-top: 70px; 4 | } 5 | .col-centered { 6 | display: inline-block; 7 | float: none; 8 | text-align: left; 9 | margin-right: -4px; 10 | } 11 | .middle { 12 | line-height: 50px; 13 | } 14 | .navbar-nav { 15 | background-color: rgba(255, 255, 255, 0.4); 16 | } 17 | .navbar .nav > li > a { 18 | text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.4); 19 | color: #fff; 20 | transition: background .3s ease; 21 | } 22 | .logo { 23 | font-size: 220%; 24 | color: #fff; 25 | text-shadow: -3px 0 3px #333; 26 | } 27 | .logo:hover, .logo:focus { 28 | text-decoration: none; 29 | color: #000; 30 | } 31 | .topnavcollapse { 32 | border-bottom: 1px solid #737a7c; 33 | background-color: rgba(46, 52, 54, 0.8); 34 | } 35 | .topnavcollapse .navbar-nav { 36 | margin-top: 2px; 37 | background-color: rgba(0, 0, 0, 0); 38 | } 39 | .topnavcollapse ul.navbar-nav li a:hover, 40 | .topnavcollapse ul.navbar-nav li a:focus, 41 | .topnavcollapse ul.navbar-nav li.active { 42 | outline: 0; 43 | color: #111; 44 | text-decoration: none; 45 | background-color: rgba(255,255,255,.6); 46 | } 47 | hr.mod { 48 | width: 85%; 49 | margin-bottom: 20px; 50 | border: 0; 51 | height: 1px; 52 | background-image: linear-gradient(to left, rgba(0,0,0,0), rgba(0,0,0,0.75), rgba(0,0,0,0)); 53 | } 54 | .thumb-dir { 55 | height: 380px; 56 | background: transparent url("https://drive.google.com/uc?id=0B7Qw2Viq0WpiUEowd1otb0dLRjA") no-repeat center center; 57 | } 58 | .thumb-portfolio { 59 | background: transparent url("https://drive.google.com/uc?id=0B7Qw2Viq0WpiWVpMYVdxb3Z0UWs") no-repeat center center; 60 | } 61 | .paragraph { 62 | font-size: 150%; 63 | } -------------------------------------------------------------------------------- /examples/example2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | scroll-watcher — Personal Portfolio 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 39 |
40 |
41 |

Something about me

42 |

43 |
44 |
45 |
46 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur gravida, nulla et posuere dictum, lacus urna lacinia lacus, in aliquet tellus ante ac dolor. Etiam dignissim sem sed posuere vehicula. Maecenas fringilla tellus quis magna pellentesque, ut molestie velit malesuada. Morbi porttitor lorem sem, vitae consequat leo fermentum id. Phasellus at massa odio. Nulla feugiat pulvinar ligula nec volutpat. Phasellus a urna et dui tempor tempor quis vel ipsum. Fusce quis sem non ipsum vehicula auctor. Etiam vel gravida magna. Cras non nunc purus. Integer porta, risus non aliquam volutpat, magna justo varius mi, vitae sollicitudin arcu eros ac turpis. Donec mattis ex sed nisl porta, et ullamcorper ligula facilisis.

47 |

Sed ac ex eget elit hendrerit ullamcorper. Nulla tincidunt pulvinar risus, ut tincidunt libero laoreet eu. Pellentesque consectetur sed nulla in consequat. Nam quis mi massa. Aenean gravida felis vitae erat auctor aliquam. Cras posuere blandit lacus vel fermentum. In tristique maximus tellus, quis vehicula odio placerat at. Nunc laoreet euismod arcu.

48 |
49 |
50 |
51 |
52 |
53 |
54 |

Portfolio

55 |

56 |
57 |
58 |
59 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur gravida, nulla et posuere dictum, lacus urna lacinia lacus, in aliquet tellus ante ac dolor. Etiam dignissim sem sed posuere vehicula. Maecenas fringilla tellus quis magna pellentesque, ut molestie velit malesuada. Morbi porttitor lorem sem, vitae consequat leo fermentum id. Phasellus at massa odio. Nulla feugiat pulvinar ligula nec volutpat. Phasellus a urna et dui tempor tempor quis vel ipsum. Fusce quis sem non ipsum vehicula auctor. Etiam vel gravida magna. Cras non nunc purus. Integer porta, risus non aliquam volutpat, magna justo varius mi, vitae sollicitudin arcu eros ac turpis. Donec mattis ex sed nisl porta, et ullamcorper ligula facilisis.

60 |

Sed ac ex eget elit hendrerit ullamcorper. Nulla tincidunt pulvinar risus, ut tincidunt libero laoreet eu. Pellentesque consectetur sed nulla in consequat. Nam quis mi massa. Aenean gravida felis vitae erat auctor aliquam. Cras posuere blandit lacus vel fermentum. In tristique maximus tellus, quis vehicula odio placerat at. Nunc laoreet euismod arcu.

61 |
62 |
63 |
64 |
65 |
66 |
67 |

Contact

68 |

69 |
70 |
71 |
72 | 73 |
74 |
75 | 76 | 77 | 78 | 79 |
80 |
81 |
82 |
83 | 84 |
85 |
86 | 87 | 88 | 89 | 90 |
91 |
92 |
93 |
94 | 95 |
96 |
97 | 98 | 99 | 100 | 101 |
102 |
103 |
104 | 105 |
106 | 107 |
108 | 109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | 117 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /examples/example2.js: -------------------------------------------------------------------------------- 1 | /* global smoothScroll, ScrollWatcher */ 2 | const watcher = new ScrollWatcher(); 3 | const watchingElements = document.querySelectorAll('#about, #portfolio, #contact'); 4 | const menu = { 5 | about: document.getElementById('li-about'), 6 | portfolio: document.getElementById('li-portfolio'), 7 | contact: document.getElementById('li-contact'), 8 | }; 9 | 10 | let lastActive = menu.about; 11 | 12 | smoothScroll.init({ 13 | selectorHeader: '[data-scroll-header]', 14 | }); 15 | 16 | watcher.on('scrolling', (evt) => { 17 | // console.log('scrolling', evt); 18 | }); 19 | 20 | watcher.on('page:load', (evt) => { 21 | window.setTimeout(() => { 22 | if (watcher.windowAtBottom()) window.scrollBy(0, -1); 23 | else window.scrollBy(0, 1); 24 | }, 30); 25 | }); 26 | 27 | Array.prototype.forEach.call(watchingElements, (each) => { 28 | watcher 29 | .watch(each, { top: 100, bottom: 0 }) 30 | .on('enter', (evt) => { 31 | if (evt.scrollingDown) { 32 | lastActive.classList.remove('active'); 33 | menu[evt.target.id].classList.add('active'); 34 | lastActive = menu[evt.target.id]; 35 | } 36 | }) 37 | .on('exit', (evt) => { 38 | // console.info('exited', evt.target.id); 39 | }) 40 | .on('exit:partial', (evt) => { 41 | // console.info('partial exited', evt.target.id); 42 | }) 43 | .on('enter:full', (evt) => { 44 | // console.info('full entered', evt.target.id); 45 | if (evt.scrollingUp) { 46 | lastActive.classList.remove('active'); 47 | menu[evt.target.id].classList.add('active'); 48 | lastActive = menu[evt.target.id]; 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scroll-watcher", 3 | "version": "2.2.0", 4 | "description": "A lightweight, blazing fast, rAF based, scroll watcher.", 5 | "author": "Jonatas Walker", 6 | "homepage": "https://github.com/jonataswalker/scroll-watcher", 7 | "license": "MIT", 8 | "type": "module", 9 | "browser": "dist/scroll-watcher.min.js", 10 | "keywords": [ 11 | "scroll", 12 | "watcher", 13 | "dom", 14 | "monitor" 15 | ], 16 | "files": [ 17 | "dist/" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "git://github.com/jonataswalker/scroll-watcher.git" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/jonataswalker/scroll-watcher/issues" 25 | }, 26 | "scripts": { 27 | "dev": "npm run build -- watch", 28 | "build": "node rollup", 29 | "lint": "eslint --cache --ext .js .", 30 | "lint:fix": "npm run lint -- --fix", 31 | "test": "run-s build test:unit test:e2e", 32 | "test:e2e": "testcafe chrome test/e2e/*.test.js", 33 | "test:unit": "jest" 34 | }, 35 | "jest": { 36 | "testURL": "http://localhost/", 37 | "testEnvironment": "jsdom", 38 | "testRegex": "/test/unit/.*\\.test\\.js$", 39 | "transform": { 40 | "^.+\\.js?$": "babel-jest" 41 | } 42 | }, 43 | "babel": { 44 | "presets": [ 45 | "@babel/preset-env" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.17.9", 50 | "@babel/preset-env": "^7.16.11", 51 | "@rollup/plugin-buble": "^0.21.3", 52 | "ansi-colors": "^4.1.1", 53 | "babel-jest": "^27.5.1", 54 | "eslint": "^8.13.0", 55 | "eslint-config-jwalker": "^7.6.0", 56 | "eslint-config-prettier": "^8.5.0", 57 | "eslint-plugin-jest": "^26.1.4", 58 | "eslint-plugin-prettier": "^4.0.0", 59 | "eslint-plugin-testcafe": "^0.2.1", 60 | "fs-jetpack": "^4.3.1", 61 | "jest": "^27.5.1", 62 | "maxmin": "^4.0.0", 63 | "npm-run-all": "^4.1.5", 64 | "ora": "^6.1.0", 65 | "prettier": "^2.6.2", 66 | "pretty-bytes": "^6.0.0", 67 | "pretty-time": "^1.1.0", 68 | "rollup": "^2.70.1", 69 | "rollup-plugin-terser": "^7.0.2", 70 | "rxjs": "^7.5.5", 71 | "testcafe": "^1.18.5" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /rollup/bundle.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs'; 2 | import { basename } from 'path'; 3 | 4 | import maxmin from 'maxmin'; 5 | import { rollup } from 'rollup'; 6 | 7 | import { prettyTimeFromBigint } from './helpers.js'; 8 | import { getInputOptions, getOutputOptions, createOnWarn } from './options.js'; 9 | 10 | export default function createBundle({ minify, spinners, subscriber }) { 11 | return new Promise((resolve, reject) => { 12 | const inputOptions = getInputOptions(minify); 13 | const outputOptions = getOutputOptions(minify); 14 | const start = process.hrtime.bigint(); 15 | 16 | inputOptions.onwarn = createOnWarn(subscriber); 17 | spinners.building.start(); 18 | 19 | rollup(inputOptions) 20 | .then((bundle) => bundle.generate(outputOptions)) 21 | .then(({ output: [{ code }] }) => { 22 | let subscriberMessage; 23 | 24 | const end = process.hrtime.bigint(); 25 | const size = maxmin(code, code, true); 26 | const inputFile = basename(inputOptions.input); 27 | const outputFile = basename(outputOptions.file); 28 | const duration = prettyTimeFromBigint(start, end); 29 | 30 | writeFileSync(outputOptions.file, code); 31 | 32 | spinners.building.isSpinning && spinners.building.succeed(); 33 | subscriberMessage = `Compiled ${inputFile} -> ${outputFile} in ${duration}!`; 34 | subscriber.next({ status: 'info', message: subscriberMessage }); 35 | 36 | subscriberMessage = `Bundle size: ${size.slice(size.indexOf(' → ') + 3)}`; 37 | subscriber.next({ status: 'info', message: subscriberMessage }); 38 | 39 | resolve(); 40 | }) 41 | .catch((error) => reject(error.message)); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /rollup/helpers.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import prettyBytes from 'pretty-bytes'; 4 | import prettyTime from 'pretty-time'; 5 | 6 | export function fileSize(file) { 7 | try { 8 | const { size } = fs.statSync(file); 9 | 10 | return prettyBytes(size); 11 | } catch (error) { 12 | return error.message; 13 | } 14 | } 15 | 16 | export function prettyTimeFromBigint(start, end) { 17 | return prettyTime(Number(end - start)); 18 | } 19 | -------------------------------------------------------------------------------- /rollup/index.js: -------------------------------------------------------------------------------- 1 | import Ora from 'ora'; 2 | import rxjs from 'rxjs'; 3 | import colors from 'ansi-colors'; 4 | 5 | import createBundle from './bundle.js'; 6 | import buildAndWatch from './watch.js'; 7 | 8 | const needToWatch = process.argv.includes('watch'); 9 | 10 | const coloured = (string, status) => 11 | ({ 12 | succeed: colors.green(string), 13 | info: colors.blue(string), 14 | warn: colors.yellow(string), 15 | error: colors.red(string), 16 | }[status]); 17 | const spinners = { 18 | deps: new Ora(coloured('Bundling Dependencies', 'succeed')), 19 | building: new Ora(coloured('Build is on the way', 'succeed')), 20 | }; 21 | 22 | const { Observable } = rxjs; 23 | const build = new Observable((subscriber) => { 24 | if (needToWatch) { 25 | buildAndWatch(subscriber, spinners).catch((error) => { 26 | spinners.building.fail(coloured(error, 'error')); 27 | subscriber.error(error); 28 | }); 29 | } else { 30 | createBundle({ minify: false, spinners, subscriber }) 31 | .then(() => createBundle({ minify: true, spinners, subscriber })) 32 | .then(() => subscriber.complete()) 33 | .catch((error) => subscriber.error(error)); 34 | } 35 | }); 36 | 37 | build.subscribe({ 38 | next(data) { 39 | const { status, message } = data; 40 | 41 | if (spinners.building.isSpinning && status === 'warn') { 42 | spinners.building.warn(); 43 | } 44 | 45 | const spinner = new Ora(); 46 | 47 | spinner[status](coloured(message, status)); 48 | }, 49 | 50 | error(error) { 51 | spinners.building.fail(error); 52 | 53 | console.trace(error); 54 | }, 55 | 56 | complete() { 57 | new Ora('App bundle built!').succeed(); 58 | }, 59 | }); 60 | -------------------------------------------------------------------------------- /rollup/options.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { resolve, dirname } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | 5 | import jetpack from 'fs-jetpack'; 6 | import buble from '@rollup/plugin-buble'; 7 | import { terser } from 'rollup-plugin-terser'; 8 | 9 | const filename = fileURLToPath(import.meta.url); 10 | const resolvePath = (file) => resolve(dirname(filename), file); 11 | const pkg = JSON.parse(readFileSync(resolvePath('../package.json'))); 12 | 13 | export function createOnWarn(subscriber) { 14 | return (warning) => { 15 | // skip certain warnings 16 | if (warning.code === 'UNUSED_EXTERNAL_IMPORT') return; 17 | 18 | if (warning.code === 'NON_EXISTENT_EXPORT') { 19 | subscriber.error(warning.message); 20 | 21 | return; 22 | } 23 | 24 | subscriber.next({ status: 'warn', message: warning.message }); 25 | }; 26 | } 27 | 28 | export function getInputOptions(minify = true) { 29 | const plugins = [ 30 | buble({ objectAssign: true }), 31 | minify && terser({ output: { comments: /^!/u } }), 32 | ]; 33 | 34 | return { input: resolvePath('../src/entry.js'), plugins }; 35 | } 36 | 37 | export function getOutputOptions(minify = true) { 38 | jetpack.dir(resolvePath('../dist')); 39 | 40 | const file = minify 41 | ? resolvePath('../dist/scroll-watcher.min.js') 42 | : resolvePath('../dist/scroll-watcher.js'); 43 | const banner = ` 44 | /*! 45 | * ${pkg.name} - v${pkg.version} 46 | * Built: ${new Date()} 47 | */ 48 | `; 49 | 50 | return { banner, file, format: 'umd', name: 'ScrollWatcher' }; 51 | } 52 | -------------------------------------------------------------------------------- /rollup/watch.js: -------------------------------------------------------------------------------- 1 | import { basename } from 'path'; 2 | 3 | import { watch } from 'rollup'; 4 | 5 | import { fileSize } from './helpers.js'; 6 | import { getInputOptions, getOutputOptions, createOnWarn } from './options.js'; 7 | 8 | export default function buildAndWatch(subscriber, spinners) { 9 | return new Promise((resolve, reject) => { 10 | const inputOptions = getInputOptions(false); 11 | const outputOptions = getOutputOptions(false); 12 | 13 | inputOptions.onwarn = createOnWarn(subscriber); 14 | 15 | const watcher = watch({ 16 | ...inputOptions, 17 | output: [outputOptions], 18 | watch: { clearScreen: false }, 19 | }); 20 | 21 | watcher.on('event', (event) => { 22 | switch (event.code) { 23 | case 'START': 24 | spinners.building.start(); 25 | 26 | break; 27 | case 'BUNDLE_END': { 28 | spinners.building.isSpinning && spinners.building.succeed(); 29 | 30 | const input = basename(event.input.toString()); 31 | const bundle = event.output.toString(); 32 | 33 | let message = `Compiled ${input} -> ${basename(bundle)} in ${ 34 | event.duration / 1000 35 | } seconds!`; 36 | 37 | subscriber.next({ status: 'info', message }); 38 | 39 | message = `At: ${new Date()}`; 40 | subscriber.next({ status: 'info', message }); 41 | 42 | message = `Bundle size: ${fileSize(bundle)}`; 43 | subscriber.next({ status: 'info', message }); 44 | 45 | break; 46 | } 47 | case 'ERROR': 48 | reject(event.error.message); 49 | 50 | break; 51 | } 52 | }); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/base.js: -------------------------------------------------------------------------------- 1 | import { 2 | assert, 3 | resetProperties, 4 | resetPartialProperties, 5 | getProperties, 6 | fireEvents, 7 | } from './helpers/mix.js'; 8 | import { 9 | isElement, 10 | getOffset, 11 | getDocumentHeight, 12 | getScroll, 13 | getViewportSize, 14 | } from './helpers/dom.js'; 15 | import { DEFAULT_OFFSET, EVENT_TYPE } from './constants.js'; 16 | import Mitt from './emitter.js'; 17 | 18 | export default function () { 19 | const watcher = { 20 | counter: 0, 21 | requestAnimationId: null, 22 | lastXY: [], 23 | watching: {}, 24 | viewport: getViewportSize(), 25 | props: null, 26 | }; 27 | 28 | const emitter = new Mitt(); 29 | 30 | function getScrollData() { 31 | const xy = getScroll(); 32 | const scrollingDown = xy[1] > watcher.lastXY[1]; 33 | 34 | watcher.lastXY = xy; 35 | 36 | return { 37 | scrollX: xy[0], 38 | scrollY: xy[1], 39 | scrollingDown, 40 | scrollingUp: !scrollingDown, 41 | }; 42 | } 43 | 44 | function recalculate(item) { 45 | const el = { 46 | top: item.dimensions.top + item.offset.top, 47 | bottom: item.dimensions.top + item.offset.bottom + item.dimensions.height, 48 | }; 49 | 50 | const vp = { 51 | top: watcher.lastXY[1], 52 | bottom: watcher.lastXY[1] + watcher.viewport.h, 53 | }; 54 | 55 | item.isAboveViewport = el.top < vp.top; 56 | item.isBelowViewport = el.bottom > vp.bottom; 57 | item.isInViewport = el.top <= vp.bottom && el.bottom > vp.top; 58 | item.isFullyInViewport = 59 | (el.top >= vp.top && el.bottom <= vp.bottom) || 60 | (item.isAboveViewport && item.isBelowViewport); 61 | item.isFullyOut = !item.isInViewport && item.wasInViewport; 62 | item.isPartialOut = item.wasFullyInViewport && !item.isFullyInViewport && !item.isFullyOut; 63 | } 64 | 65 | function handleAnimationFrame() { 66 | let requestId; 67 | 68 | const tick = () => { 69 | const changed = watcher.lastXY.join(',') !== getScroll().join(','); 70 | 71 | if (changed) { 72 | const evtData = getScrollData(); 73 | 74 | emitter.emit(EVENT_TYPE.SCROLLING, evtData); 75 | 76 | Object.keys(watcher.watching).forEach((key) => { 77 | evtData.target = watcher.watching[key].node; 78 | recalculate(watcher.watching[key]); 79 | fireEvents(watcher.watching[key], evtData); 80 | }); 81 | } 82 | 83 | requestId = window.requestAnimationFrame(tick); 84 | }; 85 | 86 | requestId = window.requestAnimationFrame(tick); 87 | 88 | return requestId; 89 | } 90 | 91 | function initialize() { 92 | handleAnimationFrame(); 93 | 94 | const onReadyState = () => { 95 | if (typeof document !== 'undefined') { 96 | switch (document.readyState) { 97 | case 'loading': 98 | case 'interactive': 99 | break; 100 | case 'complete': 101 | emitter.emit(EVENT_TYPE.PAGELOAD, { 102 | scrollX: watcher.lastXY[0], 103 | scrollY: watcher.lastXY[1], 104 | }); 105 | document.removeEventListener('readystatechange', onReadyState); 106 | 107 | break; 108 | 109 | default: 110 | } 111 | } 112 | }; 113 | 114 | document.addEventListener('readystatechange', onReadyState, false); 115 | window.addEventListener( 116 | 'resize', 117 | () => { 118 | watcher.viewport = getViewportSize(); 119 | }, 120 | false 121 | ); 122 | } 123 | 124 | function watch(element, initOffset) { 125 | const node = isElement(element) ? element : document.querySelector(element); 126 | 127 | assert(isElement(node), "Couldn't find target in DOM"); 128 | assert( 129 | typeof initOffset === 'number' || 130 | typeof initOffset === 'object' || 131 | typeof initOffset === 'undefined', 132 | '@param `initOffset` should be number or Object or undefined!' 133 | ); 134 | 135 | const optionsOffset = 136 | typeof initOffset === 'number' 137 | ? { top: initOffset, bottom: initOffset } 138 | : Object.assign(DEFAULT_OFFSET, initOffset); 139 | 140 | const watchingEmitter = new Mitt(); 141 | const watching = { 142 | node, 143 | offset: optionsOffset, 144 | dimensions: getOffset(node), 145 | emitter: watchingEmitter, 146 | }; 147 | 148 | watcher.counter += 1; 149 | watcher.watching[watcher.counter] = watching; 150 | resetProperties(watching); 151 | 152 | return { 153 | target: node, 154 | props: getProperties(watching), 155 | 156 | update() { 157 | const data = getScrollData(); 158 | 159 | data.target = watching.node; 160 | window.cancelAnimationFrame(watcher.requestAnimationId); 161 | resetPartialProperties(watching); 162 | 163 | watching.dimensions = getOffset(node); 164 | recalculate(watching); 165 | 166 | watcher.props = getProperties(watching); 167 | fireEvents(watching, data); 168 | handleAnimationFrame(); 169 | 170 | return this; 171 | }, 172 | 173 | once(eventName, callback) { 174 | const wrappedHandler = (evt) => { 175 | callback(evt); 176 | watchingEmitter.off(eventName, wrappedHandler); 177 | }; 178 | 179 | watchingEmitter.on(eventName, wrappedHandler); 180 | 181 | return this; 182 | }, 183 | 184 | on(eventName, callback) { 185 | watchingEmitter.on(eventName, callback); 186 | 187 | return this; 188 | }, 189 | 190 | off(eventName, callback) { 191 | watchingEmitter.off(eventName, callback); 192 | 193 | return this; 194 | }, 195 | }; 196 | } 197 | 198 | /** 199 | * @param {Number|undefined} offset How far to offset. 200 | * @return {Boolean} Whether window is scrolled to bottom 201 | */ 202 | function windowAtBottom(offset = 0) { 203 | const scrolled = watcher.lastXY[1]; 204 | const viewHeight = watcher.viewport.h; 205 | 206 | // eslint-disable-next-line no-param-reassign 207 | offset = Number.parseInt(offset, 10); 208 | 209 | return scrolled + viewHeight >= getDocumentHeight() - offset; 210 | } 211 | 212 | /** 213 | * @param {Number|undefined} offset How far to offset. 214 | * @return {Boolean} Whether window is scrolled to top 215 | */ 216 | function windowAtTop(offset = 0) { 217 | // eslint-disable-next-line no-param-reassign 218 | offset = Number.parseInt(offset, 10); 219 | 220 | return watcher.lastXY[1] <= offset; 221 | } 222 | 223 | return { initialize, watch, emitter, windowAtBottom, windowAtTop }; 224 | } 225 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const EVENT_TYPE = { 2 | PAGELOAD: 'page:load', 3 | SCROLLING: 'scrolling', 4 | ENTER: 'enter', 5 | FULL_ENTER: 'enter:full', 6 | EXIT_PARTIAL: 'exit:partial', 7 | EXIT: 'exit', 8 | }; 9 | 10 | export const DEFAULT_OFFSET = { 11 | top: 0, 12 | bottom: 0, 13 | }; 14 | -------------------------------------------------------------------------------- /src/emitter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Based on https://github.com/developit/mitt 3 | */ 4 | 5 | export default function (all = new Map()) { 6 | return { 7 | all, 8 | 9 | /** 10 | * Register an event handler for the given type. 11 | * @param {string|symbol} type Type of event to listen for, or `"*"` for all events 12 | * @param {Function} handler Function to call in response to given event 13 | */ 14 | on(type, handler) { 15 | const handlers = all.get(type); 16 | const added = handlers && handlers.push(handler); 17 | 18 | if (!added) { 19 | all.set(type, [handler]); 20 | } 21 | }, 22 | 23 | /** 24 | * Remove an event handler for the given type. 25 | * @param {string|symbol} type Type of event to unregister `handler` from, or `"*"` 26 | * @param {Function} handler Handler function to remove 27 | */ 28 | off(type, handler) { 29 | const handlers = all.get(type); 30 | 31 | if (handlers) { 32 | handlers.splice(handlers.indexOf(handler), 1); 33 | } 34 | }, 35 | 36 | /** 37 | * Invoke all handlers for the given type. 38 | * If present, `"*"` handlers are invoked after type-matched handlers. 39 | * 40 | * Note: Manually firing "*" handlers is not supported. 41 | * 42 | * @param {string|symbol} type The event type to invoke 43 | * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler 44 | */ 45 | emit(type, evt) { 46 | (all.get(type) || []).slice().map((handler) => handler(evt)); 47 | (all.get('*') || []).slice().map((handler) => handler(type, evt)); 48 | }, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/entry.js: -------------------------------------------------------------------------------- 1 | import base from './base.js'; 2 | 3 | export default function () { 4 | const { initialize, watch, windowAtBottom, windowAtTop, emitter } = base(); 5 | 6 | initialize(); 7 | 8 | return { 9 | watch, 10 | on: emitter.on, 11 | off: emitter.off, 12 | emit: emitter.emit, 13 | windowAtBottom, 14 | windowAtTop, 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/dom.js: -------------------------------------------------------------------------------- 1 | function classRegex(classname) { 2 | // eslint-disable-next-line security/detect-non-literal-regexp 3 | return new RegExp(`(^|\\s+) ${classname} (\\s+|$)`, 'u'); 4 | } 5 | 6 | /** 7 | * @param {Element} element DOM node. 8 | * @param {String} classname Classname. 9 | * @return {Boolean} 10 | */ 11 | export function hasClass(element, classname) { 12 | // use native if available 13 | return element.classList 14 | ? element.classList.contains(classname) 15 | : classRegex(classname).test(element.className); 16 | } 17 | 18 | /** 19 | * @param {Element|Array} element DOM node or array of nodes. 20 | * @param {String|Array} classname Class or array of classes. 21 | * For example: 'class1 class2' or ['class1', 'class2'] 22 | */ 23 | export function addClass(element, classname) { 24 | if (Array.isArray(element)) { 25 | element.forEach((each) => addClass(each, classname)); 26 | 27 | return; 28 | } 29 | 30 | const array = Array.isArray(classname) ? classname : classname.split(/\s+/u); 31 | 32 | let i = array.length; 33 | 34 | while (i--) { 35 | if (!hasClass(element, array[i])) { 36 | if (element.classList) { 37 | element.classList.add(array[i]); 38 | } else { 39 | element.className = `${element.className} ${array[i]}`.trim(); 40 | } 41 | } 42 | } 43 | } 44 | 45 | /** 46 | * @param {Element|Array} element DOM node or array of nodes. 47 | * @param {String|Array} classname Class or array of classes. 48 | * For example: 'class1 class2' or ['class1', 'class2'] 49 | * @param {Number|undefined} timeout Timeout to add a class. 50 | */ 51 | export function removeClass(element, classname) { 52 | if (Array.isArray(element) || NodeList.prototype.isPrototypeOf(element)) { 53 | element.forEach((each) => removeClass(each, classname)); 54 | 55 | return; 56 | } 57 | 58 | const array = Array.isArray(classname) ? classname : classname.split(/\s+/u); 59 | 60 | let i = array.length; 61 | 62 | while (i--) { 63 | if (hasClass(element, array[i])) { 64 | if (element.classList) { 65 | element.classList.remove(array[i]); 66 | } else { 67 | element.className = element.className.replace(classRegex(array[i]), ' ').trim(); 68 | } 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * @param {Element|Array} element DOM node or array of nodes. 75 | * @param {String} classname Classe. 76 | */ 77 | export function toggleClass(element, classname) { 78 | if (Array.isArray(element)) { 79 | element.forEach((each) => toggleClass(each, classname)); 80 | 81 | return; 82 | } 83 | 84 | // use native if available 85 | if (element.classList) { 86 | element.classList.toggle(classname); 87 | } else { 88 | hasClass(element, classname) 89 | ? removeClass(element, classname) 90 | : addClass(element, classname); 91 | } 92 | } 93 | 94 | export function getScroll() { 95 | return [ 96 | window.pageXOffset || 97 | (document.documentElement && document.documentElement.scrollLeft) || 98 | document.body.scrollLeft, 99 | window.pageYOffset || 100 | (document.documentElement && document.documentElement.scrollTop) || 101 | document.body.scrollTop, 102 | ]; 103 | } 104 | 105 | export function getViewportSize() { 106 | return { 107 | w: window.innerWidth || document.documentElement.clientWidth, 108 | h: window.innerHeight || document.documentElement.clientHeight, 109 | }; 110 | } 111 | 112 | export function getDocumentHeight() { 113 | return Math.max( 114 | document.body.scrollHeight, 115 | document.documentElement.scrollHeight, 116 | document.body.offsetHeight, 117 | document.documentElement.offsetHeight, 118 | document.documentElement.clientHeight 119 | ); 120 | } 121 | 122 | export function getOffset(element) { 123 | const rect = element.getBoundingClientRect(); 124 | const { documentElement } = document; 125 | const left = rect.left + window.pageXOffset - documentElement.clientLeft; 126 | const top = rect.top + window.pageYOffset - documentElement.clientTop; 127 | const width = element.offsetWidth; 128 | const height = element.offsetHeight; 129 | const right = left + width; 130 | const bottom = top + height; 131 | 132 | return { width, height, top, bottom, right, left }; 133 | } 134 | 135 | export function isElement(object) { 136 | // DOM, Level2 137 | if ('HTMLElement' in window) { 138 | return !!object && object instanceof HTMLElement; 139 | } 140 | 141 | // Older browsers 142 | return !!object && typeof object === 'object' && object.nodeType === 1 && !!object.nodeName; 143 | } 144 | -------------------------------------------------------------------------------- /src/helpers/mix.js: -------------------------------------------------------------------------------- 1 | import { EVENT_TYPE } from '../constants.js'; 2 | 3 | export function assert(condition, message = 'Assertion failed') { 4 | if (!condition) { 5 | if (typeof Error !== 'undefined') throw new Error(message); 6 | 7 | throw message; 8 | } 9 | } 10 | 11 | export function emptyArray(array) { 12 | while (array.length > 0) array.pop(); 13 | } 14 | 15 | export function resetProperties(item) { 16 | item.isInViewport = false; 17 | item.wasInViewport = false; 18 | item.isAboveViewport = false; 19 | item.wasAboveViewport = false; 20 | item.isBelowViewport = false; 21 | item.wasBelowViewport = false; 22 | item.isPartialOut = false; 23 | item.wasPartialOut = false; 24 | item.isFullyOut = false; 25 | item.wasFullyOut = false; 26 | item.isFullyInViewport = false; 27 | item.wasFullyInViewport = false; 28 | } 29 | 30 | export function resetPartialProperties(item) { 31 | item.isInViewport = false; 32 | item.isAboveViewport = false; 33 | item.isBelowViewport = false; 34 | item.isPartialOut = false; 35 | item.isFullyOut = false; 36 | item.isFullyInViewport = false; 37 | } 38 | 39 | export function getProperties(item) { 40 | return { 41 | isInViewport: item.isInViewport, 42 | isFullyInViewport: item.isFullyInViewport, 43 | isAboveViewport: item.isAboveViewport, 44 | isBelowViewport: item.isBelowViewport, 45 | isPartialOut: item.isPartialOut, 46 | isFullyOut: item.isFullyOut, 47 | }; 48 | } 49 | 50 | export function fireEvents(item, data) { 51 | if (item.isInViewport && !item.wasInViewport) { 52 | item.wasInViewport = true; 53 | item.wasFullyOut = false; 54 | item.emitter.emit(EVENT_TYPE.ENTER, data); 55 | } 56 | 57 | if (item.isPartialOut && !item.wasPartialOut) { 58 | item.wasPartialOut = true; 59 | item.wasFullyInViewport = false; 60 | item.emitter.emit(EVENT_TYPE.EXIT_PARTIAL, data); 61 | } 62 | 63 | if (item.isFullyOut && !item.wasFullyOut) { 64 | item.wasFullyOut = true; 65 | item.wasInViewport = false; 66 | item.wasFullyInViewport = false; 67 | item.emitter.emit(EVENT_TYPE.EXIT, data); 68 | } 69 | 70 | if (item.isFullyInViewport && !item.wasFullyInViewport) { 71 | item.wasFullyInViewport = true; 72 | item.wasPartialOut = false; 73 | item.wasFullyOut = false; 74 | item.emitter.emit(EVENT_TYPE.FULL_ENTER, data); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test/e2e/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | globals: 2 | ScrollWatcher: readonly 3 | extends: 4 | - plugin:testcafe/recommended 5 | plugins: 6 | - testcafe 7 | rules: 8 | require-atomic-updates: 0 9 | import/unambiguous: 0 10 | import/no-unassigned-import: 0 11 | promise/catch-or-return: 0 12 | promise/no-nesting: 0 13 | -------------------------------------------------------------------------------- /test/e2e/constants.js: -------------------------------------------------------------------------------- 1 | export const windowSize = { w: 600, h: 400 }; 2 | 3 | // from CSS 4 | export const dragSize = { w: 140, h: 80 }; 5 | 6 | export const dragPosition = { top: 100, left: 100 }; 7 | 8 | // also from CSS 9 | export const betweenRect = 200; 10 | 11 | export const dragClasses = { 12 | enter: 'enter', 13 | partialExit: 'partial-exit', 14 | fullyEnter: 'fully-enter', 15 | exit: 'exit', 16 | }; 17 | -------------------------------------------------------------------------------- /test/e2e/drag.test.js: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | 3 | import { dragClasses, dragPosition, dragSize } from './constants.js'; 4 | import { drag, waitForWatcherEvent, getViewport } from './helpers.js'; 5 | 6 | const rect1 = Selector('#rect1'); 7 | 8 | // eslint-disable-next-line no-unused-expressions 9 | fixture`Dragging`.page`./pages/drag.html`; 10 | 11 | test('Out of Viewport', async (t) => { 12 | const viewport = await getViewport(); 13 | const outOfViewport = viewport.h + dragPosition.top + dragSize.h; 14 | 15 | await drag(outOfViewport)(); 16 | await t 17 | .expect(waitForWatcherEvent()) 18 | .eql(dragClasses.exit) 19 | .expect(rect1.hasClass(dragClasses.exit)) 20 | .ok() 21 | .expect(rect1.hasClass(dragClasses.partialExit)) 22 | .notOk() 23 | .expect(rect1.hasClass(dragClasses.enter)) 24 | .notOk() 25 | .expect(rect1.hasClass(dragClasses.fullyEnter)) 26 | .notOk(); 27 | }); 28 | 29 | test('Fully enter in Viewport', async (t) => { 30 | await drag(100)(); 31 | await t 32 | .expect(waitForWatcherEvent()) 33 | .eql(dragClasses.fullyEnter) 34 | .expect(rect1.hasClass(dragClasses.fullyEnter)) 35 | .ok() 36 | .expect(rect1.hasClass(dragClasses.partialExit)) 37 | .notOk() 38 | .expect(rect1.hasClass(dragClasses.exit)) 39 | .notOk(); 40 | }); 41 | 42 | test('Partial exit', async (t) => { 43 | const viewport = await getViewport(); 44 | const partialExit = viewport.h - dragSize.h / 2; 45 | 46 | await drag(partialExit)(); 47 | await t 48 | .expect(waitForWatcherEvent()) 49 | .eql(dragClasses.partialExit) 50 | .expect(rect1.hasClass(dragClasses.partialExit)) 51 | .ok() 52 | .expect(rect1.hasClass(dragClasses.exit)) 53 | .notOk() 54 | .expect(rect1.hasClass(dragClasses.fullyEnter)) 55 | .notOk(); 56 | }); 57 | 58 | test('Partial enter in Viewport', async (t) => { 59 | const viewport = await getViewport(); 60 | const outOfViewport = viewport.h + dragPosition.top + dragSize.h; 61 | const partialEnter = viewport.h - dragSize.h / 2; 62 | 63 | await drag(outOfViewport)(); 64 | await drag(partialEnter)(); 65 | await t 66 | .expect(waitForWatcherEvent()) 67 | .eql(dragClasses.enter) 68 | .expect(rect1.hasClass(dragClasses.enter)) 69 | .ok() 70 | .expect(rect1.hasClass(dragClasses.exit)) 71 | .notOk() 72 | .expect(rect1.hasClass(dragClasses.fullyEnter)) 73 | .notOk(); 74 | }); 75 | -------------------------------------------------------------------------------- /test/e2e/helpers.js: -------------------------------------------------------------------------------- 1 | import { ClientFunction, Selector } from 'testcafe'; 2 | 3 | const rect1 = Selector('#rect1'); 4 | 5 | export function drag(top) { 6 | return ClientFunction( 7 | () => 8 | new Promise((resolve) => { 9 | const button = document.querySelector('#btn-update'); 10 | 11 | rect1().style.top = `${top}px`; 12 | button.click(); 13 | window.setTimeout(resolve, 200); 14 | }), 15 | { dependencies: { top, rect1 } } 16 | ); 17 | } 18 | 19 | export function scroll(total) { 20 | return ClientFunction( 21 | () => 22 | new Promise((resolve) => { 23 | window.scrollBy(0, total); 24 | window.setTimeout(resolve, 300); 25 | }), 26 | { dependencies: { total } } 27 | ); 28 | } 29 | 30 | export const getViewport = ClientFunction(() => ({ 31 | w: window.innerWidth, 32 | h: window.innerHeight, 33 | })); 34 | 35 | export const waitForWatcherEvent = ClientFunction( 36 | () => 37 | new Promise((resolve) => { 38 | const input = document.querySelector('#actual-class'); 39 | const intervalID = window.setInterval(() => { 40 | const { value } = input; 41 | 42 | if (value) { 43 | input.value = ''; 44 | clearInterval(intervalID); 45 | resolve(value); 46 | } 47 | }, 10); 48 | }) 49 | ); 50 | 51 | export function waitForRectEvent(id) { 52 | return ClientFunction( 53 | () => 54 | new Promise((resolve) => { 55 | const input = document.getElementById(id); 56 | const intervalID = window.setInterval(() => { 57 | const { value } = input; 58 | 59 | if (value) { 60 | clearInterval(intervalID); 61 | resolve(value); 62 | } 63 | }, 20); 64 | }), 65 | { dependencies: { id } } 66 | ); 67 | } 68 | 69 | export const clearAllClasses = ClientFunction( 70 | () => 71 | new Promise((resolve) => { 72 | [ 73 | 'actual-class1', 74 | 'actual-class2', 75 | 'actual-class3', 76 | 'actual-class4', 77 | 'actual-class5', 78 | 'actual-class6', 79 | ].forEach((id) => { 80 | document.getElementById(id).value = ''; 81 | }); 82 | resolve(); 83 | }) 84 | ); 85 | 86 | -------------------------------------------------------------------------------- /test/e2e/pages/drag.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 1em/1.5 BlinkMacSystemFont,-apple-system,"Segoe UI","Roboto","Oxygen","Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue","Helvetica","Arial",sans-serif; 3 | color: #222; 4 | font-weight: 400; 5 | padding: 20px 40px; 6 | background: linear-gradient(#efefef, #999) fixed; 7 | } 8 | .move { 9 | position: absolute; 10 | top: 100px; 11 | left: 100px; 12 | width: 140px; 13 | height: 80px; 14 | border-radius: 100%; 15 | color: #fff; 16 | background-color: #29e; 17 | cursor: pointer; 18 | } 19 | .container { 20 | position: absolute; 21 | left: 100px; 22 | width: 50px; 23 | height: 50px; 24 | border: 1px solid #333; 25 | } 26 | .inner { 27 | position: relative; 28 | top: 50%; 29 | transform: translateY(-50%); 30 | text-align: center; 31 | } 32 | .enter { 33 | background-color: #849a6f; 34 | } 35 | .partial-exit { 36 | background-color: #f40; 37 | } 38 | .fully-enter { 39 | background-color: #4E9A06; 40 | } 41 | 42 | .move2 { 43 | top: 300px; 44 | left: 200px; 45 | } 46 | .move3 { 47 | top: 500px; 48 | left: 300px; 49 | } 50 | .move4 { 51 | top: 700px; 52 | left: 400px; 53 | } 54 | .move5 { 55 | top: 900px; 56 | left: 500px; 57 | } 58 | .move6 { 59 | top: 1100px; 60 | left: 600px; 61 | } 62 | .move7 { 63 | top: 1300px; 64 | left: 700px; 65 | } 66 | 67 | #empty-space { 68 | position: absolute; 69 | top: 1300px; 70 | height: 1000px; 71 | width: 100%; 72 | } -------------------------------------------------------------------------------- /test/e2e/pages/drag.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | scroll-watcher 5 | 6 | 7 | 8 | 9 | 10 | 11 |

Actual (CSS class) position:

12 | 13 | 14 |
15 |
16 |

Move me

17 |
18 |
19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/e2e/pages/drag.js: -------------------------------------------------------------------------------- 1 | const watcher = new ScrollWatcher(); 2 | const enter = ['enter', 'fully-enter']; 3 | const exit = ['exit', 'partial-exit']; 4 | const all = enter.concat(exit); 5 | 6 | const rect1 = document.getElementById('rect1'); 7 | const inputClass = document.getElementById('actual-class'); 8 | const buttonUpdate = document.getElementById('btn-update'); 9 | 10 | const rect = watcher 11 | .watch(rect1) 12 | .on('enter', () => { 13 | rect1.classList.remove(...all); 14 | rect1.classList.add(enter[0]); 15 | inputClass.value = enter[0]; 16 | }) 17 | .on('exit', () => { 18 | rect1.classList.remove(...all); 19 | rect1.classList.add(exit[0]); 20 | inputClass.value = exit[0]; 21 | }) 22 | .on('enter:full', () => { 23 | rect1.classList.remove(...all); 24 | rect1.classList.add(enter[1]); 25 | inputClass.value = enter[1]; 26 | }) 27 | .on('exit:partial', () => { 28 | rect1.classList.remove(...all); 29 | rect1.classList.add(exit[1]); 30 | inputClass.value = exit[1]; 31 | }); 32 | 33 | buttonUpdate.addEventListener('click', () => { 34 | rect.update(); 35 | }); 36 | -------------------------------------------------------------------------------- /test/e2e/pages/scroll.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | scroll-watcher 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | #rect1 actual (CSS class) position: 13 | 14 |
15 |
16 | #rect2 actual (CSS class) position: 17 | 18 |
19 |
20 | #rect3 actual (CSS class) position: 21 | 22 |
23 |
24 | #rect4 actual (CSS class) position: 25 | 26 |
27 |
28 | #rect5 actual (CSS class) position: 29 | 30 |
31 |
32 | #rect6 actual (CSS class) position: 33 | 34 |
35 | 36 |
37 |
38 |

Rect 1

39 |
40 |
41 |
42 |
43 |
44 |

Rect 2

45 |
46 |
47 |
48 |
49 |
50 |

Rect 3

51 |
52 |
53 |
54 |
55 |
56 |

Rect 4

57 |
58 |
59 |
60 |
61 |
62 |

Rect 5

63 |
64 |
65 |
66 |
67 |
68 |

Rect 6

69 |
70 |
71 |
72 |
73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /test/e2e/pages/scroll.js: -------------------------------------------------------------------------------- 1 | const watcher = new ScrollWatcher(); 2 | const enter = ['enter', 'fully-enter']; 3 | const exit = ['exit', 'partial-exit']; 4 | const all = [...enter, ...exit]; 5 | 6 | const $ = (id) => document.getElementById(id); 7 | 8 | function setCssClass(target, klass) { 9 | switch (target) { 10 | case $('rect1'): 11 | $('actual-class1').value = klass; 12 | 13 | break; 14 | case $('rect2'): 15 | $('actual-class2').value = klass; 16 | 17 | break; 18 | case $('rect3'): 19 | $('actual-class3').value = klass; 20 | 21 | break; 22 | case $('rect4'): 23 | $('actual-class4').value = klass; 24 | 25 | break; 26 | case $('rect5'): 27 | $('actual-class5').value = klass; 28 | 29 | break; 30 | case $('rect6'): 31 | $('actual-class6').value = klass; 32 | 33 | break; 34 | 35 | default: 36 | } 37 | } 38 | 39 | let firstChild; 40 | 41 | Array.prototype.forEach.call(document.querySelectorAll('.move'), (each) => { 42 | watcher 43 | .watch(each) 44 | .on('enter', (event_) => { 45 | firstChild = event_.target.firstElementChild; 46 | firstChild.lastElementChild.textContent = 'entered'; 47 | event_.target.classList.remove(...all); 48 | event_.target.classList.add('enter'); 49 | setCssClass(event_.target, 'enter'); 50 | }) 51 | .on('exit', (event_) => { 52 | event_.target.classList.remove(...all); 53 | event_.target.classList.add('exit'); 54 | setCssClass(event_.target, 'exit'); 55 | }) 56 | .on('enter:full', (event_) => { 57 | firstChild = event_.target.firstElementChild; 58 | firstChild.lastElementChild.textContent = 'fully entered'; 59 | event_.target.classList.remove(...all); 60 | event_.target.classList.add('fully-enter'); 61 | setCssClass(event_.target, 'fully-enter'); 62 | }) 63 | .on('exit:partial', (event_) => { 64 | firstChild = event_.target.firstElementChild; 65 | firstChild.lastElementChild.textContent = 'partial exited'; 66 | event_.target.classList.remove(...all); 67 | event_.target.classList.add('partial-exit'); 68 | setCssClass(event_.target, 'partial-exit'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/e2e/scroll.test.js: -------------------------------------------------------------------------------- 1 | import { scroll, waitForRectEvent, clearAllClasses } from './helpers.js'; 2 | import { dragClasses, dragPosition, dragSize, betweenRect } from './constants.js'; 3 | 4 | // eslint-disable-next-line no-unused-expressions 5 | fixture`Scrolling`.page`./pages/scroll.html`; 6 | 7 | let amountScrolled = dragPosition.top + dragSize.h / 2; 8 | let actualClass; 9 | 10 | test('#rect1', async (t) => { 11 | await clearAllClasses(); 12 | await scroll(amountScrolled)(); 13 | 14 | actualClass = await waitForRectEvent('actual-class1')(); 15 | await t.expect(actualClass).eql(dragClasses.partialExit); 16 | 17 | await clearAllClasses(); 18 | await scroll(amountScrolled * -1)(); 19 | 20 | actualClass = await waitForRectEvent('actual-class1')(); 21 | await t.expect(actualClass).eql(dragClasses.fullyEnter); 22 | }); 23 | 24 | test('#rect2', async (t) => { 25 | amountScrolled += betweenRect + dragSize.h / 4; 26 | 27 | await clearAllClasses(); 28 | await scroll(amountScrolled)(); 29 | 30 | actualClass = await waitForRectEvent('actual-class2')(); 31 | // await t.debug(); 32 | await t.expect(actualClass).eql(dragClasses.partialExit); 33 | 34 | actualClass = await waitForRectEvent('actual-class1')(); 35 | await t.expect(actualClass).eql(dragClasses.exit); 36 | 37 | await clearAllClasses(); 38 | await scroll(amountScrolled * -1)(); 39 | 40 | actualClass = await waitForRectEvent('actual-class2')(); 41 | await t.expect(actualClass).eql(dragClasses.fullyEnter); 42 | }); 43 | -------------------------------------------------------------------------------- /test/unit/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: 2 | - plugin:jest/recommended 3 | plugins: 4 | - jest 5 | rules: 6 | import/unambiguous: 0 7 | import/no-unassigned-import: 0 8 | promise/catch-or-return: 0 9 | promise/no-nesting: 0 10 | -------------------------------------------------------------------------------- /test/unit/helpers.js: -------------------------------------------------------------------------------- 1 | const ScrollWatcher = require('../../dist/scroll-watcher.js'); 2 | 3 | const event = { 4 | name: 'foo', 5 | count: 2, 6 | data: { value: 99 }, 7 | }; 8 | 9 | // http://stackoverflow.com/a/32461436/4640499 10 | function myPromise(ms, callback) { 11 | return new Promise((resolve, reject) => { 12 | // Set up the real work 13 | callback(resolve, reject); 14 | // Set up the timeout 15 | // eslint-disable-next-line prefer-promise-reject-errors 16 | setTimeout(() => reject(`Promise timed out after ${ms} ms`), ms); 17 | }); 18 | } 19 | 20 | module.exports = { 21 | event, 22 | 23 | emitAndListen: (total) => 24 | myPromise(2000, (resolve) => { 25 | const watcher = new ScrollWatcher(); 26 | 27 | let count = 0; 28 | 29 | watcher.on(event.name, () => { 30 | count += 1; 31 | 32 | if (count === total) resolve(count); 33 | }); 34 | 35 | for (let i = 1; i <= total; i += 1) { 36 | watcher.emit(event.name, event.data); 37 | } 38 | }), 39 | }; 40 | -------------------------------------------------------------------------------- /test/unit/instance.test.js: -------------------------------------------------------------------------------- 1 | const { emitAndListen, event } = require('./helpers.js'); 2 | 3 | describe('Instance listeners', () => { 4 | test('#on', () => { 5 | expect.assertions(1); 6 | 7 | return emitAndListen(event.count).then((res) => { 8 | expect(res).toBe(event.count); 9 | }); 10 | }); 11 | }); 12 | --------------------------------------------------------------------------------