diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..178135c --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +/dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2e1255 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +node-debug.log +TODO.md +yarn-error.log +yarn.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad278e9 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# PortalVue + +> A Portal Component for Vuejs, to render DOM outside of a component, anywhere in the document. + +

+ PortalVue Logo +

+ + +For more detailed documentation and additional Information, please visit the docs + +## Installation + +``` +npm install -g portal-vue + +// in .js +import PortalVue from 'portal-vue' +Vue.use(PortalVue) +``` + + + +## Usage + +```html + +

This slot content will be rendered wherever the with name 'destination' + is located.

+
+ + + + +``` diff --git a/build/rollup.conf.prod.js b/build/rollup.conf.prod.js new file mode 100644 index 0000000..5808672 --- /dev/null +++ b/build/rollup.conf.prod.js @@ -0,0 +1,32 @@ +import babel from 'rollup-plugin-babel' +import vue from 'rollup-plugin-vue' +import commonjs from 'rollup-plugin-commonjs' +import nodeResolve from 'rollup-plugin-node-resolve' + +const babelConfig = { + // runtimeHelpers: true, + exclude: 'node_modules/**', +} + +const nodeResolveOptions = { + module: true, jsnext: true, + extensions: ['.js', '.vue'], +} + +export default { + entry: './src/index.js', + external: ['vue'], + globals: { + vue: 'Vue', + }, + format: 'umd', + moduleName: 'PortalVue', + dest: './dist/portal-vue.js', // equivalent to --output + sourceMap: true, + plugins: [ + nodeResolve(nodeResolveOptions), + vue({ compileTemplate: false }), + commonjs(), + babel(babelConfig), + ], +} diff --git a/build/webpack.conf.js b/build/webpack.conf.js new file mode 100644 index 0000000..a4f9e59 --- /dev/null +++ b/build/webpack.conf.js @@ -0,0 +1,80 @@ +var path = require('path') +var webpack = require('webpack') +var FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin') + +const config = { + entry: { + example: path.resolve(__dirname, '../example/index.js'), + vendor: ['vue'], + }, + output: { + path: path.resolve(__dirname, '../example'), + publicPath: '/', + library: 'VuePortal', + libraryTarget: 'umd', + filename: '[name].build.js', + }, + resolve: { + extensions: ['.js', '.json', '.vue'], + alias: { + 'vue$': 'vue/dist/vue.common', + }, + }, + module: { + rules: [ + { + test: /\.(js|vue)$/, + loader: 'eslint-loader', + enforce: 'pre', + exclude: /node_modules/, + options: { + formatter: require('eslint-friendly-formatter'), + }, + }, + { + test: /\.js$/, + loader: 'babel-loader', + exclude: path.resolve(__dirname, '../node_modules'), + }, + { + test: /\.css$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + }, + ], + }, + { + test: /\.vue$/, + loader: 'vue-loader', + exclude: /node_modules/, + }, + ], + }, + plugins: [ + new FriendlyErrorsWebpackPlugin(), + new webpack.HotModuleReplacementPlugin(), + new webpack.DefinePlugin({ + 'process.env': { + NODE_ENV: process.env.NODE_ENV ? JSON.stringify(process.env.NODE_ENV) : "'development'", + }, + }), + ], + devtool: 'source-map', + performance: { + hints: false, + }, + devServer: { + contentBase: 'example/', + inline: true, + hot: true, + quiet: true, + stats: { + colors: true, + chunks: false, + }, + }, +} + +module.exports = config diff --git a/build/webpack.test.conf.js b/build/webpack.test.conf.js new file mode 100644 index 0000000..5d995b8 --- /dev/null +++ b/build/webpack.test.conf.js @@ -0,0 +1,9 @@ +var merge = require('webpack-merge') + +var devConfig = require('./webpack.conf.js') + +var config = merge(devConfig, { + devtool: '#inline-source-map', +}) + +module.exports = config diff --git a/dist/portal-vue.js b/dist/portal-vue.js new file mode 100644 index 0000000..d2524ce --- /dev/null +++ b/dist/portal-vue.js @@ -0,0 +1,293 @@ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('vue')) : + typeof define === 'function' && define.amd ? define(['vue'], factory) : + (global.PortalVue = factory(global.Vue)); +}(this, (function (Vue) { 'use strict'; + +Vue = 'default' in Vue ? Vue['default'] : Vue; + +var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; + +function extractAttributes(el) { + var map = el.hasAttributes() ? el.attributes : []; + var attrs = {}; + for (var i = 0; i < map.length; i++) { + var attr = map[i]; + if (attr.value) { + attrs[attr.name] = attr.value === '' ? true : attr.value; + } + } + return attrs; +} + +function freeze(item) { + if (Array.isArray(item) || (typeof item === 'undefined' ? 'undefined' : _typeof(item)) === 'object') { + return Object.freeze(item); + } + return item; +} + +var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +var routes = {}; + +var Wormhole = function () { + function Wormhole(routes) { + _classCallCheck(this, Wormhole); + + this.routes = routes; + this.clearQueue = []; + this.updateQueue = []; + this.runScheduled = false; + } + + _createClass(Wormhole, [{ + key: 'send', + value: function send(name, passengers) { + var job = { name: name, passengers: passengers }; + this.updateQueue.push(job); + this._scheduleRun(); + } + }, { + key: 'close', + value: function close(name) { + var job = { name: name }; + this.clearQueue.push(job); + this._scheduleRun(); + } + }, { + key: '_scheduleRun', + value: function _scheduleRun() { + if (!this.runScheduled) { + this.runScheduled = true; + + setTimeout(this._runQueue.bind(this), 0); + } + } + }, { + key: '_runQueue', + value: function _runQueue() { + var _this = this; + + var keys = Object.keys(this.routes); + + this.clearQueue.forEach(function (_ref) { + var name = _ref.name; + + if (keys.includes(name)) { + _this.routes[name] = undefined; + } + }); + this.clearQueue = []; + + this.updateQueue.forEach(function (_ref2) { + var name = _ref2.name, + passengers = _ref2.passengers; + + if (keys.includes(name)) { + _this.routes[name] = freeze(passengers); + } else { + Vue.set(_this.routes, name, freeze(passengers)); + } + }); + this.updateQueue = []; + + this.runScheduled = false; + } + }]); + + return Wormhole; +}(); +var wormhole = new Wormhole(routes); + +var Target = { + name: 'portalTarget', + props: { + attributes: { type: Object }, + name: { type: String, required: true }, + slim: { type: Boolean, default: false }, + tag: { type: String, default: 'div' } + }, + data: function data() { + return { + routes: routes + }; + }, + mounted: function mounted() { + this.updateAttributes(); + }, + updated: function updated() { + this.updateAttributes(); + }, + beforeDestroy: function beforeDestroy() { + this.$el.innerHTML = ''; + }, + + + methods: { + updateAttributes: function updateAttributes() { + if (this.attributes) { + var attrs = this.attributes; + var el = this.$el; + + if (attrs.class) { + attrs.class.trim().split(' ').forEach(function (klass) { + el.classList.add(klass); + }); + delete attrs.class; + } + + var keys = Object.keys(attrs); + + for (var i = 0; i < keys.length; i++) { + el.setAttribute(keys[i], attrs[keys[i]]); + } + } + } + }, + computed: { + passengers: function passengers() { + return this.routes[this.name] || null; + }, + renderSlim: function renderSlim() { + var passengers = this.passengers || []; + return passengers.length === 1 && !this.attributes && this.slim; + } + }, + + render: function render(h) { + var children = this.passengers || []; + + if (this.renderSlim) { + return children[0]; + } else { + return h(this.tag, { + class: { 'vue-portal-target': true } + }, children); + } + } +}; + +var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + +function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } + +var Portal = { + name: 'portal', + props: { + disabled: { type: Boolean, default: false }, + slim: { type: Boolean, default: false }, + tag: { type: [String], default: 'DIV' }, + targetEl: { type: [String, HTMLElement] }, + to: { type: String, required: true } + }, + + mounted: function mounted() { + if (this.targetEl) { + this.mountToTarget(); + } + if (!this.disabled) { + this.sendUpdate(); + } + }, + updated: function updated() { + if (this.disabled) { + this.clear(); + } else { + this.sendUpdate(); + } + }, + beforeDestroy: function beforeDestroy() { + this.clear(); + if (this.mountedComp) { + this.mountedComp.$destroy(); + } + }, + + + watch: { + to: function to(newValue, oldValue) { + oldValue && this.clear(oldValue); + this.sendUpdate(); + }, + targetEl: function targetEl(newValue, oldValue) { + this.mountToTarget(); + } + }, + + methods: { + sendUpdate: function sendUpdate() { + if (this.to) { + wormhole.send(this.to, [].concat(_toConsumableArray(this.$slots.default))); + } else { + console.warn('[vue-portal]: You have to define a targte via the `to` prop.'); + } + }, + clear: function clear(target) { + wormhole.close(target || this.to); + }, + mountToTarget: function mountToTarget() { + var el = void 0; + var target = this.targetEl; + + if (target instanceof HTMLElement) { + el = target; + } else if (typeof target === 'string') { + el = document.querySelector(this.targetEl); + } else { + console.warn('[vue-portal]: value of targetEl must eb of type String or HTMLElement'); + return; + } + + var attributes = extractAttributes(el); + + if (el) { + var _target = new Vue(_extends({}, Target, { + propsData: { + name: this.to || Math.round(Math.random() * 10000000), + tag: el.tagName, + attributes: attributes + } + })); + _target.$mount(el); + this.mountedComp = _target; + } else { + console.warn('[vue-portal]: The specified targetEl ' + this.targetEl + ' was not found'); + } + } + }, + + render: function render(h) { + var children = this.$slots.default; + + if (children.length && this.disabled) { + return children.length <= 1 && this.slim ? children[0] : h(this.tag, children); + } else { + return h(this.tag, { class: { 'v-portal': true }, style: { display: 'none' }, key: 'v-portal-placeholder' }); + } + } +}; + +function install(Vue$$1) { + var opts = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + + Vue$$1.component(opts.portalName || 'portal', Portal); + Vue$$1.component(opts.portalTargetName || 'portal-target', Target); +} +if (typeof window !== 'undefined' && window.Vue) { + console.log('auto install!'); + window.Vue.use({ install: install }); +} + +var index = { + install: install, + Portal: Portal, + PortalTarget: Target +}; + +return index; + +}))); +//# sourceMappingURL=portal-vue.js.map diff --git a/dist/portal-vue.js.map b/dist/portal-vue.js.map new file mode 100644 index 0000000..78f4612 --- /dev/null +++ b/dist/portal-vue.js.map @@ -0,0 +1 @@ +{"version":3,"file":"portal-vue.js","sources":["../src/utils.js","../src/components/wormhole.js","../src/components/portal-target.vue","../src/components/portal.vue","../src/index.js"],"sourcesContent":["export function extractAttributes (el) {\n const map = el.hasAttributes() ? el.attributes : []\n const attrs = {}\n for (let i = 0; i < map.length; i++) {\n const attr = map[i]\n if (attr.value) {\n attrs[attr.name] = attr.value === '' ? true : attr.value\n }\n }\n return attrs\n}\n\nexport function freeze (item) {\n if (Array.isArray(item) || typeof item === 'object') {\n return Object.freeze(item)\n }\n return item\n}\n","import Vue from 'vue'\nimport { freeze } from '../utils'\nconst routes = {}\n\nexport { routes }\n\nexport class Wormhole {\n constructor (routes) {\n this.routes = routes\n this.clearQueue = []\n this.updateQueue = []\n this.runScheduled = false\n }\n\n send (name, passengers) {\n const job = { name, passengers }\n this.updateQueue.push(job)\n this._scheduleRun()\n }\n\n close (name) {\n const job = { name }\n this.clearQueue.push(job)\n this._scheduleRun()\n }\n\n _scheduleRun () {\n if (!this.runScheduled) {\n this.runScheduled = true\n\n setTimeout(this._runQueue.bind(this), 0)\n }\n }\n\n _runQueue () {\n const keys = Object.keys(this.routes)\n\n this.clearQueue.forEach(({ name }) => {\n if (keys.includes(name)) {\n this.routes[name] = undefined\n }\n })\n this.clearQueue = []\n\n this.updateQueue.forEach(({ name, passengers }) => {\n if (keys.includes(name)) {\n this.routes[name] = freeze(passengers)\n } else {\n Vue.set(this.routes, name, freeze(passengers))\n }\n })\n this.updateQueue = []\n\n this.runScheduled = false\n }\n\n}\nconst wormhole = new Wormhole(routes)\nexport default wormhole\n","\n","\n","import Portal from './components/portal.vue'\nimport PortalTarget from './components/portal-target.vue'\n\nfunction install (Vue, opts = {}) {\n Vue.component(opts.portalName || 'portal', Portal)\n Vue.component(opts.portalTargetName || 'portal-target', PortalTarget)\n}\nif (typeof window !== 'undefined' && window.Vue) {\n console.log('auto install!')\n window.Vue.use({ install: install })\n}\n\nexport default {\n install,\n Portal,\n PortalTarget,\n}\n"],"names":["extractAttributes","el","map","hasAttributes","attributes","attrs","i","length","attr","value","name","freeze","item","Array","isArray","Object","routes","Wormhole","clearQueue","updateQueue","runScheduled","passengers","job","push","_scheduleRun","_runQueue","bind","keys","forEach","includes","undefined","set","wormhole","type","String","required","Boolean","default","updateAttributes","$el","innerHTML","class","trim","split","klass","classList","add","setAttribute","slim","h","children","renderSlim","tag","HTMLElement","targetEl","mountToTarget","disabled","sendUpdate","clear","mountedComp","$destroy","newValue","oldValue","to","send","$slots","warn","target","close","document","querySelector","Vue","Target","Math","round","random","tagName","$mount","style","display","key","install","opts","component","portalName","Portal","portalTargetName","PortalTarget","window","log","use"],"mappings":"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"} \ No newline at end of file diff --git a/dist/portal-vue.min.js b/dist/portal-vue.min.js new file mode 100644 index 0000000..7e1907e --- /dev/null +++ b/dist/portal-vue.min.js @@ -0,0 +1 @@ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("vue")):"function"==typeof define&&define.amd?define(["vue"],e):t.PortalVue=e(t.Vue)}(this,function(t){"use strict";function e(t){for(var e=t.hasAttributes()?t.attributes:[],n={},r=0;r1&&void 0!==arguments[1]?arguments[1]:{};t.component(e.portalName||"portal",h),t.component(e.portalTargetName||"portal-target",d)}t="default"in t?t.default:t;var u="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},i=function(){function t(t,e){for(var n=0;n + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/portal-vue-logo.gif b/docs/assets/portal-vue-logo.gif new file mode 100644 index 0000000..2d37147 Binary files /dev/null and b/docs/assets/portal-vue-logo.gif differ diff --git a/docs/config.js b/docs/config.js new file mode 100644 index 0000000..eb19fa4 --- /dev/null +++ b/docs/config.js @@ -0,0 +1,21 @@ +/* global self */ +self.$config = { + landing: true, + home: 'getting-started.md', + nav: [ + { title: 'PortalVue', path: '/' }, + { title: 'Getting Started', path: '/getting-started' }, + { title: 'Documentation', type: 'dropdown', items: [ + { title: 'Installation Instructions', type: 'label' }, + { title: 'Installation', path: '/docs/installation' }, + { type: 'sep' }, + { title: 'Components', type: 'label' }, + { title: 'Portal', path: '/docs/portal' }, + { title: 'PortalTarget', path: '/docs/portal-target' }, + ], + }, + { title: 'Examples', path: '/examples' }, + ], + repo: 'linusborg/portal-vue', + twitter: 'Linus_Borg', +} diff --git a/docs/docs/installation.md b/docs/docs/installation.md new file mode 100644 index 0000000..167d47a --- /dev/null +++ b/docs/docs/installation.md @@ -0,0 +1,72 @@ +# Installation + +## Possible ways to install + +### NPM + +This is the recommended way to install this Plugin. + +Install vom npm as a dependency: +``` bash +npm install --save portal-vue +# or with yarn: +yarn add portal-vue +``` +Then include rthe package in your application and register it with Vue: + +``` javascript +import PortalVue from 'portal-vue' // as ES6 module +var PortalVue = require('portal-vue') // OR as a commonjs require + +Vue.use(PortalVue) +``` + +### CDN + +PortalVue is available through a couple of CDNs, I recommend +unpkg.com + +Just include the script tag *after* the one of Vue.js + +```html + + +``` + +In this case, the plugin will auto-install itself, so there is no need to call Vue.use(). + +The components will be named `` and ``, respectively. + + +### Local copy + +Of course oyu can include PortalVue into your page as a local file on your server as well. + +The same rules and restrictions as for CDN apply. + +## Install Options + +When installing with `Vue.use()`, you can pass options to change the component names. +```javascript +Vue.use(PortalVue, { + portalNme: 'my-portal', // default: 'portal' + portalTargetname: 'my-target', // default:'portal-target' +}) +``` +These options would make the components available globally as `` and `` respectively. + +## Using Components manually + +If you don't want to register the components globally, don't do `Vue.use('PortalVue')` + +Instead, import the plugin in those components you need them, and register them locally: +```javascript +import { Portal, PortalTarget } from 'portal-vue' + +export default { + components: { + Portal, + PortalTarget + } +} +``` diff --git a/docs/docs/portal-target.md b/docs/docs/portal-target.md new file mode 100644 index 0000000..4258d42 --- /dev/null +++ b/docs/docs/portal-target.md @@ -0,0 +1,62 @@ +# + + +This component is an outlet for any content that was sent by a `` component. It renders received content and doesn't do much else. + +## Example usage + +```html + +``` + +## Props API + +### name + +|Type|Required|Default| +|----|--------|-------| +|`String`|yes|none| + +Defines the name of this portal-target. `` components can send content to this instance by this name. + +### slim + +|Type|Required|Default| +|----|--------|-------| +|`Boolean`|no|`false`| + +When set to true, the component will check if the sent content has only one root node. If that is the case, the component will *not* render a root node of its own but instead just output the conent as-is. + +**Source** +```html + +

