const camelizeRE = /-(\w)/g; const camelize = str => { return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : '') }; const hyphenateRE = /\B([A-Z])/g; const hyphenate = str => { return str.replace(hyphenateRE, '-$1').toLowerCase() }; function getInitialProps (propsList) { const res = {}; propsList.forEach(key => { res[key] = undefined; }); return res } function injectHook (options, key, hook) { options[key] = [].concat(options[key] || []); options[key].unshift(hook); } function callHooks (vm, hook) { if (vm) { const hooks = vm.$options[hook] || []; hooks.forEach(hook => { hook.call(vm); }); } } function createCustomEvent (name, args) { return new CustomEvent(name, { bubbles: false, cancelable: false, detail: args }) } const isBoolean = val => /function Boolean/.test(String(val)); const isNumber = val => /function Number/.test(String(val)); function convertAttributeValue (value, name, { type } = {}) { if (isBoolean(type)) { if (value === 'true' || value === 'false') { return value === 'true' } if (value === '' || value === name || value != null) { return true } return value } else if (isNumber(type)) { const parsed = parseFloat(value, 10); return isNaN(parsed) ? value : parsed } else { return value } } function toVNodes (h, children) { const res = []; for (let i = 0, l = children.length; i < l; i++) { res.push(toVNode(h, children[i])); } return res } function toVNode (h, node) { if (node.nodeType === 3) { return node.data.trim() ? node.data : null } else if (node.nodeType === 1) { const data = { attrs: getAttributes(node), domProps: { innerHTML: node.innerHTML } }; if (data.attrs.slot) { data.slot = data.attrs.slot; delete data.attrs.slot; } return h(node.tagName, data) } else { return null } } function getAttributes (node) { const res = {}; for (let i = 0, l = node.attributes.length; i < l; i++) { const attr = node.attributes[i]; res[attr.nodeName] = attr.nodeValue; } return res } function wrap (Vue, Component) { const isAsync = typeof Component === 'function' && !Component.cid; let isInitialized = false; let hyphenatedPropsList; let camelizedPropsList; let camelizedPropsMap; function initialize (Component) { if (isInitialized) return const options = typeof Component === 'function' ? Component.options : Component; // extract props info const propsList = Array.isArray(options.props) ? options.props : Object.keys(options.props || {}); hyphenatedPropsList = propsList.map(hyphenate); camelizedPropsList = propsList.map(camelize); const originalPropsAsObject = Array.isArray(options.props) ? {} : options.props || {}; camelizedPropsMap = camelizedPropsList.reduce((map, key, i) => { map[key] = originalPropsAsObject[propsList[i]]; return map }, {}); // proxy $emit to native DOM events injectHook(options, 'beforeCreate', function () { const emit = this.$emit; this.$emit = (name, ...args) => { this.$root.$options.customElement.dispatchEvent(createCustomEvent(name, args)); return emit.call(this, name, ...args) }; }); injectHook(options, 'created', function () { // sync default props values to wrapper on created camelizedPropsList.forEach(key => { this.$root.props[key] = this[key]; }); }); // proxy props as Element properties camelizedPropsList.forEach(key => { Object.defineProperty(CustomElement.prototype, key, { get () { return this._wrapper.props[key] }, set (newVal) { this._wrapper.props[key] = newVal; }, enumerable: false, configurable: true }); }); isInitialized = true; } function syncAttribute (el, key) { const camelized = camelize(key); const value = el.hasAttribute(key) ? el.getAttribute(key) : undefined; el._wrapper.props[camelized] = convertAttributeValue( value, key, camelizedPropsMap[camelized] ); } class CustomElement extends HTMLElement { constructor () { const self = super(); self.attachShadow({ mode: 'open' }); const wrapper = self._wrapper = new Vue({ name: 'shadow-root', customElement: self, shadowRoot: self.shadowRoot, data () { return { props: {}, slotChildren: [] } }, render (h) { return h(Component, { ref: 'inner', props: this.props }, this.slotChildren) } }); // Use MutationObserver to react to future attribute & slot content change const observer = new MutationObserver(mutations => { let hasChildrenChange = false; for (let i = 0; i < mutations.length; i++) { const m = mutations[i]; if (isInitialized && m.type === 'attributes' && m.target === self) { syncAttribute(self, m.attributeName); } else { hasChildrenChange = true; } } if (hasChildrenChange) { wrapper.slotChildren = Object.freeze(toVNodes( wrapper.$createElement, self.childNodes )); } }); observer.observe(self, { childList: true, subtree: true, characterData: true, attributes: true }); } get vueComponent () { return this._wrapper.$refs.inner } connectedCallback () { const wrapper = this._wrapper; if (!wrapper._isMounted) { // initialize attributes const syncInitialAttributes = () => { wrapper.props = getInitialProps(camelizedPropsList); hyphenatedPropsList.forEach(key => { syncAttribute(this, key); }); }; if (isInitialized) { syncInitialAttributes(); } else { // async & unresolved Component().then(resolved => { if (resolved.__esModule || resolved[Symbol.toStringTag] === 'Module') { resolved = resolved.default; } initialize(resolved); syncInitialAttributes(); }); } // initialize children wrapper.slotChildren = Object.freeze(toVNodes( wrapper.$createElement, this.childNodes )); wrapper.$mount(); this.shadowRoot.appendChild(wrapper.$el); } else { callHooks(this.vueComponent, 'activated'); } } disconnectedCallback () { callHooks(this.vueComponent, 'deactivated'); } } if (!isAsync) { initialize(Component); } return CustomElement } export default wrap;