├── .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 | 
3 | 
4 | 
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 |
53 |
59 |
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 |
--------------------------------------------------------------------------------