├── .npmignore ├── .gitignore ├── index.d.ts ├── .eslintrc.js ├── src ├── index.js └── sticky.js ├── rollup.config.js ├── examples └── basic │ ├── app.js │ └── index.html ├── .prettierrc ├── package.json ├── LICENSE └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.log 4 | vue-sticky-directive.js 5 | package-lock.json -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { DirectiveOptions, PluginObject } from 'vue'; 2 | declare const Sticky: DirectiveOptions & PluginObject; 3 | export default Sticky; 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | extends: 'standard', 5 | env: { 6 | browser: true 7 | }, 8 | globals: { 9 | Vue: true 10 | } 11 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Sticky from './sticky'; 2 | 3 | const install = function(Vue) { 4 | Vue.directive('Sticky', Sticky); 5 | }; 6 | 7 | if (window.Vue) { 8 | Vue.use(install); 9 | } 10 | 11 | Sticky.install = install; 12 | 13 | export default Sticky; 14 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | 3 | export default { 4 | entry: './src/index.js', 5 | dest: 'vue-sticky-directive.js', 6 | plugins: [ 7 | babel({ 8 | exclude: 'node_modules/**', 9 | presets: ['es2015-rollup'] 10 | }) 11 | ], 12 | format: 'umd', 13 | moduleName: 'VueStickyDirective' 14 | } 15 | -------------------------------------------------------------------------------- /examples/basic/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import Sticky from '../../src/sticky.js'; 3 | 4 | Vue.directive('Sticky', Sticky); 5 | 6 | new Vue({ 7 | el: document.querySelector('.app'), 8 | template: '#app-template', 9 | data: { 10 | stickyEnabled: true, 11 | }, 12 | methods: { 13 | onStick(data) { 14 | console.log(data); 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "htmlWhitespaceSensitivity": "css", 5 | "insertPragma": false, 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "proseWrap": "preserve", 10 | "requirePragma": false, 11 | "semi": true, 12 | "singleQuote": true, 13 | "tabWidth": 2, 14 | "trailingComma": "all", 15 | "useTabs": false 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-sticky-directive", 3 | "version": "0.0.10", 4 | "description": "A powerful vue directive make element sticky.", 5 | "main": "vue-sticky-directive.js", 6 | "module": "./src/index.js", 7 | "typings": "./index.d.ts", 8 | "directories": { 9 | "example": "example" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/mehwww/vue-sticky-directive.git" 14 | }, 15 | "scripts": { 16 | "build": "rollup -c", 17 | "prepublish": "npm run build" 18 | }, 19 | "keywords": [ 20 | "vue", 21 | "sticky" 22 | ], 23 | "author": "meh", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "babel-core": "^6.25.0", 27 | "babel-eslint": "^7.2.3", 28 | "babel-preset-es2015-rollup": "^3.0.0", 29 | "eslint": "^4.4.1", 30 | "eslint-config-standard": "^10.2.1", 31 | "eslint-plugin-import": "^2.7.0", 32 | "eslint-plugin-node": "^5.1.1", 33 | "eslint-plugin-promise": "^3.5.0", 34 | "eslint-plugin-standard": "^3.0.1", 35 | "rollup": "^0.47.4", 36 | "rollup-plugin-babel": "^3.0.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-present Meh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-sticky-directive 2 | 3 | vue-sticky-directive is a powerful vue directive make element sticky. 4 | 5 | # Install 6 | 7 | ```Bash 8 | npm install vue-sticky-directive --save 9 | ``` 10 | 11 | ES2015 12 | ```JavaScript 13 | // register globally 14 | import Sticky from 'vue-sticky-directive' 15 | Vue.use(Sticky) 16 | 17 | // or for a single instance 18 | import Sticky from 'vue-sticky-directive' 19 | new Vue({ 20 | directives: {Sticky} 21 | }) 22 | ``` 23 | 24 | # Usage 25 | 26 | Use `v-sticky` directive to enable element postion sticky, and use `sticky-*` attributes to define its options. Sticky element will find its nearest element with `sticky-container` attribute or its parent node if faild as the releative element. 27 | 28 | [basic example](https://mehwww.github.io/vue-sticky-directive/examples/basic/) 29 | 30 | ```HTML 31 |
32 |
33 | ... 34 |
35 |
36 | ``` 37 | 38 | # Options 39 | * `sticky-offset` - set sticky offset, it support a vm variable name or a js expression like `{top: 10, bottom: 20}` 40 | * `top`_(number)_ - set the top breakpoint (default: `0`) 41 | * `bottom`_(number)_ - set the bottom breakpoint (default: `0`) 42 | * `sticky-side`_(string)_ - decide which side should be sticky, you can set `top`、`bottom` or `both` (default: `top`) 43 | * `sticky-z-index` _(number)_ - to set the z-index of element to stick 44 | * `on-stick` _(function)_ - callback when sticky and release, receiveing 1 argument with object indicating the state, like: 45 | 46 | ```javascript 47 | // The element is sticked on top 48 | { 49 | bottom: false, 50 | top: true, 51 | sticked: true 52 | } 53 | ``` 54 | 55 | An expression that evaluates to false set on `v-sticky` can be used to disable stickiness condtionally. 56 | 57 | ```HTML 58 |
59 |
60 | ... 61 |
62 |
63 | ``` 64 | ```JavaScript 65 | export defaults { 66 | data () { 67 | shouldStick: false 68 | } 69 | } 70 | ``` 71 | 72 | # License 73 | 74 | MIT 75 | 76 | 77 | -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Basic 8 | 51 | 52 | 53 |
54 | 90 | 91 | 92 | 98 | 99 | -------------------------------------------------------------------------------- /src/sticky.js: -------------------------------------------------------------------------------- 1 | const namespace = '@@vue-sticky-directive'; 2 | const events = [ 3 | 'resize', 4 | 'scroll', 5 | 'touchstart', 6 | 'touchmove', 7 | 'touchend', 8 | 'pageshow', 9 | 'load', 10 | ]; 11 | 12 | const batchStyle = (el, style = {}, className = {}) => { 13 | for (let k in style) { 14 | el.style[k] = style[k]; 15 | } 16 | for (let k in className) { 17 | if (className[k] && !el.classList.contains(k)) { 18 | el.classList.add(k); 19 | } else if (!className[k] && el.classList.contains(k)) { 20 | el.classList.remove(k); 21 | } 22 | } 23 | }; 24 | 25 | class Sticky { 26 | constructor(el, vm) { 27 | this.el = el; 28 | this.vm = vm; 29 | this.unsubscribers = []; 30 | this.isPending = false; 31 | this.state = { 32 | isTopSticky: null, 33 | isBottomSticky: null, 34 | height: null, 35 | width: null, 36 | xOffset: null, 37 | }; 38 | 39 | this.lastState = { 40 | top: null, 41 | bottom: null, 42 | sticked: false, 43 | }; 44 | 45 | const offset = this.getAttribute('sticky-offset') || {}; 46 | const side = this.getAttribute('sticky-side') || 'top'; 47 | const zIndex = this.getAttribute('sticky-z-index') || '10'; 48 | const onStick = this.getAttribute('on-stick') || null; 49 | 50 | this.options = { 51 | topOffset: Number(offset.top) || 0, 52 | bottomOffset: Number(offset.bottom) || 0, 53 | shouldTopSticky: side === 'top' || side === 'both', 54 | shouldBottomSticky: side === 'bottom' || side === 'both', 55 | zIndex: zIndex, 56 | onStick: onStick, 57 | }; 58 | } 59 | 60 | doBind() { 61 | if (this.unsubscribers.length > 0) { 62 | return; 63 | } 64 | const { el, vm } = this; 65 | vm.$nextTick(() => { 66 | this.placeholderEl = document.createElement('div'); 67 | this.containerEl = this.getContainerEl(); 68 | el.parentNode.insertBefore(this.placeholderEl, el); 69 | events.forEach(event => { 70 | const fn = this.update.bind(this); 71 | this.unsubscribers.push(() => window.removeEventListener(event, fn)); 72 | this.unsubscribers.push(() => 73 | this.containerEl.removeEventListener(event, fn), 74 | ); 75 | window.addEventListener(event, fn, { passive: true }); 76 | this.containerEl.addEventListener(event, fn, { passive: true }); 77 | }); 78 | }); 79 | } 80 | 81 | doUnbind() { 82 | this.unsubscribers.forEach(fn => fn()); 83 | this.unsubscribers = []; 84 | this.resetElement(); 85 | } 86 | 87 | update() { 88 | if (!this.isPending) { 89 | requestAnimationFrame(() => { 90 | this.isPending = false; 91 | this.recomputeState(); 92 | this.updateElements(); 93 | }); 94 | this.isPending = true; 95 | } 96 | } 97 | 98 | isTopSticky() { 99 | if (!this.options.shouldTopSticky) return false; 100 | const fromTop = this.state.placeholderElRect.top; 101 | const fromBottom = this.state.containerElRect.bottom; 102 | 103 | const topBreakpoint = this.options.topOffset; 104 | const bottomBreakpoint = this.options.bottomOffset; 105 | 106 | return fromTop <= topBreakpoint && fromBottom >= bottomBreakpoint; 107 | } 108 | 109 | isBottomSticky() { 110 | if (!this.options.shouldBottomSticky) return false; 111 | const fromBottom = 112 | window.innerHeight - this.state.placeholderElRect.top - this.state.height; 113 | const fromTop = window.innerHeight - this.state.containerElRect.top; 114 | 115 | const topBreakpoint = this.options.topOffset; 116 | const bottomBreakpoint = this.options.bottomOffset; 117 | 118 | return fromBottom <= bottomBreakpoint && fromTop >= topBreakpoint; 119 | } 120 | 121 | recomputeState() { 122 | this.state = Object.assign({}, this.state, { 123 | height: this.getHeight(), 124 | width: this.getWidth(), 125 | xOffset: this.getXOffset(), 126 | placeholderElRect: this.getPlaceholderElRect(), 127 | containerElRect: this.getContainerElRect(), 128 | }); 129 | this.state.isTopSticky = this.isTopSticky(); 130 | this.state.isBottomSticky = this.isBottomSticky(); 131 | } 132 | 133 | fireEvents() { 134 | if ( 135 | typeof this.options.onStick === 'function' && 136 | (this.lastState.top !== this.state.isTopSticky || 137 | this.lastState.bottom !== this.state.isBottomSticky || 138 | this.lastState.sticked !== 139 | (this.state.isTopSticky || this.state.isBottomSticky)) 140 | ) { 141 | this.lastState = { 142 | top: this.state.isTopSticky, 143 | bottom: this.state.isBottomSticky, 144 | sticked: this.state.isBottomSticky || this.state.isTopSticky, 145 | }; 146 | this.options.onStick(this.lastState); 147 | } 148 | } 149 | 150 | updateElements() { 151 | const placeholderStyle = { paddingTop: 0 }; 152 | const elStyle = { 153 | position: 'static', 154 | top: 'auto', 155 | bottom: 'auto', 156 | left: 'auto', 157 | width: 'auto', 158 | zIndex: this.options.zIndex, 159 | }; 160 | const placeholderClassName = { 'vue-sticky-placeholder': true }; 161 | const elClassName = { 162 | 'vue-sticky-el': true, 163 | 'top-sticky': false, 164 | 'bottom-sticky': false, 165 | }; 166 | 167 | if (this.state.isTopSticky) { 168 | elStyle.position = 'fixed'; 169 | elStyle.top = this.options.topOffset + 'px'; 170 | elStyle.left = this.state.xOffset + 'px'; 171 | elStyle.width = this.state.width + 'px'; 172 | const bottomLimit = 173 | this.state.containerElRect.bottom - 174 | this.state.height - 175 | this.options.bottomOffset - 176 | this.options.topOffset; 177 | if (bottomLimit < 0) { 178 | elStyle.top = bottomLimit + this.options.topOffset + 'px'; 179 | } 180 | placeholderStyle.paddingTop = this.state.height + 'px'; 181 | elClassName['top-sticky'] = true; 182 | } else if (this.state.isBottomSticky) { 183 | elStyle.position = 'fixed'; 184 | elStyle.bottom = this.options.bottomOffset + 'px'; 185 | elStyle.left = this.state.xOffset + 'px'; 186 | elStyle.width = this.state.width + 'px'; 187 | const topLimit = 188 | window.innerHeight - 189 | this.state.containerElRect.top - 190 | this.state.height - 191 | this.options.bottomOffset - 192 | this.options.topOffset; 193 | if (topLimit < 0) { 194 | elStyle.bottom = topLimit + this.options.bottomOffset + 'px'; 195 | } 196 | placeholderStyle.paddingTop = this.state.height + 'px'; 197 | elClassName['bottom-sticky'] = true; 198 | } else { 199 | placeholderStyle.paddingTop = 0; 200 | } 201 | 202 | batchStyle(this.el, elStyle, elClassName); 203 | batchStyle(this.placeholderEl, placeholderStyle, placeholderClassName); 204 | 205 | this.fireEvents(); 206 | } 207 | 208 | resetElement() { 209 | ['position', 'top', 'bottom', 'left', 'width', 'zIndex'].forEach(attr => { 210 | this.el.style.removeProperty(attr); 211 | }); 212 | this.el.classList.remove('bottom-sticky', 'top-sticky'); 213 | const { parentNode } = this.placeholderEl; 214 | if (parentNode) { 215 | parentNode.removeChild(this.placeholderEl); 216 | } 217 | } 218 | 219 | getContainerEl() { 220 | let node = this.el.parentNode; 221 | while ( 222 | node && 223 | node.tagName !== 'HTML' && 224 | node.tagName !== 'BODY' && 225 | node.nodeType === 1 226 | ) { 227 | if (node.hasAttribute('sticky-container')) { 228 | return node; 229 | } 230 | node = node.parentNode; 231 | } 232 | return this.el.parentNode; 233 | } 234 | 235 | getXOffset() { 236 | return this.placeholderEl.getBoundingClientRect().left; 237 | } 238 | 239 | getWidth() { 240 | return this.placeholderEl.getBoundingClientRect().width; 241 | } 242 | 243 | getHeight() { 244 | return this.el.getBoundingClientRect().height; 245 | } 246 | 247 | getPlaceholderElRect() { 248 | return this.placeholderEl.getBoundingClientRect(); 249 | } 250 | 251 | getContainerElRect() { 252 | return this.containerEl.getBoundingClientRect(); 253 | } 254 | 255 | getAttribute(name) { 256 | const expr = this.el.getAttribute(name); 257 | let result = undefined; 258 | if (expr) { 259 | if (this.vm[expr]) { 260 | result = this.vm[expr]; 261 | } else { 262 | try { 263 | result = eval(`(${expr})`); 264 | } catch (error) { 265 | result = expr; 266 | } 267 | } 268 | } 269 | return result; 270 | } 271 | } 272 | 273 | export default { 274 | inserted(el, bind, vnode) { 275 | if (typeof bind.value === 'undefined' || bind.value) { 276 | el[namespace] = new Sticky(el, vnode.context); 277 | el[namespace].doBind(); 278 | } 279 | }, 280 | unbind(el, bind, vnode) { 281 | if (el[namespace]) { 282 | el[namespace].doUnbind(); 283 | el[namespace] = undefined; 284 | } 285 | }, 286 | componentUpdated(el, bind, vnode) { 287 | if (typeof bind.value === 'undefined' || bind.value) { 288 | if (!el[namespace]) { 289 | el[namespace] = new Sticky(el, vnode.context); 290 | } 291 | el[namespace].doBind(); 292 | } else { 293 | if (el[namespace]) { 294 | el[namespace].doUnbind(); 295 | } 296 | } 297 | }, 298 | }; 299 | --------------------------------------------------------------------------------