├── .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 |
30 |
33 |
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 |
23 |
26 |
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 |
38 |
41 |
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 |
74 |
77 |
80 |
81 |
82 | Active panel: {{activePanelName}}
83 |
84 |
85 |
86 |
87 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | PanelSnap
2 |
3 |
4 |
5 |
6 |
7 |
8 |
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 |
38 |
41 |
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 |
86 |
89 |
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 |
--------------------------------------------------------------------------------