@muban/muban
Version:
Writing components for server-rendered HTML
372 lines (369 loc) • 17.6 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any,@typescript-eslint/ban-types,no-console,max-lines */
import { ref, unref } from '@vue/reactivity';
import { bindCollection, BindComponent, bindComponentCollection, bindElement, } from '../bindings/bindingDefinitions';
import { getParentComponentElement } from '../utils/domUtils';
import { getComponentForElement } from '../utils/global';
/**
* Ensures that the passed element is a direct child of the parent, so that the
* parent is the "owner" of that child. If correct, return the element, otherwise
* return null.
* @param parent
* @param element
* @param ignoreGuard
*/
export function ensureElementIsComponentChild(parent, element, ignoreGuard = false) {
if (!element) {
return null;
}
if (ignoreGuard || parent === element) {
// valid if self
return element;
}
const parentComponentElement = getParentComponentElement(element);
if (parent === parentComponentElement) {
// valid if direct parent
return element;
}
return null;
}
/**
* Checks if a child component instance for a Ref, that is about to be created
* already exists as a globally created component, and re-parents it
* @param element
* @param instance
*/
function getExistingGlobalRefComponent(element, instance) {
var _a;
const existingComponent = getComponentForElement(element);
let refInstance;
if (existingComponent) {
// only if the component currently links to the app root
// in that case, it was initialized globally already
if (((_a = existingComponent.__instance.parent) === null || _a === void 0 ? void 0 : _a.uid) === 0) {
// relink this component to it's proper parent
existingComponent.__instance.parent = instance;
refInstance = existingComponent;
}
else {
// TODO: not sure what to do here, for now we just let the component be
// re-created, which shows additional warnings
// eslint-disable-next-line no-console
console.error('This refComponent does already exist as part of another parent', existingComponent.__instance.parent);
}
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore used before assigned - incorrect
return refInstance;
}
export function refElement(refIdOrQuery, { isRequired = true, ignoreGuard } = {}) {
return {
ref: typeof refIdOrQuery === 'string' ? refIdOrQuery : '[custom]',
type: 'element',
queryRef(parent) {
// also check for data-ref on the root element to not have to refactor
// when moving the div (e.g. when adding wrapping containers)
if (this.ref === '_self_' || parent.dataset.ref === this.ref) {
return parent;
}
if (typeof refIdOrQuery === 'function') {
const element = refIdOrQuery(parent);
return ensureElementIsComponentChild(parent, element, ignoreGuard);
}
else {
try {
const elementList = parent.querySelectorAll(`[data-ref="${this.ref}"]`);
return (Array.from(elementList).find((elementInList) => ensureElementIsComponentChild(parent, elementInList, ignoreGuard)) || null);
}
catch (error) {
if (error instanceof DOMException) {
// eslint-disable-next-line no-console
console.warn(`
[Error querying ref] The first argument of refElement should be the value of a data-ref in the DOM, not a querySelector.
If you want to select a custom target, pass a function like;
refElement((parent) => parent.querySelector('${this.ref}'));
`);
}
throw error;
}
}
},
createRef(instance) {
var _a;
const elementRef = ref();
const getElement = (initial = false) => {
// when ref is not provided, pick the component element itself
const element = this.queryRef(instance.element);
if (isRequired && !element && (initial || elementRef.value !== element)) {
// eslint-disable-next-line no-console
console.error('Element not found', this.ref);
}
return element;
};
elementRef.value = (_a = getElement(true)) !== null && _a !== void 0 ? _a : undefined;
return {
type: 'element',
getBindingDefinition(props) {
return bindElement(elementRef, props);
},
// TODO: this is currently not reactive, so is only correct in the setup function, not in async code or callbacks
element: elementRef.value,
refreshRefs() {
const element = getElement();
if (element !== elementRef.value) {
elementRef.value = element !== null && element !== void 0 ? element : undefined;
}
},
};
},
isRequired,
};
}
export function refCollection(refIdOrQuery, { minimumItemsRequired = 0, ignoreGuard } = {}) {
return {
ref: typeof refIdOrQuery === 'string' ? refIdOrQuery : '[custom]',
type: 'collection',
queryRef(parent) {
let elements;
if (typeof refIdOrQuery === 'function') {
elements = refIdOrQuery(parent);
}
else {
try {
elements = Array.from(parent.querySelectorAll(`[data-ref="${refIdOrQuery}"]`));
}
catch (error) {
if (error instanceof DOMException) {
// eslint-disable-next-line no-console
console.warn(`
[Error querying ref] The first argument of refElement should be the value of a data-ref in the DOM, not a querySelector.
If you want to select a custom target, pass a function like;
refElement((parent) => parent.querySelector('${refIdOrQuery}'));
`);
}
throw error;
}
}
return elements.filter((element) => Boolean(ensureElementIsComponentChild(parent, element, ignoreGuard)));
},
createRef(instance) {
const elementsRef = ref([]);
const getElements = () => {
const elements = this.queryRef(instance.element);
if (elements.length < minimumItemsRequired) {
// eslint-disable-next-line no-console
console.error(`Expected at least "${minimumItemsRequired}" elements, but found "${elements.length}"`, `[data-ref="${this.ref}"]`);
}
return elements.map((element) => ref(element));
};
elementsRef.value = getElements();
return {
type: 'collection',
getBindingDefinition(props) {
return bindCollection(elementsRef, props);
},
getElements() {
return elementsRef.value.map((refItem) => unref(refItem));
},
// elements: elementsRef.value,
getRefs() {
return elementsRef.value.map((elementRef) => {
return {
type: 'element',
getBindingDefinition(props) {
return bindElement(elementRef, props);
},
// TODO: this is currently not reactive, so is only correct in the setup function, not in async code or callbacks
element: elementRef.value,
};
});
},
refreshRefs() {
const elements = getElements();
// only re-assign if some refs are actually different
if (elements.length !== elementsRef.value.length ||
!elements.every((elementRef) => elementsRef.value.some((oldRef) => oldRef.value === elementRef.value))) {
// first, "de-ref" the old array to trigger binding cleanup
elementsRef.value.forEach((refItem) => {
// but only if it doesn't exist in the new array
if (!elements.includes(refItem)) {
// eslint-disable-next-line no-param-reassign
refItem.value = undefined;
}
});
elementsRef.value = elements;
}
},
};
},
};
}
export function refComponent(component, { ref: refIdOrQuery, isRequired = true, ignoreGuard, } = {}) {
const components = Array.isArray(component) ? component : [component];
const getQuery = () => {
return refIdOrQuery
? `[data-ref="${refIdOrQuery}"]`
: components.map((c) => `[data-component="${c.displayName}"]`).join(', ');
};
return {
ref: typeof refIdOrQuery === 'function' ? '[custom]' : refIdOrQuery,
componentRef: components[0].displayName,
type: 'component',
queryRef(parent) {
let element;
if (typeof refIdOrQuery === 'function') {
element = refIdOrQuery(parent);
}
else {
const elementList = parent.querySelectorAll(getQuery());
element =
Array.from(elementList).find((elementInList) => ensureElementIsComponentChild(parent, elementInList, ignoreGuard)) || null;
}
if (element && !components.some((c) => c.displayName === (element === null || element === void 0 ? void 0 : element.dataset.component))) {
console.error(`[refComponent] Selected element that doesn't match any of the passed components`, element, components.map((c) => c.displayName));
return null;
}
return ensureElementIsComponentChild(parent, element, ignoreGuard);
},
createRef(instance) {
const instanceRef = ref();
// eslint-disable-next-line consistent-return
const getComponent = (initialRender = false) => {
var _a;
const element = this.queryRef(instance.element);
if (initialRender && isRequired && !element) {
// eslint-disable-next-line no-console
console.error('Component not found in DOM', getQuery());
}
// return if instance was already created for this element
if (element === ((_a = instanceRef.value) === null || _a === void 0 ? void 0 : _a.element)) {
return instanceRef.value;
}
if (element) {
const newComponentFactory = (Array.isArray(component) ? component : [component]).find((c) => c.displayName === element.dataset.component);
if (newComponentFactory) {
let refInstance = getExistingGlobalRefComponent(element, instance);
if (!refInstance) {
// create new component instance
// TODO: This component only gets "set up" when HTML is updated through `bindTemplate`.
// If not, this is just an empty component that doesn't do anything.
// Would be nice if we could improve this
refInstance = newComponentFactory(element, { parent: instance });
}
instance.children.push(refInstance);
return refInstance;
}
}
};
instanceRef.value = getComponent(true);
return {
type: 'component',
getBindingDefinition(props) {
return BindComponent(instanceRef, props);
},
component: instanceRef.value,
refreshRefs() {
instanceRef.value = getComponent();
},
};
},
isRequired,
};
}
export function refComponents(component, { ref: refIdOrQuery, minimumItemsRequired = 0, ignoreGuard, } = {}) {
const components = Array.isArray(component) ? component : [component];
const getQuery = () => {
return refIdOrQuery
? `[data-ref="${refIdOrQuery}"]`
: components.map((c) => `[data-component="${c.displayName}"]`).join(', ');
};
return {
ref: typeof refIdOrQuery === 'function' ? '[custom]' : refIdOrQuery,
componentRef: components[0].displayName,
type: 'componentCollection',
queryRef(parent) {
let elements;
if (typeof refIdOrQuery === 'function') {
elements = refIdOrQuery(parent);
}
else {
elements = Array.from(parent.querySelectorAll(getQuery()));
}
return elements
.filter((element) => Boolean(ensureElementIsComponentChild(parent, element, ignoreGuard)))
.filter((element) => {
if (!components.some((c) => c.displayName === (element === null || element === void 0 ? void 0 : element.dataset.component))) {
// eslint-disable-next-line no-console
console.error(`[refComponent] Selected element that doesn't match any of the passed components`, element, components.map((c) => c.displayName));
return false;
}
return true;
});
},
createRef(instance) {
const instancesRef = ref([]);
const getComponents = () => {
const elements = this.queryRef(instance.element);
if (elements.length < minimumItemsRequired) {
// eslint-disable-next-line no-console
console.error(`Expected at least "${minimumItemsRequired}" elements, but found "${elements.length}"`, `[data-ref="${this.ref}"]`);
}
return elements.map((element) => {
const existingInstance = instancesRef.value
.map((instanceRef) => instanceRef.value.element)
.indexOf(element);
if (existingInstance === -1) {
const newComponentFactory = (Array.isArray(component) ? component : [component]).find((c) => c.displayName === element.dataset.component);
if (newComponentFactory) {
let refInstance = getExistingGlobalRefComponent(element, instance);
if (!refInstance) {
// create new component instance
refInstance = newComponentFactory(element, { parent: instance });
}
instance.children.push(refInstance);
return ref(refInstance);
}
}
return instancesRef.value[existingInstance];
});
};
instancesRef.value = getComponents();
return {
type: 'componentCollection',
getBindingDefinition(props) {
return bindComponentCollection(instancesRef, props);
},
getComponents() {
return instancesRef.value.map((refItem) => unref(refItem));
},
getRefs() {
return instancesRef.value.map((instanceRef) => {
return {
type: 'component',
getBindingDefinition(props) {
return BindComponent(instanceRef, props);
},
component: instanceRef.value,
};
});
},
refreshRefs() {
// eslint-disable-next-line no-shadow
const components = getComponents();
// only re-assign if some refs are actually different
if (components.length !== instancesRef.value.length ||
!components.every((elementRef) => instancesRef.value.some((oldRef) => oldRef.value === elementRef.value))) {
// first, "de-ref" the old array to trigger binding cleanup
instancesRef.value.forEach((refItem) => {
// but only if it doesn't exist in the new array
if (!components.includes(refItem)) {
// eslint-disable-next-line no-param-reassign
refItem.value = undefined;
}
});
instancesRef.value = components;
}
},
};
},
};
}