├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── pull.yml │ └── push.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── release.config.js ├── rollup.config.js ├── src ├── config.ts ├── directive.ts ├── index.ts ├── plugin.ts └── scroll.ts ├── tests ├── directive.spec.ts └── plugin.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['airbnb-typescript/base'], 4 | parserOptions: { 5 | project: './tsconfig.json', 6 | }, 7 | rules: { 8 | '@typescript-eslint/no-extra-semi': 'off', 9 | 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /.github/workflows/pull.yml: -------------------------------------------------------------------------------- 1 | name: pull 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | - alpha 7 | jobs: 8 | 9 | preview: 10 | name: preview 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v1 15 | - name: Install dependencies 16 | run: npm ci 17 | - name: Dry run release 18 | env: 19 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 20 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | run: npx semantic-release --dry-run --no-ci 22 | 23 | build: 24 | name: build 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v1 29 | - name: Install dependencies 30 | run: npm ci 31 | - name: Run build script 32 | run: npm run build 33 | 34 | lint: 35 | name: lint 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v1 40 | - name: Install dependencies 41 | run: npm ci 42 | - name: Run lint script 43 | run: npm run lint 44 | 45 | test: 46 | name: test 47 | runs-on: ubuntu-latest 48 | steps: 49 | - name: Checkout 50 | uses: actions/checkout@v1 51 | - name: Install dependencies 52 | run: npm ci 53 | - name: Run test script 54 | run: npm run test 55 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: push 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - alpha 7 | jobs: 8 | 9 | release: 10 | name: release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v1 15 | - name: Install dependencies 16 | run: npm ci 17 | - name: Release 18 | env: 19 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 20 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | run: npx semantic-release 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Theodore Messinezis 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 | ## Installing 2 | 3 | #### Using a package manager (recommended) 4 | 5 | The recommended way of installing _vue-chat-scroll_ is using the [npm package](https://www.npmjs.com/package/vue-chat-scroll/v/alpha) with the npm (or yarn) package manager: 6 | 7 | ```bash 8 | npm i vue-chat-scroll@alpha 9 | ``` 10 | 11 | After installing the package, you must use the _vue-chat-scroll_ [plugin](https://vuejs.org/v2/guide/plugins.html#Using-a-Plugin) : 12 | 13 | ```js 14 | 15 | import VueChatScroll from 'vue-chat-scroll'; 16 | 17 | Vue.use(VueChatScroll); 18 | 19 | new Vue(...); 20 | ``` 21 | 22 | #### Using a script tag 23 | 24 | If working on a proof of concept or a fiddle, it can be easier to use a script tag. We recommend using a CDN such as unpkg or jsdelvr. 25 | 26 | ```html 27 | 28 | ``` 29 | 30 | _vue-chat-scroll_ will attempt to auto-register itself with Vue. This should work as long as `window.Vue` is defined. 31 | 32 | ## Usage 33 | 34 | We aim to make using _vue-chat-scroll_ as straightforward as possible. Simply using the `v-chat-scroll` directive should take care of most use cases. 35 | 36 | ```html 37 |
38 | ... 39 |
40 | ``` 41 | 42 | You may configure the directive by passing an object as well. For example, the `enable` configuration flag: 43 | 44 | ```html 45 |
46 | ... 47 |
48 | ``` 49 | 50 | Please refer to the `Config` interface and `defaultConfig` object in [config.ts](src/config.ts) to find out more about what can be configured, as well as what the default configuration values are. 51 | 52 | ## Examples 53 | 54 | 🧸 Bear with us, all of this is work in progress. We'll be adding some examples of how this plugin can be used to build a fully-featured chat (such as Slack's one), or even a console looking log viewer. 55 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: true, 3 | preset: 'ts-jest', 4 | collectCoverage: true, 5 | collectCoverageFrom: [ 6 | 'src/**/*.ts', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-chat-scroll", 3 | "description": "Vue directive to keep things scrolled to the bottom", 4 | "keywords": [ 5 | "vue", 6 | "directive", 7 | "chat", 8 | "scroll", 9 | "bottom" 10 | ], 11 | "license": "MIT", 12 | "author": "Theodore Messinezis", 13 | "files": [ 14 | "dist/vue-chat-scroll.js" 15 | ], 16 | "main": "dist/vue-chat-scroll.js", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/theomessin/vue-chat-scroll.git" 20 | }, 21 | "scripts": { 22 | "build": "rollup -c", 23 | "lint": "eslint --ext .ts ./", 24 | "test": "jest" 25 | }, 26 | "devDependencies": { 27 | "@rollup/plugin-typescript": "^4.0.0", 28 | "@semantic-release/exec": "^5.0.0", 29 | "@types/jest": "^25.2.1", 30 | "@typescript-eslint/eslint-plugin": "^2.27.0", 31 | "@vue/test-utils": "^1.0.0-beta.33", 32 | "eslint": "^6.8.0", 33 | "eslint-config-airbnb-typescript": "^7.2.0", 34 | "eslint-plugin-import": "^2.20.2", 35 | "eslint-plugin-react": "^7.19.0", 36 | "jest": "^25.3.0", 37 | "rollup": "^2.3.3", 38 | "rollup-plugin-uglify": "^6.0.4", 39 | "semantic-release": "^17.0.4", 40 | "ts-jest": "^25.3.1", 41 | "tslib": "^1.11.1", 42 | "typescript": "^3.8.3", 43 | "vue": "^2.0.0", 44 | "vue-template-compiler": "^2.6.11" 45 | }, 46 | "peerDependencies": { 47 | "vue": "^2.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ['master', { name: 'alpha', prerelease: true }], 3 | repositoryUrl: 'git@github.com:theomessin/vue-chat-scroll.git', 4 | plugins: [ 5 | ['@semantic-release/exec', { prepareCmd: 'npm run build' }], 6 | ['@semantic-release/github', { assets: 'dist/**/*.js' }], 7 | '@semantic-release/release-notes-generator', 8 | '@semantic-release/commit-analyzer', 9 | '@semantic-release/npm', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { uglify } from 'rollup-plugin-uglify'; 2 | import typescript from '@rollup/plugin-typescript'; 3 | 4 | export default { 5 | input: 'src/index.ts', 6 | output: { 7 | file: 'dist/vue-chat-scroll.js', 8 | name: 'vue-chat-scroll', 9 | format: 'umd', 10 | }, 11 | plugins: [ 12 | typescript(), 13 | uglify(), 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This interface defines the config for the v-chat-scroll directive. 3 | */ 4 | export interface Config { 5 | enabled: boolean, 6 | handlePrepend: boolean, 7 | }; 8 | 9 | /** 10 | * This object defines the default config for the directive. 11 | */ 12 | export const defaultConfig: Config = { 13 | enabled: true, 14 | handlePrepend: false, 15 | }; 16 | -------------------------------------------------------------------------------- /src/directive.ts: -------------------------------------------------------------------------------- 1 | import { DirectiveOptions } from 'vue'; 2 | import { Config, defaultConfig } from './config'; 3 | import { scroll } from './scroll'; 4 | 5 | const observers: WeakMap = new WeakMap(); 6 | const heights: WeakMap = new WeakMap(); 7 | 8 | /** 9 | * This function is called when a mutation is observed. 10 | * The config is used to determine what to do. 11 | */ 12 | const mutationObserved = (el: Element, config: Config): void => { 13 | // if not enabled, do nothing. 14 | if (config.enabled === false) return; 15 | 16 | // if not handling prepend, simply scroll. 17 | if (config.handlePrepend === false) { 18 | scroll(el); 19 | return; 20 | } 21 | 22 | // if handling prepend, we need to calculate where to scroll to. 23 | // We're prepending if scrollTop is zero and heights has the el. 24 | // ScrollTop will be difference in scrollHeight before and after. 25 | const scrollTop = (el.scrollTop === 0 && heights.has(el)) 26 | && (el.scrollHeight - heights.get(el)); 27 | 28 | scroll(el, scrollTop); 29 | heights.set(el, el.scrollHeight); 30 | }; 31 | 32 | /** 33 | * This object defines the directive itself. 34 | */ 35 | export const directive: DirectiveOptions = { 36 | inserted: (el, binding) => { 37 | const config: Config = { ...defaultConfig, ...binding.value }; 38 | mutationObserved(el, config); 39 | }, 40 | 41 | /** 42 | * When the directive binding is updated we have to update our MutationObserver. 43 | * We disconnect the old MutationObserver (if it already exists in observers). 44 | * We then create and save a new MutationObserver with the new callback. 45 | */ 46 | update: (el, binding) => { 47 | if (observers.has(el)) observers.get(el).disconnect(); 48 | const config: Config = { ...defaultConfig, ...binding.value }; 49 | const mutationCallback: MutationCallback = () => { mutationObserved(el, config); }; 50 | 51 | const mutationObserver = new MutationObserver(mutationCallback); 52 | mutationObserver.observe(el, { childList: true, subtree: true }); 53 | observers.set(el, mutationObserver); 54 | }, 55 | }; 56 | 57 | export default directive; 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | import { bootstrap, plugin } from './plugin'; 3 | 4 | bootstrap(); 5 | export default plugin; 6 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { PluginObject } from 'vue'; 2 | import { directive } from './directive'; 3 | 4 | export const plugin: PluginObject = { 5 | install: (Vue) => { 6 | Vue.directive('chat-scroll', directive); 7 | }, 8 | }; 9 | 10 | export const bootstrap = () => { 11 | /* istanbul ignore else */ 12 | if (typeof window !== 'undefined' && window.Vue) { 13 | window.Vue.use(plugin); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/scroll.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | /** 4 | * This function will set the scrollTop of an element using either the 5 | * scroll method if available, or by changing the scrollTop property. 6 | * If no scrollTop is specified, it'll scroll to the bottom. 7 | */ 8 | export const scroll = (el: Element, scrollTop?: number): void => { 9 | const top = scrollTop || el.scrollHeight - el.clientHeight; 10 | if (typeof el.scroll === 'function') el.scroll({ top }); 11 | else el.scrollTop = top; // eslint-disable-line no-param-reassign 12 | }; 13 | 14 | export default scroll; 15 | -------------------------------------------------------------------------------- /tests/directive.spec.ts: -------------------------------------------------------------------------------- 1 | import * as vueTestUtils from '@vue/test-utils'; 2 | import { directive } from '../src/directive'; 3 | import { scroll } from '../src/scroll'; 4 | 5 | jest.mock('../src/scroll'); 6 | beforeEach(() => { jest.clearAllMocks(); }); 7 | 8 | test('it automatically calls scrolls on init', () => { 9 | const Component: Vue.Component = { 10 | template: '
', 11 | }; 12 | 13 | const localDirective = directive; 14 | const localVue = vueTestUtils.createLocalVue(); 15 | localVue.directive('chat-scroll', localDirective); 16 | 17 | const wrapper = vueTestUtils.mount(Component, { localVue }); 18 | expect(scroll).toHaveBeenCalledTimes(1); 19 | expect(scroll).toHaveBeenCalledWith(wrapper.element); 20 | }); 21 | 22 | test('it calls scroll when an item is added', async () => { 23 | const Component: Vue.Component = { 24 | data: () => ({ items: [] }), 25 | template: '
{{ item }}
', 26 | }; 27 | 28 | const localDirective = directive; 29 | localDirective.inserted = jest.fn(); 30 | const localVue = vueTestUtils.createLocalVue(); 31 | localVue.directive('chat-scroll', localDirective); 32 | const wrapper = vueTestUtils.mount(Component, { localVue }); 33 | 34 | (wrapper.vm as any).$data.items.push('A new item'); 35 | await (wrapper.vm as any).$nextTick(); 36 | expect(scroll).toHaveBeenCalledTimes(1); 37 | expect(scroll).toHaveBeenCalledWith(wrapper.element); 38 | }); 39 | 40 | test('it calls scroll when an item is removed', async () => { 41 | const Component: Vue.Component = { 42 | data: () => ({ items: [1, 2, 3] }), 43 | template: '
{{ item }}
', 44 | }; 45 | 46 | const localDirective = directive; 47 | localDirective.inserted = jest.fn(); 48 | const localVue = vueTestUtils.createLocalVue(); 49 | localVue.directive('chat-scroll', localDirective); 50 | const wrapper = vueTestUtils.mount(Component, { localVue }); 51 | 52 | (wrapper.vm as any).$data.items.pop(); 53 | await (wrapper.vm as any).$nextTick(); 54 | expect(scroll).toHaveBeenCalledTimes(1); 55 | expect(scroll).toHaveBeenCalledWith(wrapper.element); 56 | }); 57 | 58 | test('it obeys the enabled configuration parameter', async () => { 59 | const Component: Vue.Component = { 60 | data: () => ({ enabled: false, items: [1, 2, 3] }), 61 | template: '
{{ item }}
', 62 | }; 63 | 64 | const localDirective = directive; 65 | const localVue = vueTestUtils.createLocalVue(); 66 | localVue.directive('chat-scroll', localDirective); 67 | const wrapper = vueTestUtils.mount(Component, { localVue }); 68 | 69 | (wrapper.vm as any).$data.items.pop(); 70 | await (wrapper.vm as any).$nextTick(); 71 | expect(scroll).not.toHaveBeenCalled(); 72 | 73 | (wrapper.vm as any).$data.enabled = true; 74 | (wrapper.vm as any).$data.items.pop(); 75 | await (wrapper.vm as any).$nextTick(); 76 | expect(scroll).toHaveBeenCalledTimes(1); 77 | expect(scroll).toHaveBeenCalledWith(wrapper.element); 78 | }); 79 | 80 | test('it correctly works when prepending', async () => { 81 | const Component: Vue.Component = { 82 | data: () => ({ handlePrepend: true, items: [1, 2, 3] }), 83 | template: '
{{ item }}
', 84 | }; 85 | 86 | const localDirective = directive; 87 | const localVue = vueTestUtils.createLocalVue(); 88 | localVue.directive('chat-scroll', localDirective); 89 | const wrapper = vueTestUtils.mount(Component, { localVue }); 90 | 91 | // Set the current wrapper to be 50 pixels tall. 92 | jest.spyOn(wrapper.element, 'scrollHeight', 'get').mockImplementation(() => 50); 93 | // We've scrolled all the way to the bottom. 94 | jest.spyOn(wrapper.element, 'scrollTop', 'get').mockImplementation(() => 50); 95 | 96 | (wrapper.vm as any).$data.items.pop(); 97 | await (wrapper.vm as any).$nextTick(); 98 | expect(scroll).toHaveBeenCalledWith(wrapper.element, false); 99 | 100 | // We've now scrolled all the way to the top. 101 | jest.spyOn(wrapper.element, 'scrollTop', 'get').mockImplementation(() => 0); 102 | // Now the current wrapper should be 75 pixels tall (25 pixel increase). 103 | jest.spyOn(wrapper.element, 'scrollHeight', 'get').mockImplementation(() => 75); 104 | 105 | (wrapper.vm as any).$data.items.unshift(0); 106 | await (wrapper.vm as any).$nextTick(); 107 | expect(scroll).toHaveBeenCalledWith(wrapper.element, 25); 108 | }); 109 | -------------------------------------------------------------------------------- /tests/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import { createLocalVue } from '@vue/test-utils'; 2 | import { bootstrap, plugin } from '../src/plugin'; 3 | import { directive } from '../src/directive'; 4 | 5 | test('it registers the directive', async () => { 6 | const localVue = createLocalVue(); 7 | localVue.directive = jest.fn(); 8 | localVue.use(plugin); 9 | expect(localVue.directive).toHaveBeenCalledTimes(1); 10 | expect(localVue.directive).toBeCalledWith('chat-scroll', directive); 11 | }); 12 | 13 | test('it auto uses itself when window.Vue is defined', () => { 14 | window.Vue = createLocalVue(); 15 | window.Vue.use = jest.fn(); 16 | bootstrap(); // We assume this gets called automatically. 17 | expect(window.Vue.use).toHaveBeenCalledTimes(1); 18 | expect(window.Vue.use).toBeCalledWith(plugin); 19 | }); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // 3 | } 4 | --------------------------------------------------------------------------------