├── .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: '',
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: '',
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: '',
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: '',
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 |
--------------------------------------------------------------------------------