├── .babelrc ├── .gitignore ├── .nvmrc ├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── .travis.yml ├── LICENSE ├── README.md ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ ├── initially-visible.coffee │ ├── scroll-to-reveal.coffee │ └── trigger-late.coffee ├── plugins │ └── index.js └── support │ ├── commands.coffee │ ├── index.js │ └── vars.coffee ├── index.coffee ├── index.js ├── package.json ├── stories ├── box │ ├── Always.vue │ ├── Once.vue │ ├── Shorthand.vue │ └── styles.css └── index.stories.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "browsers": "last 2 versions, IE 10" 6 | } 7 | }] 8 | ] 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 30 | node_modules 31 | 32 | # Testing 33 | test/e2e/reports 34 | test/e2e/screenshots 35 | selenium-debug.log 36 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 8.* -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-knobs/register' 2 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure, addParameters } from '@storybook/vue'; 2 | 3 | // Option defaults: 4 | addParameters({ 5 | options: { 6 | name: 'in-viewport-directive', 7 | url: 'https://github.com/BKWLD/vue-in-viewport-directive', 8 | } 9 | }) 10 | 11 | // automatically import all files ending in *.stories.js 12 | const req = require.context('../stories', true, /\.stories\.js$/); 13 | function loadStories() { 14 | req.keys().forEach(filename => req(filename)); 15 | } 16 | 17 | configure(loadStories, module); 18 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function({ config, mode }) { 2 | 3 | // Add to resolve extensions 4 | config.resolve.extensions.push('.coffee') 5 | 6 | // Add Coffeescript 7 | config.module.rules.push({ 8 | test: /\.coffee$/, 9 | loader: 'babel-loader!coffee-loader', 10 | }) 11 | 12 | return config 13 | }; 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 8 5 | 6 | cache: 7 | yarn: true 8 | directories: 9 | - ~/.cache 10 | 11 | env: 12 | CYPRESS_baseUrl: https://bkwld.github.io/vue-in-viewport-directive/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Bukwild 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue In Viewport Directive [![Build Status](https://travis-ci.org/BKWLD/vue-in-viewport-directive.svg?branch=master)](https://travis-ci.org/BKWLD/vue-in-viewport-directive) 2 | 3 | Vue 2 directive that sets CSS classes on its host element based on the elements current position in the viewport. These classes are: 4 | 5 | - `in-viewport` - Some part of the element is within the viewport 6 | - `above-viewport` - Some part of the element is above the viewport 7 | - `below-viewport` - Some part of the element is below the viewport 8 | 9 | 10 | You may want to check out the mixin version of this package: [vue-in-viewport-mixin](https://github.com/BKWLD/vue-in-viewport-mixin). 11 | 12 | Demo: https://bkwld.github.io/vue-in-viewport-directive 13 | 14 | 15 | ## Usage 16 | 17 | Note, this should not be applied to elements / components that are setting a dynamic class through Vue. See [this issue](https://github.com/BKWLD/vue-in-viewport-directive/issues/4). 18 | 19 | * Register the directive: 20 | ```js 21 | import Vue from 'vue' 22 | import inViewportDirective from 'vue-in-viewport-directive' 23 | Vue.directive('in-viewport', inViewportDirective) 24 | ``` 25 | 26 | * Use the classes to trigger CSS transitions (for instance): 27 | ```html 28 |
29 |
30 |
31 | ``` 32 | ```css 33 | .box { 34 | opacity: 0; 35 | transition: opacity .3s; 36 | } 37 | .box.in-viewport { 38 | opacity: 1; 39 | } 40 | ``` 41 | 42 | * Set default offsets: 43 | ```js 44 | import inViewportDirective from 'vue-in-viewport-directive' 45 | inViewportDirective.defaults.margin = '-10% 0%' 46 | Vue.directive('in-viewport', inViewportDirective) 47 | ``` 48 | 49 | * *Compatibility note*: This package requires IE >= 10 because it uses `classList`. [Polyfill classList](https://github.com/eligrey/classList.js) if you need to support older browsers. 50 | * *Compatibility note*: This package a [polyfill for IntersectionObserver](https://github.com/w3c/IntersectionObserver/tree/master/polyfill) in browsers like IE. 51 | 52 | #### Global methods 53 | 54 | You can disable all updates and re-enable them globally: 55 | 56 | ```js 57 | import { enable, disable } from 'vue-in-viewport-directive' 58 | disable() 59 | setTimeout(enable, 500) 60 | ``` 61 | 62 | This can be used during full page transitions to trigger all the in viewport transitions 63 | only once the page transition finishes. 64 | 65 | ## Arguments 66 | 67 | #### Modifiers 68 | 69 | - `once` - Whether to remove listeners once the element enters viewport. If the element is in viewport when mounted, listeners are never added. 70 | 71 | 72 | #### Value 73 | 74 | - Set the value to a string in the style of [IntersectionObserver rootMargin](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver#Parameters) to apply an offset to when the in viewport classes get added. 75 | ```html 76 |
77 | ``` 78 | 79 | - Or, set it via an option: 80 | ```html 81 |
82 | ``` 83 | 84 | - Conditionally disable with `disabled`: 85 | ```html 86 |
87 | ``` 88 | 89 | 90 | ## Tests 91 | 92 | 1. Start Storybook: `yarn storybook` 93 | 2. Open Cypress: `yarn cypress open` 94 | 95 | The Travis tests that run on deploy run against [the demo site](https://bkwld.github.io/vue-in-viewport-mixin) which gets updated as part of the `npm version` 96 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:6006/", 3 | "video": false 4 | } -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/initially-visible.coffee: -------------------------------------------------------------------------------- 1 | { viewportH, boxH } = require '../support/vars' 2 | context 'Initially visible story', -> 3 | 4 | beforeEach -> 5 | cy.viewport 800, 800 6 | cy.visit 'iframe.html?id=examples--initially-visible' 7 | 8 | it 'is initially visible', -> 9 | cy.checkState 10 | in: true 11 | above: false 12 | below: false 13 | 14 | it 'is not fully visible after 1px of scroll', -> 15 | cy.scroll 1 16 | cy.checkState 17 | in: true 18 | above: true 19 | below: false 20 | 21 | it 'is still not fully visible after 10px of scroll', -> 22 | cy.scroll 10 23 | cy.checkState 24 | in: true 25 | above: true 26 | below: false 27 | 28 | it 'is hidden after scrolling the box height (200px)', -> 29 | cy.scroll boxH 30 | cy.checkState 31 | in: false 32 | above: true 33 | below: false 34 | 35 | -------------------------------------------------------------------------------- /cypress/integration/scroll-to-reveal.coffee: -------------------------------------------------------------------------------- 1 | { viewportH, boxH } = require '../support/vars' 2 | context 'Scroll to reveal story', -> 3 | 4 | beforeEach -> 5 | cy.viewport 800, viewportH 6 | cy.visit 'iframe.html?id=examples--scroll-to-reveal' 7 | 8 | it 'is initially hidden', -> 9 | cy.checkState 10 | in: false 11 | above: false 12 | below: true 13 | 14 | it 'is visible after 1px of scroll', -> 15 | cy.scroll 1 16 | cy.checkState 17 | in: true 18 | above: false 19 | below: true 20 | 21 | it 'is fully visible after scrolling the box height (200px)', -> 22 | cy.scroll boxH 23 | cy.checkState 24 | in: true 25 | above: false 26 | below: false 27 | 28 | it 'is still fully visible after scrolling 100vh', -> 29 | cy.scroll viewportH 30 | cy.checkState 31 | in: true 32 | above: false 33 | below: false 34 | 35 | it 'is still no longer fully visible after 1 more px of scroll', -> 36 | cy.scroll viewportH + 1 37 | cy.checkState 38 | in: true 39 | above: true 40 | below: false 41 | 42 | it 'is fully hidden after scrolling 100vh plus box height', -> 43 | cy.scroll viewportH + boxH 44 | cy.checkState 45 | in: false 46 | above: true 47 | below: false 48 | -------------------------------------------------------------------------------- /cypress/integration/trigger-late.coffee: -------------------------------------------------------------------------------- 1 | { viewportH, boxH } = require '../support/vars' 2 | context 'Trigger late story', -> 3 | 4 | beforeEach -> 5 | cy.viewport 800, viewportH 6 | cy.visit 'iframe.html?id=examples--trigger-late-px' 7 | 8 | it 'is initially hidden', -> 9 | cy.checkState 10 | in: false 11 | above: false 12 | below: true 13 | 14 | it 'is not visible after 1px of scroll', -> 15 | cy.scroll 1 16 | cy.checkState 17 | in: false 18 | above: false 19 | below: true 20 | 21 | it 'becomes visible after 20px of scroll', -> 22 | cy.scroll 20 23 | cy.checkState 24 | in: true 25 | above: false 26 | below: true 27 | 28 | it 'becomes fully visible after 220px of scroll', -> 29 | cy.scroll boxH + 20 30 | cy.checkState 31 | in: true 32 | above: false 33 | below: false 34 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example plugins/index.js can be used to load plugins 3 | // 4 | // You can change the location of this file or turn off loading 5 | // the plugins file with the 'pluginsFile' configuration option. 6 | // 7 | // You can read more here: 8 | // https://on.cypress.io/plugins-guide 9 | // *********************************************************** 10 | 11 | // This function is called when a project is opened or re-opened (e.g. due to 12 | // the project's config changing) 13 | 14 | module.exports = (on, config) => { 15 | // `on` is used to hook into various events Cypress emits 16 | // `config` is the resolved Cypress config 17 | } 18 | -------------------------------------------------------------------------------- /cypress/support/commands.coffee: -------------------------------------------------------------------------------- 1 | # Check the state of the Box component 2 | Cypress.Commands.add 'checkState', (state) -> 3 | for key,val of state 4 | klass = "#{key}-viewport" 5 | if val 6 | then cy.get('.box').should 'have.class', klass 7 | else cy.get('.box').should 'not.have.class', klass 8 | return 9 | 10 | # Scroll the storybook ifrmae 11 | Cypress.Commands.add 'scroll', (y) -> 12 | cy.get('.viewport').scrollTo(0,y) 13 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands.coffee' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/support/vars.coffee: -------------------------------------------------------------------------------- 1 | module.exports = 2 | viewportH: 800 3 | boxH: 200 -------------------------------------------------------------------------------- /index.coffee: -------------------------------------------------------------------------------- 1 | # A dictionary for storing data per-element 2 | counter = 0 3 | instances = {} 4 | 5 | # Support toggling of global disabled state 6 | disabled = false 7 | export disable = -> disabled = true 8 | export enable = -> 9 | disabled = false 10 | update instance for id, instance of instances 11 | 12 | # Create instance after the element has been added to DOM 13 | startObserving = (el, binding) -> 14 | 15 | # If an indvidual instance is disabled, just add the in viewport classes 16 | # to reveal the element 17 | if binding?.value?.disabled || directive.defaults.disabled || disabled 18 | el.classList.add.apply el.classList, [ 'in-viewport' ] 19 | return 20 | 21 | # Create the instance object 22 | instance = observer: makeObserver el, binding 23 | 24 | # Generate a unique id that will be store in a data value on the element 25 | id = 'i' + counter++ 26 | el.setAttribute 'data-in-viewport', id 27 | instances[id] = instance 28 | 29 | # Make the instance 30 | makeObserver = (el, { value = {}, modifiers }) -> 31 | 32 | # Make the default root 33 | root = value.root || directive.defaults.root 34 | root = switch typeof root 35 | when 'function' then root() 36 | when 'string' then document.querySelector root 37 | when 'object' then root # Expects to be a DOMElement 38 | 39 | # Make the default margin 40 | margin = if typeof value == 'string' then value 41 | else value.margin || directive.defaults.margin 42 | 43 | # Make the observer callback 44 | callback = ([..., entry]) -> update { el, entry, modifiers } 45 | 46 | # Make the observer instance 47 | observer = new IntersectionObserver callback, 48 | root: root 49 | rootMargin: margin 50 | threshold: [0,1] 51 | 52 | # Start observing the element and return the observer 53 | observer.observe el 54 | return observer 55 | 56 | # Update element classes based on current intersection state 57 | update = ({ el, entry, modifiers }) -> 58 | 59 | # Destructure the entry to just what's needed 60 | { 61 | boundingClientRect: target 62 | rootBounds: root 63 | isIntersecting: inViewport 64 | } = entry 65 | 66 | # If rootBounds was null (sometimes happens in an iframe), make it from the 67 | # window 68 | root = { top: 0, bottom: window.innerHeight } unless root 69 | 70 | # Apply classes to element 71 | el.classList.toggle 'in-viewport', inViewport 72 | el.classList.toggle 'above-viewport', target.top < root.top 73 | el.classList.toggle 'below-viewport', target.bottom > root.bottom + 1 74 | 75 | # If set to update "once", remove listeners if in viewport 76 | removeObserver el if modifiers.once and inViewport 77 | 78 | # Compare two objects. Doing JSON.stringify to conpare as a quick way to 79 | # deep compare objects 80 | objIsSame = (obj1, obj2) -> JSON.stringify(obj1) == JSON.stringify(obj2) 81 | 82 | # Remove scrollMonitor listeners 83 | removeObserver = (el) -> 84 | id = el.getAttribute 'data-in-viewport' 85 | if instance = instances[id] 86 | instance.observer?.disconnect() 87 | delete instances[id] 88 | 89 | # Mixin definition 90 | export default directive = 91 | 92 | # Define overrideable defaults 93 | defaults: 94 | root: undefined 95 | margin: '0px 0px -1px 0px' 96 | disabled: false 97 | 98 | # Init 99 | inserted: (el, binding) -> startObserving el, binding 100 | 101 | # If the value changed, re-init observer 102 | componentUpdated: (el, binding) -> 103 | return if objIsSame binding.value, binding.oldValue 104 | removeObserver el 105 | startObserving el, binding 106 | 107 | # Cleanup 108 | unbind: (el) -> removeObserver el 109 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = exports.enable = exports.disable = void 0; 7 | 8 | function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _nonIterableRest(); } 9 | 10 | function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } 11 | 12 | function _iterableToArrayLimit(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } 13 | 14 | function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } 15 | 16 | function _typeof(obj) { if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); } 17 | 18 | // Generated by CoffeeScript 2.4.1 19 | // A dictionary for storing data per-element 20 | var counter, 21 | directive, 22 | disabled, 23 | instances, 24 | makeObserver, 25 | objIsSame, 26 | removeObserver, 27 | startObserving, 28 | update, 29 | slice = [].slice; 30 | counter = 0; 31 | instances = {}; // Support toggling of global disabled state 32 | 33 | disabled = false; 34 | 35 | var disable = function disable() { 36 | return disabled = true; 37 | }; 38 | 39 | exports.disable = disable; 40 | 41 | var enable = function enable() { 42 | var id, instance, results; 43 | disabled = false; 44 | results = []; 45 | 46 | for (id in instances) { 47 | instance = instances[id]; 48 | results.push(update(instance)); 49 | } 50 | 51 | return results; 52 | }; // Create instance after the element has been added to DOM 53 | 54 | 55 | exports.enable = enable; 56 | 57 | startObserving = function startObserving(el, binding) { 58 | var id, instance, ref; // If an indvidual instance is disabled, just add the in viewport classes 59 | // to reveal the element 60 | 61 | if ((binding != null ? (ref = binding.value) != null ? ref.disabled : void 0 : void 0) || directive.defaults.disabled || disabled) { 62 | el.classList.add.apply(el.classList, ['in-viewport']); 63 | return; 64 | } // Create the instance object 65 | 66 | 67 | instance = { 68 | observer: makeObserver(el, binding) 69 | }; // Generate a unique id that will be store in a data value on the element 70 | 71 | id = 'i' + counter++; 72 | el.setAttribute('data-in-viewport', id); 73 | return instances[id] = instance; 74 | }; // Make the instance 75 | 76 | 77 | makeObserver = function makeObserver(el, _ref) { 78 | var _ref$value = _ref.value, 79 | value = _ref$value === void 0 ? {} : _ref$value, 80 | modifiers = _ref.modifiers; 81 | var callback, margin, observer, root; // Make the default root 82 | 83 | root = value.root || directive.defaults.root; 84 | 85 | root = function () { 86 | switch (_typeof(root)) { 87 | case 'function': 88 | return root(); 89 | 90 | case 'string': 91 | return document.querySelector(root); 92 | 93 | case 'object': 94 | return root; 95 | // Expects to be a DOMElement 96 | } 97 | }(); // Make the default margin 98 | 99 | 100 | margin = typeof value === 'string' ? value : value.margin || directive.defaults.margin; // Make the observer callback 101 | 102 | callback = function callback(arg) { 103 | var arg, entry; 104 | 105 | var _slice$call = slice.call(arg, -1); 106 | 107 | var _slice$call2 = _slicedToArray(_slice$call, 1); 108 | 109 | entry = _slice$call2[0]; 110 | return update({ 111 | el: el, 112 | entry: entry, 113 | modifiers: modifiers 114 | }); 115 | }; // Make the observer instance 116 | 117 | 118 | observer = new IntersectionObserver(callback, { 119 | root: root, 120 | rootMargin: margin, 121 | threshold: [0, 1] 122 | }); // Start observing the element and return the observer 123 | 124 | observer.observe(el); 125 | return observer; 126 | }; // Update element classes based on current intersection state 127 | 128 | 129 | update = function update(_ref2) { 130 | var el = _ref2.el, 131 | entry = _ref2.entry, 132 | modifiers = _ref2.modifiers; 133 | var inViewport, root, target; 134 | target = entry.boundingClientRect; 135 | root = entry.rootBounds; 136 | inViewport = entry.isIntersecting; 137 | 138 | if (!root) { 139 | // If rootBounds was null (sometimes happens in an iframe), make it from the 140 | // window 141 | root = { 142 | top: 0, 143 | bottom: window.innerHeight 144 | }; 145 | } // Apply classes to element 146 | 147 | 148 | el.classList.toggle('in-viewport', inViewport); 149 | el.classList.toggle('above-viewport', target.top < root.top); 150 | el.classList.toggle('below-viewport', target.bottom > root.bottom + 1); 151 | 152 | if (modifiers.once && inViewport) { 153 | // If set to update "once", remove listeners if in viewport 154 | return removeObserver(el); 155 | } 156 | }; // Compare two objects. Doing JSON.stringify to conpare as a quick way to 157 | // deep compare objects 158 | 159 | 160 | objIsSame = function objIsSame(obj1, obj2) { 161 | return JSON.stringify(obj1) === JSON.stringify(obj2); 162 | }; // Remove scrollMonitor listeners 163 | 164 | 165 | removeObserver = function removeObserver(el) { 166 | var id, instance, ref; 167 | id = el.getAttribute('data-in-viewport'); 168 | 169 | if (instance = instances[id]) { 170 | if ((ref = instance.observer) != null) { 171 | ref.disconnect(); 172 | } 173 | 174 | return delete instances[id]; 175 | } 176 | }; // Mixin definition 177 | 178 | 179 | var _default = directive = { 180 | // Define overrideable defaults 181 | defaults: { 182 | root: void 0, 183 | margin: '0px 0px -1px 0px', 184 | disabled: false 185 | }, 186 | // Init 187 | inserted: function inserted(el, binding) { 188 | return startObserving(el, binding); 189 | }, 190 | // If the value changed, re-init observer 191 | componentUpdated: function componentUpdated(el, binding) { 192 | if (objIsSame(binding.value, binding.oldValue)) { 193 | return; 194 | } 195 | 196 | removeObserver(el); 197 | return startObserving(el, binding); 198 | }, 199 | // Cleanup 200 | unbind: function unbind(el) { 201 | return removeObserver(el); 202 | } 203 | }; 204 | 205 | exports.default = _default; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-in-viewport-directive", 3 | "version": "2.0.2", 4 | "description": "Vue 2 directive that adds css classes when the element is the viewport", 5 | "main": "index.js", 6 | "repository": "https://github.com/BKWLD/vue-in-viewport-directive.git", 7 | "author": "Bukwild", 8 | "license": "MIT", 9 | "scripts": { 10 | "test": "cypress run", 11 | "build": "coffee -ct index.coffee", 12 | "version": "npm run build && git add -A", 13 | "postversion": "git push --follow-tags && yarn storybook:deploy", 14 | "storybook": "start-storybook -p 6006", 15 | "storybook:build": "build-storybook", 16 | "storybook:deploy": "storybook-to-ghpages" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/BKWLD/vue-in-viewport-directive/issues" 20 | }, 21 | "homepage": "https://github.com/BKWLD/vue-in-viewport-directive#readme", 22 | "peerDependencies": { 23 | "vue": "2.x" 24 | }, 25 | "devDependencies": { 26 | "@babel/core": "^7.4.3", 27 | "@babel/preset-env": "^7.4.3", 28 | "@storybook/addon-knobs": "^5.0.6", 29 | "@storybook/addons": "^5.0.6", 30 | "@storybook/storybook-deployer": "^2.8.1", 31 | "@storybook/vue": "^5.0.6", 32 | "babel-loader": "^8.0.5", 33 | "babel-preset-vue": "^2.0.2", 34 | "coffee-loader": "^0.9.0", 35 | "coffeescript": "^2.4.1", 36 | "cypress": "^3.2.0", 37 | "intersection-observer": "^0.5.1", 38 | "vue": "^2.6.10", 39 | "vue-loader": "^15.7.0", 40 | "vue-template-compiler": "^2.6.10" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /stories/box/Always.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | 13 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /stories/box/Once.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | 13 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /stories/box/Shorthand.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | 13 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /stories/box/styles.css: -------------------------------------------------------------------------------- 1 | .box { 2 | box-sizing: border-box; 3 | width: 300px; 4 | height: 200px; 5 | background: linear-gradient(#39d6d4, #1ca7fd); 6 | border: 10px solid transparent; 7 | transition: border 1s; 8 | } 9 | 10 | .box.in-viewport { 11 | border: 10px solid #94d6ff; 12 | } 13 | 14 | .box.above-viewport { 15 | border-bottom-color: #f3cb5b; 16 | } 17 | 18 | .box.below-viewport { 19 | border-top-color: #f3cb5b; 20 | } 21 | -------------------------------------------------------------------------------- /stories/index.stories.js: -------------------------------------------------------------------------------- 1 | // Storybook deps 2 | import { storiesOf, addDecorator } from '@storybook/vue' 3 | import { 4 | withKnobs, 5 | number, 6 | text, 7 | boolean, 8 | object, 9 | } from '@storybook/addon-knobs' 10 | 11 | // Simple component to test with 12 | import 'intersection-observer' // For IE 13 | import Always from './box/Always' 14 | import Once from './box/Once' 15 | import Shorthand from './box/Shorthand' 16 | 17 | // Shared props 18 | const props = ({ 19 | marginTop = '100vh', 20 | marginBottom = '100vh', 21 | height = '', 22 | disabled = false, 23 | margin = '0px 0px -1px 0px', 24 | }) => { return { 25 | marginTop: { default: text('CSS margin-top', marginTop) }, 26 | marginBottom: { default: text('CSS margin-bottom', marginBottom) }, 27 | height: { default: text('CSS height', height) }, 28 | 29 | disabled: { default: boolean('disabled', disabled) }, 30 | margin: { default: text('margin', margin) }, 31 | }} 32 | 33 | // Shared box template. I had to make an artifical viewport box because 34 | // I couldn't set the iframe that Storybook uses as the viewport but the 35 | // without that, the viewport of the parent document was getting measured, 36 | // which was too tall 37 | // https://github.com/w3c/IntersectionObserver/issues/283 38 | const box = ` 39 |
45 | 54 |
` 55 | 56 | // Scroll down to box 57 | const initiallyHiddenBox = ` 58 |
59 |

