├── .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 |
6 |
7 |
8 |
10 |
11 |
12 |
14 |
15 |
16 |
18 |
19 |
20 |
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 |
14 |
38 |
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 |
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 | Update Watcher
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 |
--------------------------------------------------------------------------------