@twobirds/microcomponents
Version:
Micro Components Organization Class
609 lines (497 loc) • 17.9 kB
text/typescript
;
import { LooseObject } from './helpers.js';
import { McEvent } from './McEvent.js';
var isBrowser = new Function(
'try {return this===window;}catch(e){ return false;}'
);
let nativeEventNames: Set<string> = new Set();
// HTMLElement native event names
if (isBrowser()) {
Object.keys(HTMLElement.prototype)
.filter((key) => /^on/.test(key))
.forEach((eventName) => nativeEventNames.add(eventName));
}
export interface HTMLMcElement extends HTMLElement {
_mc: LooseObject;
}
// get all microcomponents from a list of HTMLElements
function getMicroComponents(elements: any[], search: string): any[] {
let mcValues: any[] = [];
elements.forEach((e) => {
if (e) {
if (e instanceof HTMLElement) {
const _mc = (e as any)?._mc;
if (!search && _mc) {
mcValues = mcValues.concat(Object.values((e as any)._mc));
} else if (search && _mc && _mc[search]) {
mcValues.push(_mc[search]);
}
}
}
});
return mcValues;
}
// get all ancestor DOM nodes up to the document root
function traverseNodes(
node: { [key: string]: any },
search: string,
firstonly = true
): any[] {
let mcValues: any[] = [];
while (node) {
if (node._mc) {
if (!search) {
mcValues = mcValues.concat(Object.values(node._mc));
} else if (search && node._mc[search]) {
mcValues.push(node._mc[search]);
}
if (firstonly) return mcValues;
}
node = node.parentNode;
}
return mcValues;
}
function isDOM(element: { [key: string]: any }) {
return element && element instanceof HTMLElement;
}
function addListener(
domNode: Node,
eventName: string,
handler: EventListenerOrEventListenerObject | null,
capture: boolean = false,
once: boolean = false
) {
let options: EventListenerOptions & { once: boolean } = { once: false };
if (capture) {
options.capture = capture;
}
if (once) {
options.once = once;
}
//console.log( 'addEventListener', eventName, ( handler as Function ).name, options );
domNode.addEventListener(eventName, handler, options);
}
function removeListener(
domNode: Node,
eventName: string,
handler: EventListenerOrEventListenerObject | null
) {
domNode.removeEventListener(eventName, handler);
}
// add a microcomponent to a target DOM node
function add(target: HTMLElement, key: string, element = {}): any {
let node = target as HTMLMcElement;
// create the _mc property if necessary
if (!node._mc) {
let debug = (DC as any)?.debug;
(DC as any).debug = false;
node._mc = new (DC as any)(target);
(DC as any).debug = debug;
delete node._mc._mc; // NOT NEEDED on the top level _mc property of a DOM HTMLElement
}
const _mc = node._mc;
// add the element with the given property key to the _mc property
if (key.length && !_mc[key]) {
_mc[key] = element;
// add attribute if HTMLElement
if (target instanceof HTMLElement && ! target.getAttribute('_mc') ) {
target.setAttribute('_mc', '');
}
}
return element;
}
// remove a micro component from the DOM
function remove(target: HTMLMcElement, key: string) {
if (!key) return; // silent fail
const _mc = target?._mc || {};
// delete the property from the HTMLElement _mc collection
if (_mc && _mc[key]) {
delete _mc[key];
}
// remove the _mc attribute if the DOM collection is empty
if (
!Object.keys(_mc).length &&
target instanceof HTMLElement &&
target.hasAttribute('_mc')
) {
target.removeAttribute('_mc');
}
return;
}
// trigger an event
function trigger(
target: HTMLElement,
ev: McEvent | string,
data: any = {},
bubble: string = 'l'
) {
let _mc = (target as any)?._mc;
if (!_mc) return;
let event =
ev instanceof McEvent
? new McEvent(ev.type, ev.data, 'l')
: new McEvent(ev, data, 'l'),
mcEvent: McEvent =
typeof ev !== 'string' ? (ev as McEvent) : new McEvent(ev, data, bubble);
[...Object.values(_mc)].forEach((mc: any) => {
//console.log( 'mc [', mc.constructor.name, ']', mc.constructor.prototype?.oneInit );
if ((mc as any)?.trigger && typeof (mc as any)?.trigger === 'function') {
(mc as any)?.trigger(event);
}
});
if (/[ud]/.test(mcEvent.bubble)) {
bubbleEvent(target, mcEvent);
}
return;
}
// run the "init" lifecycle event on the complete _mc structure
function init(element: MC | DC) {
if (element.constructor === DC) return;
setTimeout(() => {
element.trigger('Init');
/*
let keys = Object.keys( ( element as any)?._mc );
if ( keys.length ){
//console.log( 'inner Inits on [', mc.constructor.name, ']', keys );
keys?.forEach( (key: string) => {
let mc = ( element as any)?._mc?.[key];
//console.log( key, typeof mc?.oneInit, mc );
if ( ( mc as any )['oneInit'] && typeof ( mc as any ).oneInit == 'function' ){
( mc as any ).oneInit( new McEvent('Init') );
}
});
};
*/
}, 0);
}
function autoAttachListeners(element: MC | DC) {
if (!isBrowser() || element.constructor === DC) return;
const target = element.target;
if (target instanceof HTMLElement) {
let that: LooseObject = element;
Object.getOwnPropertyNames(that.constructor.prototype)
.filter(
(key) =>
/^on[A-Z]|one[A-Z]/.test(key) &&
typeof that.constructor.prototype[key] === 'function'
)
.forEach((key) => {
let once = /^one/.test(key),
withoutOnOne = key.replace(/^on[e]{0,1}/, ''), // preserves upper/lowercase for non-native events
nativeEventName = withoutOnOne.toLowerCase(),
listener = (ev: McEvent | Event | CustomEvent) => {
that.trigger(withoutOnOne, ev);
};
if (once) {
// console.log( '...is only called once', that.constructor.prototype[key] );
Object.defineProperty(element, 'on' + withoutOnOne, {
configurable: true,
enumerable: false,
writable: true,
value: (ev: any) => {
// console.log( 'onceHandler execute:', that.constructor.prototype[key] );
that.constructor.prototype[key].bind(that)(ev);
},
});
}
if (nativeEventNames.has('on' + nativeEventName)) {
// native event defined in HTMLElement
// console.log('added as native listener', target, nativeEventName );
addListener(target, nativeEventName, listener, false, once);
}
});
}
}
function bubbleEvent(target: HTMLElement, ev: McEvent) {
// at this point, either 'u' or 'd' bubbling should be indicated
// mcEvent bubbling is asynchronous, and the order of target MC execution in case of multiple matches is not guaranteed!
if (/[ud]/.test(ev.bubble)) {
setTimeout(() => {
// bubbling must include 'l' for local, or else no handlers are executed'
if (ev.bubble.indexOf('l') === -1) {
ev.bubble += 'l';
}
// bubble up towards root
if (ev.bubble.indexOf('u') > -1) {
let parent = traverseNodes(target, '', true)?.[0].target;
if (parent) DC.trigger(parent, ev);
}
// bubble down
if (ev.bubble.indexOf('d') > -1) {
let targets = new Set<HTMLMcElement>(
(target as HTMLMcElement)._mc
.children()
.map((c: unknown) => (c as any)?.target)
);
targets.forEach((target: HTMLMcElement) => {
if (target) DC.trigger(target, ev);
});
}
}, 0);
}
}
class McBase {
#target: WeakRef<any>; // avoid circular reference
constructor(target?: { [key: string]: any }, debug: boolean = false) {
let that: unknown = this,
element = that as MC | DC;
this.#target = new WeakRef(target || {});
init(element); // async lifecycle event
autoAttachListeners(element);
}
get target(): any {
return this.#target.deref();
}
}
class MC extends McBase {
_mc?: LooseObject = {};
constructor(target?: any) {
super(target);
}
static trigger = trigger;
trigger(ev: McEvent | string, data: any = {}, bubble: string = 'l') {
let that = this;
//console.log( that, '.trigger(', ev, data, bubble, ')' );
// run all triggers
if (that.constructor === MC || that.constructor === DC) {
let event =
ev instanceof McEvent
? new McEvent(ev.type, ev.data, 'l')
: new McEvent(ev, data, 'l'),
mcEvent: McEvent =
typeof ev !== 'string'
? (ev as McEvent)
: new McEvent(ev, data, bubble);
[...Object.getOwnPropertyNames(that)].forEach((mc) => {
if (
(that as any)[mc]?.trigger &&
typeof (that as any)[mc]?.trigger === 'function'
) {
(that as any)[mc]?.trigger(event);
}
});
if (/[ud]/.test(mcEvent.bubble)) {
bubbleEvent(that.target, mcEvent);
}
return; // must return here!
}
// only this instance
if ((that as any) instanceof MC || (that as any) instanceof DC) {
const that = this,
mcEvent: McEvent =
typeof ev !== 'string'
? (ev as McEvent)
: new McEvent(ev, data, bubble),
handlerName =
'on' + mcEvent.type[0].toUpperCase() + mcEvent.type.slice(1);
// console.log('trigger', handlerName, mcEvent, data, bubble );
if (mcEvent.bubble.indexOf('l') > -1) {
if (
!mcEvent.immediateStopped &&
typeof (that as any)[handlerName] === 'function'
) {
// console.log( 'execute handler:', handlerName );
(that as any)[handlerName](mcEvent);
if ((that as any).hasOwnProperty(handlerName)) {
// it is a once handler
delete (that as any)[handlerName]; // remove after first execution
}
}
}
// if event stopped or local only, handling is cancelled
if (mcEvent.stopped || mcEvent.bubble === 'l') {
return that;
}
//console.log('bubble', mcEvent);
if (/[ud]/.test(mcEvent.bubble)) {
bubbleEvent(that.target, mcEvent);
}
}
}
}
class DC extends McBase {
_mc?: LooseObject = {};
constructor(target: HTMLElement, debug: boolean = false) {
super(target);
}
static add: Function = add;
static remove: Function = remove;
static trigger: Function = trigger;
trigger(ev: McEvent | string, data: any = {}, bubble?: string) {
MC.prototype.trigger.call(this, ev, data, bubble);
}
parent(search: string = ''): any[] {
let target: HTMLElement = (this as any).target;
if (!isDOM(target)) return [];
return traverseNodes((target as any).parentNode, search, true);
}
ancestors(search: string = ''): any[] {
let target: any = (this as any)?.target;
if (!isDOM(target)) return [];
return traverseNodes((this as any).target.parentNode, search, false);
}
children(search: string = ''): any[] {
let target: any = (this as any)?.target;
if (!isDOM(target)) return [];
const id = Math.random().toString().replace('.', '');
const selector = '[_mc]:not([temp_id="' + id + '"] [_mc] [_mc])';
target.setAttribute('temp_id', id);
const myDomElements = target.querySelectorAll(selector);
const mcValues = getMicroComponents([...myDomElements], search);
target.removeAttribute('temp_id');
return mcValues;
}
descendants(search: string = ''): any[] {
let target: any = (this as any)?.target;
if (!isDOM(target)) return [];
const myDomElements = target.querySelectorAll('*[_mc]');
return getMicroComponents([...myDomElements], search);
}
}
/*
unresolvedCEs (code below this comment):
a repository (a hashmap) of undefined custom elements:
the name is the tagname of the micro component (like "my-custom-component")
if they are loading, the value is a script element
if they are loaded, the entry is deleted and the browser instanciates it automatically
if they cannot be loaded, the browser will throw an error
If the custom element ( defined by its tagname, like "my-custom-component") is not defined, it will be on-demand loaded:
The file name is assumed to be "/my/custom/component.js"
The file must define a custom element via the defineCE() function included in the "elements" module.
This function is a wrapper for customElementRegistry.define(), which you can also use directly in the imported code.
In general this behaves like a custom runtime import for custom elements in the browser.
The only difference is that the micro component is loaded only when needed, and not on page load.
*/
const CEs = new Map();
/*
loadedMCs (code below this comment):
a repository (a hashmap) of micro components:
the name is the name of the micro component (like "my-mc")
if they are loading, the value is a script element
if they are loaded, the value is the micro component class itself
This is mostly used for old style progressive enhancement:
If the micro component ( defined in the HTMLElements "_mc" attribute, like "my-mc") is not defined, it will be on-demand loaded:
The file name is assumed to be "/my/mc.js"
The file needs to have a default export, and it should be a class that extends the DC class ( since it is in a HTMLElements attribute )
In general this behaves like a custom runtime import for classes in the browser.
The only difference is that the micro component is loaded only when needed, and not on page load.
*/
const MCs = new Map();
function makeLoadScript( element: string ): HTMLScriptElement {
const fileName: string = './' + element.split('-').join('/')+'.js';
const se: HTMLScriptElement = document.createElement('script');
se.setAttribute('src', fileName);
se.setAttribute('blocking', 'render');
se.async = true;
se.setAttribute('type', 'module');
se.setAttribute( 'name', element);
se.setAttribute( 'loading', '');
addListener( se, 'load', (ev) => {
// console.info('loaded MC', elementName);
se.removeAttribute( 'loading' );
se.setAttribute( 'loaded', '');
});
addListener( se, 'error', (ev) => {
console.error('could not load Custom Element code from', fileName);
});
return se;
}
function onLoadCallback( script: HTMLScriptElement ) {
// create callback script tag
// it is essential to do this in a timeout and via another script tag,
// to force it to be a top level module allowing for the use of "import"
setTimeout( function(){
// create callback script tag
const importFileName = script.getAttribute('src');
const elementName = script.getAttribute('name');
const se: HTMLScriptElement = document.createElement('script');
se.async = true;
se.setAttribute( 'type', 'module');
// --- autoload script
const code = /*javascript*/`
import _ from "${importFileName}";
import { DC } from "./microcomponents.js";
const elements = Array.from(document.querySelectorAll('[_mc*="${elementName}"]')).filter( element => element instanceof HTMLElement );
elements.forEach((element) => {
// add class to MCs repository
autoload.state.MCs.set( '${elementName}', _ );
// add instance to HTMLElement
DC.add(element, _.name, new _(element));
// remove instance name from elements "_mc" attribute
element.setAttribute('_mc', element.getAttribute('_mc').replace('${elementName}', '').replace( /\w\w/g, ' ').trim());
});
setTimeout( () => {
document.body.querySelector('script[name="${elementName}]"')?.remove();
},0);
`;
// --- end autoload script
se.innerHTML = code;
document.body.append(se);
}, 0);
}
function loadUndefinedMicroComponents() {
// load unresolved custom eleents
[ ...document.querySelectorAll(':not(:defined)') ] // undefined custom elements
.filter( (element) => !!element
&& !customElements.get(element.tagName.toLowerCase()) // not in CustomElementsRegistry
&& !CEs.has(element.tagName.toLowerCase()) // not in loading list
)
.forEach((el) => {
const elementName = el.tagName.toLowerCase(),
fileName: string = './'+elementName.split('-').join('/')+'.js';
const se: HTMLScriptElement = makeLoadScript( elementName );
addListener( se, 'load', () => {
CEs.set( elementName, customElements.get(el.tagName.toLowerCase()) );
});
CEs.set( elementName, 'loading');
document.head.append(se);
// instanciation of the micro component will be handled by the browser automatically
// module code must define the micro component class and add it to the customElementRegistry
});
[ ...document.querySelectorAll('[_mc]:not([_mc=""]') ] // undefined DOM components
.filter( (element) => !!element )
.forEach((el) => {
const elements = el.getAttribute('_mc')?.split(' ').filter( m => !!m ) || [];
const elementsToBeLoaded = elements.filter( m => !MCs.has( m ) );
// load MC components that do not exist yet
elementsToBeLoaded?.forEach( elementName => {
const se: HTMLScriptElement = makeLoadScript( elementName );
addListener( se, 'load', () => onLoadCallback( document.head.querySelector(`script[name="${elementName}"]`) as HTMLScriptElement) );
MCs.set( elementName, 'loading');
document.head.append(se);
});
// loading MC components already have callbacks attached
const elementsAlreadyLoaded = elements.filter( m => MCs.has( m ) && !MCs.get( m )!.length);
// MCs that are already loaded will trigger the onLoadCallback manually
elementsAlreadyLoaded?.forEach( elementName => {
setTimeout( ()=>{
//console.log('attaching', elementName, (autoload as any).state.MCs.get(elementName));
DC.add(el, elementName, new ( (autoload as any).state.MCs.get(elementName) as any)(el)); // add instance to DOM
const newAttribute = (el as any).getAttribute('_mc')
.replace('${elementName}', '')
.replace( /\w\w/g, ' ')
.trim() || '_mc';
(el as any).setAttribute('_mc', ); // set new "_mc" attribute
}, 5);
});
});
}
let autoloadEnabled: boolean = false;
let observer: MutationObserver | null = null;
function autoload(enable: boolean = true): boolean {
if (!enable) {
observer?.disconnect();
observer = null;
return autoloadEnabled;
}
autoloadEnabled = enable;
observer = new MutationObserver(loadUndefinedMicroComponents);
setTimeout(() => {
observer!.observe(document.body, { childList: true, subtree: true });
loadUndefinedMicroComponents();
}, 5);
(window as any).autoload = (window as any).autoload || autoload;
return autoloadEnabled;
}
(autoload as LooseObject).state = { CEs, MCs };
export { addListener, removeListener, McBase, MC, DC, McEvent, autoload };