@stencil/vue-output-target
Version:
Vue output target for @stencil/core components.
306 lines (301 loc) • 13.4 kB
JavaScript
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;
;