├── .nvmrc ├── .eslintignore ├── .gitignore ├── .babelrc ├── .editorconfig ├── .eslintrc ├── src ├── panelsnap.test.js ├── utilities.js ├── panelsnap.js └── utilities.test.js ├── .github ├── FUNDING.yml └── workflows │ ├── build-test-release.yml │ └── codeql-analysis.yml ├── docs ├── demos │ ├── basic │ │ └── index.html │ ├── jquery │ │ └── index.html │ ├── horizontal │ │ └── index.html │ ├── vue │ │ └── index.html │ ├── menu │ │ └── index.html │ └── react │ │ └── index.html ├── style.css ├── index.html └── panelsnap.js ├── LICENSE ├── rollup.config.js ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | stable 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | docs/ 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | coverage/ 4 | lib/ 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ "@babel/preset-env", { "modules": false }] 4 | ], 5 | "env": { 6 | "test": { 7 | "presets": ["@babel/preset-env"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base", 3 | "plugins": ["jest"], 4 | "env": { 5 | "browser": true, 6 | "jest/globals": true 7 | }, 8 | "rules": { 9 | "func-names": 0, 10 | "no-param-reassign": 0, 11 | "no-mixed-operators": [2, { 12 | "allowSamePrecedence": true 13 | }] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/panelsnap.test.js: -------------------------------------------------------------------------------- 1 | import PanelSnap from './panelsnap'; 2 | 3 | describe('Constructor', () => { 4 | xtest('can init PanelSnap', () => { 5 | const instance = new PanelSnap(); 6 | expect(instance).toBeInstanceOf(PanelSnap); 7 | }); 8 | 9 | xtest('prevents duplicate init of PanelSnap on the same element', () => { 10 | // On default container: body 11 | expect(() => [ 12 | new PanelSnap(), 13 | new PanelSnap(), 14 | ]).toThrow('already initialised'); 15 | 16 | // On custom element 17 | const container = document.createElement('div'); 18 | expect(() => [ 19 | new PanelSnap({ container }), 20 | new PanelSnap({ container }), 21 | ]).toThrow('already initialised'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: guidobouman # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /docs/demos/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PanelSnap - basic demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 25 | 26 | 27 |
28 |

First

29 |
30 |
31 |

Second

32 |
33 |
34 |

Third

35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /docs/demos/jquery/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PanelSnap - jQuery demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 18 | 19 | 20 |
21 |

First

22 |
23 |
24 |

Second

25 |
26 |
27 |

Third

28 |
29 | 30 | 31 | 32 | 33 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /docs/demos/horizontal/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PanelSnap - horizontal demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 33 | 34 | 35 |
36 |

First

37 |
38 |
39 |

Second

40 |
41 |
42 |

Third

43 |
44 | 45 | 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2013-present, Guido Bouman 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 | -------------------------------------------------------------------------------- /.github/workflows/build-test-release.yml: -------------------------------------------------------------------------------- 1 | name: PanelSnap build, test & release 2 | 3 | on: 4 | push: 5 | branches: [ "master", "develop" ] 6 | pull_request: 7 | branches: [ "master", "develop" ] 8 | 9 | jobs: 10 | build-test-release: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [12.x, 14.x, 16.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | - name: Install 26 | run: npm install 27 | 28 | - name: Build 29 | run: npm run build 30 | 31 | - name: Lint 32 | run: npm run lint 33 | 34 | - name: Test 35 | run: npm run test 36 | 37 | - name: Report coverage 38 | uses: codacy/codacy-coverage-reporter-action@v1.3.0 39 | with: 40 | project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} 41 | coverage-reports: coverage/lcov.info 42 | 43 | - name: Release 44 | if: github.action_ref == 'master' && github.event_name == 'push' 45 | run: npm run semantic-release 46 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import license from 'rollup-plugin-license'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 5 | import pkg from './package.json'; 6 | 7 | const banner = `/** 8 | * PanelSnap.js v${pkg.version} 9 | * Copyright (c) 2013-present, Guido Bouman 10 | * 11 | * This source code is licensed under the MIT license found in the 12 | * LICENSE file in the root directory of this source tree. 13 | */`; 14 | 15 | const plugins = [ 16 | babel({ 17 | exclude: ['node_modules/**'], 18 | }), 19 | terser(), 20 | license({ 21 | banner, 22 | }), 23 | ]; 24 | 25 | export default [ 26 | { 27 | input: 'src/panelsnap.js', 28 | output: [ 29 | { file: pkg.main, format: 'cjs' }, 30 | { file: pkg.module, format: 'es' }, 31 | ], 32 | external: ['tweezer.js'], 33 | plugins, 34 | }, 35 | { 36 | input: 'src/panelsnap.js', 37 | output: [ 38 | { file: pkg.browser, format: 'umd', name: 'PanelSnap' }, 39 | { file: 'docs/panelsnap.js', format: 'umd', name: 'PanelSnap' }, 40 | ], 41 | plugins: [...plugins, nodeResolve()], 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "panelsnap", 3 | "version": "0.0.0-development", 4 | "description": "A JavaScript plugin that provides snapping functionality to a set of panels within your interface.", 5 | "main": "lib/panelsnap.cjs.js", 6 | "browser": "lib/panelsnap.umd.js", 7 | "module": "lib/panelsnap.esm.js", 8 | "sideEffects": false, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/guidobouman/panelsnap" 12 | }, 13 | "author": "Guido Bouman", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/guidobouman/panelsnap/issues" 17 | }, 18 | "homepage": "https://panelsnap.com/", 19 | "keywords": [ 20 | "snap", 21 | "snapping", 22 | "element", 23 | "elements", 24 | "panel", 25 | "panels", 26 | "scroll", 27 | "scrolling" 28 | ], 29 | "dependencies": { 30 | "tweezer.js": "^1.5.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.14.3", 34 | "@babel/preset-env": "^7.14.4", 35 | "@rollup/plugin-babel": "^5.3.0", 36 | "@rollup/plugin-node-resolve": "^13.0.0", 37 | "babel-jest": "^27.0.2", 38 | "codacy-coverage": "^3.2.0", 39 | "commitizen": "^4.2.4", 40 | "cz-conventional-changelog": "^3.3.0", 41 | "eslint": "^7.27.0", 42 | "eslint-config-airbnb-base": "^14.2.1", 43 | "eslint-plugin-import": "^2.23.4", 44 | "eslint-plugin-jest": "^24.3.6", 45 | "jest": "^27.0.4", 46 | "rollup": "^2.50.6", 47 | "rollup-plugin-license": "^2.4.0", 48 | "rollup-plugin-terser": "^7.0.2", 49 | "semantic-release": "^19.0.3" 50 | }, 51 | "scripts": { 52 | "dev": "rollup -c -w", 53 | "build": "rollup -c", 54 | "lint": "eslint .", 55 | "test": "jest --coverage", 56 | "report-coverage": "cat ./coverage/lcov.info | codacy-coverage", 57 | "semantic-release": "semantic-release", 58 | "prepack": "npm run build", 59 | "commit": "git-cz" 60 | }, 61 | "browserslist": [ 62 | "> 0.25%" 63 | ], 64 | "config": { 65 | "commitizen": { 66 | "path": "./node_modules/cz-conventional-changelog" 67 | } 68 | }, 69 | "jest": { 70 | "testEnvironment": "jsdom" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /docs/demos/vue/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PanelSnap - Vue demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 48 | 49 | 68 | 69 | 70 |
71 |
72 |

First

73 |
74 |
75 |

Second

76 |
77 |
78 |

Third

79 |
80 | 81 |
82 | Active panel: {{activePanelName}} 83 |
84 |
85 | 86 | 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

PanelSnap

2 | 3 |

4 | Codacy Status 5 | Build Status 6 | Version 7 | License 8 | Bundled Size 9 |

10 | 11 |

12 | A JavaScript library that provides snapping functionality to a set of panels within your interface. 13 |

14 | 15 | --- 16 | 17 | ## Introduction 18 | 19 | PanelSnap is a framework agnostic JavaScript library. This means that it works in every JavaScript project, wheter you use Vue, React, jQuery or plain vanilla JavaScript. It can snap both horizontally & vertically, connect with menu's and fire events based on user behaviour. 20 | 21 | ## Installation 22 | 23 | ```bash 24 | npm install panelsnap 25 | ``` 26 | 27 | ```js 28 | import PanelSnap from 'panelsnap'; 29 | 30 | const instance = new PanelSnap(); 31 | ``` 32 | 33 | ```html 34 | 35 |
36 | ... 37 |
38 |
39 | ... 40 |
41 |
42 | ... 43 |
44 | 45 | ``` 46 | 47 | ## Documentation 48 | 49 | In its simplest form, PanelSnap does not need any configuration. For more advanced scenarios, PanelSnap can be adopted to about every usecase through its settings object. 50 | 51 | Check out the documentation at [https://panelsnap.com](https://panelsnap.com) or the `docs` folder for all the different possibilities. 52 | 53 | ## Credits 54 | 55 | - [jellea](https://github.com/jellea) for early feedback and brainpickings. 56 | - [aalexandrov](https://github.com/aalexandrov) for small improvements & bugfixes. 57 | - [akreitals](https://github.com/akreitals) for fixing keyboard navigation when disabled. 58 | - [brumm](https://github.com/brumm) far a panel count bug. 59 | - [dpaquette](https://github.com/dpaquette) for the offset option. 60 | - [wudi96](https://github.com/wudi96) for button navigation. 61 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ develop, master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ develop ] 20 | schedule: 21 | - cron: '45 19 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /docs/demos/menu/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PanelSnap - menu demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 44 | 45 | 81 | 82 | 83 |
84 |

First

85 |
86 |
87 |

Second

88 |
89 |
90 |

Third

91 |
92 | 93 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /docs/demos/react/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | 17 | 18 | PanelSnap - Vue demo 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 80 | 81 | 96 | 97 | 98 |
99 | 100 | 101 | -------------------------------------------------------------------------------- /src/utilities.js: -------------------------------------------------------------------------------- 1 | export function getScrollingElement(container) { 2 | if (container !== document.body) { 3 | return container; 4 | } 5 | 6 | if ('scrollingElement' in document) { 7 | return document.scrollingElement; 8 | } 9 | 10 | // Fallback for legacy browsers 11 | if (navigator.userAgent.indexOf('WebKit') > -1) { 12 | return document.body; 13 | } 14 | 15 | return document.documentElement; 16 | } 17 | 18 | export function getScrollEventContainer(container) { 19 | return container === document.body ? window : getScrollingElement(container); 20 | } 21 | 22 | function getContainerRect(container) { 23 | if (container === document.body) { 24 | const htmlElement = document.documentElement; 25 | return { 26 | top: 0, 27 | left: 0, 28 | bottom: htmlElement.clientHeight, 29 | right: htmlElement.clientWidth, 30 | height: htmlElement.clientHeight, 31 | width: htmlElement.clientWidth, 32 | }; 33 | } 34 | 35 | return container.getBoundingClientRect(); 36 | } 37 | 38 | export function getTargetScrollOffset(container, element, toBottom = false, toRight = false) { 39 | const containerRect = getContainerRect(container); 40 | const elementRect = element.getBoundingClientRect(); 41 | const scrollTop = elementRect.top - containerRect.top; 42 | const scrollLeft = elementRect.left - containerRect.left; 43 | const topCorrection = toBottom ? elementRect.height - containerRect.height : 0; 44 | const leftCorrection = toRight ? elementRect.width - containerRect.width : 0; 45 | const scrollingElement = getScrollingElement(container); 46 | 47 | return { 48 | top: scrollTop + topCorrection + scrollingElement.scrollTop, 49 | left: scrollLeft + leftCorrection + scrollingElement.scrollLeft, 50 | }; 51 | } 52 | 53 | export function getElementsInContainerViewport(container, elementList) { 54 | const containerRect = getContainerRect(container); 55 | 56 | return elementList.filter((element) => { 57 | const elementRect = element.getBoundingClientRect(); 58 | 59 | return ( 60 | elementRect.top < containerRect.bottom 61 | && elementRect.right > containerRect.left 62 | && elementRect.bottom > containerRect.top 63 | && elementRect.left < containerRect.right 64 | ); 65 | }); 66 | } 67 | 68 | export function elementFillsContainer(container, element) { 69 | const containerRect = getContainerRect(container); 70 | const elementRect = element.getBoundingClientRect(); 71 | 72 | return ( 73 | elementRect.top <= containerRect.top 74 | && elementRect.bottom >= containerRect.bottom 75 | && elementRect.left <= containerRect.left 76 | && elementRect.right >= containerRect.right 77 | ); 78 | } 79 | 80 | // Taken from MDN 81 | // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Safely_detecting_option_support 82 | export const passiveIsSupported = (function () { 83 | let isSupported = false; 84 | 85 | try { 86 | const options = Object.defineProperty({}, 'passive', { 87 | get() { // eslint-disable-line getter-return 88 | isSupported = true; 89 | }, 90 | }); 91 | 92 | window.addEventListener('test', null, options); 93 | window.removeEventListener('test', null, options); 94 | } catch (e) { 95 | // Do nothing 96 | } 97 | 98 | return isSupported; 99 | }()); 100 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | -moz-box-sizing: border-box; 4 | outline-offset: 3px; 5 | } 6 | 7 | *:focus { 8 | outline: 3px solid #000000; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | color: #000000; 14 | font-family: 'Lato'; 15 | font-weight: 300; 16 | font-size: 24px; 17 | min-height: 100vh; 18 | background: #ffffff; 19 | } 20 | 21 | @media screen and (max-width: 768px) { 22 | body { 23 | font-size: 20px; 24 | } 25 | } 26 | 27 | section { 28 | display: flex; 29 | flex-direction: column; 30 | position: relative; 31 | overflow: hidden; 32 | padding: 50px; 33 | width: 100%; 34 | min-height: 100vh; 35 | background: #ffffff; 36 | } 37 | 38 | @media screen and (max-width: 768px) { 39 | section { 40 | padding: 25px; 41 | } 42 | } 43 | 44 | section:nth-child(2n) { 45 | background: #edf7f5; 46 | } 47 | 48 | .panels section { 49 | background: #d6e8e5; 50 | min-height: 100%; 51 | } 52 | 53 | .panels section:nth-child(2) { 54 | min-height: calc(100% + 200px); 55 | } 56 | 57 | .panels section:nth-child(2n) { 58 | background: #c2d6d2; 59 | } 60 | 61 | .panels.horizontal { 62 | display: flex; 63 | } 64 | 65 | .panels.horizontal section { 66 | min-height: 0; 67 | min-width: 100%; 68 | } 69 | 70 | .panels.horizontal section:nth-child(2) { 71 | min-width: calc(100% + 200px); 72 | } 73 | 74 | section .explanation, 75 | section pre { 76 | background: #edf7f5; 77 | } 78 | 79 | section:nth-child(2n) .explanation, 80 | section:nth-child(2n) pre { 81 | background: #ffffff; 82 | } 83 | 84 | section.introduction { 85 | align-items: center; 86 | justify-content: center; 87 | } 88 | 89 | section.introduction .center { 90 | text-align: center; 91 | } 92 | 93 | section.introduction .center p { 94 | margin-left: auto; 95 | margin-right: auto; 96 | max-width: 900px; 97 | } 98 | 99 | section.introduction .center p a { 100 | word-break: break-all; 101 | } 102 | 103 | section.introduction .bottom { 104 | position: absolute; 105 | bottom: 0; 106 | left: 0; 107 | right: 0; 108 | padding-bottom: 50px; 109 | 110 | text-align: center; 111 | font-size: 60%; 112 | } 113 | 114 | section.introduction .bottom:after { 115 | display: block; 116 | margin: 1em auto 0; 117 | height: 20px; 118 | width: 20px; 119 | 120 | border-right: 1px solid; 121 | border-bottom: 1px solid; 122 | content: ''; 123 | 124 | -moz-transform: rotate(45deg); 125 | -o-transform: rotate(45deg); 126 | -webkit-transform: rotate(45deg); 127 | transform: rotate(45deg); 128 | } 129 | 130 | section.options, 131 | section.options_explained { 132 | min-height: 100vh; 133 | height: auto; 134 | } 135 | 136 | h1 { 137 | font-weight: 100; 138 | text-transform: uppercase; 139 | font-size: 300%; 140 | margin: 0 0 25px; 141 | } 142 | 143 | @media screen and (max-width: 768px) { 144 | h1 { 145 | font-size: 200%; 146 | } 147 | } 148 | 149 | h2 { 150 | font-weight: inherit; 151 | } 152 | 153 | a { 154 | color: inherit; 155 | text-decoration: underline; 156 | } 157 | 158 | .group { 159 | flex-grow: 1; 160 | display: flex; 161 | flex-wrap: wrap; 162 | margin: -25px; 163 | } 164 | 165 | .group > * { 166 | margin: 25px; 167 | } 168 | 169 | .panels, 170 | pre { 171 | flex-grow: 99999; 172 | flex-basis: 300px; 173 | } 174 | 175 | .panels { 176 | overflow: auto; 177 | } 178 | 179 | .explanation, 180 | pre { 181 | margin: 0; 182 | overflow-x: auto; 183 | padding: 50px; 184 | } 185 | 186 | @media screen and (max-width: 768px) { 187 | .explanation, 188 | pre { 189 | padding: 25px; 190 | } 191 | } 192 | 193 | .explanation dt, 194 | pre { 195 | font-size: 80%; 196 | font-family: Ubunutu Mono, monospace; 197 | } 198 | 199 | .explanation { 200 | flex-grow: 1; 201 | } 202 | 203 | .explanation dt:after { 204 | content: ':'; 205 | } 206 | 207 | .explanation dd { 208 | margin: 0 0 0 10px; 209 | } 210 | 211 | .explanation dd + dt { 212 | margin: 25px 0 0; 213 | } 214 | 215 | .aside { 216 | flex-grow: 1; 217 | flex-basis: 300px; 218 | } 219 | 220 | .aside > *:first-child { 221 | margin-top: 0; 222 | } 223 | 224 | .aside > *:last-child { 225 | margin-bottom: 0; 226 | } 227 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PanelSnap 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 18 | 19 | 20 |
21 |
22 |

23 | PanelSnap 24 |

25 |

26 | A JavaScript plugin that, after scrolling, snaps to blocks of content which I like to call panels. It's API makes the plugin 27 | easy to tailor to the needs of your project. 28 |

29 |

30 | Each of the following panels will explain a specific aspect of the PanelSnap plugin. 31 |

32 |

33 | https://github.com/guidobouman/panelsnap 34 |

35 |
36 |
37 |
38 | 39 |
40 |

41 | Basic setup 42 |

43 |
<!doctype html>
 44 | <html>
 45 |   <head>
 46 |     <script src="/path/to/panelsnap.js" defer></script>
 47 |     <script>
 48 |         document.addEventListener("DOMContentLoaded", function() {
 49 |           new PanelSnap();
 50 |         });
 51 |     </script>
 52 |   </head>
 53 |   <body>
 54 |     <section>
 55 |       ...
 56 |     </section>
 57 |     <section>
 58 |       ...
 59 |     </section>
 60 |     <section>
 61 |       ...
 62 |     </section>
 63 |   </body>
 64 | </html>
65 |
66 |
67 |

68 | Options 69 |

70 |
<script>
 71 |   var defaultOptions = {
 72 |     container: document.body,
 73 |     panelSelector: '> section',
 74 |     directionThreshold: 50,
 75 |     delay: 0,
 76 |     duration: 300,
 77 |     easing: function(t) { return t },
 78 |   };
 79 | 
 80 |   new PanelSnap(options);
 81 | </script>
82 |
83 | 84 |
85 |

Options explained

86 |
87 |
container
88 |
(element) The (scrolling) container that contains the panels.
89 |
panelSelector
90 |
(string) The css selector to find the panels. (scoped within the container).
91 |
directionThreshold
92 |
(interger) The amount of pixels required to scroll before the plugin detects a direction and snaps to the next panel.
93 |
delay
94 |
(integer) The amount of miliseconds the plugin pauzes before snapping to a panel.
95 |
duration
96 |
(integer) The amount of miliseconds in which the plugin snaps to the desired panel.
97 |
easing
98 |
(function) An easing function specificing the snapping motion.
99 |
100 |
101 | 102 |
103 |

104 | API 105 |

106 |
107 |
on([string] eventName, [function] callbackFunction(panel))
108 |
109 | Subscribe to a specific event with your own function. Where eventName is one of `activatePanel`, `snapStart`, `snapStop` 110 | and callbackFunction is a function that gets called with the panel that is the subject of the event. 111 |
112 | 113 |
off([string] eventName, [function] callbackFunction(panel))
114 |
115 | Unsubscribe one of your subscriptions. Where eventName is one of `activatePanel`, `snapStart`, `snapStop` and callbackFunction 116 | is the function that was used to subscribe to the event. 117 |
118 | 119 |
snapToPanel([DOM Element] panel)
120 |
121 | Trigger a snap to a panel. Where panel is one of the panels within the container. 122 |
123 | 124 |
destroy()
125 |
126 | Destroy the PanelSnap instance and clear all state & eventlisteners. This allows for a new PanelSnap instance to be created on 127 | the same container if so desired. 128 |
129 |
130 |
131 | 132 |
133 |

134 | Demos 135 |

136 |

137 | Common usecases are explained through a set of demos. 138 |

139 | 160 |

161 | These demos can also be found on 162 | GitHub. 163 |

164 | 165 | 166 | -------------------------------------------------------------------------------- /src/panelsnap.js: -------------------------------------------------------------------------------- 1 | import Tweezer from 'tweezer.js'; 2 | 3 | import { 4 | getScrollingElement, 5 | getScrollEventContainer, 6 | getTargetScrollOffset, 7 | getElementsInContainerViewport, 8 | elementFillsContainer, 9 | passiveIsSupported, 10 | } from './utilities'; 11 | 12 | let INSTANCE_COUNTER = 0; 13 | const TWEEN_MAX_VALUE = 10000; 14 | 15 | const defaultOptions = { 16 | container: document.body, 17 | panelSelector: '> section', 18 | directionThreshold: 50, 19 | delay: 0, 20 | duration: 300, 21 | easing: (t) => t, 22 | }; 23 | 24 | export default class PanelSnap { 25 | constructor(options) { 26 | this.options = { 27 | ...defaultOptions, 28 | ...options, 29 | }; 30 | 31 | if (this.options.container.dataset.panelsnapId) { 32 | throw new Error('PanelSnap is already initialised on this container, aborting.'); 33 | } 34 | 35 | this.container = this.options.container; 36 | this.scrollContainer = getScrollingElement(this.container); 37 | this.scrollEventContainer = getScrollEventContainer(this.container); 38 | 39 | INSTANCE_COUNTER += 1; 40 | this.instanceIndex = INSTANCE_COUNTER; 41 | this.container.dataset.panelsnapId = this.instanceIndex; 42 | 43 | const panelQuery = `[data-panelsnap-id="${this.instanceIndex}"] ${this.options.panelSelector}`; 44 | this.panelList = Array.from(document.querySelectorAll(panelQuery)); 45 | 46 | this.events = []; 47 | this.isEnabled = true; 48 | this.isInteracting = false; 49 | this.scrollTimeout = null; 50 | this.resetAnimation(); 51 | 52 | this.onInteractStart = this.onInteractStart.bind(this); 53 | this.onInteractStop = this.onInteractStop.bind(this); 54 | this.onInteractStart = this.onInteractStart.bind(this); 55 | this.onInteractStop = this.onInteractStop.bind(this); 56 | this.onInteractStart = this.onInteractStart.bind(this); 57 | this.onInteractStop = this.onInteractStop.bind(this); 58 | this.onScroll = this.onScroll.bind(this); 59 | this.onInteract = this.onInteract.bind(this); 60 | 61 | this.scrollEventContainer.addEventListener('keydown', this.onInteractStart, passiveIsSupported && { passive: true }); 62 | this.scrollEventContainer.addEventListener('keyup', this.onInteractStop, passiveIsSupported && { passive: true }); 63 | this.scrollEventContainer.addEventListener('mousedown', this.onInteractStart, passiveIsSupported && { passive: true }); 64 | this.scrollEventContainer.addEventListener('mouseup', this.onInteractStop, passiveIsSupported && { passive: true }); 65 | this.scrollEventContainer.addEventListener('touchstart', this.onInteractStart, passiveIsSupported && { passive: true }); 66 | this.scrollEventContainer.addEventListener('touchend', this.onInteractStop, passiveIsSupported && { passive: true }); 67 | this.scrollEventContainer.addEventListener('scroll', this.onScroll, passiveIsSupported && { passive: true }); 68 | this.scrollEventContainer.addEventListener('wheel', this.onInteract, passiveIsSupported && { passive: true }); 69 | 70 | this.findSnapTarget(); 71 | } 72 | 73 | destroy() { 74 | // Stop current animations 75 | this.stopAnimation(); 76 | 77 | // Prevent future activity 78 | this.disable(); 79 | 80 | // Remove event lisiteners 81 | this.scrollEventContainer.removeEventListener('keydown', this.onInteractStart, passiveIsSupported && { passive: true }); 82 | this.scrollEventContainer.removeEventListener('keyup', this.onInteractStop, passiveIsSupported && { passive: true }); 83 | this.scrollEventContainer.removeEventListener('mousedown', this.onInteractStart, passiveIsSupported && { passive: true }); 84 | this.scrollEventContainer.removeEventListener('mouseup', this.onInteractStop, passiveIsSupported && { passive: true }); 85 | this.scrollEventContainer.removeEventListener('touchstart', this.onInteractStart, passiveIsSupported && { passive: true }); 86 | this.scrollEventContainer.removeEventListener('touchend', this.onInteractStop, passiveIsSupported && { passive: true }); 87 | this.scrollEventContainer.removeEventListener('scroll', this.onScroll, passiveIsSupported && { passive: true }); 88 | this.scrollEventContainer.removeEventListener('wheel', this.onInteract, passiveIsSupported && { passive: true }); 89 | 90 | // Remove instance association 91 | delete this.options.container.dataset.panelsnapId; 92 | } 93 | 94 | enable() { 95 | this.isEnabled = true; 96 | } 97 | 98 | disable() { 99 | this.isEnabled = false; 100 | } 101 | 102 | on(name, handler) { 103 | const currentHandlers = this.events[name] || []; 104 | this.events[name] = [...currentHandlers, handler]; 105 | 106 | if (name === 'activatePanel') { 107 | handler.call(this, this.activePanel); 108 | } 109 | } 110 | 111 | off(name, handler) { 112 | const currentHandlers = this.events[name] || []; 113 | this.events[name] = currentHandlers.filter((h) => h !== handler); 114 | } 115 | 116 | emit(name, value) { 117 | const currentHandlers = this.events[name] || []; 118 | currentHandlers.forEach((h) => h.call(this, value)); 119 | } 120 | 121 | onInteractStart() { 122 | this.stopAnimation(); 123 | this.isInteracting = true; 124 | } 125 | 126 | onInteractStop() { 127 | this.isInteracting = false; 128 | this.findSnapTarget(); 129 | } 130 | 131 | onInteract() { 132 | this.stopAnimation(); 133 | this.onScroll(); 134 | } 135 | 136 | onScroll() { 137 | clearTimeout(this.scrollTimeout); 138 | 139 | if (this.isInteracting || this.animation) { 140 | return; 141 | } 142 | 143 | this.scrollTimeout = setTimeout(this.findSnapTarget.bind(this), 50 + this.options.delay); 144 | } 145 | 146 | findSnapTarget() { 147 | const deltaY = this.scrollContainer.scrollTop - this.currentScrollOffset.top; 148 | const deltaX = this.scrollContainer.scrollLeft - this.currentScrollOffset.left; 149 | this.currentScrollOffset = { 150 | top: this.scrollContainer.scrollTop, 151 | left: this.scrollContainer.scrollLeft, 152 | }; 153 | 154 | const panelsInViewport = getElementsInContainerViewport(this.container, this.panelList); 155 | if (panelsInViewport.length === 0) { 156 | throw new Error('PanelSnap could not find a snappable panel, aborting.'); 157 | } 158 | 159 | if (panelsInViewport.length > 1) { 160 | if ( 161 | Math.abs(deltaY) < this.options.directionThreshold 162 | && Math.abs(deltaX) < this.options.directionThreshold 163 | && this.activePanel 164 | ) { 165 | this.snapToPanel(this.activePanel, deltaY > 0, deltaX > 0); 166 | return; 167 | } 168 | 169 | const targetIndex = deltaY > 0 || deltaX > 0 ? 1 : panelsInViewport.length - 2; 170 | this.snapToPanel(panelsInViewport[targetIndex], deltaY < 0, deltaX < 0); 171 | return; 172 | } 173 | 174 | const visiblePanel = panelsInViewport[0]; 175 | if (elementFillsContainer(this.container, visiblePanel)) { 176 | this.activatePanel(visiblePanel); 177 | return; 178 | } 179 | 180 | // TODO: Only one partial panel in viewport, add support for space between panels? 181 | // eslint-disable-next-line no-console 182 | console.error('PanelSnap does not support space between panels, snapping back.'); 183 | this.snapToPanel(visiblePanel, deltaY > 0, deltaX > 0); 184 | } 185 | 186 | snapToPanel(panel, toBottom = false, toRight = false) { 187 | this.activatePanel(panel); 188 | 189 | if (!this.isEnabled) { 190 | return; 191 | } 192 | 193 | if (this.animation) { 194 | this.animation.stop(); 195 | } 196 | 197 | this.targetScrollOffset = getTargetScrollOffset(this.container, panel, toBottom, toRight); 198 | 199 | this.animation = new Tweezer({ 200 | start: 0, 201 | end: TWEEN_MAX_VALUE, 202 | duration: this.options.duration, 203 | }); 204 | 205 | this.animation.on('tick', this.animationTick.bind(this)); 206 | 207 | this.animation.on('done', () => { 208 | this.emit('snapStop', panel); 209 | this.resetAnimation(); 210 | }); 211 | 212 | this.emit('snapStart', panel); 213 | this.animation.begin(); 214 | } 215 | 216 | animationTick(value) { 217 | const scrollTopDelta = this.targetScrollOffset.top - this.currentScrollOffset.top; 218 | const scrollTop = this.currentScrollOffset.top + (scrollTopDelta * value / TWEEN_MAX_VALUE); 219 | this.scrollContainer.scrollTop = scrollTop; 220 | 221 | const scrollLeftDelta = this.targetScrollOffset.left - this.currentScrollOffset.left; 222 | const scrollLeft = this.currentScrollOffset.left + (scrollLeftDelta * value / TWEEN_MAX_VALUE); 223 | this.scrollContainer.scrollLeft = scrollLeft; 224 | } 225 | 226 | stopAnimation() { 227 | if (!this.animation) { 228 | return; 229 | } 230 | 231 | this.animation.stop(); 232 | this.resetAnimation(); 233 | } 234 | 235 | resetAnimation() { 236 | this.currentScrollOffset = { 237 | top: this.scrollContainer.scrollTop, 238 | left: this.scrollContainer.scrollLeft, 239 | }; 240 | this.targetScrollOffset = { 241 | top: 0, 242 | left: 0, 243 | }; 244 | this.animation = null; 245 | } 246 | 247 | activatePanel(panel) { 248 | if (this.activePanel === panel) { 249 | return; 250 | } 251 | 252 | this.emit('activatePanel', panel); 253 | this.activePanel = panel; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /docs/panelsnap.js: -------------------------------------------------------------------------------- 1 | /** 2 | * PanelSnap.js v0.0.0-development 3 | * Copyright (c) 2013-present, Guido Bouman 4 | * 5 | * This source code is licensed under the MIT license found in the 6 | * LICENSE file in the root directory of this source tree. 7 | */ 8 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).PanelSnap=e()}(this,(function(){"use strict";function t(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),n.push.apply(n,i)}return n}function e(e){for(var n=1;nt.length)&&(e=t.length);for(var n=0,i=new Array(e);n0&&void 0!==arguments[0]?arguments[0]:{};a(this,t),this.start=e.start,this.end=e.end,this.decimal=e.decimal}return s(t,[{key:"getIntermediateValue",value:function(t){return this.decimal?t:Math.round(t)}},{key:"getFinalValue",value:function(){return this.end}}]),t}(),c=function(){function t(t,e){for(var n=0;n0&&void 0!==arguments[0]?arguments[0]:{};h(this,t),this.duration=e.duration||1e3,this.ease=e.easing||this._defaultEase,this.tweener=e.tweener||new l(e),this.start=this.tweener.start,this.end=this.tweener.end,this.frame=null,this.next=null,this.isRunning=!1,this.events={},this.direction=this.startthis.end&&t>=this.next}[this.direction]}},{key:"_defaultEase",value:function(t,e,n,i){return(t/=i/2)<1?n/2*t*t+e:-n/2*(--t*(t-2)-1)+e}}]),t}();function f(t){return t!==document.body?t:"scrollingElement"in document?document.scrollingElement:navigator.userAgent.indexOf("WebKit")>-1?document.body:document.documentElement}function d(t){if(t===document.body){var e=document.documentElement;return{top:0,left:0,bottom:e.clientHeight,right:e.clientWidth,height:e.clientHeight,width:e.clientWidth}}return t.getBoundingClientRect()}function v(t,e){var n=arguments.length>2&&void 0!==arguments[2]&&arguments[2],i=arguments.length>3&&void 0!==arguments[3]&&arguments[3],r=d(t),o=e.getBoundingClientRect(),s=o.top-r.top,a=o.left-r.left,l=n?o.height-r.height:0,c=i?o.width-r.width:0,h=f(t);return{top:s+l+h.scrollTop,left:a+c+h.scrollLeft}}var p=function(){var t=!1;try{var e=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("test",null,e),window.removeEventListener("test",null,e)}catch(t){}return t}(),m=0,b=1e4,y={container:document.body,panelSelector:"> section",directionThreshold:50,delay:0,duration:300,easing:function(t){return t}};return function(){function t(n){if(function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}(this,t),this.options=e(e({},y),n),this.options.container.dataset.panelsnapId)throw new Error("PanelSnap is already initialised on this container, aborting.");var i;this.container=this.options.container,this.scrollContainer=f(this.container),this.scrollEventContainer=(i=this.container)===document.body?window:f(i),m+=1,this.instanceIndex=m,this.container.dataset.panelsnapId=this.instanceIndex;var r='[data-panelsnap-id="'.concat(this.instanceIndex,'"] ').concat(this.options.panelSelector);this.panelList=Array.from(document.querySelectorAll(r)),this.events=[],this.isEnabled=!0,this.isInteracting=!1,this.scrollTimeout=null,this.resetAnimation(),this.onInteractStart=this.onInteractStart.bind(this),this.onInteractStop=this.onInteractStop.bind(this),this.onInteractStart=this.onInteractStart.bind(this),this.onInteractStop=this.onInteractStop.bind(this),this.onInteractStart=this.onInteractStart.bind(this),this.onInteractStop=this.onInteractStop.bind(this),this.onScroll=this.onScroll.bind(this),this.onInteract=this.onInteract.bind(this),this.scrollEventContainer.addEventListener("keydown",this.onInteractStart,p&&{passive:!0}),this.scrollEventContainer.addEventListener("keyup",this.onInteractStop,p&&{passive:!0}),this.scrollEventContainer.addEventListener("mousedown",this.onInteractStart,p&&{passive:!0}),this.scrollEventContainer.addEventListener("mouseup",this.onInteractStop,p&&{passive:!0}),this.scrollEventContainer.addEventListener("touchstart",this.onInteractStart,p&&{passive:!0}),this.scrollEventContainer.addEventListener("touchend",this.onInteractStop,p&&{passive:!0}),this.scrollEventContainer.addEventListener("scroll",this.onScroll,p&&{passive:!0}),this.scrollEventContainer.addEventListener("wheel",this.onInteract,p&&{passive:!0}),this.findSnapTarget()}var i,o,s;return i=t,(o=[{key:"destroy",value:function(){this.stopAnimation(),this.disable(),this.scrollEventContainer.removeEventListener("keydown",this.onInteractStart,p&&{passive:!0}),this.scrollEventContainer.removeEventListener("keyup",this.onInteractStop,p&&{passive:!0}),this.scrollEventContainer.removeEventListener("mousedown",this.onInteractStart,p&&{passive:!0}),this.scrollEventContainer.removeEventListener("mouseup",this.onInteractStop,p&&{passive:!0}),this.scrollEventContainer.removeEventListener("touchstart",this.onInteractStart,p&&{passive:!0}),this.scrollEventContainer.removeEventListener("touchend",this.onInteractStop,p&&{passive:!0}),this.scrollEventContainer.removeEventListener("scroll",this.onScroll,p&&{passive:!0}),this.scrollEventContainer.removeEventListener("wheel",this.onInteract,p&&{passive:!0}),delete this.options.container.dataset.panelsnapId}},{key:"enable",value:function(){this.isEnabled=!0}},{key:"disable",value:function(){this.isEnabled=!1}},{key:"on",value:function(t,e){var n=this.events[t]||[];this.events[t]=[].concat(r(n),[e]),"activatePanel"===t&&e.call(this,this.activePanel)}},{key:"off",value:function(t,e){var n=this.events[t]||[];this.events[t]=n.filter((function(t){return t!==e}))}},{key:"emit",value:function(t,e){var n=this;(this.events[t]||[]).forEach((function(t){return t.call(n,e)}))}},{key:"onInteractStart",value:function(){this.stopAnimation(),this.isInteracting=!0}},{key:"onInteractStop",value:function(){this.isInteracting=!1,this.findSnapTarget()}},{key:"onInteract",value:function(){this.stopAnimation(),this.onScroll()}},{key:"onScroll",value:function(){clearTimeout(this.scrollTimeout),this.isInteracting||this.animation||(this.scrollTimeout=setTimeout(this.findSnapTarget.bind(this),50+this.options.delay))}},{key:"findSnapTarget",value:function(){var t=this.scrollContainer.scrollTop-this.currentScrollOffset.top,e=this.scrollContainer.scrollLeft-this.currentScrollOffset.left;this.currentScrollOffset={top:this.scrollContainer.scrollTop,left:this.scrollContainer.scrollLeft};var n,i,r,o=(n=this.container,i=this.panelList,r=d(n),i.filter((function(t){var e=t.getBoundingClientRect();return e.topr.left&&e.bottom>r.top&&e.left1){if(Math.abs(t)0,e>0);var s=t>0||e>0?1:o.length-2;this.snapToPanel(o[s],t<0,e<0)}else{var a=o[0];!function(t,e){var n=d(t),i=e.getBoundingClientRect();return i.top<=n.top&&i.bottom>=n.bottom&&i.left<=n.left&&i.right>=n.right}(this.container,a)?(console.error("PanelSnap does not support space between panels, snapping back."),this.snapToPanel(a,t>0,e>0)):this.activatePanel(a)}}},{key:"snapToPanel",value:function(t){var e=this,n=arguments.length>1&&void 0!==arguments[1]&&arguments[1],i=arguments.length>2&&void 0!==arguments[2]&&arguments[2];this.activatePanel(t),this.isEnabled&&(this.animation&&this.animation.stop(),this.targetScrollOffset=v(this.container,t,n,i),this.animation=new u({start:0,end:b,duration:this.options.duration}),this.animation.on("tick",this.animationTick.bind(this)),this.animation.on("done",(function(){e.emit("snapStop",t),e.resetAnimation()})),this.emit("snapStart",t),this.animation.begin())}},{key:"animationTick",value:function(t){var e=this.targetScrollOffset.top-this.currentScrollOffset.top,n=this.currentScrollOffset.top+e*t/b;this.scrollContainer.scrollTop=n;var i=this.targetScrollOffset.left-this.currentScrollOffset.left,r=this.currentScrollOffset.left+i*t/b;this.scrollContainer.scrollLeft=r}},{key:"stopAnimation",value:function(){this.animation&&(this.animation.stop(),this.resetAnimation())}},{key:"resetAnimation",value:function(){this.currentScrollOffset={top:this.scrollContainer.scrollTop,left:this.scrollContainer.scrollLeft},this.targetScrollOffset={top:0,left:0},this.animation=null}},{key:"activatePanel",value:function(t){this.activePanel!==t&&(this.emit("activatePanel",t),this.activePanel=t)}}])&&n(i.prototype,o),s&&n(i,s),t}()})); 9 | -------------------------------------------------------------------------------- /src/utilities.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | getScrollingElement, 3 | getTargetScrollOffset, 4 | getElementsInContainerViewport, 5 | elementFillsContainer, 6 | passiveIsSupported, 7 | } from './utilities'; 8 | 9 | const SCREEN_WIDTH = 800; 10 | const SCREEN_HEIGHT = 600; 11 | 12 | Object.defineProperty(document.documentElement, 'clientWidth', { value: SCREEN_WIDTH }); 13 | Object.defineProperty(document.documentElement, 'clientHeight', { value: SCREEN_HEIGHT }); 14 | 15 | describe('getScrolllingElement', () => { 16 | test('returns same element when container is not body', () => { 17 | const container = document.createElement('div'); 18 | expect(getScrollingElement(container)).toBe(container); 19 | }); 20 | 21 | test('returns scrollingElement when container is body on modern browser', () => { 22 | const container = document.body; 23 | const scrollContainer = document.createElement('div'); 24 | document.scrollingElement = scrollContainer; 25 | expect(getScrollingElement(container)).toBe(scrollContainer); 26 | 27 | // TODO: Jest should clean this up... 28 | delete document.scrollingElement; 29 | }); 30 | 31 | test('returns same element when container is body on WebKit browser', () => { 32 | Object.defineProperty(window.navigator, 'userAgent', { value: 'WebKit', configurable: true }); 33 | const container = document.body; 34 | expect(getScrollingElement(container)).toBe(container); 35 | }); 36 | 37 | test('returns document element when container is body on legacy browser', () => { 38 | Object.defineProperty(window.navigator, 'userAgent', { value: 'legacy browser', configurable: true }); 39 | const container = document.body; 40 | const scrollContainer = document.documentElement; 41 | expect(getScrollingElement(container)).toBe(scrollContainer); 42 | }); 43 | }); 44 | 45 | describe('getTargetScrollOffset', () => { 46 | function testElements(options) { 47 | const { 48 | scrollOffset, 49 | containerDimensions, 50 | targetDimensions, 51 | expectedResult, 52 | toBottom, 53 | toRight, 54 | } = options; 55 | 56 | const container = document.createElement('div'); 57 | container.scrollTop = scrollOffset.top; 58 | container.scrollLeft = scrollOffset.left; 59 | container.getBoundingClientRect = () => ({ 60 | ...containerDimensions, 61 | height: containerDimensions.bottom - containerDimensions.top, 62 | width: containerDimensions.right - containerDimensions.left, 63 | }); 64 | 65 | const target = document.createElement('div'); 66 | target.getBoundingClientRect = () => ({ 67 | ...targetDimensions, 68 | height: targetDimensions.bottom - targetDimensions.top, 69 | width: targetDimensions.right - targetDimensions.left, 70 | }); 71 | 72 | expect(getTargetScrollOffset(container, target, !!toBottom, !!toRight)).toEqual(expectedResult); 73 | } 74 | 75 | test('calculates scrollOffset for target element', () => { 76 | testElements({ 77 | scrollOffset: { top: 0, left: 0 }, 78 | containerDimensions: { top: 0, left: 0 }, 79 | targetDimensions: { top: 0, left: 0 }, 80 | expectedResult: { top: 0, left: 0 }, 81 | }); 82 | 83 | testElements({ 84 | scrollOffset: { top: 100, left: 100 }, 85 | containerDimensions: { top: 0, left: 0 }, 86 | targetDimensions: { top: -100, left: -100 }, 87 | expectedResult: { top: 0, left: 0 }, 88 | }); 89 | 90 | testElements({ 91 | scrollOffset: { top: 100, left: 100 }, 92 | containerDimensions: { top: 0, left: 0 }, 93 | targetDimensions: { top: 0, left: 0 }, 94 | expectedResult: { top: 100, left: 100 }, 95 | }); 96 | 97 | testElements({ 98 | scrollOffset: { top: 0, left: 0 }, 99 | containerDimensions: { top: 0, left: 0 }, 100 | targetDimensions: { top: 100, left: 100 }, 101 | expectedResult: { top: 100, left: 100 }, 102 | }); 103 | 104 | testElements({ 105 | scrollOffset: { top: 100, left: 100 }, 106 | containerDimensions: { top: 0, left: 0 }, 107 | targetDimensions: { top: 100, left: 100 }, 108 | expectedResult: { top: 200, left: 200 }, 109 | }); 110 | 111 | testElements({ 112 | scrollOffset: { top: 100, left: 100 }, 113 | containerDimensions: { top: 100, left: 100 }, 114 | targetDimensions: { top: 100, left: 100 }, 115 | expectedResult: { top: 100, left: 100 }, 116 | }); 117 | }); 118 | 119 | test('calculates scrollOffset for target element bottom', () => { 120 | testElements({ 121 | scrollOffset: { top: 0, left: 0 }, 122 | containerDimensions: { 123 | top: 0, bottom: 300, left: 0, right: 300, 124 | }, 125 | targetDimensions: { 126 | top: 0, bottom: 300, left: 0, right: 300, 127 | }, 128 | expectedResult: { top: 0, left: 0 }, 129 | toBottom: true, 130 | toRight: true, 131 | }); 132 | 133 | testElements({ 134 | scrollOffset: { top: 100, left: 100 }, 135 | containerDimensions: { 136 | top: 0, bottom: 300, left: 0, right: 300, 137 | }, 138 | targetDimensions: { 139 | top: -100, bottom: 200, left: -100, right: 200, 140 | }, 141 | expectedResult: { top: 0, left: 0 }, 142 | toBottom: true, 143 | toRight: true, 144 | }); 145 | 146 | testElements({ 147 | scrollOffset: { top: 100, left: 100 }, 148 | containerDimensions: { 149 | top: 0, bottom: 300, left: 0, right: 300, 150 | }, 151 | targetDimensions: { 152 | top: 0, bottom: 300, left: 0, right: 300, 153 | }, 154 | expectedResult: { top: 100, left: 100 }, 155 | toBottom: true, 156 | toRight: true, 157 | }); 158 | 159 | testElements({ 160 | scrollOffset: { top: 0, left: 0 }, 161 | containerDimensions: { 162 | top: 0, bottom: 300, left: 0, right: 300, 163 | }, 164 | targetDimensions: { 165 | top: 100, bottom: 400, left: 100, right: 400, 166 | }, 167 | expectedResult: { top: 100, left: 100 }, 168 | toBottom: true, 169 | toRight: true, 170 | }); 171 | 172 | testElements({ 173 | scrollOffset: { top: 100, left: 100 }, 174 | containerDimensions: { 175 | top: 0, bottom: 300, left: 0, right: 300, 176 | }, 177 | targetDimensions: { 178 | top: 100, bottom: 400, left: 100, right: 400, 179 | }, 180 | expectedResult: { top: 200, left: 200 }, 181 | toBottom: true, 182 | toRight: true, 183 | }); 184 | 185 | testElements({ 186 | scrollOffset: { top: 100, left: 100 }, 187 | containerDimensions: { 188 | top: 100, bottom: 400, left: 100, right: 400, 189 | }, 190 | targetDimensions: { 191 | top: 100, bottom: 400, left: 100, right: 400, 192 | }, 193 | expectedResult: { top: 100, left: 100 }, 194 | toBottom: true, 195 | toRight: true, 196 | }); 197 | }); 198 | 199 | function testBodyElements(options) { 200 | const { 201 | scrollOffset, 202 | targetDimensions, 203 | expectedResult, 204 | toBottom, 205 | toRight, 206 | } = options; 207 | 208 | const container = document.body; 209 | getScrollingElement(document.body).scrollTop = scrollOffset.top; 210 | getScrollingElement(document.body).scrollLeft = scrollOffset.left; 211 | 212 | const target = document.createElement('div'); 213 | target.getBoundingClientRect = () => ({ 214 | ...targetDimensions, 215 | height: targetDimensions.bottom - targetDimensions.top, 216 | width: targetDimensions.right - targetDimensions.left, 217 | }); 218 | 219 | expect(getTargetScrollOffset(container, target, !!toBottom, !!toRight)).toEqual(expectedResult); 220 | } 221 | 222 | test('calculates scrollOffset for target element in body', () => { 223 | testBodyElements({ 224 | scrollOffset: { top: 0, left: 0 }, 225 | targetDimensions: { top: 0, left: 0 }, 226 | expectedResult: { top: 0, left: 0 }, 227 | }); 228 | 229 | testBodyElements({ 230 | scrollOffset: { top: 100, left: 100 }, 231 | targetDimensions: { top: -100, left: -100 }, 232 | expectedResult: { top: 0, left: 0 }, 233 | }); 234 | 235 | testBodyElements({ 236 | scrollOffset: { top: 100, left: 100 }, 237 | targetDimensions: { top: 0, left: 0 }, 238 | expectedResult: { top: 100, left: 100 }, 239 | }); 240 | 241 | testBodyElements({ 242 | scrollOffset: { top: 0, left: 0 }, 243 | targetDimensions: { top: 100, left: 100 }, 244 | expectedResult: { top: 100, left: 100 }, 245 | }); 246 | 247 | testBodyElements({ 248 | scrollOffset: { top: 100, left: 100 }, 249 | targetDimensions: { top: 100, left: 100 }, 250 | expectedResult: { top: 200, left: 200 }, 251 | }); 252 | }); 253 | 254 | test('calculates scrollOffset for target element bottom in body', () => { 255 | testBodyElements({ 256 | scrollOffset: { top: 0, left: 0 }, 257 | targetDimensions: { 258 | top: 0, 259 | left: 0, 260 | bottom: SCREEN_HEIGHT, 261 | right: SCREEN_WIDTH, 262 | }, 263 | expectedResult: { top: 0, left: 0 }, 264 | toBottom: true, 265 | toRight: true, 266 | }); 267 | 268 | testBodyElements({ 269 | scrollOffset: { top: 100, left: 100 }, 270 | targetDimensions: { 271 | top: -100, 272 | left: -100, 273 | bottom: SCREEN_HEIGHT - 100, 274 | right: SCREEN_WIDTH - 100, 275 | }, 276 | expectedResult: { top: 0, left: 0 }, 277 | toBottom: true, 278 | toRight: true, 279 | }); 280 | 281 | testBodyElements({ 282 | scrollOffset: { top: 100, left: 100 }, 283 | targetDimensions: { 284 | top: 0, 285 | left: 0, 286 | bottom: SCREEN_HEIGHT, 287 | right: SCREEN_WIDTH, 288 | }, 289 | expectedResult: { top: 100, left: 100 }, 290 | toBottom: true, 291 | toRight: true, 292 | }); 293 | 294 | testBodyElements({ 295 | scrollOffset: { top: 0, left: 0 }, 296 | targetDimensions: { 297 | top: 100, 298 | left: 100, 299 | bottom: SCREEN_HEIGHT + 100, 300 | right: SCREEN_WIDTH + 100, 301 | }, 302 | expectedResult: { top: 100, left: 100 }, 303 | toBottom: true, 304 | toRight: true, 305 | }); 306 | 307 | testBodyElements({ 308 | scrollOffset: { top: 100, left: 100 }, 309 | targetDimensions: { 310 | top: 100, 311 | left: 100, 312 | bottom: SCREEN_HEIGHT + 100, 313 | right: SCREEN_WIDTH + 100, 314 | }, 315 | expectedResult: { top: 200, left: 200 }, 316 | toBottom: true, 317 | toRight: true, 318 | }); 319 | }); 320 | }); 321 | 322 | describe('getElementsInContainerViewport', () => { 323 | function getElements(scrollOffset, amountOfElements, elementSize, horizontal = false) { 324 | return Array.from(Array(amountOfElements), (_, i) => { 325 | const target = document.createElement('div'); 326 | const elementOffset = (i * elementSize) - scrollOffset; 327 | target.getBoundingClientRect = () => ({ 328 | top: horizontal ? 0 : elementOffset, 329 | bottom: horizontal ? SCREEN_HEIGHT : elementOffset + elementSize, 330 | left: horizontal ? elementOffset : 0, 331 | right: horizontal ? elementOffset + elementSize : SCREEN_WIDTH, 332 | }); 333 | 334 | return target; 335 | }); 336 | } 337 | 338 | test('finds vertical elements in body viewport', () => { 339 | const test1 = getElementsInContainerViewport(document.body, getElements(0, 5, 300)); 340 | expect(test1).toHaveLength(2); 341 | 342 | const test2 = getElementsInContainerViewport(document.body, getElements(200, 5, 300)); 343 | expect(test2).toHaveLength(3); 344 | 345 | const test3 = getElementsInContainerViewport(document.body, getElements(600, 3, 300)); 346 | expect(test3).toHaveLength(1); 347 | }); 348 | 349 | test('finds horizontal elements in body viewport', () => { 350 | const test1 = getElementsInContainerViewport(document.body, getElements(0, 5, 400, true)); 351 | expect(test1).toHaveLength(2); 352 | 353 | const test2 = getElementsInContainerViewport(document.body, getElements(200, 5, 400, true)); 354 | expect(test2).toHaveLength(3); 355 | 356 | const test3 = getElementsInContainerViewport(document.body, getElements(800, 3, 400, true)); 357 | expect(test3).toHaveLength(1); 358 | }); 359 | 360 | test('finds vertical elements in non-body viewport', () => { 361 | const container = getElements(0, 1, 400)[0]; 362 | 363 | const test1 = getElementsInContainerViewport(container, getElements(0, 5, 300)); 364 | expect(test1).toHaveLength(2); 365 | 366 | const test2 = getElementsInContainerViewport(container, getElements(0, 5, 400)); 367 | expect(test2).toHaveLength(1); 368 | }); 369 | 370 | test('finds horizontal elements in non-body viewport', () => { 371 | const container = getElements(0, 1, 400, true)[0]; 372 | 373 | const test1 = getElementsInContainerViewport(container, getElements(0, 5, 300, true)); 374 | expect(test1).toHaveLength(2); 375 | 376 | const test2 = getElementsInContainerViewport(container, getElements(0, 5, 400, true)); 377 | expect(test2).toHaveLength(1); 378 | }); 379 | }); 380 | 381 | describe('elementFillsContainer', () => { 382 | function getElement(top, bottom, left = 0, right = SCREEN_WIDTH) { 383 | const target = document.createElement('div'); 384 | target.getBoundingClientRect = () => ({ 385 | top, bottom, left, right, 386 | }); 387 | 388 | return target; 389 | } 390 | 391 | test('checks element in body viewport', () => { 392 | expect(elementFillsContainer(document.body, getElement(-100, SCREEN_HEIGHT + 100))).toBe(true); 393 | expect(elementFillsContainer(document.body, getElement(0, SCREEN_HEIGHT))).toBe(true); 394 | expect(elementFillsContainer(document.body, getElement(1, SCREEN_HEIGHT + 1))).toBe(false); 395 | expect(elementFillsContainer(document.body, getElement(-1, SCREEN_HEIGHT - 1))).toBe(false); 396 | }); 397 | 398 | test('checks element in non-body viewport', () => { 399 | const container = getElement(200, 500); 400 | expect(elementFillsContainer(container, getElement(100, 600))).toBe(true); 401 | expect(elementFillsContainer(container, getElement(200, 500))).toBe(true); 402 | expect(elementFillsContainer(container, getElement(201, 501))).toBe(false); 403 | expect(elementFillsContainer(container, getElement(99, 499))).toBe(false); 404 | }); 405 | }); 406 | 407 | describe('isPassiveSupported', () => { 408 | test('returns true in JSDOM', () => { 409 | expect(passiveIsSupported).toBe(true); 410 | }); 411 | }); 412 | --------------------------------------------------------------------------------