UNPKG

@stencil/vue-output-target

Version:

Vue output target for @stencil/core components.

306 lines (301 loc) 13.4 kB
'use strict'; var vue = require('vue'); const LOG_PREFIX = '[vue-output-target]'; /** * returns true if the value is a primitive, e.g. string, number, boolean * @param value - the value to check * @returns true if the value is a primitive, false otherwise */ function isPrimitive(value) { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; } function defineStencilSSRComponent(options) { return vue.defineComponent({ async setup(props, context) { /** * resolve light dom into a string */ const slots = vue.useSlots(); let renderedLightDom = ''; if (typeof slots.default === 'function') { const ssrLightDom = vue.createSSRApp({ render: () => slots.default() }); const { renderToString: vueRenderToString } = await import('vue/server-renderer'); renderedLightDom = await vueRenderToString(ssrLightDom, { context }); } /** * compose element props into a string */ let stringProps = ''; for (const [key, value] of Object.entries(props)) { if (typeof value === 'undefined') { continue; } /** * Stencils metadata tells us which properties can be serialized */ const propName = options.props?.[key][1]; const propValue = isPrimitive(value) ? typeof value === 'boolean' ? /** * omit boolean properties that are false all together */ value ? '"true"' : undefined : `"${value}"` : Array.isArray(value) && value.every(isPrimitive) ? JSON.stringify(value) : undefined; if (!propName || !propValue) { console.warn(`${LOG_PREFIX} ignore component property "${key}" for ${options.tagName} ` + "- property type is unknown or not a primitive and can't be serialized"); continue; } stringProps += ` ${propName}=${propValue}`; } /** * transform component into Declarative Shadow DOM by lazy loading the hydrate module */ const toSerialize = `<${options.tagName}${stringProps}>${renderedLightDom}</${options.tagName}>`; const { renderToString } = await options.hydrateModule; const { html } = await renderToString(toSerialize, { fullDocument: false, serializeShadowRoot: true, }); if (!html) { throw new Error(`'${options.tagName}' component did not render anything.`); } return vue.compile(html /** * by default Vue strips out the <style> tag, so this little trick * makes it work by wrapping it in a component tag */ .replace('<style>', `<component :is="'style'">`) .replace('</style>', '</component>'), { comments: true, isCustomElement: (tag) => tag === options.tagName, }); }, props: Object.entries(options.props || {}).reduce((acc, [key, value]) => { acc[key] = value[0]; return acc; }, {}), /** * the template tags can be arbitrary as they will be replaced with above compiled template */ template: '<div></div>', }); } const UPDATE_VALUE_EVENT = 'update:modelValue'; const MODEL_VALUE = 'modelValue'; const ROUTER_LINK_VALUE = 'routerLink'; const NAV_MANAGER = 'navManager'; const ROUTER_PROP_PREFIX = 'router'; const ARIA_PROP_PREFIX = 'aria'; /** * Starting in Vue 3.1.0, all properties are * added as keys to the props object, even if * they are not being used. In order to correctly * account for both value props and v-model props, * we need to check if the key exists for Vue <3.1.0 * and then check if it is not undefined for Vue >= 3.1.0. * See https://github.com/vuejs/vue-next/issues/3889 */ const EMPTY_PROP = Symbol(); const DEFAULT_EMPTY_PROP = { default: EMPTY_PROP }; const getComponentClasses = (classes) => { return classes?.split(' ') || []; }; const getElementClasses = (el, componentClasses, defaultClasses = []) => { const combinedClasses = new Set([ ...Array.from(el?.classList || []), ...Array.from(componentClasses), ...defaultClasses, ]); return Array.from(combinedClasses); }; /** * Create a callback to define a Vue component wrapper around a Web Component. * * @prop name - The component tag name (i.e. `ion-button`) * @prop componentProps - An array of properties on the * component. These usually match up with the @Prop definitions * in each component's TSX file. * @prop emitProps - An array of for event listener on the Component. * these usually match up with the @Event definitions * in each compont's TSX file. * @prop customElement - An option custom element instance to pass * to customElements.define. Only set if `includeImportCustomElements: true` in your config. * @prop modelProp - The prop that v-model binds to (i.e. value) * @prop modelUpdateEvent - The event that is fired from your Web Component when the value changes (i.e. ionChange) */ const defineContainer = (name, defineCustomElement, componentProps = [], emitProps = [], modelProp, modelUpdateEvent) => { /** * Create a Vue component wrapper around a Web Component. * Note: The `props` here are not all properties on a component. * They refer to whatever properties are set on an instance of a component. */ if (defineCustomElement !== undefined) { defineCustomElement(); } const emits = emitProps; const props = [ROUTER_LINK_VALUE, ...componentProps].reduce((acc, prop) => { acc[prop] = DEFAULT_EMPTY_PROP; return acc; }, {}); if (modelProp) { emits.push(UPDATE_VALUE_EVENT); props[MODEL_VALUE] = DEFAULT_EMPTY_PROP; } return vue.defineComponent((props, { attrs, slots, emit }) => { let modelPropValue = modelProp ? props[modelProp] : undefined; const containerRef = vue.ref(); const classes = new Set(getComponentClasses(attrs.class)); vue.onMounted(() => { /** * we register the event emmiter for @Event definitions * so we can use @event */ emitProps.forEach((eventName) => { containerRef.value?.addEventListener(eventName, (e) => { emit(eventName, e); }); }); }); /** * This directive is responsible for updating any reactive * reference associated with v-model on the component. * This code must be run inside of the "created" callback. * Since the following listener callbacks as well as any potential * event callback defined in the developer's app are set on * the same element, we need to make sure the following callbacks * are set first so they fire first. If the developer's callback fires first * then the reactive reference will not have been updated yet. */ const vModelDirective = { created: (el) => { const eventsNames = Array.isArray(modelUpdateEvent) ? modelUpdateEvent : [modelUpdateEvent]; eventsNames.forEach((eventName) => { el.addEventListener(eventName, (e) => { /** * Only update the v-model binding if the event's target is the element we are * listening on. For example, Component A could emit ionChange, but it could also * have a descendant Component B that also emits ionChange. We only want to update * the v-model for Component A when ionChange originates from that element and not * when ionChange bubbles up from Component B. */ if (e.target.tagName === el.tagName && modelProp) { modelPropValue = (e?.target)[modelProp]; emit(UPDATE_VALUE_EVENT, modelPropValue); } }); }); }, }; const currentInstance = vue.getCurrentInstance(); const hasRouter = currentInstance?.appContext?.provides[NAV_MANAGER]; const navManager = hasRouter ? vue.inject(NAV_MANAGER) : undefined; const elBeforeHydrate = currentInstance?.vnode.el; const handleRouterLink = (ev) => { const { routerLink } = props; if (routerLink === EMPTY_PROP) return; if (navManager !== undefined) { /** * This prevents the browser from * performing a page reload when pressing * an Ionic component with routerLink. * The page reload interferes with routing * and causes ion-back-button to disappear * since the local history is wiped on reload. */ ev.preventDefault(); let navigationPayload = { event: ev }; for (const key in props) { const value = props[key]; if (props.hasOwnProperty(key) && key.startsWith(ROUTER_PROP_PREFIX) && value !== EMPTY_PROP) { navigationPayload[key] = value; } } navManager.navigate(navigationPayload); } else { console.warn('Tried to navigate, but no router was found. Make sure you have mounted Vue Router.'); } }; return () => { modelPropValue = props[modelProp]; getComponentClasses(attrs.class).forEach((value) => { classes.add(value); }); // @ts-expect-error const oldClick = props.onClick; const handleClick = (ev) => { if (oldClick !== undefined) { oldClick(ev); } if (!ev.defaultPrevented) { handleRouterLink(ev); } }; const propsToAdd = { ref: containerRef, class: getElementClasses(elBeforeHydrate, classes), onClick: handleClick, }; /** * We can use Object.entries here * to avoid the hasOwnProperty check, * but that would require 2 iterations * where as this only requires 1. */ for (const key in props) { const value = props[key]; if ((props.hasOwnProperty(key) && value !== EMPTY_PROP) || key.startsWith(ARIA_PROP_PREFIX)) { propsToAdd[key] = value; } /** * register event handlers on the component */ const eventHandlerKey = 'on' + key.slice(0, 1).toUpperCase() + key.slice(1); const eventHandler = attrs[eventHandlerKey]; if (containerRef.value && attrs.hasOwnProperty(eventHandlerKey) && 'addEventListener' in containerRef.value) { containerRef.value.addEventListener(key, eventHandler); } } if (modelProp) { /** * If form value property was set using v-model * then we should use that value. * Otherwise, check to see if form value property * was set as a static value (i.e. no v-model). */ if (props[MODEL_VALUE] !== EMPTY_PROP) { propsToAdd[modelProp] = props[MODEL_VALUE]; } else if (modelPropValue !== EMPTY_PROP) { propsToAdd[modelProp] = modelPropValue; } } // If router link is defined, add href to props // in order to properly render an anchor tag inside // of components that should become activatable and // focusable with router link. if (ROUTER_LINK_VALUE in props && props[ROUTER_LINK_VALUE] !== EMPTY_PROP) { propsToAdd.href = props[ROUTER_LINK_VALUE]; } /** * vModelDirective is only needed on components that support v-model. * As a result, we conditionally call withDirectives with v-model components. */ const node = vue.h(name, propsToAdd, slots.default && slots.default()); return modelProp === undefined ? node : vue.withDirectives(node, [[vModelDirective]]); }; }, { name, props, emits, }); }; exports.defineContainer = defineContainer; exports.defineStencilSSRComponent = defineStencilSSRComponent;