├── index.js ├── .travis.yml ├── .npmignore ├── .eslintignore ├── .gitignore ├── .eslintrc ├── tests ├── functional │ ├── saucelabs.sh │ ├── sticky.spec.js │ ├── page.html │ ├── sticky-functional.jsx │ └── bootstrap.js └── unit │ └── Sticky-test.js ├── LICENSE.md ├── package.json ├── README.md ├── Gruntfile.js └── src └── Sticky.jsx /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/Sticky'); 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "0.12" 5 | after_success: 6 | - "npm run func" 7 | - "cat artifacts/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | /artifacts/ 3 | /bin/ 4 | /build/ 5 | /coverage/ 6 | /docs/ 7 | /results/ 8 | /src/ 9 | /tests/ 10 | /tmp/ 11 | lcov-* 12 | xunit* 13 | TEST_* 14 | *.log 15 | *~ 16 | *.tap 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *-debug.log 3 | artifacts/ 4 | build/ 5 | components-dist/ 6 | configs/atomizer.json 7 | dist/ 8 | node_modules/ 9 | npm-*.log 10 | protractor-batch-artifacts/ 11 | results/ 12 | tests/functional/bootstrap.js 13 | -------------------------------------------------------------------------------- /.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 | .eslintcache 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | plugins: 3 | - "react" 4 | env: { 5 | es6: true 6 | } 7 | ecmaFeatures: { 8 | modules: true, 9 | jsx: true 10 | } 11 | rules: 12 | valid-jsdoc: [2, { "requireReturn": false }] 13 | 'react/jsx-uses-react': [1] 14 | -------------------------------------------------------------------------------- /tests/functional/saucelabs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo branch $TRAVIS_BRANCH 4 | echo pull request $TRAVIS_PULL_REQUEST 5 | 6 | if [[ "x" == "x$SAUCE_USERNAME" ]]; then 7 | echo $0 "is missing env var SAUCE_USERNAME" 8 | exit 1 9 | fi 10 | if [[ "x" == "x$SAUCE_ACCESS_KEY" ]]; then 11 | echo $0 "is missing env var SAUCE_ACCESS_KEY" 12 | exit 1 13 | fi 14 | 15 | build=$$ 16 | if [[ "x" != "x$TRAVIS_BUILD_NUMBER" ]]; then 17 | build=$TRAVIS_BUILD_NUMBER 18 | fi 19 | echo build $build 20 | 21 | echo 22 | ./node_modules/.bin/grunt functional 23 | -------------------------------------------------------------------------------- /tests/functional/sticky.spec.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, beforeEach, expect, window, document */ 2 | 3 | function $ (selector) { 4 | var node = document.querySelector(selector); 5 | var rect = node.getBoundingClientRect(); 6 | return { 7 | getRect: function () { 8 | return rect; 9 | }, 10 | getTop: function () { 11 | return rect.top; 12 | }, 13 | getBottom: function () { 14 | return rect.bottom; 15 | } 16 | }; 17 | } 18 | 19 | describe('Sticky', function () { 20 | beforeEach(function (done) { 21 | window.scrollTo(0, 0); 22 | setTimeout(function test () { 23 | done(); 24 | }, 100); 25 | }); 26 | 27 | it('Sticky 1 should stick to the top', function (done) { 28 | window.scrollTo(0, 500); 29 | 30 | setTimeout(function test () { 31 | // console.log($('#sticky-1').getRect()); 32 | expect($('#sticky-1').getTop()).to.equal(0, 'sticky-1'); 33 | done(); 34 | }, 200); 35 | }); 36 | 37 | it('Sticky 2 should not stick to the top', function (done) { 38 | window.scrollTo(0, 500); 39 | setTimeout(function test () { 40 | // console.log($('#sticky-2').getRect()); 41 | expect($('#sticky-2').getTop()).to.below(0, 'sticky-2'); 42 | 43 | window.scrollTo(0, 1200); 44 | setTimeout(function test () { 45 | // console.log($('#sticky-2').getRect()); 46 | expect($('#sticky-2').getBottom()).to.below(window.innerHeight, 'sticky-2'); 47 | done(); 48 | }, 200); 49 | }, 200); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Software License Agreement (BSD License) 2 | ======================================== 3 | 4 | Copyright (c) 2015, Yahoo Inc. All rights reserved. 5 | ---------------------------------------------------- 6 | 7 | Redistribution and use of this software in source and binary forms, with or 8 | without modification, are permitted provided that the following conditions are 9 | met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | * Neither the name of Yahoo Inc. nor the names of YUI's contributors may be 17 | used to endorse or promote products derived from this software without 18 | specific prior written permission of Yahoo Inc. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-stickynode", 3 | "version": "1.0.12", 4 | "description": "A performant and comprehensive React sticky", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "grunt react", 8 | "devtest": "grunt unit", 9 | "func": "./tests/functional/saucelabs.sh", 10 | "lint": "eslint --cache --ext .js,.jsx . --fix", 11 | "test": "grunt cover" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/yahoo/react-stickynode" 16 | }, 17 | "keywords": [ 18 | "Sticky", 19 | "React" 20 | ], 21 | "author": { 22 | "name": "Hank Hsiao", 23 | "email": "hankxiao@yahoo-inc.com" 24 | }, 25 | "contributors": [ 26 | { 27 | "name": "Steve Carlson", 28 | "email": "yasteve@yahoo-inc.com" 29 | } 30 | ], 31 | "dependencies": { 32 | "classnames": "^2.0.0", 33 | "react-addons-shallow-compare": "^0.14.2", 34 | "subscribe-ui-event": "^1.0.0" 35 | }, 36 | "devDependencies": { 37 | "babel": "^5.0.0", 38 | "coveralls": "^2.11.1", 39 | "es5-shim": "^4.0.0", 40 | "eslint-plugin-react": "^3.4.2", 41 | "eslint": "^1.5.1", 42 | "expect.js": "^0.3.1", 43 | "grunt-atomizer": "^3.0.0", 44 | "grunt-babel": "^5.0.0", 45 | "grunt-cli": "^0.1.13", 46 | "grunt-contrib-clean": "^0.7.0", 47 | "grunt-contrib-connect": "^0.11.2", 48 | "grunt-contrib-jshint": "^0.11.2", 49 | "grunt-contrib-watch": "^0.6.1", 50 | "grunt-saucelabs": "^8.3.2", 51 | "grunt-shell": "^1.1.2", 52 | "grunt-webpack": "^1.0.8", 53 | "grunt": "^0.4.5", 54 | "istanbul": "^0.4.0", 55 | "jsdom": "^7.0.2", 56 | "jsx-loader": "^0.13.2", 57 | "jsx-test": "^0.8.0", 58 | "minimist": "^1.2.0", 59 | "mocha": "^2.0", 60 | "mockery": "^1.4.0", 61 | "pre-commit": "^1.0.0", 62 | "react-dom": "^0.14.2", 63 | "react": "^0.14.2", 64 | "xunit-file": "~0.0.9" 65 | }, 66 | "peerDependencies": { 67 | "react": "^0.14.2", 68 | "react-dom": "^0.14.2" 69 | }, 70 | "precommit": [ 71 | "lint", 72 | "test" 73 | ], 74 | "license": "BSD-3-Clause" 75 | } 76 | -------------------------------------------------------------------------------- /tests/functional/page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sticky Functional Test 5 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 | 23 | 27 | 30 | 31 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /tests/functional/sticky-functional.jsx: -------------------------------------------------------------------------------- 1 | /* global require, React */ 2 | 3 | var classNames = require('classnames'); 4 | var content = []; 5 | var Sticky = require('../../dist/Sticky'); 6 | 7 | for (var i = 0; i < 10; i++) { 8 | content.push( 9 | '

