UNPKG

@twobirds/microcomponents

Version:

Micro Components Organization Class

609 lines (497 loc) 17.9 kB
'use strict'; 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 };