Only one content element

+
+ + + +``` +**Result** +```html +

Only one content element

+``` + +### tag + +|Type|Required|Default| +|----|--------|-------| +|`String`|no|`'DIV'`| + +Defines the type of tag that should be rendered as a root component. + +**Source** +```html + +``` + +**Result** +```html + + + +``` diff --git a/docs/docs/portal.md b/docs/docs/portal.md new file mode 100644 index 0000000..eb428ec --- /dev/null +++ b/docs/docs/portal.md @@ -0,0 +1,121 @@ +# + +Wrap any content that you want to render somehwere else in a `` component. + +## Example usage + +```html + +

This coonent will be sent through the portal

+
+``` + +## Props API + +### disabled + +|Type|Required|Default| +|----|--------|-------| +|`Boolean`|no|`false`| + +When `true`, the slot content will *not* be send through the portal to the defined PortalTarget. + +Instead, they will be rendered in place: +**Source** +```html +
+ +

some content

+
+
+``` +**Result** +```html +
+
+

some content

+
+
+``` + +### slim + +|Type|Required|Default| +|----|--------|-------| +|`Boolean`|no|`false`| + +When set to true, the component will check if the sent content has only one root node. If that is the case, the component will *not* render a root node of its own but instead just output the conent as-is. + +

This prop only has an effect when the 'disabled' prop is set as well

+ +**Source** +```html + +

