├── .github
├── dependabot.yml
└── workflows
│ └── test.yml
├── .gitignore
├── .npmignore
├── .prettierignore
├── LICENSE.md
├── README.md
├── babel.config.json
├── eslint.config.mjs
├── package.json
├── src
└── Sticky.jsx
├── tests
├── functional
│ ├── bootstrap.js
│ ├── index.html
│ ├── sticky-functional.jsx
│ └── sticky.spec.js
├── helpers
│ └── rAF.js
└── unit
│ └── Sticky.test.js
├── wdio.conf.js
└── webpack.config.js
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: '/'
5 | schedule:
6 | interval: weekly
7 | time: '13:00'
8 | open-pull-requests-limit: 10
9 | ignore:
10 | - dependency-name: babel
11 | versions:
12 | - '> 5.8.38'
13 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Testing
5 |
6 | on:
7 | push:
8 | branches: [main]
9 | pull_request:
10 | branches: [main]
11 |
12 | jobs:
13 | test:
14 | runs-on: ubuntu-latest
15 |
16 | strategy:
17 | matrix:
18 | node-version: [20.x]
19 |
20 | steps:
21 | - uses: actions/checkout@v3
22 | - name: Use Node.js ${{ matrix.node-version }}
23 | uses: actions/setup-node@v3
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 | - run: npm i
27 | - run: npm run lint
28 | - run: npm test
29 | # - name: Build fixture
30 | # run: npm run func:build
31 | # - name: Set envs
32 | # run: |
33 | # echo "FUNC_PATH=dist/${GITHUB_WORKFLOW}/${GITHUB_JOB}/${GITHUB_RUN_NUMBER}" >> ${GITHUB_ENV}
34 | # - name: Deploy to gh-pages
35 | # uses: peaceiris/actions-gh-pages@v3.8.0
36 | # with:
37 | # deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
38 | # destination_dir: ./${{ env.FUNC_PATH }}
39 | # keep_files: true
40 | # publish_dir: ./tests/functional/dist
41 | # - name: Func test
42 | # run: npm run func -- --baseUrl http://yahoo.github.io/
43 | # env:
44 | # SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
45 | # SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | artifacts/
2 | build/
3 | results/
4 | coverage/
5 | node_modules/
6 | tmp/
7 | dist/
8 | *.log
9 | *.tap
10 | tests/functional/css/
11 | tests/functional/bundle.js
12 | tests/functional/sticky-functional.js
13 | .DS_Store
14 | .eslintcache
15 | package-lock.json
16 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .babelrc
2 | .editorconfig
3 | .eslint*
4 | .github/
5 | .prettierignore
6 | /artifacts/
7 | /bin/
8 | /build/
9 | /coverage/
10 | /docs/
11 | /results/
12 | /src/
13 | /tests/
14 | /tmp/
15 | *.conf*.js*
16 | eslint.config.mjs
17 | lcov-*
18 | xunit*
19 | TEST_*
20 | *.log
21 | *~
22 | *.tap
23 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | coverage
2 | dist
3 | node_modules
4 | tests/functional/dist/
5 | package*.json
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # Software License Agreement (BSD License)
2 |
3 | ## Copyright (c) 2015, Yahoo Inc. All rights reserved.
4 |
5 | Redistribution and use of this software in source and binary forms, with or
6 | without modification, are permitted provided that the following conditions are
7 | met:
8 |
9 | - Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 | - Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 | - Neither the name of Yahoo Inc. nor the names of YUI's contributors may be
15 | used to endorse or promote products derived from this software without
16 | specific prior written permission of Yahoo Inc.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-stickynode
2 |
3 | [](http://badge.fury.io/js/react-stickynode)
4 | 
5 |
6 | A performant and comprehensive React sticky component.
7 |
8 | A sticky component wraps a sticky target and keeps the target in the viewport as the user scrolls the page. Most sticky components handle the case where the sticky target is shorter than the viewport, but not the case where a sticky target is taller than the viewport. The reason is that the expected behavior and implementation is much more complicated.
9 |
10 | `react-stickynode` handles not only regular case but the long sticky target case in a natural way. In the regular case, when scrolling the page down, `react-stickynode` will stick to the top of the viewport. But in the case of a taller sticky target, it will scroll along with the page until its bottom reaches the bottom of the viewport. In other words, it looks like the bottom of viewport pulls the bottom of a sticky target down when scrolling the page down. On the other hand, when scrolling the page up, the top of viewport pulls the top of a sticky target up.
11 |
12 | This behavior gives the content in a tall sticky target more chance to be shown. This is especially good for the case where many ADs are in the right rail.
13 |
14 | Another highlight is that `react-stickynode` can handle the case where a sticky target uses percentage as its width unit. For a responsive designed page, it is especially useful.
15 |
16 | ## Features
17 |
18 | - Retrieve `scrollTop` only once for all sticky components.
19 | - Listen to throttled scrolling to have better performance.
20 | - Use rAF to update sticky status to have better performance.
21 | - Support top offset from the top of screen.
22 | - Support bottom boundary to stop sticky status.
23 | - Support any sticky target with various width units.
24 |
25 | ## Usage
26 |
27 | ```bash
28 | npm install react-stickynode
29 | ```
30 |
31 | The sticky uses Modernizr `csstransforms3d` and `prefixed` ([link](http://modernizr.com/download/?-csstransforms3d-prefixed)) features to detect IE8/9, so it can downgrade not to use transform3d.
32 |
33 | ```js
34 | import Sticky from 'react-stickynode';
35 |
36 |
37 |
38 | ;
39 | ```
40 |
41 | ```js
42 | import Sticky from 'react-stickynode';
43 |
44 |
45 |
46 | ;
47 | ```
48 |
49 | ### Props
50 |
51 | | Name | Type | Note |
52 | | ------------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53 | | `enabled` | Boolean | The switch to enable or disable Sticky (true by default). |
54 | | `top` | Number/String | The offset from the top of window where the top of the element will be when sticky state is triggered (0 by default). If it is a selector to a target (via `querySelector()`), the offset will be the height of the target. |
55 | | `bottomBoundary` | Number/String | The offset from the top of document which release state will be triggered when the bottom of the element reaches at. If it is a selector to a target (via `querySelector()`), the offset will be the bottom of the target. |
56 | | `innerZ` | Number/String | z-index of the sticky. |
57 | | `enableTransforms` | Boolean | Enable the use of CSS3 transforms (true by default). |
58 | | `activeClass` | String | Class name to be applied to the element when the sticky state is active (`active` by default). |
59 | | `innerClass` | String | Class name to be applied to the inner element (`''` by default). |
60 | | `innerActiveClass` | String | Class name to be applied to the inner element when the sticky state is active (`''` by default). |
61 | | `className` | String | Class name to be applied to the element independent of the sticky state. |
62 | | `releasedClass` | String | Class name to be applied to the element when the sticky state is released (`released` by default). |
63 | | `onStateChange` | Function | Callback for when the sticky state changes. See below. |
64 | | `shouldFreeze` | Function | Callback to indicate when the sticky plugin should freeze position and ignore scroll/resize events. See below. |
65 |
66 | ### Handling State Change
67 |
68 | You can be notified when the state of the sticky component changes by passing a callback to the `onStateChange` prop. The callback will receive an object in the format `{status: CURRENT_STATUS}`, with `CURRENT_STATUS` being an integer representing the status:
69 |
70 | | Value | Name | Note |
71 | | ----- | ----------------- | --------------------------------------------------------------------------- |
72 | | `0` | `STATUS_ORIGINAL` | The default status, located at the original position. |
73 | | `1` | `STATUS_RELEASED` | The released status, located at somewhere on document, but not default one. |
74 | | `2` | `STATUS_FIXED` | The sticky status, located fixed to the top or the bottom of screen. |
75 |
76 | You can access the statuses as static constants to use for comparison.
77 |
78 | ```jsx
79 | import Sticky from 'react-stickynode';
80 |
81 | const handleStateChange = (status) => {
82 | if (status.status === Sticky.STATUS_FIXED) {
83 | console.log('the component is sticky');
84 | }
85 | };
86 |
87 |
88 |
89 | ;
90 | ```
91 |
92 | Also `Sticky` supports children functions:
93 |
94 | ```jsx
95 | import Sticky from 'react-stickynode';
96 |
97 |
98 | {(status) => {
99 | if (status.status === Sticky.STATUS_FIXED) {
100 | return 'the component is sticky';
101 | }
102 | if (status.status === Sticky.STATUS_ORIGINAL) {
103 | return 'the component in the original position';
104 | }
105 | return 'the component is released';
106 | }}
107 | ;
108 | ```
109 |
110 | ### Freezing
111 |
112 | You can provide a function in the `shouldFreeze` prop which will tell the component to temporarily stop updating during prop and state changes, as well as ignore scroll and resize events. This function should return a boolean indicating whether the component should currently be frozen.
113 |
114 | ## Development
115 |
116 | ### Linting
117 |
118 | ```bash
119 | npm run lint
120 | ```
121 |
122 | ### Unit Test
123 |
124 | ```bash
125 | npm test
126 | ```
127 |
128 | ### Functional Test
129 |
130 | ```bash
131 | npm run func:local
132 | ```
133 |
134 | ## License
135 |
136 | This software is free to use under the BSD license.
137 | See the [LICENSE file](./LICENSE.md) for license text and copyright information.
138 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-react"],
3 | "env": {
4 | "commonjs": {
5 | "plugins": ["add-module-exports"],
6 | "presets": [
7 | [
8 | "@babel/preset-env",
9 | {
10 | "corejs": 3,
11 | "useBuiltIns": "usage",
12 | "modules": "commonjs"
13 | }
14 | ]
15 | ]
16 | },
17 | "es": {
18 | "presets": [
19 | [
20 | "@babel/preset-env",
21 | {
22 | "corejs": 3,
23 | "useBuiltIns": "usage",
24 | "modules": false
25 | }
26 | ]
27 | ]
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js';
2 | import globals from 'globals';
3 | import reactPlugin from 'eslint-plugin-react';
4 |
5 | export default [
6 | js.configs.recommended,
7 | reactPlugin.configs.flat.recommended,
8 | {
9 | ignores: [
10 | '.idea/',
11 | '*-debug.log',
12 | 'artifacts/',
13 | 'build/',
14 | 'components-dist/',
15 | 'configs/atomizer.json',
16 | 'dist/',
17 | 'node_modules/',
18 | 'npm-*.log',
19 | 'protractor-batch-artifacts/',
20 | 'results/',
21 | 'tests/functional/bootstrap.js',
22 | ],
23 | },
24 | {
25 | files: ['**/*.{js,mjs,cjs,jsx,mjsx,ts,tsx,mtsx}'],
26 | languageOptions: {
27 | ...reactPlugin.configs.flat.recommended.languageOptions,
28 | ecmaVersion: 2024,
29 | globals: {
30 | ...globals.browser,
31 | ...globals.jest,
32 | ...globals.mocha,
33 | ...globals.node,
34 | ...globals.protractor,
35 | },
36 | parserOptions: {
37 | ecmaFeatures: {
38 | jsx: true,
39 | },
40 | sourceType: 'module',
41 | },
42 | },
43 | plugins: {
44 | react: reactPlugin,
45 | },
46 | rules: {
47 | indent: [2, 4, { SwitchCase: 1 }],
48 | quotes: [0, 'single'],
49 | 'dot-notation': [2, { allowKeywords: false }],
50 | 'no-console': 0,
51 | 'no-prototype-builtins': 0,
52 | 'no-unexpected-multiline': 0,
53 | },
54 | settings: {
55 | react: {
56 | version: 'detect',
57 | },
58 | },
59 | },
60 | ];
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-stickynode",
3 | "version": "5.0.2",
4 | "description": "A performant and comprehensive React sticky component",
5 | "main": "./dist/cjs/Sticky.js",
6 | "module": "./dist/es/Sticky.js",
7 | "scripts": {
8 | "build": "npm run clean && npm run build:commonjs && npm run build:es",
9 | "build:commonjs": "babel --env-name commonjs src -d dist/cjs",
10 | "build:es": "babel --env-name es src -d dist/es",
11 | "build:babel": "babel tests/functional --out-dir tests/functional/dist/",
12 | "build:copy": "cp tests/functional/*.html tests/functional/dist/",
13 | "build:css": "atomizer -o tests/functional/dist/atomic.css ./tests/functional/dist/*-functional.js",
14 | "build:webpack": "webpack",
15 | "clean": "rm -rf dist",
16 | "format": "prettier --write .",
17 | "format:check": "prettier --check .",
18 | "func": "wdio",
19 | "func:build": "rm -rf tests/functional/dist && npm run build:babel && npm run build:css && npm run build:copy && npm run build:webpack",
20 | "func:local": "npm run func:build && wdio",
21 | "lint": "eslint . --fix && npm run format:check",
22 | "prepublish": "npm run build",
23 | "prestart": "npm run prefunc",
24 | "test": "jest --coverage tests/unit"
25 | },
26 | "repository": {
27 | "type": "git",
28 | "url": "https://github.com/yahoo/react-stickynode"
29 | },
30 | "keywords": [
31 | "Sticky",
32 | "React"
33 | ],
34 | "author": {
35 | "name": "Hank Hsiao",
36 | "email": "hankxiao@yahoo-inc.com"
37 | },
38 | "contributors": [
39 | {
40 | "name": "Steve Carlson",
41 | "email": "yasteve@yahoo-inc.com"
42 | }
43 | ],
44 | "engines": {
45 | "node": ">=16",
46 | "npm": ">=8.4"
47 | },
48 | "dependencies": {
49 | "classnames": "^2.0.0",
50 | "prop-types": "^15.6.0",
51 | "shallowequal": "^1.0.0",
52 | "subscribe-ui-event": "^3.0.0"
53 | },
54 | "devDependencies": {
55 | "@babel/cli": "^7.8.4",
56 | "@babel/core": "^7.9.6",
57 | "@babel/preset-env": "^7.9.6",
58 | "@babel/preset-react": "^7.9.4",
59 | "@babel/register": "^7.9.0",
60 | "@testing-library/jest-dom": "^6.6.3",
61 | "@testing-library/react": "^16.1.0",
62 | "@wdio/cli": "^9.4.1",
63 | "@wdio/local-runner": "^9.4.1",
64 | "@wdio/mocha-framework": "^9.2.8",
65 | "@wdio/sauce-service": "^9.4.1",
66 | "@wdio/spec-reporter": "^9.2.14",
67 | "@wdio/static-server-service": "^9.2.2",
68 | "atomizer": "^3.9.1",
69 | "babel-jest": "^29.0.0",
70 | "babel-plugin-add-module-exports": "^1.0.4",
71 | "chromedriver": "^134.0.0",
72 | "core-js": "^3.39.0",
73 | "eslint": "^9.0.0",
74 | "eslint-plugin-react": "^7.37.1",
75 | "gh-pages": "^6.0.0",
76 | "globals": "^16.0.0",
77 | "jest": "^29.0.0",
78 | "jest-environment-jsdom": "^29.0.0",
79 | "mocha": "^11.0.1",
80 | "pre-commit": "^1.0.0",
81 | "prettier": "^3.4.2",
82 | "react": "^19.0.0",
83 | "react-dom": "^19.0.0",
84 | "react-test-renderer": "^19.0.0",
85 | "wdio-chromedriver-service": "^8.0.0",
86 | "webpack": "^5.41.1",
87 | "webpack-cli": "^6.0.1",
88 | "webpack-dev-server": "^5.0.4"
89 | },
90 | "peerDependencies": {
91 | "react": "^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
92 | "react-dom": "^0.14.2 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
93 | },
94 | "precommit": [
95 | "lint",
96 | "test"
97 | ],
98 | "license": "BSD-3-Clause",
99 | "browserslist": "> 0.25%, not dead",
100 | "prettier": {
101 | "singleQuote": true,
102 | "tabWidth": 4
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/Sticky.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2015, Yahoo! Inc.
3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
4 | */
5 |
6 | 'use strict';
7 |
8 | import React, { Component } from 'react';
9 | import PropTypes from 'prop-types';
10 |
11 | import { subscribe } from 'subscribe-ui-event';
12 | import classNames from 'classnames';
13 | import shallowEqual from 'shallowequal';
14 |
15 | // constants
16 | const STATUS_ORIGINAL = 0; // The default status, locating at the original position.
17 | const STATUS_RELEASED = 1; // The released status, locating at somewhere on document but not default one.
18 | const STATUS_FIXED = 2; // The sticky status, locating fixed to the top or the bottom of screen.
19 |
20 | let TRANSFORM_PROP = 'transform';
21 |
22 | // global variable for all instances
23 | let doc;
24 | let docBody;
25 | let docEl;
26 | let canEnableTransforms = true; // Use transform by default, so no Sticky on lower-end browser when no Modernizr
27 | let M;
28 | let scrollDelta = 0;
29 | let win;
30 | let winHeight = -1;
31 |
32 | class Sticky extends Component {
33 | constructor(props, context) {
34 | super(props, context);
35 | this.handleResize = this.handleResize.bind(this);
36 | this.handleScroll = this.handleScroll.bind(this);
37 | this.handleScrollStart = this.handleScrollStart.bind(this);
38 | this.delta = 0;
39 | this.stickyTop = 0;
40 | this.stickyBottom = 0;
41 | this.frozen = false;
42 | this.skipNextScrollEvent = false;
43 | this.scrollTop = -1;
44 |
45 | this.bottomBoundaryTarget;
46 | this.topTarget;
47 | this.subscribers;
48 |
49 | this.state = {
50 | top: 0, // A top offset from viewport top where Sticky sticks to when scrolling up
51 | bottom: 0, // A bottom offset from viewport top where Sticky sticks to when scrolling down
52 | width: 0, // Sticky width
53 | height: 0, // Sticky height
54 | x: 0, // The original x of Sticky
55 | y: 0, // The original y of Sticky
56 | topBoundary: 0, // The top boundary on document
57 | bottomBoundary: Infinity, // The bottom boundary on document
58 | status: STATUS_ORIGINAL, // The Sticky status
59 | pos: 0, // Real y-axis offset for rendering position-fixed and position-relative
60 | activated: false, // once browser info is available after mounted, it becomes true to avoid checksum error
61 | };
62 | }
63 |
64 | getTargetHeight(target) {
65 | return (target && target.offsetHeight) || 0;
66 | }
67 |
68 | getTopPosition(top) {
69 | // a top argument can be provided to override reading from the props
70 | top = top || this.props.top || 0;
71 | if (typeof top === 'string') {
72 | if (!this.topTarget) {
73 | this.topTarget = doc.querySelector(top);
74 | }
75 | top = this.getTargetHeight(this.topTarget);
76 | }
77 | return top;
78 | }
79 |
80 | getTargetBottom(target) {
81 | if (!target) {
82 | return -1;
83 | }
84 | const rect = target.getBoundingClientRect();
85 | return this.scrollTop + rect.bottom;
86 | }
87 |
88 | getBottomBoundary(bottomBoundary) {
89 | // a bottomBoundary can be provided to avoid reading from the props
90 | let boundary = bottomBoundary || this.props.bottomBoundary;
91 |
92 | // TODO, bottomBoundary was an object, depricate it later.
93 | if (typeof boundary === 'object') {
94 | boundary = boundary.value || boundary.target || 0;
95 | }
96 |
97 | if (typeof boundary === 'string') {
98 | if (!this.bottomBoundaryTarget) {
99 | this.bottomBoundaryTarget = doc.querySelector(boundary);
100 | }
101 | boundary = this.getTargetBottom(this.bottomBoundaryTarget);
102 | }
103 | return boundary && boundary > 0 ? boundary : Infinity;
104 | }
105 |
106 | reset() {
107 | this.setState({
108 | status: STATUS_ORIGINAL,
109 | pos: 0,
110 | });
111 | }
112 |
113 | release(pos) {
114 | this.setState({
115 | status: STATUS_RELEASED,
116 | pos: pos - this.state.y,
117 | });
118 | }
119 |
120 | fix(pos) {
121 | this.setState({
122 | status: STATUS_FIXED,
123 | pos: pos,
124 | });
125 | }
126 |
127 | /**
128 | * Update the initial position, width, and height. It should update whenever children change.
129 | * @param {Object} options optional top and bottomBoundary new values
130 | */
131 | updateInitialDimension(options) {
132 | options = options || {};
133 |
134 | if (!this.outerElement || !this.innerElement) {
135 | return;
136 | }
137 |
138 | const outerRect = this.outerElement.getBoundingClientRect();
139 | const innerRect = this.innerElement.getBoundingClientRect();
140 |
141 | const width = outerRect.width || outerRect.right - outerRect.left;
142 | const height = innerRect.height || innerRect.bottom - innerRect.top;
143 | const outerY = outerRect.top + this.scrollTop;
144 |
145 | this.setState({
146 | top: this.getTopPosition(options.top),
147 | bottom: Math.min(this.state.top + height, winHeight),
148 | width,
149 | height,
150 | x: outerRect.left,
151 | y: outerY,
152 | bottomBoundary: this.getBottomBoundary(options.bottomBoundary),
153 | topBoundary: outerY,
154 | });
155 | }
156 |
157 | handleResize(e, ae) {
158 | if (this.props.shouldFreeze()) {
159 | return;
160 | }
161 |
162 | winHeight = ae.resize.height;
163 | this.updateInitialDimension();
164 | this.update();
165 | }
166 |
167 | handleScrollStart(e, ae) {
168 | this.frozen = this.props.shouldFreeze();
169 |
170 | if (this.frozen) {
171 | return;
172 | }
173 |
174 | if (this.scrollTop === ae.scroll.top) {
175 | // Scroll position hasn't changed,
176 | // do nothing
177 | this.skipNextScrollEvent = true;
178 | } else {
179 | this.scrollTop = ae.scroll.top;
180 | this.updateInitialDimension();
181 | }
182 | }
183 |
184 | handleScroll(e, ae) {
185 | // Scroll doesn't need to be handled
186 | if (this.skipNextScrollEvent) {
187 | this.skipNextScrollEvent = false;
188 | return;
189 | }
190 |
191 | scrollDelta = ae.scroll.delta;
192 | this.scrollTop = ae.scroll.top;
193 | this.update();
194 | }
195 |
196 | /**
197 | * Update Sticky position.
198 | */
199 | update() {
200 | var disabled =
201 | !this.props.enabled ||
202 | this.state.bottomBoundary - this.state.topBoundary <=
203 | this.state.height ||
204 | (this.state.width === 0 && this.state.height === 0);
205 |
206 | if (disabled) {
207 | if (this.state.status !== STATUS_ORIGINAL) {
208 | this.reset();
209 | }
210 | return;
211 | }
212 |
213 | var delta = scrollDelta;
214 | // "top" and "bottom" are the positions that this.state.top and this.state.bottom project
215 | // on document from viewport.
216 | var top = this.scrollTop + this.state.top;
217 | var bottom = this.scrollTop + this.state.bottom;
218 |
219 | // There are 2 principles to make sure Sticky won't get wrong so much:
220 | // 1. Reset Sticky to the original postion when "top" <= topBoundary
221 | // 2. Release Sticky to the bottom boundary when "bottom" >= bottomBoundary
222 | if (top <= this.state.topBoundary) {
223 | // #1
224 | this.reset();
225 | } else if (bottom >= this.state.bottomBoundary) {
226 | // #2
227 | this.stickyBottom = this.state.bottomBoundary;
228 | this.stickyTop = this.stickyBottom - this.state.height;
229 | this.release(this.stickyTop);
230 | } else {
231 | if (this.state.height > winHeight - this.state.top) {
232 | // In this case, Sticky is higher then viewport minus top offset
233 | switch (this.state.status) {
234 | case STATUS_ORIGINAL:
235 | this.release(this.state.y);
236 | this.stickyTop = this.state.y;
237 | this.stickyBottom = this.stickyTop + this.state.height;
238 | // Commentting out "break" is on purpose, because there is a chance to transit to FIXED
239 | // from ORIGINAL when calling window.scrollTo().
240 | // break;
241 | /* eslint-disable-next-line no-fallthrough */
242 | case STATUS_RELEASED:
243 | // If "top" and "bottom" are inbetween stickyTop and stickyBottom, then Sticky is in
244 | // RELEASE status. Otherwise, it changes to FIXED status, and its bottom sticks to
245 | // viewport bottom when scrolling down, or its top sticks to viewport top when scrolling up.
246 | this.stickyBottom = this.stickyTop + this.state.height;
247 | if (delta > 0 && bottom > this.stickyBottom) {
248 | this.fix(this.state.bottom - this.state.height);
249 | } else if (delta < 0 && top < this.stickyTop) {
250 | this.fix(this.state.top);
251 | }
252 | break;
253 | case STATUS_FIXED:
254 | var toRelease = true;
255 | var pos = this.state.pos;
256 | var height = this.state.height;
257 | // In regular cases, when Sticky is in FIXED status,
258 | // 1. it's top will stick to the screen top,
259 | // 2. it's bottom will stick to the screen bottom,
260 | // 3. if not the cases above, then it's height gets changed
261 | if (delta > 0 && pos === this.state.top) {
262 | // case 1, and scrolling down
263 | this.stickyTop = top - delta;
264 | this.stickyBottom = this.stickyTop + height;
265 | } else if (
266 | delta < 0 &&
267 | pos === this.state.bottom - height
268 | ) {
269 | // case 2, and scrolling up
270 | this.stickyBottom = bottom - delta;
271 | this.stickyTop = this.stickyBottom - height;
272 | } else if (
273 | pos !== this.state.bottom - height &&
274 | pos !== this.state.top
275 | ) {
276 | // case 3
277 | // This case only happens when Sticky's bottom sticks to the screen bottom and
278 | // its height gets changed. Sticky should be in RELEASE status and update its
279 | // sticky bottom by calculating how much height it changed.
280 | const deltaHeight =
281 | pos + height - this.state.bottom;
282 | this.stickyBottom = bottom - delta + deltaHeight;
283 | this.stickyTop = this.stickyBottom - height;
284 | } else {
285 | toRelease = false;
286 | }
287 |
288 | if (toRelease) {
289 | this.release(this.stickyTop);
290 | }
291 | break;
292 | }
293 | } else {
294 | // In this case, Sticky is shorter then viewport minus top offset
295 | // and will always fix to the top offset of viewport
296 | this.fix(this.state.top);
297 | }
298 | }
299 | this.delta = delta;
300 | }
301 |
302 | componentDidUpdate(prevProps, prevState) {
303 | if (
304 | prevState.status !== this.state.status &&
305 | this.props.onStateChange
306 | ) {
307 | this.props.onStateChange({ status: this.state.status });
308 | }
309 |
310 | // check if we are up-to-date, is triggered in case of scroll restoration
311 | if (this.state.top !== prevState.top) {
312 | this.updateInitialDimension();
313 | this.update();
314 | }
315 |
316 | const arePropsChanged = !shallowEqual(this.props, prevProps);
317 | if (arePropsChanged) {
318 | // if the props for enabling are toggled, then trigger the update or reset depending on the current props
319 | if (prevProps.enabled !== this.props.enabled) {
320 | if (this.props.enabled) {
321 | this.setState({ activated: true }, () => {
322 | this.updateInitialDimension();
323 | this.update();
324 | });
325 | } else {
326 | this.setState({ activated: false }, () => {
327 | this.reset();
328 | });
329 | }
330 | }
331 | // if the top or bottomBoundary props were changed, then trigger the update
332 | else if (
333 | prevProps.top !== this.props.top ||
334 | prevProps.bottomBoundary !== this.props.bottomBoundary
335 | ) {
336 | this.updateInitialDimension();
337 | this.update();
338 | }
339 | }
340 | }
341 |
342 | componentWillUnmount() {
343 | const subscribers = this.subscribers || [];
344 | for (var i = subscribers.length - 1; i >= 0; i--) {
345 | this.subscribers[i].unsubscribe();
346 | }
347 | }
348 |
349 | componentDidMount() {
350 | // Only initialize the globals if this is the first
351 | // time this component type has been mounted
352 | if (!win) {
353 | win = window;
354 | doc = document;
355 | docEl = doc.documentElement;
356 | docBody = doc.body;
357 | winHeight = win.innerHeight || docEl.clientHeight;
358 | M = window.Modernizr;
359 | // No Sticky on lower-end browser when no Modernizr
360 | if (M && M.prefixed) {
361 | canEnableTransforms = M.csstransforms3d;
362 | TRANSFORM_PROP = M.prefixed('transform');
363 | }
364 | }
365 |
366 | // when mount, the scrollTop is not necessary on the top
367 | this.scrollTop = docBody.scrollTop + docEl.scrollTop;
368 |
369 | if (this.props.enabled) {
370 | this.setState({ activated: true });
371 | this.updateInitialDimension();
372 | this.update();
373 | }
374 | // bind the listeners regardless if initially enabled - allows the component to toggle sticky functionality
375 | this.subscribers = [
376 | subscribe('scrollStart', this.handleScrollStart.bind(this), {
377 | useRAF: true,
378 | }),
379 | subscribe('scroll', this.handleScroll.bind(this), {
380 | useRAF: true,
381 | enableScrollInfo: true,
382 | }),
383 | subscribe('resize', this.handleResize.bind(this), {
384 | enableResizeInfo: true,
385 | }),
386 | ];
387 | }
388 |
389 | translate(style, pos) {
390 | const enableTransforms =
391 | canEnableTransforms && this.props.enableTransforms;
392 | if (enableTransforms && this.state.activated) {
393 | style[TRANSFORM_PROP] =
394 | 'translate3d(0,' + Math.round(pos) + 'px,0)';
395 | } else {
396 | style.top = pos + 'px';
397 | }
398 | }
399 |
400 | shouldComponentUpdate(nextProps, nextState) {
401 | return (
402 | !this.props.shouldFreeze() &&
403 | !(
404 | shallowEqual(this.props, nextProps) &&
405 | shallowEqual(this.state, nextState)
406 | )
407 | );
408 | }
409 |
410 | render() {
411 | // TODO, "overflow: auto" prevents collapse, need a good way to get children height
412 | const innerStyle = {
413 | position: this.state.status === STATUS_FIXED ? 'fixed' : 'relative',
414 | top: this.state.status === STATUS_FIXED ? '0px' : '',
415 | zIndex: this.props.innerZ,
416 | };
417 | const outerStyle = {};
418 |
419 | // always use translate3d to enhance the performance
420 | this.translate(innerStyle, this.state.pos);
421 | if (this.state.status !== STATUS_ORIGINAL) {
422 | innerStyle.width = this.state.width + 'px';
423 | outerStyle.height = this.state.height + 'px';
424 | }
425 |
426 | const outerClasses = classNames(
427 | 'sticky-outer-wrapper',
428 | this.props.className,
429 | {
430 | [this.props.activeClass]: this.state.status === STATUS_FIXED,
431 | [this.props.releasedClass]:
432 | this.state.status === STATUS_RELEASED,
433 | },
434 | );
435 |
436 | const innerClasses = classNames(
437 | 'sticky-inner-wrapper',
438 | this.props.innerClass,
439 | {
440 | [this.props.innerActiveClass]:
441 | this.state.status === STATUS_FIXED,
442 | },
443 | );
444 |
445 | const children = this.props.children;
446 |
447 | return (
448 |
{
450 | this.outerElement = outer;
451 | }}
452 | className={outerClasses}
453 | style={outerStyle}
454 | >
455 |
{
457 | this.innerElement = inner;
458 | }}
459 | className={innerClasses}
460 | style={innerStyle}
461 | >
462 | {typeof children === 'function'
463 | ? children({ status: this.state.status })
464 | : children}
465 |
466 |
467 | );
468 | }
469 | }
470 |
471 | Sticky.displayName = 'Sticky';
472 |
473 | Sticky.defaultProps = {
474 | shouldFreeze: function () {
475 | return false;
476 | },
477 | enabled: true,
478 | top: 0,
479 | bottomBoundary: 0,
480 | enableTransforms: true,
481 | activeClass: 'active',
482 | releasedClass: 'released',
483 | onStateChange: null,
484 | innerClass: '',
485 | innerActiveClass: '',
486 | };
487 |
488 | /**
489 | * @param {Bool} enabled A switch to enable or disable Sticky.
490 | * @param {String/Number} top A top offset px for Sticky. Could be a selector representing a node
491 | * whose height should serve as the top offset.
492 | * @param {String/Number} bottomBoundary A bottom boundary px on document where Sticky will stop.
493 | * Could be a selector representing a node whose bottom should serve as the bottom boudary.
494 | */
495 | Sticky.propTypes = {
496 | children: PropTypes.element,
497 | enabled: PropTypes.bool,
498 | top: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
499 | bottomBoundary: PropTypes.oneOfType([
500 | PropTypes.object, // TODO, may remove
501 | PropTypes.string,
502 | PropTypes.number,
503 | ]),
504 | enableTransforms: PropTypes.bool,
505 | activeClass: PropTypes.string,
506 | releasedClass: PropTypes.string,
507 | innerClass: PropTypes.string,
508 | innerActiveClass: PropTypes.string,
509 | className: PropTypes.string,
510 | onStateChange: PropTypes.func,
511 | shouldFreeze: PropTypes.func,
512 | innerZ: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
513 | };
514 |
515 | Sticky.STATUS_ORIGINAL = STATUS_ORIGINAL;
516 | Sticky.STATUS_RELEASED = STATUS_RELEASED;
517 | Sticky.STATUS_FIXED = STATUS_FIXED;
518 |
519 | export default Sticky;
520 |
--------------------------------------------------------------------------------
/tests/functional/bootstrap.js:
--------------------------------------------------------------------------------
1 | /*global window */
2 | window.React = require('react');
3 | window.ReactDOM = require('react-dom');
4 | window.StickyDemo = require('./sticky-functional.js');
5 |
--------------------------------------------------------------------------------
/tests/functional/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Sticky Functional Test
5 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/tests/functional/sticky-functional.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/react-in-jsx-scope,react/prop-types */
2 | const classNames = require('classnames');
3 | const Sticky = require('../../../dist/cjs/Sticky');
4 |
5 | let content = [];
6 | for (let i = 0; i < 10; i++) {
7 | content.push(
8 | '',
9 | 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. ' +
10 | "Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, " +
11 | 'when an unknown printer took a galley of type and scrambled it to make a type specimen book. ' +
12 | 'It has survived not only five centuries, but also the leap into electronic typesetting, ' +
13 | 'remaining essentially unchanged. ' +
14 | 'It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, ' +
15 | 'and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.',
16 | '
',
17 | );
18 | }
19 | content = content.join('');
20 |
21 | const TestText = ({ className, id }) => {
22 | return (
23 |
28 | );
29 | };
30 |
31 | const StickyDemo = () => {
32 | return (
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
45 |
46 |
47 |
48 |
49 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | module.exports = StickyDemo;
63 |
--------------------------------------------------------------------------------
/tests/functional/sticky.spec.js:
--------------------------------------------------------------------------------
1 | const { FUNC_PATH } = process.env;
2 |
3 | // utils
4 | const innerHeight = () => browser.execute(() => window.innerHeight);
5 | const scrollTo = (x, y) => browser.execute(`window.scrollTo(${x}, ${y});`);
6 | // wdio workaround https://github.com/webdriverio/webdriverio/issues/3608
7 | const getRect = async (selector) =>
8 | browser.execute((el) => el.getBoundingClientRect(), await $(selector));
9 |
10 | describe('Sticky', () => {
11 | beforeEach(async () => {
12 | // FUNC_PATH set by CI to test github pages
13 | const url = FUNC_PATH ? `/react-stickynode/${FUNC_PATH}` : '/';
14 | await browser.url(url);
15 | });
16 |
17 | it('Sticky 1 should stick to the top', async () => {
18 | await scrollTo(0, 500);
19 | expect((await getRect('#sticky-1')).top).toEqual(0, 'sticky-1');
20 | });
21 |
22 | it('Sticky 2 should not stick to the top', async () => {
23 | await scrollTo(0, 500);
24 | expect((await getRect('#sticky-2')).top).toBeLessThan(0, 'sticky-2');
25 |
26 | await scrollTo(0, 1200);
27 | expect((await getRect('#sticky-2')).bottom).toBeLessThan(
28 | await innerHeight(),
29 | 'sticky-2',
30 | );
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/tests/helpers/rAF.js:
--------------------------------------------------------------------------------
1 | global.requestAnimationFrame = function (callback) {
2 | setTimeout(callback, 0);
3 | };
4 |
--------------------------------------------------------------------------------
/tests/unit/Sticky.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 | /**
5 | * Copyright 2015, Yahoo! Inc.
6 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
7 | */
8 |
9 | 'use strict';
10 |
11 | process.env.NODE_ENV = 'development';
12 |
13 | require('@testing-library/jest-dom');
14 | const ee = require('subscribe-ui-event/dist/globalVars').EE;
15 | const { act, render, screen } = require('@testing-library/react');
16 | const React = require('react');
17 | const Sticky = require('../../dist/cjs/Sticky');
18 |
19 | const STICKY_CLASS_OUTER = 'sticky-outer-wrapper';
20 | const STICKY_CLASS_INNER = 'sticky-inner-wrapper';
21 |
22 | let ae;
23 | let inner;
24 | let outer;
25 |
26 | let STICKY_WIDTH = 100;
27 | let STICKY_HEIGHT = 300;
28 | let STICKY_TOP = 0;
29 | let SCROLL_POS = 0;
30 |
31 | ae = {
32 | scroll: {
33 | top: SCROLL_POS,
34 | delta: 0,
35 | },
36 | resize: {
37 | height: 0,
38 | },
39 | };
40 |
41 | window.HTMLElement.prototype.getBoundingClientRect = function () {
42 | return {
43 | bottom: STICKY_TOP - SCROLL_POS + STICKY_HEIGHT,
44 | height: STICKY_HEIGHT,
45 | left: 0,
46 | right: STICKY_WIDTH,
47 | top: STICKY_TOP - SCROLL_POS,
48 | width: STICKY_WIDTH,
49 | };
50 | };
51 |
52 | document.querySelector = function () {
53 | return {
54 | offsetHeight: 20,
55 | getBoundingClientRect: function () {
56 | return {
57 | bottom: 400 - SCROLL_POS,
58 | };
59 | },
60 | };
61 | };
62 |
63 | Object.defineProperties(window.HTMLElement.prototype, {
64 | offsetHeight: {
65 | get: function () {
66 | return STICKY_HEIGHT;
67 | },
68 | },
69 | offsetWidth: {
70 | get: function () {
71 | return STICKY_WIDTH;
72 | },
73 | },
74 | });
75 |
76 | window.scrollTo = function (x, y) {
77 | SCROLL_POS = y;
78 | ae.scroll.delta = SCROLL_POS - ae.scroll.top;
79 | ae.scroll.top = SCROLL_POS;
80 | ee.emit('scrollStart:raf', {}, ae);
81 | ee.emit('scroll:raf', {}, ae);
82 | };
83 |
84 | window.resizeTo = function (x, y) {
85 | ae.resize.height = y;
86 | ee.emit('resize:50', {}, ae);
87 | };
88 |
89 | function shouldBeFixedAt(t, pos) {
90 | const style = t._style || t.style;
91 | expect(style.width).toBe('100px');
92 | expect(style.transform).toBe('translate3d(0,' + pos + 'px,0)');
93 | expect(style.position).toBe('fixed');
94 | expect(style.top).toBe('0px');
95 | }
96 |
97 | function shouldBeReleasedAt(t, pos) {
98 | const style = t._style || t.style;
99 | expect(style.width).toBe('100px');
100 | expect(style.transform).toBe('translate3d(0,' + pos + 'px,0)');
101 | expect(style.position).toBe('relative');
102 | expect(style.top).toBe('');
103 | }
104 |
105 | function shouldBeReset(t) {
106 | const style = t._style || t.style;
107 | expect(style.transform).toBe('translate3d(0,0px,0)');
108 | expect(style.position).toBe('relative');
109 | expect(style.top).toBe('');
110 | }
111 |
112 | function checkTransform3d(inner) {
113 | const style = inner._style || inner.style;
114 | expect(style.transform).toContain('translate3d');
115 | }
116 |
117 | describe('Sticky', () => {
118 | beforeEach(() => {
119 | STICKY_WIDTH = 100;
120 | STICKY_HEIGHT = 300;
121 | STICKY_TOP = 0;
122 | SCROLL_POS = 0;
123 | ae.scroll.top = 0;
124 | ae.scroll.delta = 0;
125 | });
126 |
127 | afterEach(() => {
128 | // jsx.unmountComponent();
129 | });
130 |
131 | test('should work as expected (short Sticky)', () => {
132 | const { container } = render( );
133 |
134 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
135 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
136 |
137 | expect(outer.className).toContain(STICKY_CLASS_OUTER);
138 | expect(inner.className).toContain(STICKY_CLASS_INNER);
139 |
140 | // should always have translate3d
141 | checkTransform3d(inner);
142 |
143 | // Scroll down to 10px, and Sticky should fix
144 | act(() => {
145 | window.scrollTo(0, 10);
146 | });
147 | shouldBeFixedAt(inner, 0);
148 | expect(outer.className).toContain('active');
149 | expect(outer.className).not.toContain('released');
150 |
151 | // Scroll up to 0px, and Sticky should reset
152 | act(() => {
153 | window.scrollTo(0, 0);
154 | });
155 | shouldBeReset(inner);
156 | expect(outer.className).not.toContain('active');
157 | expect(outer.className).not.toContain('released');
158 | });
159 |
160 | test('should call the callback on state change', () => {
161 | const callback = jest.fn();
162 | render( );
163 |
164 | expect(callback).not.toHaveBeenCalled();
165 |
166 | // Scroll down to 10px, and status should change to FIXED
167 | act(() => {
168 | window.scrollTo(0, 10);
169 | });
170 | expect(callback).toHaveBeenCalledWith({ status: Sticky.STATUS_FIXED });
171 |
172 | // Scroll up to 0px, and Sticky should reset
173 | act(() => {
174 | window.scrollTo(0, 0);
175 | });
176 | expect(callback).toHaveBeenCalledTimes(2);
177 | expect(callback).toHaveBeenCalledWith({ status: Sticky.STATUS_FIXED });
178 | });
179 |
180 | test('should call the children function on state change', () => {
181 | const childrenStub = jest.fn().mockReturnValue(null);
182 | expect(childrenStub).not.toHaveBeenCalled();
183 |
184 | render({childrenStub} );
185 |
186 | // Scroll down to 10px, and status should change to FIXED
187 | act(() => {
188 | window.scrollTo(0, 10);
189 | });
190 | expect(childrenStub).toHaveBeenCalledWith({
191 | status: Sticky.STATUS_FIXED,
192 | });
193 |
194 | // Scroll up to 0px, and Sticky should reset
195 | act(() => {
196 | window.scrollTo(0, 0);
197 | });
198 | expect(childrenStub).toHaveBeenCalledWith({
199 | status: Sticky.STATUS_FIXED,
200 | });
201 | });
202 |
203 | test('should work as expected (long Sticky)', () => {
204 | STICKY_HEIGHT = 1200;
205 | const { container } = render( );
206 |
207 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
208 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
209 |
210 | expect(outer.className).toContain(STICKY_CLASS_OUTER);
211 | expect(inner.className).toContain(STICKY_CLASS_INNER);
212 |
213 | // should always have translate3d
214 | checkTransform3d(inner);
215 |
216 | // Scroll down to 10px, and Sticky should stay as it was
217 | act(() => {
218 | window.scrollTo(0, 10);
219 | });
220 | shouldBeReleasedAt(inner, 0);
221 | expect(outer.className).not.toContain('active');
222 |
223 | // Scroll down to 1500px, and Sticky should fix to the bottom
224 | act(() => {
225 | window.scrollTo(0, 1500);
226 | });
227 | shouldBeFixedAt(inner, -432);
228 | expect(outer.className).toContain('active');
229 | expect(outer.className).not.toContain('released');
230 |
231 | // Scroll up to 1300px, and Sticky should release
232 | act(() => {
233 | window.scrollTo(0, 1300);
234 | });
235 | shouldBeReleasedAt(inner, 1068);
236 | expect(outer.className).not.toContain('active');
237 | expect(outer.className).toContain('released');
238 |
239 | // Scroll down to 1350px, and Sticky should release as it was
240 | act(() => {
241 | window.scrollTo(0, 1350);
242 | });
243 | shouldBeReleasedAt(inner, 1068);
244 | expect(outer.className).not.toContain('active');
245 | expect(outer.className).toContain('released');
246 |
247 | // Scroll up to 10px, and Sticky should fix
248 | act(() => {
249 | window.scrollTo(0, 10);
250 | });
251 | shouldBeFixedAt(inner, 0);
252 | expect(outer.className).toContain('active');
253 | expect(outer.className).not.toContain('released');
254 |
255 | // Scroll down to 20px, and Sticky should release
256 | act(() => {
257 | window.scrollTo(0, 20);
258 | });
259 | shouldBeReleasedAt(inner, 10);
260 | expect(outer.className).not.toContain('active');
261 | expect(outer.className).toContain('released');
262 | });
263 |
264 | test('should work as expected with original postion 20px from top (short Sticky)', () => {
265 | STICKY_TOP = 20;
266 | const { container } = render( );
267 |
268 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
269 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
270 |
271 | expect(outer.className).toContain(STICKY_CLASS_OUTER);
272 | expect(inner.className).toContain(STICKY_CLASS_INNER);
273 |
274 | // should always have translate3d
275 | checkTransform3d(inner);
276 |
277 | // Scroll down to 10px, and Sticky should stay
278 | act(() => {
279 | window.scrollTo(0, 10);
280 | });
281 | shouldBeReset(inner);
282 | expect(outer.className).not.toContain('active');
283 | expect(outer.className).not.toContain('released');
284 |
285 | // Scroll down to 50px, and Sticky should fix
286 | act(() => {
287 | window.scrollTo(0, 50);
288 | });
289 | shouldBeFixedAt(inner, 0);
290 | expect(outer.className).toContain('active');
291 | expect(outer.className).not.toContain('released');
292 | });
293 |
294 | test('should work as expected with original top 20px and 400px bottom boundary (short Sticky)', () => {
295 | STICKY_TOP = 20;
296 | const { container } = render( );
297 |
298 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
299 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
300 |
301 | expect(outer.className).toContain(STICKY_CLASS_OUTER);
302 | expect(inner.className).toContain(STICKY_CLASS_INNER);
303 |
304 | // should always have translate3d
305 | checkTransform3d(inner);
306 |
307 | // Scroll down to 10px, and Sticky should stay
308 | act(() => {
309 | window.scrollTo(0, 10);
310 | });
311 | shouldBeReset(inner);
312 | expect(outer.className).not.toContain('active');
313 | expect(outer.className).not.toContain('released');
314 |
315 | // Scroll down to 50px, and Sticky should fix
316 | act(() => {
317 | window.scrollTo(0, 50);
318 | });
319 | shouldBeFixedAt(inner, 0);
320 | expect(outer.className).toContain('active');
321 | expect(outer.className).not.toContain('released');
322 |
323 | // Scroll down to 150px, and Sticky should release
324 | act(() => {
325 | window.scrollTo(0, 150);
326 | });
327 | shouldBeReleasedAt(inner, 80);
328 | expect(outer.className).not.toContain('active');
329 | expect(outer.className).toContain('released');
330 | });
331 |
332 | test('should not be sticky if bottom boundary is shorter then its height (short Sticky)', () => {
333 | const { container } = render( );
334 |
335 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
336 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
337 |
338 | expect(outer.className).toContain(STICKY_CLASS_OUTER);
339 | expect(inner.className).toContain(STICKY_CLASS_INNER);
340 |
341 | // should always have translate3d
342 | checkTransform3d(inner);
343 |
344 | // Scroll down to 10px, and Sticky should stay
345 | act(() => {
346 | window.scrollTo(0, 10);
347 | });
348 | shouldBeReset(inner);
349 | expect(outer.className).not.toContain('active');
350 | expect(outer.className).not.toContain('released');
351 |
352 | // Micic status was not 0 (STATUS_ORIGINAL), scroll down to 20px, and Sticky should stay
353 | // container.state.status = 2; // STATUS_FIXED;
354 | act(() => {
355 | window.scrollTo(0, 20);
356 | });
357 | shouldBeReset(inner);
358 | expect(outer.className).not.toContain('active');
359 | expect(outer.className).not.toContain('released');
360 | });
361 |
362 | test('should work as expected with selector bottom boundary (short Sticky)', () => {
363 | const { container } = render(
364 | ,
365 | );
366 |
367 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
368 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
369 |
370 | expect(outer.className).toContain(STICKY_CLASS_OUTER);
371 | expect(inner.className).toContain(STICKY_CLASS_INNER);
372 |
373 | // should always have translate3d
374 | checkTransform3d(inner);
375 |
376 | // Scroll down to 10px, and Sticky should fix
377 | act(() => {
378 | window.scrollTo(0, 10);
379 | });
380 | shouldBeFixedAt(inner, 20);
381 | expect(outer.className).toContain('active');
382 | expect(outer.className).not.toContain('released');
383 |
384 | // Scroll down to 50px, and Sticky should fix
385 | act(() => {
386 | window.scrollTo(0, 50);
387 | });
388 | shouldBeFixedAt(inner, 20);
389 | expect(outer.className).toContain('active');
390 | expect(outer.className).not.toContain('released');
391 |
392 | // Scroll down to 150px, and Sticky should release
393 | act(() => {
394 | window.scrollTo(0, 150);
395 | });
396 | shouldBeReleasedAt(inner, 100);
397 | expect(outer.className).not.toContain('active');
398 | expect(outer.className).toContain('released');
399 | });
400 |
401 | test('should stick to the top when window resizes larger then Sticky (long Sticky)', () => {
402 | STICKY_HEIGHT = 800;
403 | const { container } = render( );
404 |
405 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
406 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
407 |
408 | expect(outer.className).toContain(STICKY_CLASS_OUTER);
409 | expect(inner.className).toContain(STICKY_CLASS_INNER);
410 |
411 | // should always have translate3d
412 | checkTransform3d(inner);
413 |
414 | // Scroll down to 10px, and Sticky should fix
415 | act(() => {
416 | window.scrollTo(0, 10);
417 | });
418 | shouldBeReleasedAt(inner, 0);
419 | expect(outer.className).not.toContain('active');
420 | expect(outer.className).toContain('released');
421 |
422 | act(() => {
423 | window.resizeTo(0, 900);
424 | });
425 | shouldBeFixedAt(inner, 0);
426 | expect(outer.className).toContain('active');
427 | expect(outer.className).not.toContain('released');
428 |
429 | // Resize back
430 | act(() => {
431 | window.resizeTo(0, 768);
432 | });
433 | });
434 |
435 | test('should release when height gets changed (long Sticky)', () => {
436 | STICKY_HEIGHT = 1200;
437 | let { container } = render( );
438 |
439 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
440 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
441 |
442 | expect(outer.className).toContain(STICKY_CLASS_OUTER);
443 | expect(inner.className).toContain(STICKY_CLASS_INNER);
444 |
445 | // should always have translate3d
446 | checkTransform3d(inner);
447 |
448 | // Scroll down to 10px, and Sticky should stay as it was
449 | act(() => {
450 | window.scrollTo(0, 10);
451 | });
452 | shouldBeReleasedAt(inner, 0);
453 | expect(outer.className).not.toContain('active');
454 | expect(outer.className).toContain('released');
455 |
456 | // Scroll down to 1500px, and Sticky should fix to the bottom
457 | act(() => {
458 | window.scrollTo(0, 1500);
459 | });
460 | shouldBeFixedAt(inner, -432);
461 | expect(outer.className).toContain('active');
462 | expect(outer.className).not.toContain('released');
463 |
464 | // !!!! THESE TESTS FAIL, NEED TO FIGURE OUT WHY !!!!
465 |
466 | /*
467 | // Change Sticky's height
468 | STICKY_HEIGHT = 1300;
469 |
470 | // Scroll down to 1550px, and Sticky should release and stay where it was
471 | act(() => {
472 | window.scrollTo(0, 1550);
473 | });
474 | shouldBeReleasedAt(inner, 1068);
475 | expect(outer.className).not.toContain('active');
476 | expect(outer.className).toContain('released');
477 |
478 | // Scroll down to 1650px, and Sticky should become fixed again
479 | act(() => {
480 | window.scrollTo(0, 1650);
481 | });
482 | shouldBeFixedAt(inner, -532);
483 | expect(outer.className).toContain('active');
484 | expect(outer.className).not.toContain('released');
485 | */
486 | });
487 |
488 | describe('should allow the sticky functionality to be toggled off', () => {
489 | // eslint-disable-next-line react/prop-types
490 | const TestComponent = ({ enabled, boundary, name }) => {
491 | return (
492 | <>
493 |
497 | {name}
498 | {enabled &&
}
499 |
500 | >
501 | );
502 | };
503 |
504 | test('toggles the enabled prop off', () => {
505 | const { rerender } = render(
506 | React.createElement(TestComponent, {
507 | enabled: true,
508 | boundary: '',
509 | name: 'JOE',
510 | }),
511 | );
512 |
513 | // Assert initial state
514 | expect(screen.getByText('JOE')).toBeInTheDocument();
515 |
516 | // Use queryByTestId or queryById if 'boundary' refers to an element
517 | const boundaryElement =
518 | screen.queryByTestId('boundary') ||
519 | document.getElementById('boundary');
520 | expect(boundaryElement).toBeInTheDocument(); // This verifies the element exists
521 |
522 | // Toggle the enabled prop off
523 | rerender(
524 | React.createElement(TestComponent, {
525 | enabled: false,
526 | boundary: '',
527 | name: 'JOE',
528 | }),
529 | );
530 | expect(screen.getByText('JOE')).toBeInTheDocument();
531 | expect(screen.queryByText('boundary')).toBeNull(); // No boundary div when disabled
532 | });
533 |
534 | test('updates name while not enabled', () => {
535 | const { rerender } = render(
536 | React.createElement(TestComponent, {
537 | enabled: false,
538 | boundary: '',
539 | name: 'JOE',
540 | }),
541 | );
542 |
543 | // Update the name prop
544 | rerender(
545 | React.createElement(TestComponent, {
546 | enabled: false,
547 | boundary: '',
548 | name: 'JENKINS',
549 | }),
550 | );
551 |
552 | // Assert updated state
553 | expect(screen.getByText('JENKINS')).toBeInTheDocument();
554 | expect(screen.queryByText('boundary')).toBeNull(); // Still disabled
555 | });
556 |
557 | test('updates boundary while not enabled', () => {
558 | const { rerender } = render(
559 | React.createElement(TestComponent, {
560 | enabled: false,
561 | boundary: '',
562 | name: 'JENKINS',
563 | }),
564 | );
565 |
566 | // Update the boundary prop
567 | rerender(
568 | React.createElement(TestComponent, {
569 | enabled: false,
570 | boundary: '-not-present',
571 | name: 'JENKINS',
572 | }),
573 | );
574 | expect(screen.getByText('JENKINS')).toBeInTheDocument();
575 | expect(screen.queryByText('boundary')).toBeNull(); // Still disabled
576 |
577 | // Reset the boundary
578 | rerender(
579 | React.createElement(TestComponent, {
580 | enabled: false,
581 | boundary: '',
582 | name: 'JENKINS',
583 | }),
584 | );
585 | expect(screen.getByText('JENKINS')).toBeInTheDocument();
586 | expect(screen.queryByText('boundary')).toBeNull(); // Still disabled
587 | });
588 |
589 | test('toggles the enabled prop on', () => {
590 | const { rerender } = render(
591 | React.createElement(TestComponent, {
592 | enabled: false,
593 | boundary: '',
594 | name: 'JENKINS',
595 | }),
596 | );
597 |
598 | // Toggle the enabled prop on
599 | rerender(
600 | React.createElement(TestComponent, {
601 | enabled: true,
602 | boundary: '',
603 | name: 'JENKINS',
604 | }),
605 | );
606 | expect(screen.getByText('JENKINS')).toBeInTheDocument();
607 | // Use queryByTestId or queryById if 'boundary' refers to an element
608 | const boundaryElement =
609 | screen.queryByTestId('boundary') ||
610 | document.getElementById('boundary');
611 | expect(boundaryElement).toBeInTheDocument(); // Boundary div appears
612 | });
613 | });
614 |
615 | test('should apply custom class props', () => {
616 | const { container } = render(
617 | ,
625 | );
626 |
627 | outer = container.querySelector(`.${STICKY_CLASS_OUTER}`);
628 | inner = container.querySelector(`.${STICKY_CLASS_INNER}`);
629 |
630 | expect(outer.className).toContain('custom');
631 | expect(inner.className).toContain('custom-inner');
632 |
633 | // Scroll down to 10px, and Sticky should fix
634 | act(() => {
635 | window.scrollTo(0, 10);
636 | });
637 | shouldBeFixedAt(inner, 0);
638 | expect(outer.className).toContain('custom-active');
639 | expect(outer.className).not.toContain('custom-released');
640 | expect(inner.className).toContain('custom-inner-active');
641 |
642 | // Scroll up to 0px, and Sticky should reset
643 | act(() => {
644 | window.scrollTo(0, 0);
645 | });
646 | shouldBeReset(inner);
647 | expect(outer.className).not.toContain('custom-active');
648 | expect(outer.className).not.toContain('custom-released');
649 | expect(inner.className).not.toContain('custom-inner-active');
650 |
651 | // Scroll down to 150px, and Sticky should release
652 | act(() => {
653 | window.scrollTo(0, 150);
654 | });
655 | shouldBeReleasedAt(inner, 100);
656 | expect(outer.className).not.toContain('custom-active');
657 | expect(outer.className).toContain('custom-released');
658 | expect(inner.className).not.toContain('custom-inner-active');
659 | });
660 | });
661 |
--------------------------------------------------------------------------------
/wdio.conf.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | bail: 0,
3 | connectionRetryCount: 3,
4 | connectionRetryTimeout: 30000,
5 | framework: 'mocha',
6 | logLevel: 'error',
7 | maxInstances: 10,
8 | mochaOpts: {
9 | ui: 'bdd',
10 | timeout: 30000,
11 | },
12 | reporters: ['spec'],
13 | specs: ['./tests/functional/*.spec.js'],
14 | waitforTimeout: 10000,
15 | };
16 |
17 | if (process.env.CI) {
18 | // Saucelabs configuration
19 | config.capabilities = [
20 | {
21 | acceptInsecureCerts: true,
22 | browserName: 'chrome',
23 | browserVersion: 'latest',
24 | maxInstances: 5,
25 | platformName: 'Windows 10',
26 | },
27 | ];
28 | config.key = process.env.SAUCE_ACCESS_KEY;
29 | config.services = [
30 | [
31 | 'sauce',
32 | {
33 | sauceConnect: true,
34 | },
35 | ],
36 | ];
37 | config.user = process.env.SAUCE_USERNAME;
38 | } else {
39 | // Local webdriver runner
40 | config.baseUrl = 'http://localhost:5000';
41 | config.capabilities = [
42 | {
43 | maxInstances: 5,
44 | browserName: 'chrome',
45 | acceptInsecureCerts: true,
46 | },
47 | ];
48 | config.headless = true;
49 | config.runner = 'local';
50 | config.services = [
51 | ['chromedriver'],
52 | [
53 | 'static-server',
54 | {
55 | folders: [{ mount: '/', path: './tests/functional/dist' }],
56 | port: 5000,
57 | },
58 | ],
59 | ];
60 | }
61 |
62 | exports.config = config;
63 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const funcDir = path.join(__dirname, 'tests', 'functional', 'dist');
3 |
4 | module.exports = {
5 | mode: 'development',
6 | entry: path.join(funcDir, 'bootstrap.js'),
7 | output: {
8 | path: funcDir,
9 | },
10 | module: {
11 | rules: [
12 | { test: /\.css$/, use: [{ loader: 'style' }, { loader: 'css' }] },
13 | { test: /\.json$/, loader: 'json-loader' },
14 | ],
15 | },
16 | };
17 |
--------------------------------------------------------------------------------