', 10 | 'Lorem Ipsum is simply dummy text of the printing and typesetting industry. ' + 11 | 'Lorem Ipsum has been the industry\'s standard dummy text ever since the 1500s, ' + 12 | 'when an unknown printer took a galley of type and scrambled it to make a type specimen book. ' + 13 | 'It has survived not only five centuries, but also the leap into electronic typesetting, ' + 14 | 'remaining essentially unchanged. ' + 15 | 'It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, ' + 16 | 'and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.', 17 | '

' 18 | ); 19 | } 20 | 21 | content = content.join(''); 22 | 23 | var TestText = React.createClass({ 24 | render: function () { 25 | return ( 26 |
28 | ); 29 | } 30 | }); 31 | 32 | var StickyDemo = React.createClass({ 33 | render: function () { 34 | return ( 35 |
36 |
37 | 38 | 39 | 40 |
41 |
42 | 43 | 44 | 45 |
46 |
47 | 48 | 49 | 50 |
51 |
52 | 53 |
54 |
55 | ); 56 | } 57 | }); 58 | 59 | module.exports = StickyDemo; 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-stickynode 2 | [![npm version](https://badge.fury.io/js/react-stickynode.svg)](http://badge.fury.io/js/react-stickynode) 3 | [![Build Status](https://travis-ci.org/yahoo/react-stickynode.svg?branch=master)](https://travis-ci.org/yahoo/react-stickynode) 4 | [![Coverage Status](https://coveralls.io/repos/yahoo/react-stickynode/badge.svg)](https://coveralls.io/r/yahoo/react-stickynode) 5 | [![Dependency Status](https://david-dm.org/yahoo/react-stickynode.svg)](https://david-dm.org/yahoo/react-stickynode) 6 | [![devDependency Status](https://david-dm.org/yahoo/react-stickynode/dev-status.svg)](https://david-dm.org/yahoo/react-stickynode#info=devDependencies) 7 | 8 | A performant and comprehensive React sticky component. 9 | 10 | A sticky component wraps a sticky target and remains the target in viewport as an user scrolls the page. Most sticky components handle the case where the sticky target is shorter then viewport, but not the case where a sticky target taller then viewport. The reason is the behavior expectation and implementation is much more complicated. 11 | 12 | `react-stickynode` handles not only regular case but the long sticky target case in a natural way. In regular case, when scrolling page down, `react-stickynode` will stick to the top of viewport. But in the case of taller sticky target, it will scroll along with the page until its bottom reaches the bottom of viewport. In other words, it looks like the bottom of viewport pull the bottom of a sticky target down when scrolling page down. On the other hand, when scrolling page up, the top of viewport pulls the top of a sticky target up. 13 | 14 | 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. 15 | 16 | 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. 17 | 18 | This is also inspired by [Steve Carlson](https://github.com/src-code). 19 | 20 | ## Features 21 | 22 | - Retrieve scrollTop only once for all sticky components. 23 | - Listen to throttled scrolling to have better performance. 24 | - Use rAF to update sticky status to have better performance. 25 | - Support top offset from the top of screen. 26 | - Support bottom boundary to stop sticky status. 27 | - Support any sticky target with various width units. 28 | 29 | ## Usage 30 | 31 | The sticky uses Modernizr `csstransforms3d` and `prefixed` features to detect IE8/9, so it can downgrade not to use transform3d. 32 | 33 | http://modernizr.com/download/?-csstransforms3d-prefixed 34 | 35 | ```js 36 | var Sticky = require('react-stickynode'); 37 | 38 | 39 | 40 | ``` 41 | 42 | ```js 43 | var Sticky = require('react-stickynode'); 44 | 45 | 46 | 47 | ``` 48 | 49 | ### Props 50 | 51 | - `enabled {Boolean}` - The switch to enable or disable Sticky (true by default). 52 | - `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. 53 | - `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. 54 | - `enableTransforms {Boolean}` - Enable the use of CSS3 transforms (true by default) 55 | 56 | ## Install & Development 57 | 58 | **Install** 59 | ```bash 60 | npm install react-stickynode 61 | ``` 62 | 63 | **Unit Test** 64 | ```bash 65 | grunt unit 66 | ``` 67 | 68 | ## License 69 | 70 | This software is free to use under the BSD license. 71 | See the [LICENSE file](./LICENSE.md) for license text and copyright information. 72 | -------------------------------------------------------------------------------- /tests/functional/bootstrap.js: -------------------------------------------------------------------------------- 1 | /*global window */ 2 | /*! modernizr 3.2.0 (Custom Build) | MIT * 3 | * http://modernizr.com/download/?-csstransforms3d-prefixed !*/ 4 | !function(e,n,t){function r(e){var n=S.className,t=Modernizr._config.classPrefix||"";if(w&&(n=n.baseVal),Modernizr._config.enableJSClass){var r=new RegExp("(^|\\s)"+t+"no-js(\\s|$)");n=n.replace(r,"$1"+t+"js$2")}Modernizr._config.enableClasses&&(n+=" "+t+e.join(" "+t),w?S.className.baseVal=n:S.className=n)}function s(e){return e.replace(/([a-z])-([a-z])/g,function(e,n,t){return n+t.toUpperCase()}).replace(/^-/,"")}function i(e,n){return typeof e===n}function o(){var e,n,t,r,s,o,a;for(var f in C)if(C.hasOwnProperty(f)){if(e=[],n=C[f],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(t=0;td;d++)if(h=e[d],v=j.style[h],a(h,"-")&&(h=s(h)),j.style[h]!==t){if(o||i(r,"undefined"))return u(),"pfx"==n?h:!0;try{j.style[h]=r}catch(y){}if(j.style[h]!=v)return u(),"pfx"==n?h:!0}return u(),!1}function v(e,n,t,r,s){var o=e.charAt(0).toUpperCase()+e.slice(1),a=(e+" "+z.join(o+" ")+o).split(" ");return i(n,"string")||i(n,"undefined")?h(a,n,r,s):(a=(e+" "+N.join(o+" ")+o).split(" "),l(a,n,t))}function g(e,n,r){return v(e,t,t,n,r)}var y=[],C=[],x={_version:"3.2.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,n){var t=this;setTimeout(function(){n(t[e])},0)},addTest:function(e,n,t){C.push({name:e,fn:n,options:t})},addAsyncTest:function(e){C.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=x,Modernizr=new Modernizr;var S=n.documentElement,w="svg"===S.nodeName.toLowerCase(),_="CSS"in e&&"supports"in e.CSS,b="supportsCSS"in e;Modernizr.addTest("supports",_||b);var P="Moz O ms Webkit",z=x._config.usePrefixes?P.split(" "):[];x._cssomPrefixes=z;var E=function(n){var r,s=prefixes.length,i=e.CSSRule;if("undefined"==typeof i)return t;if(!n)return!1;if(n=n.replace(/^@/,""),r=n.replace(/-/g,"_").toUpperCase()+"_RULE",r in i)return"@"+n;for(var o=0;s>o;o++){var a=prefixes[o],f=a.toUpperCase()+"_"+r;if(f in i)return"@-"+a.toLowerCase()+"-"+n}return!1};x.atRule=E;var N=x._config.usePrefixes?P.toLowerCase().split(" "):[];x._domPrefixes=N;var T=x.testStyles=d,k={elem:f("modernizr")};Modernizr._q.push(function(){delete k.elem});var j={style:k.elem.style};Modernizr._q.unshift(function(){delete j.style}),x.testAllProps=v;x.prefixed=function(e,n,t){return 0===e.indexOf("@")?E(e):(-1!=e.indexOf("-")&&(e=s(e)),n?v(e,n,t):v(e,"pfx"))};x.testAllProps=g,Modernizr.addTest("csstransforms3d",function(){var e=!!g("perspective","1px",!0),n=Modernizr._config.usePrefixes;if(e&&(!n||"webkitPerspective"in S.style)){var t,r="#modernizr{width:0;height:0}";Modernizr.supports?t="@supports (perspective: 1px)":(t="@media (transform-3d)",n&&(t+=",(-webkit-transform-3d)")),t+="{#modernizr{width:7px;height:18px;margin:0;padding:0;border:0}}",T(r+t,function(n){e=7===n.offsetWidth&&18===n.offsetHeight})}return e}),o(),r(y),delete x.addTest,delete x.addAsyncTest;for(var L=0;L'] 56 | } 57 | ] 58 | }, 59 | tmp: { 60 | files: [ 61 | { 62 | dot: true, 63 | src: ['<%= project.tmp %>'] 64 | } 65 | ] 66 | }, 67 | functional: { 68 | files: [ 69 | { 70 | dot: true, 71 | src: [ 72 | '<%= project.functional %>/bundle.js', 73 | '<%= project.functional %>/css/atomic.css', 74 | '<%= project.functional %>/console.js', 75 | '<%= project.functional %>/*-functional.js' 76 | ] 77 | } 78 | ] 79 | } 80 | }, 81 | // atomizer 82 | atomizer: { 83 | // used by functional tests 84 | functional: { 85 | files: [{ 86 | src: ['<%= project.src %>/*.jsx', 'tests/**/*.jsx', 'tests/**/*.html'], 87 | dest: 'tests/functional/css/atomic.css' 88 | }] 89 | } 90 | }, 91 | // react 92 | // compiles jsx to js 93 | babel: { 94 | dist: { 95 | options: { 96 | sourceMap: false 97 | }, 98 | files: [ 99 | { 100 | expand: true, 101 | cwd: '<%= project.src %>', 102 | src: ['**/*.*'], 103 | dest: '<%= project.dist %>/', 104 | extDot: 'last', 105 | ext: '.js' 106 | } 107 | ] 108 | }, 109 | functional: { 110 | options: { 111 | sourceMap: false 112 | }, 113 | files: [ 114 | { 115 | expand: true, 116 | src: ['<%= project.functional %>/**/*.jsx'], 117 | extDot: 'last', 118 | ext: '.js' 119 | } 120 | ] 121 | }, 122 | unit: { 123 | files: [ 124 | { 125 | expand: true, 126 | src: [ 127 | '<%= project.unit %>/**/*.*' 128 | ], 129 | dest: '<%= project.tmp %>', 130 | extDot: 'last', 131 | ext: '.js' 132 | } 133 | ] 134 | } 135 | }, 136 | // shell 137 | // shell commands to run protractor and istanbul 138 | shell: { 139 | istanbul: { 140 | options: { 141 | execOptions: { 142 | env: env 143 | } 144 | }, 145 | command: 'node node_modules/istanbul/lib/cli.js cover --dir <%= project.coverage_dir %> ' + 146 | '-- ./node_modules/mocha/bin/_mocha <%= project.tmp %>/<%= project.unit %> ' + 147 | '--recursive --reporter xunit-file' 148 | }, 149 | mocha: { 150 | command: './node_modules/mocha/bin/mocha <%= project.tmp %>/<%= project.unit %> ' + 151 | '--recursive --reporter spec' 152 | } 153 | }, 154 | // webpack 155 | // create js rollup with webpack module loader for functional tests 156 | webpack: { 157 | functional: { 158 | entry: './<%= project.functional %>/bootstrap.js', 159 | output: { 160 | path: './<%= project.functional %>/' 161 | }, 162 | module: { 163 | loaders: [ 164 | { test: /\.css$/, loader: 'style!css' }, 165 | { test: /\.jsx$/, loader: 'jsx-loader' }, 166 | { test: /\.json$/, loader: 'json-loader'} 167 | ] 168 | } 169 | } 170 | }, 171 | // connect 172 | // setup server for functional tests 173 | connect: { 174 | functional: { 175 | options: { 176 | port: 9999, 177 | base: ['<%= project.functional %>', '.'] 178 | } 179 | }, 180 | functionalOpen: { 181 | options: { 182 | port: 9999, 183 | base: ['<%= project.functional %>', '.'], 184 | open: { 185 | target: 'http://127.0.0.1:9999/tests/functional/page.html' 186 | } 187 | } 188 | } 189 | }, 190 | watch: { 191 | functional: { 192 | files: [ 193 | '<%= project.src%>/*.jsx', 194 | '<%= project.functional%>/*.jsx', 195 | '<%= project.functional%>/*.html' 196 | ], 197 | tasks: ['dist', 'functional-debug'] 198 | } 199 | }, 200 | 'saucelabs-mocha': { 201 | all: { 202 | options: { 203 | testname: 'react-i13n func test', 204 | urls: [ 205 | 'http://127.0.0.1:9999/tests/functional/page.html' 206 | ], 207 | 208 | build: process.env.TRAVIS_BUILD_NUMBER, 209 | sauceConfig: { 210 | 'record-video': true, 211 | 'capture-html': false, 212 | 'record-screenshots': false 213 | }, 214 | throttled: 3, 215 | browsers: [ 216 | { 217 | browserName: 'internet explorer', 218 | platform: 'Windows 7', 219 | version: '9' 220 | }, 221 | { 222 | browserName: 'internet explorer', 223 | platform: 'Windows 8', 224 | version: '10' 225 | }, 226 | { 227 | browserName: 'internet explorer', 228 | platform: 'Windows 8.1', 229 | version: '11' 230 | }, 231 | { 232 | browserName: 'chrome', 233 | platform: 'Windows 7', 234 | version: '37' 235 | }, 236 | { 237 | browserName: 'firefox', 238 | platform: 'Windows 7', 239 | version: '32' 240 | }, 241 | { 242 | browserName: 'iphone', 243 | platform: 'OS X 10.9', 244 | version: '7.1' 245 | }, 246 | { 247 | browserName: 'android', 248 | platform: 'Linux', 249 | version: '4.4' 250 | }, 251 | { 252 | browserName: 'safari', 253 | platform: 'OS X 10.9', 254 | version: '7' 255 | } 256 | ] 257 | } 258 | } 259 | } 260 | }); 261 | 262 | grunt.loadNpmTasks('grunt-saucelabs'); 263 | 264 | // register custom tasks 265 | 266 | // functional 267 | // 2. run atomizer functional 268 | // 3. compile jsx to js in tests/functional/ 269 | // 4. copy files to tests/functional/ 270 | // 5. use webpack to create a js bundle to tests/functional/ 271 | // 6. get local ip address and available port then store in grunt config 272 | // 7. set up local server to run functional tests 273 | // 9. run protractor 274 | grunt.registerTask('functional', [ 275 | 'atomizer:functional', 276 | 'babel:functional', 277 | 'webpack:functional', 278 | 'connect:functional', 279 | 'saucelabs-mocha', 280 | 'clean:functional' 281 | ]); 282 | 283 | // similar to functional, but don't run protractor, just open the test page 284 | grunt.registerTask('functional-debug', [ 285 | 'atomizer:functional', 286 | 'babel:functional', 287 | 'webpack:functional', 288 | 'connect:functionalOpen', 289 | 'watch:functional' 290 | ]); 291 | 292 | // cover 293 | // 1. clean tmp/ 294 | // 2. compile jsx to js in tmp/ 295 | // 3. run istanbul cover in tmp/ using mocha command 296 | // 4. clean tmp/ 297 | grunt.registerTask('cover', [ 298 | 'clean:tmp', 299 | 'clean:dist', 300 | 'babel:unit', 301 | 'babel:dist', 302 | 'shell:istanbul', 303 | 'clean:tmp' 304 | ]); 305 | 306 | grunt.registerTask('unit', [ 307 | 'clean:tmp', 308 | 'clean:dist', 309 | 'babel:unit', 310 | 'babel:dist', 311 | 'shell:mocha' 312 | ]); 313 | 314 | // dist 315 | // 1. clean dist/ 316 | // 2. compile jsx to js in dist/ 317 | grunt.registerTask('dist', ['clean:dist', 'babel:dist']); 318 | grunt.registerTask('test', ['clean:dist', 'babel:dist', 'clean:tmp', 'babel:unit']); 319 | 320 | // default 321 | grunt.registerTask('default', ['dist']); 322 | }; 323 | -------------------------------------------------------------------------------- /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 | /* global window, document */ 6 | 7 | 'use strict'; 8 | 9 | import React, { Component, PropTypes } from 'react'; 10 | 11 | import classNames from 'classnames'; 12 | import shallowCompare from 'react-addons-shallow-compare'; 13 | import { subscribe } from 'subscribe-ui-event'; 14 | 15 | // constants 16 | var STATUS_ORIGINAL = 0; // The default status, locating at the original position. 17 | var STATUS_RELEASED = 1; // The released status, locating at somewhere on document but not default one. 18 | var STATUS_FIXED = 2; // The sticky status, locating fixed to the top or the bottom of screen. 19 | var TRANSFORM_PROP = 'transform'; 20 | 21 | // global variable for all instances 22 | var doc; 23 | var docBody; 24 | var docEl; 25 | var canEnableTransforms = true; // Use transform by default, so no Sticky on lower-end browser when no Modernizr 26 | var M; 27 | var scrollDelta = 0; 28 | var scrollTop = -1; 29 | var win; 30 | var winHeight = -1; 31 | 32 | if (typeof window !== 'undefined' && typeof document !== 'undefined') { 33 | win = window; 34 | doc = document; 35 | docEl = doc.documentElement; 36 | docBody = doc.body; 37 | scrollTop = docBody.scrollTop + docEl.scrollTop; 38 | winHeight = win.innerHeight || docEl.clientHeight; 39 | M = window.Modernizr; 40 | // No Sticky on lower-end browser when no Modernizr 41 | if (M) { 42 | canEnableTransforms = M.csstransforms3d; 43 | TRANSFORM_PROP = M.prefixed('transform'); 44 | } 45 | } 46 | 47 | class Sticky extends Component { 48 | constructor (props, context) { 49 | super(props, context); 50 | this.handleResize = this.handleResize.bind(this); 51 | this.handleScroll = this.handleScroll.bind(this); 52 | this.handleScrollStart = this.handleScrollStart.bind(this); 53 | this.delta = 0; 54 | this.stickyTop = 0; 55 | this.stickyBottom = 0; 56 | 57 | this.bottomBoundaryTarget; 58 | this.topTarget; 59 | this.subscribers; 60 | 61 | this.state = { 62 | top: 0, // A top offset px from screen top for Sticky when scrolling down 63 | bottom: 0, // A bottom offset px from screen top for Sticky when scrolling up *1* 64 | width: 0, // Sticky width 65 | height: 0, // Sticky height 66 | x: 0, // The original x of Sticky 67 | y: 0, // The original y of Sticky 68 | topBoundary: 0, // The top boundary on document 69 | bottomBoundary: Infinity, // The bottom boundary on document 70 | status: STATUS_ORIGINAL, // The Sticky status 71 | pos: 0, // Real y-axis offset for rendering position-fixed and position-relative 72 | activated: false // once browser info is available after mounted, it becomes true to avoid checksum error 73 | }; 74 | } 75 | 76 | getTargetHeight (target) { 77 | return target && target.offsetHeight || 0; 78 | } 79 | 80 | getTopPosition () { 81 | var self = this; 82 | // TODO, topTarget is for current layout, may remove 83 | var top = self.props.top || self.props.topTarget || 0; 84 | if (typeof top === 'string') { 85 | if (!self.topTarget) { 86 | self.topTarget = doc.querySelector(top); 87 | } 88 | top = self.getTargetHeight(self.topTarget); 89 | } 90 | return top; 91 | } 92 | 93 | getTargetBottom (target) { 94 | if (!target) { 95 | return -1; 96 | } 97 | var rect = target.getBoundingClientRect(); 98 | return scrollTop + rect.bottom; 99 | } 100 | 101 | getBottomBoundary () { 102 | var self = this; 103 | 104 | var boundary = self.props.bottomBoundary; 105 | 106 | // TODO, bottomBoundary was an object, depricate it later. 107 | if (typeof boundary === 'object') { 108 | boundary = boundary.value || boundary.target || 0; 109 | } 110 | 111 | if (typeof boundary === 'string') { 112 | if (!self.bottomBoundaryTarget) { 113 | self.bottomBoundaryTarget = doc.querySelector(boundary); 114 | } 115 | boundary = self.getTargetBottom(self.bottomBoundaryTarget); 116 | } 117 | return boundary && boundary > 0 ? boundary : Infinity; 118 | } 119 | 120 | reset () { 121 | this.setState({ 122 | status: STATUS_ORIGINAL, 123 | pos: 0 124 | }); 125 | } 126 | 127 | release (pos) { 128 | this.setState({ 129 | status: STATUS_RELEASED, 130 | pos: pos - this.state.y 131 | }); 132 | } 133 | 134 | fix (pos) { 135 | this.setState({ 136 | status: STATUS_FIXED, 137 | pos: pos 138 | }); 139 | } 140 | 141 | /** 142 | * Update the initial position, width, and height. It should update whenever children change. 143 | */ 144 | updateInitialDimension () { 145 | var self = this; 146 | 147 | var outer = self.refs.outer; 148 | var inner = self.refs.inner; 149 | var outerRect = outer.getBoundingClientRect(); 150 | var innerRect = inner.getBoundingClientRect(); 151 | 152 | var width = outerRect.width || outerRect.right - outerRect.left; 153 | var height = innerRect.height || innerRect.bottom - innerRect.top;; 154 | var outerY = outerRect.top + scrollTop; 155 | 156 | self.setState({ 157 | top: self.getTopPosition(), 158 | bottom: Math.min(self.state.top + height, winHeight), 159 | width: width, 160 | height: height, 161 | x: outerRect.left, 162 | y: outerY, 163 | bottomBoundary: self.getBottomBoundary(), 164 | topBoundary: outerY 165 | }); 166 | } 167 | 168 | handleResize (e, ae) { 169 | winHeight = ae.resize.height; 170 | this.updateInitialDimension(); 171 | this.update(); 172 | } 173 | 174 | handleScrollStart (e, ae) { 175 | scrollTop = ae.scroll.top; 176 | this.updateInitialDimension(); 177 | } 178 | 179 | handleScroll (e, ae) { 180 | scrollDelta = ae.scroll.delta; 181 | scrollTop = ae.scroll.top; 182 | this.update(); 183 | } 184 | 185 | /** 186 | * Update Sticky position. 187 | * In this function, all coordinates of Sticky and scren are projected to document, so the local variables 188 | * "top"/"bottom" mean the expected top/bottom of Sticky on document. They will move when scrolling. 189 | * 190 | * There are 2 principles to make sure Sticky won't get wrong so much: 191 | * 1. Reset Sticky to the original postion when "top" <= topBoundary 192 | * 2. Release Sticky to the bottom boundary when "bottom" >= bottomBoundary 193 | * 194 | * If "top" and "bottom" are between the boundaries, Sticky will always fix to the top of screen 195 | * when it is shorter then screen. If Sticky is taller then screen, then it will 196 | * 1. Fix to the bottom of screen when scrolling down and "bottom" > Sticky current bottom 197 | * 2. Fix to the top of screen when scrolling up and "top" < Sticky current top 198 | * (The above 2 points act kind of "bottom" dragging Sticky down or "top" dragging it up.) 199 | * 3. Release Sticky when "top" and "bottom" are between Sticky current top and bottom. 200 | */ 201 | update () { 202 | var self = this; 203 | 204 | if (self.state.bottomBoundary - self.state.topBoundary <= self.state.height || !self.props.enabled) { 205 | if (self.state.status !== STATUS_ORIGINAL) { 206 | self.reset(); 207 | } 208 | return; 209 | } 210 | 211 | var delta = scrollDelta; 212 | var top = scrollTop + self.state.top; 213 | var bottom = scrollTop + self.state.bottom; 214 | 215 | if (top <= self.state.topBoundary) { 216 | self.reset(); 217 | } else if (bottom >= self.state.bottomBoundary) { 218 | self.stickyBottom = self.state.bottomBoundary; 219 | self.stickyTop = self.stickyBottom - self.state.height; 220 | self.release(self.stickyTop); 221 | } else { 222 | if (self.state.height > winHeight - self.state.top) { 223 | // In this case, Sticky is larger then screen minus sticky top 224 | switch (self.state.status) { 225 | case STATUS_ORIGINAL: 226 | self.release(self.state.y); 227 | self.stickyTop = self.state.y; 228 | self.stickyBottom = self.stickyTop + self.state.height; 229 | break; 230 | case STATUS_RELEASED: 231 | if (delta > 0 && bottom > self.stickyBottom) { // scroll down 232 | self.fix(self.state.bottom - self.state.height); 233 | } else if (delta < 0 && top < self.stickyTop) { // scroll up 234 | this.fix(self.state.top); 235 | } 236 | break; 237 | case STATUS_FIXED: 238 | var isChanged = true; 239 | if (delta > 0 && self.state.pos === self.state.top) { // scroll down 240 | self.stickyTop = top - delta; 241 | self.stickyBottom = self.stickyTop + self.state.height; 242 | } else if (delta < 0 && self.state.pos === self.state.bottom - self.state.height) { // up 243 | self.stickyBottom = bottom - delta; 244 | self.stickyTop = self.stickyBottom - self.state.height; 245 | } else { 246 | isChanged = false; 247 | } 248 | 249 | if (isChanged) { 250 | self.release(self.stickyTop); 251 | } 252 | break; 253 | } 254 | } else { 255 | self.fix(self.state.top); 256 | } 257 | } 258 | self.delta = delta; 259 | } 260 | 261 | componentWillReceiveProps () { 262 | this.forceUpdate(); 263 | } 264 | 265 | componentWillUnmount () { 266 | var subscribers = this.subscribers || []; 267 | for (var i = subscribers.length - 1; i >= 0; i--) { 268 | this.subscribers[i].unsubscribe(); 269 | } 270 | } 271 | 272 | componentDidMount () { 273 | var self = this; 274 | if (self.props.enabled) { 275 | self.setState({activated: true}); 276 | self.updateInitialDimension(); 277 | self.subscribers = [ 278 | subscribe('scrollStart', self.handleScrollStart.bind(self), {useRAF: true}), 279 | subscribe('scroll', self.handleScroll.bind(self), {useRAF: true, enableScrollInfo: true}), 280 | subscribe('resize', self.handleResize.bind(self), {enableResizeInfo: true}) 281 | ]; 282 | } 283 | } 284 | 285 | translate (style, pos) { 286 | var enableTransforms = canEnableTransforms && this.props.enableTransforms 287 | if (enableTransforms && this.state.activated) { 288 | style[TRANSFORM_PROP] = 'translate3d(0,' + pos + 'px,0)'; 289 | } else { 290 | style.top = pos; 291 | } 292 | } 293 | 294 | shouldComponentUpdate (nextProps, nextState) { 295 | return shallowCompare(this, nextProps, nextState); 296 | } 297 | 298 | render () { 299 | var self = this; 300 | // TODO, "overflow: auto" prevents collapse, need a good way to get children height 301 | var innerStyle = { 302 | position: self.state.status === STATUS_FIXED ? 'fixed' : 'relative', 303 | top: self.state.status === STATUS_FIXED ? '0' : '' 304 | }; 305 | var outerStyle = {}; 306 | 307 | // always use translate3d to enhance the performance 308 | self.translate(innerStyle, self.state.pos); 309 | if (self.state.status !== STATUS_ORIGINAL) { 310 | innerStyle.width = self.state.width; 311 | outerStyle.height = self.state.height; 312 | } 313 | 314 | return ( 315 |
316 |
317 | {self.props.children} 318 |
319 |
320 | ); 321 | } 322 | } 323 | 324 | Sticky.defaultProps = { 325 | enabled: true, 326 | top: 0, 327 | bottomBoundary: 0, 328 | enableTransforms: true 329 | }; 330 | 331 | /** 332 | * @param {Bool} enabled A switch to enable or disable Sticky. 333 | * @param {String/Number} top A top offset px for Sticky. Could be a selector representing a node 334 | * whose height should serve as the top offset. 335 | * @param {String/Number} bottomBoundary A bottom boundary px on document where Sticky will stop. 336 | * Could be a selector representing a node whose bottom should serve as the bottom boudary. 337 | */ 338 | Sticky.propTypes = { 339 | enabled: PropTypes.bool, 340 | top: PropTypes.oneOfType([ 341 | PropTypes.string, 342 | PropTypes.number 343 | ]), 344 | bottomBoundary: PropTypes.oneOfType([ 345 | PropTypes.object, // TODO, may remove 346 | PropTypes.string, 347 | PropTypes.number 348 | ]), 349 | enableTransforms: PropTypes.bool 350 | }; 351 | 352 | module.exports = Sticky; 353 | --------------------------------------------------------------------------------