Only one content element

+
+``` +**Result** +```html +

Only one content element

+``` + +### `tag` + +|Type|Required|Default| +|----|--------|-------| +|`String`|no|`'DIV'`| + +Defines the type of tag that should be rendered as a root element. + +

This prop only works when the 'disabled' prop is true

+ +**Source** +```html + +

some content

+
+``` +**Result** +```html + +

some content

+
+``` + +### `targetEl` + +### `To` +|Type|Required|Default| +|----|--------|-------| +|`String`|yes|none| + +Defines the name of the `` component that the slot contents should be sent to. This mounts a new instance of the +
PortalTarget
component. + +

+ This feature should only be used on elements outside of the scope of your Vue app, + because it replaces the target element while mounting the

PortalVue
instance, which can lead to unwanted + side effects in your Vue App. +

+ +**Source** +```html + +

some content

+
+ +
+ +
+ +``` +**Result** +```html +
+ +
+
+

some content

+
+
+``` diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..ec20e48 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,3 @@ +# Examples + +> Coming soon. Still working on the JSFiddles ;) diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..a5c3ba2 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,144 @@ +# Getting Started with PortalVue + +## What is PortalVue? + +PortalVue is a set of components that allow you to render a component's template +(or a part of it) anywhere in the document - even outside of your the part that is controlled by your Vue App! + +## Setup + +Install Package: +```bash +npm install --save portal-vue +# or with yarn +yarn add portal-vue +``` +Add it to your application: +```javascript +import PortalVue from 'portal-vue' + +Vue.use(PortalVue) +``` + +For more detailed Installation instructions, additional options and Installation via CDN, +see the Installation Page in the Documentation. + +### Simple Example + +```html + +

