├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── nuxt ├── index.js └── plugin.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── index.js ├── mixin.js ├── style.js └── utils.js └── tests └── index.test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | 'airbnb-base', 9 | ], 10 | globals: { 11 | Atomics: 'readonly', 12 | SharedArrayBuffer: 'readonly', 13 | }, 14 | parserOptions: { 15 | ecmaVersion: 2018, 16 | sourceType: 'module', 17 | }, 18 | plugins: [ 19 | 'vue', 20 | ], 21 | rules: { 22 | 'import/no-unresolved': 'off', 23 | 'no-underscore-dangle': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Amir Momenian 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 Component Style 2 | ![npm](https://img.shields.io/npm/dm/vue-component-style) 3 | ![npm](https://img.shields.io/npm/v/vue-component-style) 4 | ![NPM](https://img.shields.io/npm/l/vue-component-style) 5 | 6 | A `Vue` mixin to add `style` section to components. 7 | 8 | ## Features 9 | 10 | - Zero Dependency 11 | - Tiny (~1kb gzipped) 12 | - Simple Setup and Usage 13 | - Nested Support 14 | - Pseudo Selector Support 15 | - SSR Support 16 | - Scoped to Component 17 | 18 | ## Install 19 | 20 | ```bash 21 | npm i vue-component-style 22 | ``` 23 | 24 | ## Setup 25 | 26 | ### Vue App 27 | 28 | ```javascript 29 | import Vue from 'vue'; 30 | import VueComponentStyle from 'vue-component-style'; 31 | 32 | Vue.use(VueComponentStyle); 33 | ``` 34 | 35 | ### Nuxt App 36 | 37 | _nuxt.config.js_: 38 | ```javascript 39 | module.exports = { 40 | modules: [ 41 | 'vue-component-style/nuxt' 42 | ], 43 | } 44 | ``` 45 | 46 | Note that You don't need to do anything else with your webpack config or whatever. 47 | 48 | ## Usage 49 | 50 | _component.vue_: 51 | ```html 52 | 60 | 61 | 93 | ``` 94 | 95 | --- 96 | 97 | ## API Documentions 98 | 99 | ### 1 - Define Style 100 | 101 | Function this.style(helper) 102 | 103 | After activating **VueComponentStyle**, all components can have their js **style** section. Just like **data** section, you have to pass normal function that returning an Array. This function will invoke automatically with [`helper`](#helper) util object as first argument. 104 | 105 | ### 2 - Use Defined Styles 106 | 107 | Object this.$style 108 | 109 | After you defining **style** prop in your component, all your classes defined by [`style()`](#1-define-style)s are accessable with **$style** computed object inside your component instance. 110 | 111 | ### 3 - Notice When Styles Updated 112 | 113 | VueEvent 'styleChange' 114 | 115 | **styleChange** event fires when your style changes and applied to DOM. 116 | 117 | 118 | --- 119 | 120 | ### Helper 121 | 122 | You can use [`helper()`](#helper) object from first parameter of [`style()`](#1-define-style) function to defining your stylesheet. Helper object has these functions 123 | 124 | - [`className()`](#class-name) 125 | - [`mediaQuery()`](#media-query) 126 | - [`keyFrames()`](#key-frames) 127 | - [`custom()`](#custom) 128 | 129 | #### Class Name 130 | 131 | Function helper.className(name, content) 132 | 133 | To define your scopped css class styles, use this helper function. 134 | 135 | | Param | Type | Default | Description | 136 | | - | - | - | - | 137 | | name | String | | Name of your class. All of your defined names will be accessable via $style Object later. | 138 | | content | Object | {} | Your sass-style class properties. You can also style nested. | 139 | 140 | ##### Example 141 | 142 | ```javascript 143 | style({ className }) { 144 | return [ 145 | className('customClass', { 146 | color: 'red', 147 | fontWeight: 'bold', 148 | borderRadius: `${this.size}px`, 149 | '& > div': { 150 | color: 'blue', 151 | }, 152 | }), 153 | ]; 154 | } 155 | ``` 156 | 157 | #### Media Query 158 | 159 | Function helper.mediaQuery(mediaFeature, content) 160 | 161 | To define your customized style to different screen sizes, use this helper function. 162 | 163 | | Param | Type | Default | Description | 164 | | - | - | - | - | 165 | | mediaFeature | Object | | Media features. Common keys on this object are 'minWidth' and 'maxWidth'. | 166 | | content | Array | [] | List of [`className()`](#class-name)s that you need to redefine. | 167 | 168 | ##### Example 169 | 170 | ```javascript 171 | style({ mediaQuery, className }) { 172 | return [ 173 | className('responsiveClass', { 174 | width: '50%', 175 | }), 176 | mediaQuery({ maxWidth: '320px' }, [ 177 | className('responsiveClass', { 178 | width: '100%', 179 | }), 180 | ]), 181 | ]; 182 | } 183 | ``` 184 | 185 | #### Key Frames 186 | 187 | Function helper.keyFrames(name, content) 188 | 189 | To define your scopped keyframes animation with specefic name, use this helper function. 190 | 191 | | Param | Type | Default | Description | 192 | | - | - | - | - | 193 | | name | String | | Keyframes name. | 194 | | content | Object | | Keyframes properties. If you don't pass this prop, calculated hash name of already generated keyframes will be returns. | 195 | 196 | ##### Example 197 | 198 | ```javascript 199 | style({ keyFrames, className }) { 200 | return [ 201 | className('animatedThing', { 202 | color: 'blue', 203 | animationName: keyFrames('myAnimation'), 204 | animationDuration: '2s', 205 | }), 206 | keyFrames('myAnimation', { 207 | from: { 208 | color: 'blue', 209 | }, 210 | to: { 211 | color: 'red', 212 | }, 213 | ]), 214 | ]; 215 | } 216 | ``` 217 | 218 | #### Custom 219 | 220 | Function helper.custom(rule, content) 221 | 222 | To define your custom css style sections, use this helper function. **Note that styles generated by this helper function are not scopped!** 223 | 224 | | Param | Type | Default | Description | 225 | | - | - | - | - | 226 | | rule | String | | Rule name. | 227 | | content | Object | | Style properties. | 228 | 229 | ##### Example 230 | 231 | ```javascript 232 | style({ custom }) { 233 | return [ 234 | custom('@font-face', { 235 | fontFamily: 'globalFont', 236 | src: 'url(global_font.woff)', 237 | }), 238 | ]; 239 | } 240 | ``` -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | ], 5 | plugins: [ 6 | '@babel/plugin-transform-modules-commonjs', 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | coverageDirectory: 'coverage', 4 | transform: { 5 | '^.+\\.js$': 'babel-jest', 6 | }, 7 | testEnvironment: 'jest-environment-jsdom-global', 8 | }; 9 | -------------------------------------------------------------------------------- /nuxt/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export default function VueComponentStyle() { 4 | this.addPlugin({ 5 | src: path.resolve(__dirname, 'plugin.js'), 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /nuxt/plugin.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; // eslint-disable-line import/no-extraneous-dependencies 2 | import VueComponentStyle from 'vue-component-style'; // eslint-disable-line import/no-extraneous-dependencies 3 | 4 | export default ({ app: { head: { style } } }) => { 5 | Vue.prototype._ssrAppObject = { 6 | head: { 7 | style, 8 | }, 9 | }; 10 | Vue.use(VueComponentStyle); 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-component-style", 3 | "version": "1.0.3", 4 | "description": "A Vue mixin to add style section to components.", 5 | "main": "dist/vue-component-style.cjs.js", 6 | "module": "dist/vue-component-style.esm.js", 7 | "scripts": { 8 | "build": "rimraf ./dist && rollup -c rollup.config.js", 9 | "test": "jest", 10 | "lint": "eslint -c ./.eslintrc.js .", 11 | "lint:fix": "npm run lint -- --fix", 12 | "prepublish": "npm run build" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/nainemom/vue-component-style.git" 17 | }, 18 | "author": "Amir Momenian ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/nainemom/vue-component-style/issues" 22 | }, 23 | "homepage": "https://github.com/nainemom/vue-component-style#readme", 24 | "dependencies": {}, 25 | "devDependencies": { 26 | "@babel/core": "^7.7.4", 27 | "@babel/plugin-transform-modules-commonjs": "^7.7.4", 28 | "@babel/preset-env": "^7.7.4", 29 | "@vue/test-utils": "^1.0.0-beta.30", 30 | "babel-jest": "^24.9.0", 31 | "eslint": "^6.7.2", 32 | "eslint-config-airbnb-base": "^14.0.0", 33 | "eslint-plugin-import": "^2.18.2", 34 | "eslint-plugin-vue": "^6.0.1", 35 | "jest": "^24.9.0", 36 | "jest-environment-jsdom-global": "^1.2.0", 37 | "jsdom": "15.2.1", 38 | "jsdom-global": "3.0.2", 39 | "rimraf": "^3.0.0", 40 | "rollup": "^1.27.5", 41 | "rollup-plugin-terser": "^5.1.2", 42 | "vue": "^2.6.10", 43 | "vue-template-compiler": "^2.6.10" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from 'rollup-plugin-terser'; 2 | 3 | function generateConfig(format, minify) { 4 | const nameFormat = format === 'umd' ? '' : `.${format}`; 5 | const nameMinfy = minify && format === 'umd' ? '.min' : ''; 6 | const plugins = []; 7 | if (minify) { 8 | plugins.push(terser()); 9 | } 10 | return { 11 | input: 'src/index.js', 12 | output: { 13 | file: `dist/vue-component-style${nameFormat}${nameMinfy}.js`, 14 | name: 'VueComponentStyle', 15 | format, 16 | }, 17 | plugins, 18 | }; 19 | } 20 | module.exports = [ 21 | generateConfig('umd', true), 22 | generateConfig('umd', false), 23 | generateConfig('cjs', true), 24 | generateConfig('esm', true), 25 | ]; 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import mixin from './mixin'; 2 | 3 | export default { 4 | install(Vue) { 5 | Vue.mixin(mixin); 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/mixin.js: -------------------------------------------------------------------------------- 1 | import { 2 | typeOf, makeError, 3 | } from './utils'; 4 | import { injectStylesheet, deleteStylesheet, Helper } from './style'; 5 | 6 | export default { 7 | created() { 8 | this.$calcStyle(); 9 | }, 10 | methods: { 11 | $calcStyle() { 12 | const propValue = this.$options.style; 13 | if (typeOf(propValue) !== 'Undefined') { 14 | const documentObject = typeof document !== 'undefined' ? document : undefined; 15 | const ssrAppObject = this._ssrAppObject; 16 | const lastStyleId = this.$lastStyleId; 17 | // delete old stylesheet if found 18 | if (typeOf(lastStyleId) !== 'Undefined') { 19 | deleteStylesheet(lastStyleId, documentObject, ssrAppObject); 20 | } 21 | 22 | if (typeOf(propValue) === 'Function') { 23 | const styleId = `${Math.floor(99999 * Math.random())}${Date.now()}`.padStart(18, 0); 24 | const helper = Helper(styleId); 25 | const value = propValue.call(this, helper); 26 | if (typeOf(value) !== 'Array') { 27 | // style is passed and it's function, but return value is not object 28 | makeError('\'style\' function should returns Array!'); 29 | } 30 | const css = value.join(''); 31 | injectStylesheet(styleId, css, documentObject, ssrAppObject); 32 | this.$style = helper.maps; 33 | this.$lastStyleId = styleId; 34 | this.$forceUpdate(); 35 | this.$nextTick(() => { // wait until component-style new class-names applied to component 36 | setTimeout(() => { // wait until component-style updates global style tag 37 | this.$emit('styleChange', this.$style); 38 | }); 39 | }); 40 | } else { 41 | // style is passed, but with wrong value 42 | makeError('\'style\' should be function!'); 43 | } 44 | } 45 | }, 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /src/style.js: -------------------------------------------------------------------------------- 1 | import { dashCase, typeOf, each } from './utils'; 2 | 3 | const STYLESHEET_ID_KEY = 'data-vcs-id'; 4 | const STYLESHEET_TYPE = 'text/css'; 5 | 6 | const generateName = (id, name) => dashCase(`vcs-${id}-${name}`); 7 | 8 | const objectToCss = (selector, object) => { 9 | const ret = [`${selector} {`, '}']; 10 | let pointer = 1; 11 | each(object, (key, value) => { 12 | if (typeOf(value) === 'Object') { 13 | ret.push(objectToCss(key.split('&').join(selector), value)); 14 | } else { 15 | ret.splice(pointer, 0, `${dashCase(key)}:${value};`); 16 | pointer += 1; 17 | } 18 | }); 19 | return ret.join(''); 20 | }; 21 | 22 | 23 | export const Helper = (id) => { 24 | const maps = {}; 25 | return { 26 | maps, 27 | className(name, content = {}) { 28 | const generatedName = generateName(id, name); 29 | const generatedContent = objectToCss(`.${generatedName}`, content); 30 | maps[name] = generatedName; 31 | return generatedContent; 32 | }, 33 | mediaQuery(mediaFeature, content = []) { 34 | const mediaFeatures = (() => { 35 | const ret = []; 36 | each(mediaFeature, (key, value) => { 37 | ret.push(`${dashCase(key)}: ${value}`); 38 | }); 39 | return ret.join(' and '); 40 | })(); 41 | return `@media screen and (${mediaFeatures}){${content.join(' ')}}`; 42 | }, 43 | keyFrames(name, content) { 44 | const generatedName = generateName(id, name); 45 | maps[name] = generateName; 46 | if (!content) { 47 | return generatedName; 48 | } 49 | const ret = (() => { 50 | const reti = []; 51 | each(content, (key, value) => { 52 | reti.push( 53 | objectToCss(dashCase(key), value), 54 | ); 55 | }); 56 | return reti.join(' '); 57 | })(); 58 | return `@keyframes ${generatedName} { ${ret} }`; 59 | }, 60 | custom(rule, content) { 61 | return objectToCss(dashCase(rule), content); 62 | }, 63 | }; 64 | }; 65 | 66 | export function injectStylesheet( 67 | id, 68 | innerHTML, 69 | documentObject = undefined, 70 | ssrAppObject = undefined, 71 | ) { 72 | if (typeof documentObject !== 'undefined') { 73 | const el = documentObject.querySelector(`style[${STYLESHEET_ID_KEY}="${id}"]`) || documentObject.createElement('style'); 74 | el.setAttribute(STYLESHEET_ID_KEY, id); 75 | el.type = STYLESHEET_TYPE; 76 | el.innerHTML = innerHTML; 77 | documentObject.head.appendChild(el); 78 | } else if (typeof ssrAppObject !== 'undefined') { 79 | const oldEl = ssrAppObject.head.style.find((x) => x[STYLESHEET_ID_KEY] === id); 80 | if (oldEl) { 81 | oldEl.innerHTML = innerHTML; 82 | } else { 83 | ssrAppObject.head.style.push({ 84 | [STYLESHEET_ID_KEY]: id, 85 | type: STYLESHEET_TYPE, 86 | innerHTML, 87 | }); 88 | } 89 | } 90 | return id; 91 | } 92 | 93 | export function deleteStylesheet(id, documentObject, ssrAppObject) { 94 | if (typeof documentObject !== 'undefined') { 95 | const el = documentObject.querySelector(`style[${STYLESHEET_ID_KEY}="${id}"]`); 96 | if (el) { 97 | el.remove(); 98 | } 99 | } else if (typeof ssrAppObject !== 'undefined') { 100 | const index = ssrAppObject.head.style.findIndex((x) => x[STYLESHEET_ID_KEY] === id); 101 | if (index !== -1) { 102 | ssrAppObject.splice(index, 1); 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const dashCase = (str) => str.split('').map((char) => (/[A-Z]/.test(char) ? '-' : '') + char.toLowerCase()).join(''); 2 | 3 | export const makeError = (msg) => { throw new Error(`[VueComponentStyle] ${msg}`); }; 4 | 5 | export const typeOf = (x) => toString.call(x).match(/\s([a-zA-Z]+)/)[1]; 6 | 7 | export const each = (obj, cb) => (typeOf(obj) === 'Object' ? Object.keys(obj).forEach((key) => cb(key, obj[key])) : obj.forEach(cb)); 8 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue, mount } from '@vue/test-utils'; 2 | import VueComponentStyle from '../dist/vue-component-style.esm'; 3 | 4 | const $beforeEach = beforeEach; // eslint-disable-line no-undef 5 | const $test = test; // eslint-disable-line no-undef, no-console, 6 | const $expect = expect; // eslint-disable-line no-undef, no-console 7 | 8 | // reset document before each tests 9 | let Vue; 10 | $beforeEach(() => { 11 | document.head.innerHTML = ''; 12 | document.body.innerHTML = ''; 13 | Vue = createLocalVue(); 14 | Vue.use(VueComponentStyle); 15 | }); 16 | 17 | // util function to mount component to document 18 | function mountComponent(component, options = {}) { 19 | return mount(component, { 20 | attachToDocument: true, 21 | localVue: Vue, 22 | ...options, 23 | }); 24 | } 25 | 26 | 27 | $test('Bundles exports', () => { 28 | const cjs = require('../dist/vue-component-style.cjs'); // eslint-disable-line global-require 29 | const umd = require('../dist/vue-component-style'); // eslint-disable-line global-require 30 | 31 | $expect(VueComponentStyle).toHaveProperty('install'); 32 | $expect(cjs).toHaveProperty('install'); 33 | $expect(umd).toHaveProperty('install'); 34 | }); 35 | 36 | $test('Can apply static style', () => { 37 | const wrapper = mountComponent({ 38 | template: '
', 39 | style({ className }) { 40 | return [ 41 | className('a', { 42 | color: 'red', 43 | }), 44 | ]; 45 | }, 46 | }); 47 | $expect(window.getComputedStyle(wrapper.element).color).toEqual('red'); 48 | }); 49 | 50 | $test('Can apply dynamic style', () => { 51 | const component = { 52 | props: ['color'], 53 | template: '
', 54 | style({ className }) { 55 | return [ 56 | className('scopedTest', { 57 | backgroundColor: this.color, 58 | }), 59 | ]; 60 | }, 61 | }; 62 | const wrapper1 = mountComponent(component, { 63 | propsData: { 64 | color: 'blue', 65 | }, 66 | }); 67 | const wrapper2 = mountComponent(component, { 68 | propsData: { 69 | color: 'red', 70 | }, 71 | }); 72 | 73 | $expect(window.getComputedStyle(wrapper1.element).backgroundColor).toEqual('blue'); 74 | $expect(window.getComputedStyle(wrapper2.element).backgroundColor).toEqual('red'); 75 | }); 76 | 77 | 78 | // $test('Can handle changes durring runtime', () => { 79 | // const wrapper = mountComponent({ 80 | // props: ['color'], 81 | // template: '
', 82 | // style({ className }) { 83 | // return [ 84 | // className('a', { 85 | // backgroundColor: this.color, 86 | // }), 87 | // ]; 88 | // }, 89 | // }, { 90 | // propsData: { 91 | // color: 'blue', 92 | // }, 93 | // }); 94 | 95 | // wrapper.setProps({ 96 | // color: 'cyan', 97 | // }); 98 | 99 | // return new Promise((resolve) => { 100 | // wrapper.vm.$on('styleChange', () => { 101 | // $expect(window.getComputedStyle(wrapper.element).backgroundColor).toEqual('cyan'); 102 | // resolve(); 103 | // }); 104 | // }); 105 | // }); 106 | --------------------------------------------------------------------------------