👇👇👇👇👇👇👇👇👇

60 | ${box} 61 |
` 62 | 63 | // Create a bucket of stories 64 | addDecorator(withKnobs) 65 | storiesOf('Examples', module) 66 | 67 | .add('Initially visible', () => ({ 68 | components: { box: Always }, 69 | props: props({ marginTop: '0vh' }), 70 | template: box, 71 | })) 72 | 73 | .add('Scroll to reveal', () => ({ 74 | components: { box: Always }, 75 | props: props({}), 76 | template: initiallyHiddenBox, 77 | })) 78 | 79 | .add('Trigger once', () => ({ 80 | components: { box: Once }, 81 | props: props({}), 82 | template: initiallyHiddenBox, 83 | })) 84 | 85 | .add('Initially inactive', () => ({ 86 | components: { box: Always }, 87 | props: props({ disabled: true }), 88 | template: initiallyHiddenBox, 89 | })) 90 | 91 | .add('Trigger late (px)', () => ({ 92 | components: { box: Always }, 93 | props: props({ margin: '-20px 0px' }), 94 | template: initiallyHiddenBox, 95 | })) 96 | 97 | .add('Trigger late (%)', () => ({ 98 | components: { box: Shorthand }, 99 | props: props({ margin: '-20% 0%' }), 100 | template: initiallyHiddenBox, 101 | })) 102 | 103 | .add('Trigger early', () => ({ 104 | components: { box: Always }, 105 | props: props({ margin: '200px 0px' }), 106 | template: initiallyHiddenBox, 107 | })) 108 | 109 | 110 | --------------------------------------------------------------------------------