├── .circleci └── config.yml ├── .eslintrc.yml ├── .gitignore ├── .storybook └── config.js ├── LICENSE ├── README.md ├── index.d.ts ├── package-lock.json ├── package.json ├── prettier.config.js ├── scripts ├── build.js ├── options.js └── webpack.config.test.js ├── src ├── SizeObserver.js ├── SizeProvider.js └── index.js ├── stories ├── SizeTransition.vue └── index.stories.js ├── test ├── .eslintrc.yml └── index.js ├── testem.yml └── vue-size-provider.gif /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | working_directory: ~/working/repo 5 | docker: 6 | - image: circleci/node:12-browsers 7 | 8 | jobs: 9 | install: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | 14 | # Download and cache dependencies 15 | - restore_cache: 16 | keys: 17 | - v2-dependencies-{{ checksum "package-lock.json" }} 18 | # fallback to using the latest cache if no exact match is found 19 | - v2-dependencies- 20 | 21 | - run: npm install 22 | 23 | - save_cache: 24 | paths: 25 | - node_modules 26 | key: v2-dependencies-{{ checksum "package-lock.json" }} 27 | 28 | - persist_to_workspace: 29 | root: ~/working 30 | paths: 31 | - repo 32 | 33 | build: 34 | <<: *defaults 35 | steps: 36 | - attach_workspace: 37 | at: ~/working 38 | - run: npm run build 39 | 40 | test: 41 | <<: *defaults 42 | steps: 43 | - attach_workspace: 44 | at: ~/working 45 | - run: npm run test 46 | 47 | workflows: 48 | version: 2 49 | build_and_test: 50 | jobs: 51 | - install 52 | - build: 53 | requires: 54 | - install 55 | - test: 56 | requires: 57 | - install 58 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | extends: eslint-config-ktsn 3 | parser: babel-eslint 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | /dist/ 4 | /.tmp/ 5 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { configure } from '@storybook/vue' 3 | import plugin from '../src/' 4 | 5 | Vue.use(plugin) 6 | 7 | // automatically import all files ending in *.stories.js 8 | const req = require.context('../stories', true, /.stories.js$/) 9 | function loadStories() { 10 | req.keys().forEach(filename => req(filename)) 11 | } 12 | 13 | configure(loadStories, module) 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 katashin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-size-provider 2 | 3 | Declarative element size observer and provider. 4 | 5 | ## Motivation 6 | 7 | Sometimes you may want to animate an element height when its content is changed. In that case, you need to directly read height value from DOM because Virtual DOM cannot acquire element size. Since it is low-level manipulation, the code would become messier. 8 | 9 | vue-size-provider solves this problem by hiding low-level code with abstract helper components - `` and ``. The following gif is an example to show how vue-size-provider works: 10 | 11 | ![Simple demo of vue-size-provider](vue-size-provider.gif) 12 | 13 | ## Install 14 | 15 | Install it via npm: 16 | 17 | ```sh 18 | $ npm install vue-size-provider 19 | ``` 20 | 21 | Then, notify Vue to use it: 22 | 23 | ```js 24 | import Vue from 'vue' 25 | import VueSizeProvider from 'vue-size-provider' 26 | 27 | Vue.use(VueSizeProvider) 28 | ``` 29 | 30 | Or you can directly use the components: 31 | 32 | ```vue 33 | 43 | ``` 44 | 45 | ## Usage 46 | 47 | First, wrap elements that you would like to observe their size by ``. 48 | 49 | ```vue 50 | 58 | 59 | 69 | ``` 70 | 71 | Then, wrap them by `` and any element that you want to animate its size when the contents size is changed. 72 | 73 | ```vue 74 | 89 | 90 | 100 | ``` 101 | 102 | Finally, you need to write some animation code. In this example, we simply use CSS transition: 103 | 104 | ```vue 105 | 120 | 121 | 131 | 132 | 141 | ``` 142 | 143 | Note that you may want to specify `box-sizing: content-box;` to the animating element because the provided `width` and `height` are content size of the observed element. i.e. They do not include padding and border size. 144 | 145 | ## License 146 | 147 | MIT 148 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { ComponentOptions } from 'vue' 2 | 3 | export const SizeProvider: ComponentOptions 4 | export const SizeObserver: ComponentOptions 5 | export default function install(_Vue: typeof Vue): void -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-size-provider", 3 | "version": "0.2.1", 4 | "author": "katashin", 5 | "description": "Declarative element size observer and provider", 6 | "keywords": [ 7 | "Vue", 8 | "size", 9 | "resize", 10 | "declarative", 11 | "scoped slot" 12 | ], 13 | "license": "MIT", 14 | "main": "dist/vue-size-provider.cjs.js", 15 | "module": "dist/vue-size-provider.esm.js", 16 | "unpkg": "dist/vue-size-provider.js", 17 | "types": "index.d.ts", 18 | "files": [ 19 | "dist", 20 | "index.d.ts" 21 | ], 22 | "homepage": "https://github.com/ktsn/vue-size-provider", 23 | "bugs": "https://github.com/ktsn/vue-size-provider/issues", 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/ktsn/vue-size-provider.git" 27 | }, 28 | "scripts": { 29 | "prepublishOnly": "npm run release", 30 | "clean": "rm -rf dist .tmp", 31 | "build": "node scripts/build.js", 32 | "build:test": "webpack --config scripts/webpack.config.test.js", 33 | "watch:test": "webpack -w --config scripts/webpack.config.test.js", 34 | "lint": "eslint \"@(src|test|scripts)/**/*.js\"", 35 | "lint:fix": "eslint --fix \"@(src|test|scripts)/**/*.js\"", 36 | "testem": "testem", 37 | "testem:ci": "testem ci", 38 | "dev": "run-p watch:test testem", 39 | "test": "run-s lint test:unit", 40 | "test:unit": "run-s build:test testem:ci", 41 | "storybook": "start-storybook -p 6006", 42 | "release": "run-s test clean build" 43 | }, 44 | "devDependencies": { 45 | "@babel/core": "^7.6.0", 46 | "@babel/preset-env": "^7.6.0", 47 | "@storybook/vue": "^5.2.0", 48 | "babel-eslint": "^10.0.3", 49 | "babel-loader": "^8.0.6", 50 | "babel-preset-power-assert": "^3.0.0", 51 | "babel-preset-vue": "^2.0.2", 52 | "eslint": "^7.3.0", 53 | "eslint-config-ktsn": "^2.0.1", 54 | "glob": "^7.1.2", 55 | "npm-run-all": "^4.1.2", 56 | "power-assert": "^1.4.4", 57 | "prettier": "^1.18.2", 58 | "prettier-config-ktsn": "^1.0.0", 59 | "resize-observer-polyfill": "^1.5.0", 60 | "rollup": "^2.1.0", 61 | "rollup-plugin-babel": "^4.3.3", 62 | "rollup-plugin-node-resolve": "^5.2.0", 63 | "rollup-plugin-replace": "^2.0.0", 64 | "testem": "^3.0.0", 65 | "uglify-js": "^3.3.15", 66 | "vue": "^2.5.16", 67 | "vue-loader": "^15.7.1", 68 | "vue-template-compiler": "^2.5.16", 69 | "webpack": "^4.40.2", 70 | "webpack-cli": "^3.3.8" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('prettier-config-ktsn') 2 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { rollup } = require('rollup') 3 | const babel = require('rollup-plugin-babel') 4 | const replace = require('rollup-plugin-replace') 5 | const nodeResolve = require('rollup-plugin-node-resolve') 6 | const uglify = require('uglify-js') 7 | const options = require('./options') 8 | const pkg = require('../package.json') 9 | 10 | const banner = `/*! 11 | * ${pkg.name} v${pkg.version} 12 | * ${pkg.homepage} 13 | * 14 | * @license 15 | * Copyright (c) 2018 ${pkg.author} 16 | * Released under the MIT license 17 | * ${pkg.homepage}/blob/master/LICENSE 18 | * 19 | * Contains resize-observer-polyfill by que-etc 20 | * https://github.com/que-etc/resize-observer-polyfill 21 | * Released under the MIT license 22 | */` 23 | 24 | const baseConfig = { 25 | input: 'src/index.js', 26 | output: { 27 | name: capitalize(pkg.name), 28 | exports: 'named', 29 | banner 30 | }, 31 | plugins: [ 32 | nodeResolve(), 33 | babel({ 34 | exclude: 'node_modules/**', 35 | presets: [ 36 | [ 37 | '@babel/env', 38 | { 39 | modules: false 40 | } 41 | ] 42 | ] 43 | }) 44 | ] 45 | } 46 | 47 | function run(options) { 48 | if (options.length === 0) return 49 | 50 | const [head, ...tail] = options 51 | const config = genConfig(head) 52 | 53 | return build(config) 54 | .then(bundle => write(config, bundle, head.env === 'production')) 55 | .then(() => run(tail)) 56 | .catch(err => { 57 | console.error(err.stack) 58 | process.exit(1) 59 | }) 60 | } 61 | 62 | function genConfig(options) { 63 | const res = Object.assign({}, baseConfig) 64 | 65 | res.output = Object.assign({}, res.output, { 66 | file: options.output, 67 | format: options.format 68 | }) 69 | 70 | if (options.env) { 71 | res.plugins = res.plugins.concat([ 72 | replace({ 73 | 'process.env.NODE_ENV': JSON.stringify(options.env) 74 | }) 75 | ]) 76 | } 77 | 78 | return res 79 | } 80 | 81 | function build(config) { 82 | return rollup(config) 83 | } 84 | 85 | function write(config, bundle, prod) { 86 | if (!prod) { 87 | return bundle.write(config.output) 88 | } else { 89 | return bundle 90 | .generate(config.output) 91 | .then(minify) 92 | .then(({ code }) => { 93 | return new Promise((resolve, reject) => { 94 | fs.writeFile(config.output.file, code, err => { 95 | if (err) { 96 | return reject(err) 97 | } 98 | resolve() 99 | }) 100 | }) 101 | }) 102 | } 103 | } 104 | 105 | function minify({ output: [{ code }] }) { 106 | return uglify.minify(code, { 107 | compress: { 108 | toplevel: true 109 | }, 110 | output: { 111 | beautify: false, 112 | comments: /(?:^!|@license)/ 113 | } 114 | }) 115 | } 116 | 117 | function capitalize(str) { 118 | const camelized = str.replace(/[-_](\w)/g, (_, c) => c.toUpperCase()) 119 | return camelized[0].toUpperCase() + camelized.slice(1) 120 | } 121 | 122 | run(options) 123 | -------------------------------------------------------------------------------- /scripts/options.js: -------------------------------------------------------------------------------- 1 | const pkg = require('../package.json') 2 | 3 | module.exports = [ 4 | { 5 | output: `dist/${pkg.name}.cjs.js`, 6 | format: 'cjs' 7 | }, 8 | { 9 | output: `dist/${pkg.name}.esm.js`, 10 | format: 'es' 11 | }, 12 | { 13 | output: `dist/${pkg.name}.js`, 14 | format: 'umd', 15 | env: 'development' 16 | }, 17 | { 18 | output: `dist/${pkg.name}.min.js`, 19 | format: 'umd', 20 | env: 'production' 21 | } 22 | ] 23 | -------------------------------------------------------------------------------- /scripts/webpack.config.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const glob = require('glob') 3 | 4 | process.env.BABEL_ENV = 'test' 5 | 6 | module.exports = { 7 | entry: glob.sync(path.resolve(__dirname, '../test/**/*.js')), 8 | output: { 9 | path: path.resolve(__dirname, '../.tmp'), 10 | filename: 'test.js' 11 | }, 12 | resolve: { 13 | modules: ['node_modules'], 14 | extensions: ['.js'], 15 | alias: { 16 | vue$: 'vue/dist/vue.esm.js' 17 | } 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.js$/, 23 | loader: 'babel-loader', 24 | exclude: /node_modules/, 25 | options: { 26 | babelrc: false, 27 | presets: [ 28 | [ 29 | '@babel/env', 30 | { 31 | modules: false, 32 | targets: { 33 | browsers: ['last 1 Chrome versions'] 34 | } 35 | } 36 | ], 37 | 'power-assert' 38 | ] 39 | } 40 | } 41 | ] 42 | }, 43 | devtool: 'cheap-module-eval-source-map' 44 | } 45 | -------------------------------------------------------------------------------- /src/SizeObserver.js: -------------------------------------------------------------------------------- 1 | import ResizeObserver from 'resize-observer-polyfill' 2 | import { sizeProviderContext } from './SizeProvider' 3 | 4 | export default { 5 | name: 'SizeObserver', 6 | 7 | inject: { 8 | context: sizeProviderContext 9 | }, 10 | 11 | mounted() { 12 | const { context } = this 13 | context.notifySize({ 14 | width: this.$el.clientWidth, 15 | height: this.$el.clientHeight 16 | }) 17 | 18 | const ro = new ResizeObserver(entries => { 19 | const entry = entries[0] 20 | const { width, height } = entry.contentRect 21 | context.notifySize({ 22 | width, 23 | height 24 | }) 25 | }) 26 | 27 | ro.observe(this.$el) 28 | 29 | this.$once('hook:beforeDestroy', () => { 30 | ro.disconnect() 31 | }) 32 | }, 33 | 34 | render(h) { 35 | return h('div', [this.$slots.default]) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/SizeProvider.js: -------------------------------------------------------------------------------- 1 | export const sizeProviderContext = '__size_provider__' 2 | 3 | export default { 4 | name: 'SizeProvider', 5 | 6 | data() { 7 | return { 8 | size: { 9 | width: 0, 10 | height: 0 11 | } 12 | } 13 | }, 14 | 15 | methods: { 16 | notifySize({ width, height }) { 17 | this.size.width = width 18 | this.size.height = height 19 | } 20 | }, 21 | 22 | provide() { 23 | return { 24 | [sizeProviderContext]: { 25 | notifySize: this.notifySize 26 | } 27 | } 28 | }, 29 | 30 | render(h) { 31 | const children = 32 | this.$scopedSlots.default && this.$scopedSlots.default(this.size) 33 | return h('div', [children]) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import SizeProvider from './SizeProvider' 2 | import SizeObserver from './SizeObserver' 3 | 4 | export { SizeProvider, SizeObserver } 5 | 6 | export default function install(Vue) { 7 | Vue.component('size-provider', SizeProvider) 8 | Vue.component('size-observer', SizeObserver) 9 | } 10 | -------------------------------------------------------------------------------- /stories/SizeTransition.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 82 | 83 | 125 | -------------------------------------------------------------------------------- /stories/index.stories.js: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/vue' 2 | import SizeTransition from './SizeTransition.vue' 3 | 4 | storiesOf('SizeProvider', module).add('Size transition', () => SizeTransition) 5 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | mocha: true 4 | browser: true 5 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import assert from 'power-assert' 2 | import Vue from 'vue' 3 | import install from '../src/index' 4 | 5 | Vue.config.devtools = false 6 | Vue.config.productionTip = false 7 | 8 | Vue.use(install) 9 | 10 | describe('SizeProvider', () => { 11 | let wrapper, app 12 | 13 | beforeEach(() => { 14 | wrapper = document.createElement('div') 15 | wrapper.style.width = '800px' 16 | 17 | app = document.createElement('div') 18 | app.setAttribute('id', 'app') 19 | 20 | wrapper.appendChild(app) 21 | document.body.appendChild(wrapper) 22 | }) 23 | 24 | afterEach(() => { 25 | wrapper.remove() 26 | wrapper = app = undefined 27 | }) 28 | 29 | it('provides current element size', async () => { 30 | const vm = new Vue({ 31 | template: ` 32 |
37 | 38 |
39 |
40 | ` 41 | }).$mount(app) 42 | 43 | const el = vm.$refs.test 44 | 45 | // initial render 46 | assert(el.clientWidth === 0) 47 | assert(el.clientHeight === 0) 48 | 49 | // next render 50 | await nextFrame() 51 | assert(el.clientWidth === 100) 52 | assert(el.clientHeight === 50) 53 | }) 54 | 55 | it('re-renders if the element size is changed', async () => { 56 | const vm = new Vue({ 57 | data: { 58 | value: 100 59 | }, 60 | 61 | template: ` 62 |
67 | 68 |
69 |
70 | ` 71 | }).$mount(app) 72 | 73 | const el = vm.$refs.test 74 | 75 | await nextFrame() 76 | assert(el.clientWidth === 100) 77 | assert(el.clientHeight === 50) 78 | 79 | vm.value = 150 80 | await nextFrame() 81 | assert(el.clientWidth === 150) 82 | assert(el.clientHeight === 50) 83 | }) 84 | }) 85 | 86 | function nextFrame() { 87 | return new Promise(resolve => { 88 | requestAnimationFrame(resolve) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /testem.yml: -------------------------------------------------------------------------------- 1 | --- 2 | framework: mocha 3 | src_files: 4 | - .tmp/test.js 5 | launch_in_dev: 6 | - Chrome 7 | launch_in_ci: 8 | - Chrome 9 | browser_args: 10 | Chrome: 11 | - --headless 12 | - --disable-gpu 13 | - --remote-debugging-port=9222 14 | -------------------------------------------------------------------------------- /vue-size-provider.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ktsn/vue-size-provider/511aab97a5ca2e3b46533c200fe08e40e95fa249/vue-size-provider.gif --------------------------------------------------------------------------------