This slot content will be rendered wherever the with name 'destination' + is located.

+
+ + + + +``` + +See it in action in a fiddle here. + +## Feature Examples + +### Enabling/Disabling the Portal +```html + +

+ This slot content will be rendered right here as long as the `disabled` prop evaluates to `false`,
+ and will be rendered at the defined destination as when it is set to `true` +

+
+``` + +### v-if works, too + +```html + + <
    +
  • + When `userPortal` efvaluates to `true`, the portal's slot content will be rendered + at the destination. +
  • +
  • + When it evaluates to `fales`, the content will be removed from the detination +
  • +
+
+``` + +### Switching targets and sources + +the `to` prop of `` and the `name` prop of `` can be changed dynamically with `v-bind`. +```html + + Content will be dynamically sent to the destination that `name` evaluates to + + + + by changeing the `name`, you can define which portal's content should be shown. + +``` +### Rendering outside of the Vue-App + +```html + +
+ +

+ PortalVue will dynamically mount an instance of in place of the Element + with `id="widget"`, and this paragraph will be rendered inside of it. +

+
+
+ + + +``` + +## But why? + +### Working around `position: fixed` issues + +In older browsers, `position: fixed` works unreliably when the element with that poperty +is nested in a node tree that has other `position` values. + +But we normally need it to render components like modals, dialogs, notifications, snackbars +and similar UI elements in a fixed position. + +With PortalVue, you can instead render the component to a `` that you can position +as as the first child of your #app element. + +Now you can position your components with `position: absolute` instead + +```html + +
+ +
+
+ + + This modal can be positioned absolutely, working around problems with 'fixed' + + +
+
+ +``` + +### Rendering dynamic widgets + +If you use Vue for small bits and pieces on your website, but want to render something in a location at the other end of the page, PortalVue got you covered. + +### Tell us about your usecase! + +We're sure you will find use cases beyond the ones we mentioned. If you do, please +let us know by opening an issue on Github +and we will include it here. diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..8d6bae0 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,18 @@ + + + + + + + + Portal Vue - Render anywhere in the DOM + + + + +
+ + + + + diff --git a/docs/landing.html b/docs/landing.html new file mode 100644 index 0000000..67cc8fb --- /dev/null +++ b/docs/landing.html @@ -0,0 +1,120 @@ + + +
+
+
+
+

PortalVue

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ A Vue Plugin to render your component's templates anywhere in the DOM +

+
+ +
+
+
+
diff --git a/example/components/App.vue b/example/components/App.vue new file mode 100644 index 0000000..390391a --- /dev/null +++ b/example/components/App.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/example/components/comp-as-root/comp-as-root.vue b/example/components/comp-as-root/comp-as-root.vue new file mode 100644 index 0000000..5085420 --- /dev/null +++ b/example/components/comp-as-root/comp-as-root.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/example/components/disabled/index.vue b/example/components/disabled/index.vue new file mode 100644 index 0000000..7c7631f --- /dev/null +++ b/example/components/disabled/index.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/example/components/mount-to/mount-to-external.vue b/example/components/mount-to/mount-to-external.vue new file mode 100644 index 0000000..607f66d --- /dev/null +++ b/example/components/mount-to/mount-to-external.vue @@ -0,0 +1,29 @@ + + + diff --git a/example/components/source-switch/destination.vue b/example/components/source-switch/destination.vue new file mode 100644 index 0000000..fad318d --- /dev/null +++ b/example/components/source-switch/destination.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/example/components/source-switch/source-switch.vue b/example/components/source-switch/source-switch.vue new file mode 100644 index 0000000..1fdc23c --- /dev/null +++ b/example/components/source-switch/source-switch.vue @@ -0,0 +1,41 @@ + + + diff --git a/example/components/source-switch/source.vue b/example/components/source-switch/source.vue new file mode 100644 index 0000000..2cec2ad --- /dev/null +++ b/example/components/source-switch/source.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/example/components/target-switch/destination.vue b/example/components/target-switch/destination.vue new file mode 100644 index 0000000..c573148 --- /dev/null +++ b/example/components/target-switch/destination.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/example/components/target-switch/source-comp.vue b/example/components/target-switch/source-comp.vue new file mode 100644 index 0000000..bc35fa5 --- /dev/null +++ b/example/components/target-switch/source-comp.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/example/components/target-switch/target-switch.vue b/example/components/target-switch/target-switch.vue new file mode 100644 index 0000000..60c4aa1 --- /dev/null +++ b/example/components/target-switch/target-switch.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/example/components/toggle/destination.vue b/example/components/toggle/destination.vue new file mode 100644 index 0000000..2917b16 --- /dev/null +++ b/example/components/toggle/destination.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/example/components/toggle/source-comp.vue b/example/components/toggle/source-comp.vue new file mode 100644 index 0000000..d0a3c8e --- /dev/null +++ b/example/components/toggle/source-comp.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/example/components/toggle/toggle-example.vue b/example/components/toggle/toggle-example.vue new file mode 100644 index 0000000..812f73e --- /dev/null +++ b/example/components/toggle/toggle-example.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..13c756c --- /dev/null +++ b/example/index.html @@ -0,0 +1,24 @@ + + + + + + VuePortal Examples + + +
+ +
+
+

The following content is outside of the control of the Vue main instance

+

But we can render stuff here nonetheless with the mountTarget Prop

+
+
+
+
+
+
+ + + + diff --git a/example/index.js b/example/index.js new file mode 100644 index 0000000..6d1ddf7 --- /dev/null +++ b/example/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue' + +import App from './components/App.vue' + +import './styles/index.css' + +var PortalVue = process.env.NODE_ENV === 'production' + ? require('../dist/portal-vue').default + : require('../src').default + +Vue.use(PortalVue) + +new Vue({ + el: '#app', + render: h => h(App), +}) diff --git a/example/styles/index.css b/example/styles/index.css new file mode 100644 index 0000000..59db442 --- /dev/null +++ b/example/styles/index.css @@ -0,0 +1,57 @@ +html { + font-family: sans-serif; +} + +.controls { + list-style-type: none; + display: block; + border-bottom: 1px solid black; + margin-bottom: 10px; +} +.controls--item { + display: inline-block; + padding: 3px; +} +.controls--link { + text-decoration: none; + display: block; + border-radius: 5px; + padding: 5px; + color: rgb(66, 185, 131); + background-color: white; + border: 2px solid rgb(66, 185, 131); +} +.controls--link:hover { + color: white; + background-color: rgb(66, 185, 131); + border: 2px solid rgb(66, 185, 131); +} +.controls--link-active { + color: white; + background-color: rgb(66, 185, 131); + border: 2px solid rgb(66, 185, 131); +} + +.wrapper { + display: flex; +} + +.item { + border: 1px solid rgb(220, 220, 220); + box-shadow: 0 0 2px 1px rgba(0,0,0,.2); + border-radius: 2px; + margin: 15px; + margin-top: 30px; + padding: 15px; +} + +.item.source { + border-color: rgb(66, 185, 131); + border-width: 3px; +} + +.item.destination { + border-width: 3px; + border-color: rgb(135, 29, 29); +; +} diff --git a/logo.xcf b/logo.xcf new file mode 100644 index 0000000..7daf31a Binary files /dev/null and b/logo.xcf differ diff --git a/package.json b/package.json index f75f447..c119b6b 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,101 @@ { - "name": "vue-portal", - "version": "0.1.0", - "description": "a Vue component to render elements outside of a component's template, elsewhere in the DOM", - "main": "src/index.js", + "name": "portal-vue", + "version": "1.0.0-beta.1", + "description": "A Vue component to render elements outside of a component's template, elsewhere in the DOM", + "main": "dist/index.js", + "files": [ + "dist/index.js", + "src" + ], "author": "Thorsten ", - "license": "MIT" + "license": "MIT", + "repository": "git@github.com:LinusBorg/portal-vue.git", + "scripts": { + "lint": "eslint src/", + "build": "npm run lint && NODE_ENV=production rollup -c build/rollup.conf.prod.js && uglifyjs dist/portal-vue.js -c -m > dist/portal-vue.min.js", + "dev": "NODE_ENV=development webpack-dev-server --watch --config build/webpack.conf.js --inline --hot", + "prod": "NODE_ENV=production npm run build && webpack-dev-server --watch --config build/webpack.conf.js --inline --hot", + "docs": "docute ./docs", + "test:unit": "NODE_ENV=testing karma start test/karma.conf.js" + }, + "eslintConfig": { + "parser": "babel-eslint", + "extends": [ + "vue" + ], + "plugins": [ + "html", + "vue" + ], + "rules": { + "vue/jsx-uses-vars": 2, + "comma-dangle": [ + "error", + "always-multiline" + ] + } + }, + "babel": { + "presets": [ + [ + "es2015", + { + "modules": false + } + ], + "stage-2" + ], + "plugins": [], + "comments": false, + "env": { + "testing": { + "presets": [ + "es2015", + "stage-2" + ] + } + } + }, + "devDependencies": { + "babel": "^6.5.2", + "babel-core": "^6.22.1", + "babel-eslint": "^7.1.1", + "babel-loader": "^6.2.10", + "babel-preset-es2015": "^6.22.0", + "babel-preset-stage-3": "^6.22.0", + "chai": "^3.5.0", + "css-loader": "^0.26.2", + "eslint": "^3.16.1", + "eslint-config-vue": "^2.0.2", + "eslint-friendly-formatter": "^2.0.7", + "eslint-loader": "^1.6.3", + "eslint-plugin-html": "^2.0.1", + "eslint-plugin-vue": "^2.0.1", + "friendly-errors-webpack-plugin": "^1.4.0", + "inject-loader": "2.0.1", + "karma": "^1.4.1", + "karma-chrome-launcher": "^2.0.0", + "karma-mocha": "^1.3.0", + "karma-sourcemap-loader": "^0.3.7", + "karma-spec-reporter": "^0.0.30", + "karma-webpack": "^2.0.2", + "mocha": "^3.2.0", + "mocha-css": "^1.0.1", + "rollup": "^0.41.4", + "rollup-plugin-babel": "^2.7.1", + "rollup-plugin-buble": "^0.15.0", + "rollup-plugin-commonjs": "^7.0.0", + "rollup-plugin-node-resolve": "^2.0.0", + "rollup-plugin-vue": "^2.2.14", + "style-loader": "^0.13.2", + "testcafe": "^0.13.0", + "testdouble": "^1.10.2", + "testdouble-chai": "^0.5.0", + "vue": "^2.1.7", + "vue-loader": "^11.1.2", + "vue-template-compiler": "^2.1.7", + "webpack": "2.2.1", + "webpack-dev-server": "^2.4.1", + "webpack-merge": "^3.0.0" + } } diff --git a/src/components/portal-target.vue b/src/components/portal-target.vue new file mode 100644 index 0000000..9b38456 --- /dev/null +++ b/src/components/portal-target.vue @@ -0,0 +1,72 @@ + diff --git a/src/components/portal.vue b/src/components/portal.vue new file mode 100644 index 0000000..eb540c6 --- /dev/null +++ b/src/components/portal.vue @@ -0,0 +1,110 @@ + diff --git a/src/components/wormhole.js b/src/components/wormhole.js new file mode 100644 index 0000000..7c4f6ed --- /dev/null +++ b/src/components/wormhole.js @@ -0,0 +1,59 @@ +import Vue from 'vue' +import { freeze } from '../utils' +const routes = {} + +export { routes } + +export class Wormhole { + constructor (routes) { + this.routes = routes + this.clearQueue = [] + this.updateQueue = [] + this.runScheduled = false + } + + send (name, passengers) { + const job = { name, passengers } + this.updateQueue.push(job) + this._scheduleRun() + } + + close (name) { + const job = { name } + this.clearQueue.push(job) + this._scheduleRun() + } + + _scheduleRun () { + if (!this.runScheduled) { + this.runScheduled = true + + setTimeout(this._runQueue.bind(this), 0) + } + } + + _runQueue () { + const keys = Object.keys(this.routes) + + this.clearQueue.forEach(({ name }) => { + if (keys.includes(name)) { + this.routes[name] = undefined + } + }) + this.clearQueue = [] + + this.updateQueue.forEach(({ name, passengers }) => { + if (keys.includes(name)) { + this.routes[name] = freeze(passengers) + } else { + Vue.set(this.routes, name, freeze(passengers)) + } + }) + this.updateQueue = [] + + this.runScheduled = false + } + +} +const wormhole = new Wormhole(routes) +export default wormhole diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e0200cc --- /dev/null +++ b/src/index.js @@ -0,0 +1,17 @@ +import Portal from './components/portal.vue' +import PortalTarget from './components/portal-target.vue' + +function install (Vue, opts = {}) { + Vue.component(opts.portalName || 'portal', Portal) + Vue.component(opts.portalTargetName || 'portal-target', PortalTarget) +} +if (typeof window !== 'undefined' && window.Vue) { + console.log('auto install!') + window.Vue.use({ install: install }) +} + +export default { + install, + Portal, + PortalTarget, +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..548df74 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,18 @@ +export function extractAttributes (el) { + const map = el.hasAttributes() ? el.attributes : [] + const attrs = {} + for (let i = 0; i < map.length; i++) { + const attr = map[i] + if (attr.value) { + attrs[attr.name] = attr.value === '' ? true : attr.value + } + } + return attrs +} + +export function freeze (item) { + if (Array.isArray(item) || typeof item === 'object') { + return Object.freeze(item) + } + return item +} diff --git a/test/e2e/fixture.js b/test/e2e/fixture.js new file mode 100644 index 0000000..9b9f09a --- /dev/null +++ b/test/e2e/fixture.js @@ -0,0 +1,6 @@ +/* global fixture test */ +fixture `Portal Example Tests` + .page `http://localhost:8080` + +test('Toggle Example', async t => { +}) diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..da558b1 --- /dev/null +++ b/test/index.js @@ -0,0 +1,9 @@ +/** + * See /build/webpack.test.conf.js for info how this is run. + */ + + /* additional setup with other loaders (polyfills, ...)*/ +import 'mocha-css' + +const context = require.context('./unit', true, /\.spec.js$/) +context.keys().forEach(context) diff --git a/test/karma.conf.js b/test/karma.conf.js new file mode 100644 index 0000000..23711da --- /dev/null +++ b/test/karma.conf.js @@ -0,0 +1,26 @@ +var webpackConfig = require('../build/webpack.test.conf') + +module.exports = function (config) { + config.set({ + // to run in additional browsers: + // 1. install corresponding karma launcher + // http://karma-runner.github.io/0.13/config/browsers.html + // 2. add it to the `browsers` array below. + browsers: ['Chrome'], + frameworks: ['mocha'], + reporters: ['spec'], + files: ['./index.js'], + preprocessors: { + './index.js': ['webpack', 'sourcemap'] + }, + webpack: webpackConfig, + webpackMiddleware: { + noInfo: true + }, + client: { + mocha: { + reporter: 'html' + } + } + }) +} diff --git a/test/unit/helpers.js b/test/unit/helpers.js new file mode 100644 index 0000000..0f0c4c9 --- /dev/null +++ b/test/unit/helpers.js @@ -0,0 +1,11 @@ +import { default as chai, expect } from 'chai' +import td from 'testdouble' +import tdChai from 'testdouble-chai' + +// make sure to call tdChai with td to inject the dependency +chai.use(tdChai(td)) + +export { + expect, + td, +} diff --git a/test/unit/index.html b/test/unit/index.html new file mode 100644 index 0000000..1592756 --- /dev/null +++ b/test/unit/index.html @@ -0,0 +1,13 @@ + + + + Mocha Tests + + + +
+ + + + + diff --git a/test/unit/portal-target.spec.js b/test/unit/portal-target.spec.js new file mode 100644 index 0000000..2913e80 --- /dev/null +++ b/test/unit/portal-target.spec.js @@ -0,0 +1,84 @@ +/* global describe it afterEach */ +import { expect } from './helpers' +import Vue from 'vue' +import PortalTargetInj from '!!vue-loader?inject!../../src/components/portal-target' + +const routes = {} + +const PortalTarget = new PortalTargetInj({ + './wormhole': { routes: routes }, +}) + +describe('PortalTarget', function () { + afterEach(() => { + const keys = Object.keys(routes) + for (let i = 0; i < keys.length; i++) { + Vue.delete(routes, keys[i]) + } + }) + + it('renders a single element for single vNode with slim prop & single slot element', () => { + Vue.set(routes, 'target', Object.freeze(generateVNode())) + + const vm = generateTarget({ + name: 'target', + slim: true, + }) + + const el = vm.$el + expect(el.classList.contains('testnode')).to.be.true + }) + + it('renders a wrapper with class `vue-portal-target` for multiple vNodes', () => { + const vNodes = Object.freeze([generateVNode(), generateVNode()]) + Vue.set(routes, 'target', vNodes) + + const vm = generateTarget({ + name: 'target', + }) + + const el = vm.$el + return vm.$nextTick().then(() => { + expect(el.classList.contains('vue-portal-target')).to.be.true + }) + }) + + it('applies attributes correctly to root node', () => { + const vNodes = Object.freeze([generateVNode(), generateVNode()]) + Vue.set(routes, 'target', vNodes) + + const vm = generateTarget({ + name: 'target', + attributes: { + class: 'red blue', + id: 'test-id', + }, + }) + const el = vm.$el + return vm.$nextTick().then(() => { + expect(el.classList.contains('red')).to.be.true + expect(el.getAttribute('id')).to.equal('test-id') + }) + }) +}) + +// Utils +function generateTarget (props) { + const el = document.createElement('DIV') + return new Vue({ + ...PortalTarget, + name: 'target', + propsData: props, + }).$mount(el) +} + +function generateVNode () { + const el = document.createElement('DIV') + const vm = new Vue({ + el, + render (h) { + return h('div', [h('span', { class: 'testnode' }, 'Test')]) + }, + }) + return vm._vnode.children +} diff --git a/test/unit/portal.spec.js b/test/unit/portal.spec.js new file mode 100644 index 0000000..ecd6adb --- /dev/null +++ b/test/unit/portal.spec.js @@ -0,0 +1,114 @@ +/* global describe it beforeEach */ +import { expect, td } from './helpers' +import Vue from 'vue' +import PortalInj from '!!vue-loader?inject!../../src/components/portal' + +const Wormhole = td.object(['send', 'close']) +const PortalTarget = { + render (h) { return h('div') }, + props: ['name', 'id', 'tag'], +} +const Portal = PortalInj({ + './wormhole': Wormhole, + './portal-target': PortalTarget, +}) +let vm + +describe('Portal', function () { + beforeEach(function () { + td.reset() + + const el = document.createElement('DIV') + vm = new Vue({ + components: { Portal }, + data: { destination: 'destination', message: 'TestString', disabled: false, tag: 'DIV' }, + template: ` +
+ + {{message}} + +
`, + }).$mount(el) + }) + + it('renders a div element with class `v-portal`', function () { + // expect(vm.$refs.portal.$el.nodeName).to.equal('#comment') + const el = vm.$el.querySelector('.v-portal') + expect(el).not.to.be.undefined + }) + + it('renders no extra root element with slim prop', () => { + const el = document.createElement('DIV') + + vm = new Vue({ + components: { Portal }, + template: ` +
+ + + +
`, + }).$mount(el) + + const rootEl = vm.$el.querySelector('.v-portal') + expect(rootEl).to.not.be.truthy + }) + + it('renders different element when tag prop is defined', () => { + vm.tag = 'SPAN' + return vm.$nextTick().then(() => { + const el = vm.$el.querySelector('span.v-portal') + expect(el).not.to.be.undefined + }) + }) + + it('calls Wormhole.send with right content', function () { + const captor = td.matchers.captor() + + // spy called: + td.verify(Wormhole.send('destination', captor.capture())) + const vnode = captor.values[0][0] + // sent correct vnodes as slot content + expect(vnode.tag).to.equal('span') + expect(vnode.children[0].text).to.equal(vm.message) + }) + + it('calls Wormhole close & sendUpdate when destination changes', () => { + const captor = td.matchers.captor() + vm.destination = 'destination2' + return vm.$nextTick().then(() => { + td.verify(Wormhole.close('destination')) + td.verify(Wormhole.send(captor.capture()), { ignoreExtraArgs: true }) + + expect(captor.values[1]).to.equal('destination2') + return true + }) + }) + + it('calls Wormhole.close() when destroyed', () => { + vm.$refs.portal.$destroy() + td.verify(Wormhole.close('destination')) + }) + + it('calls sendUpdate when content changes', () => { + vm.message = 'New Test String' + return vm.$nextTick().then(() => { + const captor = td.matchers.captor() + td.verify(Wormhole.send('destination', captor.capture())) + + // get second call's first value + const textNode = captor.values[1][0].children[0] + expect(textNode.text).to.equal('New Test String') + }) + }) + + it('renders locally when `disabled` prop is true', () => { + vm.disabled = true + + return vm.$nextTick().then(() => { + const span = vm.$el.querySelector('#test-span') + + expect(span).not.to.be.undefined + }) + }) +}) diff --git a/test/unit/wormhole.spec.js b/test/unit/wormhole.spec.js new file mode 100644 index 0000000..7036973 --- /dev/null +++ b/test/unit/wormhole.spec.js @@ -0,0 +1,59 @@ +/* global describe it beforeEach */ +import { expect } from './helpers' +import { Wormhole } from '../../src/components/wormhole' + +let wormhole + +describe('Wormhole', function () { + beforeEach(() => { + wormhole = new Wormhole({}) + }) + + it('correctly adds passengers on send', () => { + wormhole.send('target', 'Test') + + return new Promise((resolve, reject) => { + setTimeout(() => { + expect(wormhole.routes).to.deep.equal({ target: 'Test' }) + resolve() + }, 0) + }) + }) + + it('removes content on close()', () => { + wormhole.send('target', 'Test') + + return new Promise((resolve, reject) => { + setTimeout(() => { + resolve() + }, 0) + }) + .then(() => { + wormhole.close('target') + + return new Promise((resolve, reject) => { + setTimeout(() => { + expect(wormhole.routes).to.deep.equal({ target: undefined }) + resolve() + }, 0) + }) + }) + }) + + it('the queue correctly executes sync close() before send() calls', () => { + wormhole.send('target', 'Test1') + + return new Promise((resolve, reject) => { + setTimeout(() => { + wormhole.send('target', 'Test2') + wormhole.close('target') + resolve() + }, 0) + }) + .then(() => { + setTimeout(() => { + expect(wormhole.routes).to.deep.equal({ target: 'Test2' }) + }, 0) + }) + }) +})