@furo/fbp
Version:
Declarative programming with web-components.
727 lines (658 loc) • 23 kB
text/typescript
/**
* Use this to enable FBP to your lit element written in typescript.
*/
import { LitElement, PropertyValues } from "lit";
// eslint-disable-next-line import/extensions
import {LitFBPAC} from "./LitFBPAC"
type Constructor<T = {}> = new (...args: any[]) => T;
export const LitFBP = <T extends Constructor<LitElement>>(superClass: T) => {
class MixinClass extends superClass {
private __FBPEventlistener: any[] = [];
private __wirebundle: { [key: string]: [any] } = {};
private __wireQueue: any[] = [];
private __fbpAppended: Boolean = false;
private __fbp_ready: Boolean = false;
constructor(...arg: any[]) {
super(arg[0]);
}
/**
* Auto append fbp for lit elements
* @private
*/
protected override firstUpdated(_changedProperties: PropertyValues): void {
// ensure to append only once
if (!this.__fbpAppended && this.shadowRoot !== null) {
this._appendFBP(this.shadowRoot);
this.__fbpAppended = true;
}
super.firstUpdated(_changedProperties);
}
/**
* Triggers a wire
* @param wire (String) Name of the wire like --buttonClicked
* @param detailData (*) data to pass
* @private
*/
protected _FBPTriggerWire(wire: string, detailData: any) {
if (this.__fbp_ready) {
if (this.__wirebundle[wire]) {
this.__wirebundle[wire].forEach(receiver => {
// check for hooks
if (typeof receiver === "function") {
receiver(detailData);
} else if (
typeof receiver.element[receiver.method] === "function"
) {
this._call(detailData, receiver);
} else if (receiver.property) {
let data = detailData;
if (receiver.path) {
data = this._pathGet(detailData, receiver.path);
}
this._pathSet(receiver.element, receiver.property, data);
} else if (receiver.element.localName.includes("-")) {
// retry call with whenDefined because sometimes the components are just not defined at the time ƒ-method is triggered
customElements
.whenDefined(receiver.element.localName)
.then(() => {
if (typeof receiver.element[receiver.method] === "function") {
this._call(detailData, receiver);
} else {
// eslint-disable-next-line no-console
console.warn(
`${receiver.method} is not a method of ${receiver.element.nodeName}`,
receiver.element
);
}
});
} else {
// eslint-disable-next-line no-console
console.warn(
`${receiver.method} is not a method of ${receiver.element.nodeName}`,
receiver.element
);
}
});
}
} else {
this.__enqueueTrigger(wire, detailData);
}
}
/**
*
* @param detailData
* @param receiver
* @private
*/
private _call(detailData: any, receiver: any) {
let response;
// array spreaden
if (
Array.isArray(detailData) &&
receiver.element[receiver.method].length > 1
) {
// eslint-disable-next-line prefer-spread
response = receiver.element[receiver.method].apply(
receiver.element,
detailData
);
} else {
let data = detailData;
if (receiver.path) {
data = this._pathGet(detailData, receiver.path);
}
response = receiver.element[receiver.method](data);
}
// fnret-function auslösen
receiver.element.dispatchEvent(
new CustomEvent(`fnret-${receiver.attrName}`, {
composed: false,
bubbles: false,
detail: response,
})
);
}
/**
*
* @param wire (String) Name of the wire
* @param cb (function) Callback function cb(detailData)
* @param [before] (Boolean) append before the components are triggered, default is false
* @returns {number} Index of hook
* @private
*/
protected _FBPAddWireHook(wire: string, cb: Function, before: boolean) {
// eslint-disable-next-line no-param-reassign
before = before || false;
if (this.__wirebundle[wire]) {
if (before) {
this.__wirebundle[wire].unshift(cb);
return 0;
}
const l = this.__wirebundle[wire].push(cb);
return l - 1;
}
this.__wirebundle[wire] = [cb];
return 1;
}
/**
* Log all triggered wires for this component. This function may help you at debugging.
* Select your element in the dev console and call `$0._FBPTraceWires()`
*
*
* @private
*/
protected _FBPTraceWires() {
const self = this;
// eslint-disable-next-line guard-for-in,no-restricted-syntax
for (const wire in this.__wirebundle) {
this._FBPAddWireHook(
wire,
(e: any) => {
const ua = navigator.userAgent.toLowerCase();
let agent = true;
if (ua.indexOf("safari") !== -1) {
if (ua.indexOf("chrome") > -1) {
agent = true; // Chrome
} else {
agent = false; // Safari
}
}
if (agent) {
// eslint-disable-next-line no-console
console.groupCollapsed("Trace for", `${this.nodeName}: ${wire}`);
// eslint-disable-next-line no-console
console.table([{ host: self, wire, data: e }]);
// eslint-disable-next-line no-console
console.groupCollapsed("Data");
// eslint-disable-next-line no-console
console.log(e);
// eslint-disable-next-line no-console
console.groupEnd();
// eslint-disable-next-line no-console
console.groupCollapsed("Target Elements");
// eslint-disable-next-line no-console
console.table(self.__wirebundle[wire]);
// eslint-disable-next-line no-console
console.groupEnd();
// eslint-disable-next-line no-console
console.groupCollapsed("Call Stack");
// eslint-disable-next-line no-console
console.log(new Error().stack);
// eslint-disable-next-line no-console
console.groupEnd();
// eslint-disable-next-line no-console
console.groupEnd();
}
},
true
);
}
}
/**
* Get information for the triggered wire. This function may help you at debugging.
* Select your element in the dev console and call `$0._FBPDebug('--dataReceived')`
*
* @param wire
* @param openDebugger opens the debugger console, so you can inspect your component.
* @private
*/
protected _FBPDebug(wire: string, openDebugger: boolean) {
const self = this;
this._FBPAddWireHook(
wire,
(e: any) => {
if (openDebugger) {
// eslint-disable-next-line no-debugger
debugger;
} else {
const ua = navigator.userAgent.toLowerCase();
let agent = true;
if (ua.indexOf("safari") !== -1) {
if (ua.indexOf("chrome") > -1) {
agent = true; // Chrome
} else {
agent = false; // Safari
}
}
if (agent) {
// eslint-disable-next-line no-console
console.group("Debug", `${this.nodeName}: ${wire}`);
// eslint-disable-next-line no-console
console.group("Target Elements");
// eslint-disable-next-line no-console
console.table(self.__wirebundle[wire]);
// eslint-disable-next-line no-console
console.groupEnd();
// eslint-disable-next-line no-console
console.groupCollapsed("Data");
// eslint-disable-next-line no-console
console.log(e);
// eslint-disable-next-line no-console
console.groupEnd();
// eslint-disable-next-line no-console
console.groupCollapsed("Call Stack");
// eslint-disable-next-line no-console
console.log(new Error().stack);
// eslint-disable-next-line no-console
console.groupEnd();
// eslint-disable-next-line no-console
console.groupEnd();
}
}
},
true
);
}
/**
*
* @param str
* @return {*}
* @private
*/
// eslint-disable-next-line class-methods-use-this
private __toCamelCase(str: string) {
return str.replace(/-([a-z])/g, g => g[1].toUpperCase());
}
/**
* parses the dom for flowbased programming tags
* @param dom dom node
* @private
*/
private _appendFBP(dom: ShadowRoot) {
const self = this;
const wirebundle = this.__wirebundle;
// get all elements which live in the host
const nl = dom.querySelectorAll("*");
const l = nl.length - 1;
// eslint-disable-next-line func-names
const _collectReceivers = (element: Element, i: number, attr: string) => {
// collect receiver
element.attributes[i].value.split(",").forEach(w => {
const r = this.__resolveWireAndPath(w);
// create empty if not exist
if (!wirebundle[r.receivingWire]) {
// @ts-ignore
wirebundle[r.receivingWire] = [];
}
wirebundle[r.receivingWire].push({
element,
method: this.__toCamelCase(attr),
attrName: attr,
path: r.path,
});
});
};
// eslint-disable-next-line func-names
const _collectPropertySetters = (
element: Element,
i: number,
property: string
) => {
// split multiple wires
element.attributes[i].value.split(",").forEach((w: string) => {
const r = this.__resolveWireAndPath(w);
// create empty if not exist
if (!wirebundle[r.receivingWire]) {
// @ts-ignore
wirebundle[r.receivingWire] = [];
}
wirebundle[r.receivingWire].push({
element,
property: this.__toCamelCase(property),
path: r.path,
});
});
};
// eslint-disable-next-line func-names
const _extractEventWires = (fwire: string) => {
let wire;
const trimmedWire = fwire.trim();
let type = "call";
if (trimmedWire.startsWith("((")) {
wire = trimmedWire.substring(2, trimmedWire.length - 2);
type = "setValue";
} else if (trimmedWire.startsWith("-^")) {
wire = trimmedWire.substring(2);
type = "fireOnHost";
} else if (trimmedWire.startsWith("^")) {
wire = trimmedWire.substring(1);
type = "fire";
if (trimmedWire.startsWith("^^")) {
wire = trimmedWire.substring(2);
type = "fireBubble";
}
} else if (trimmedWire === ":STOP") {
type = "stop";
wire = "stop";
} else if (trimmedWire === ":PREVENTDEFAULT") {
type = "preventdefault";
wire = "preventdefault";
} else {
wire = trimmedWire;
type = "call";
}
return { type, wire };
};
// eslint-disable-next-line no-plusplus
for (let x = l; x >= 0; --x) {
const element = nl[x];
// skip template tags
if (element.tagName === "TEMPLATE") {
// eslint-disable-next-line no-continue
continue;
}
for (let i = 0; i < element.attributes.length; i += 1) {
// collect receiving tags
if (element.attributes[i].name.startsWith("fn-")) {
const attr = element.attributes[i].name.substring(3);
_collectReceivers.call(this, element, i, attr);
// eslint-disable-next-line no-continue
continue;
}
// collect sending tags
if (element.attributes[i].name.startsWith("at-")) {
const eventname = element.attributes[i].name.substring(3);
const fwires = element.attributes[i].value;
fwires.split(",").forEach(fwire => {
const __ret = _extractEventWires(fwire);
// eslint-disable-next-line no-use-before-define
registerEvent(eventname, __ret.type, __ret.wire, element);
});
// eslint-disable-next-line no-continue
continue;
}
// collect data property setter receiver
if (element.attributes[i].name.startsWith("set-")) {
const property = element.attributes[i].name.substring(4);
_collectPropertySetters.call(this, element, i, property);
// eslint-disable-next-line no-continue
continue;
}
}
}
/**
* register event on current element
* @param eventname
* @param type
* @param wire
* @private
*/
function registerEvent(
eventname: string,
type: string,
wire: string,
element: Element
) {
// find properties in wire
// eslint-disable-next-line no-useless-escape
const match = wire.match(/([a-z0-9\-_*\.]+)/gi);
const handler: Record<string, any> = {
// prevent default and stop propagation
stop(e: Event) {
e.stopPropagation();
},
preventdefault(e: Event) {
e.preventDefault();
},
call(e: CustomEvent<any>) {
/**
* Prüfe ob die Funktion mit einem Wert aus dem Host oder mit den Details des Events ausgeführt werden soll.
* --wire(hostName) ==> wirft this.hostName in die Funktion sonst wird e.detail verwendet
*
*/
let effectiveWire = wire;
let detailData = e.detail;
if (match !== null && match.length > 1) {
// --wireName(*) sends the raw event
// --wireName(*.mouseX) sends property mouseX of the event
if (match[1].startsWith("*")) {
if (match[1].length === 1) {
// send raw event
detailData = e;
} else {
// send event subprop with *.notDetail.xxx
detailData = self._pathGet(
e,
match[1].substring(2, match[1].length)
);
}
} else {
// send host property
detailData = self._pathGet(self, match[1]);
}
// eslint-disable-next-line prefer-destructuring
effectiveWire = match[0];
}
self._FBPTriggerWire(effectiveWire, detailData);
},
fire(e: CustomEvent) {
if (match !== null && match.length > 1) {
const prop = match[1];
const theEvent = match[0];
let edetail: any;
// send details with *.sub or *
if (prop.startsWith("*")) {
if (prop.length === 1) {
// send raw event
edetail = e;
} else {
edetail = self._pathGet(e, prop.substring(2));
}
} else {
edetail = self._pathGet(self, prop);
}
const customEvent = new CustomEvent(theEvent, {
composed: false,
bubbles: true,
detail: edetail,
});
// @ts-ignore
e.currentTarget.dispatchEvent(customEvent);
} else {
const customEvent = new CustomEvent(wire, {
composed: false,
bubbles: true,
detail: e.detail,
});
// @ts-ignore
e.currentTarget.dispatchEvent(customEvent);
}
},
fireOnHost(e: CustomEvent) {
if (match !== null && match.length > 1) {
const prop = match[1];
const theEvent = match[0];
let edetail: any;
// send details with *.sub or *
if (prop.startsWith("*")) {
if (prop.length === 1) {
// send raw event
edetail = e;
} else {
edetail = self._pathGet(e, prop.substring(2));
}
} else {
edetail = self._pathGet(self, prop);
}
const customEvent = new CustomEvent(theEvent, {
composed: false,
bubbles: true,
detail: edetail,
});
self.dispatchEvent(customEvent);
} else {
const customEvent = new CustomEvent(wire, {
composed: false,
bubbles: true,
detail: e.detail,
});
self.dispatchEvent(customEvent);
}
},
fireBubble(e: CustomEvent) {
if (match !== null && match.length > 1) {
const prop = match[1];
const theEvent = match[0];
let edetail: any;
// send details with *.sub or *
if (prop.startsWith("*")) {
if (prop.length === 1) {
// send raw event
edetail = e;
} else {
edetail = self._pathGet(e, prop.substring(2));
}
} else {
edetail = self._pathGet(self, prop);
}
const customEvent = new CustomEvent(theEvent, {
composed: true,
bubbles: true,
detail: edetail,
});
// @ts-ignore
e.currentTarget.dispatchEvent(customEvent);
} else {
const customEvent = new CustomEvent(wire, {
composed: true,
bubbles: true,
detail: e.detail,
});
// @ts-ignore
e.currentTarget.dispatchEvent(customEvent);
}
},
setValue(e: any) {
self._pathSet(self, wire, e.detail);
// self.set(wire, e.detail, self);
},
};
element.addEventListener(eventname, handler[type]);
self.__FBPEventlistener.push({
element,
event: eventname,
handler: handler[type],
});
}
// queueing for _FBPTriggerWire
if (!this.__fbp_ready) {
this._FBPReady();
const queuelength = this.__wireQueue.length;
for (let i = 0; i < queuelength; i += 1) {
const t = this.__wireQueue.shift();
this._FBPTriggerWire(t.w, t.d);
}
}
}
/**
* Livecycle method
* This method is called, when the wires are ready.
* And triggers the `|--FBPready` wire. This does *not* respect a lit updateComplete
* @private
*/
protected _FBPReady() {
this.__fbp_ready = true;
this._FBPTriggerWire("|--FBPready", this);
}
/**
*
* @param wire
* @param detailData
* @private
*/
private __enqueueTrigger(wire: string, detailData: any) {
this.__wireQueue.push({ w: wire, d: detailData });
}
/**
*
* @param w
* @return {{path, receivingWire}}
* @private
*/
// eslint-disable-next-line class-methods-use-this
private __resolveWireAndPath(w: string) {
// finde --wire(*.xx.yy) => group1 = --wire group2 = xx.yy
// eslint-disable-next-line no-useless-escape
const match = w.trim().match(/(^[^\(]*)\(?\*?\.?([^\)]*)/);
// @ts-ignore
const receivingWire = match[1];
// @ts-ignore
const path = match[2];
return { receivingWire, path };
}
/**
* Reads a value from a path. If any sub-property in the path is `undefined`,
* this method returns `undefined` (will never throw.
*
* @param {Object} root Object from which to dereference path from
* @param {string | !Array<string|number>} path Path to read
* @return {*} Value at path, or `undefined` if the path could not be fully dereferenced.
* @protected
*/
protected _pathGet(root: any, path: string) {
let prop = root;
const parts = this._split(path);
// Loop over path parts[0..n-1] and dereference
for (let i = 0; i < parts.length; i += 1) {
if (!prop) {
return false;
}
const part = parts[i];
prop = prop[part];
}
return prop;
}
/**
* Sets a value to a path. If any sub-property in the path is `undefined`,
* this method will no-op.
*
* @param {Object} root Object from which to dereference path from
* @param {string | !Array<string|number>} path Path to set
* @param {*} value Value to set to path
* @private
*/
protected _pathSet(root: any, path: string, value: any) {
let prop = root;
const parts = this._split(path);
const last = parts[parts.length - 1];
// used for @-event="((prop.sub))"
if (parts.length > 1) {
// Loop over path parts[0..n-2] and dereference
for (let i = 0; i < parts.length - 1; i += 1) {
const part = parts[i];
prop = prop[part];
if (!prop) {
return false;
}
}
// Set value to object at end of path
prop[last] = value;
} else {
// Simple property set
prop[path] = value;
}
return parts.join(".");
}
/**
* Splits a path into an array of property names. Accepts either arrays
* of path parts or strings.
*
* Example:
*
* ```
* split(['foo.bar', 0, 'baz']) // ['foo', 'bar', '0', 'baz']
* split('foo.bar.0.baz') // ['foo', 'bar', '0', 'baz']
* ```
*
* @param {string | !Array<string|number>} path Input path
* @suppress {checkTypes}
* @private
*/
// eslint-disable-next-line class-methods-use-this
protected _split(path: string) {
return path.toString().split(".");
}
}
// Cast return type to the superClass type passed in
return MixinClass as unknown as Constructor<LitFBPAC> & T;
};