|
/** |
|
* HTML Element Plus – Add helper methods to HTMLElement for Web Components. |
|
*/ |
|
|
|
/*eslint-disable accessor-pairs,no-empty-function,no-unused-vars,no-use-before-define*/ |
|
|
|
/** |
|
* The class to extend in order to access helper methods from a Web Component. |
|
*/ |
|
export default class HTMLElementPlus extends HTMLElement { |
|
|
|
//#region HTMLElementPlus CONSTRUCTOR . |
|
constructor() { |
|
super(); |
|
|
|
// SHADOW ROOT REFERENCES ///////////////////////////////////////////// |
|
this.refs = new Proxy( |
|
{}, |
|
{ |
|
get: this.__getFromShadowRoot.bind(this) |
|
} |
|
); |
|
|
|
// ATTRIBUTE REFLECTING /////////////////////////////////////////////// |
|
for (let [attr, properties] of Object.entries(this.constructor.reflectedAttributes)) { |
|
if (!properties) properties = {}; |
|
|
|
// Convert the attribute name to a property name (replace dashes with camel-case) |
|
let prop = attr; |
|
|
|
if (prop.includes("-")) { |
|
prop = attr.split("-").map((value, index) => { |
|
if (index > 0) return value.slice(0,1).toUpperCase() + value.slice(1); |
|
else return value; |
|
}).join(""); |
|
} |
|
|
|
// Boolean getter + writable setter |
|
if (properties.includes("boolean")) { |
|
Object.defineProperty(this, prop, { |
|
get() { return this.hasAttribute(attr); }, |
|
configurable: true |
|
}); |
|
|
|
if (!properties.includes("readonly")) { |
|
Object.defineProperty(this, prop, { |
|
set(value) { |
|
window.setTimeout(() => { //avoid Firefox shadowm DOM bug |
|
if (value) this.setAttribute(attr, ""); |
|
else this.removeAttribute(attr); |
|
}, 0); |
|
} |
|
}); |
|
} |
|
} |
|
|
|
// Standard getter + writable setter |
|
else { |
|
Object.defineProperty(this, prop, { |
|
get() { |
|
const value = this.getAttribute(attr); |
|
return value ? this.constructor.parseAttributeValue(attr, value) : this.constructor.defaultAttributeValue(attr); |
|
}, |
|
configurable: true |
|
}); |
|
|
|
if (!properties.includes("readonly")) { |
|
Object.defineProperty(this, prop, { |
|
set(value) { |
|
window.setTimeout(() => { //avoid Firefox shadowm DOM bug |
|
this.setAttribute(attr, value); |
|
}, 0); |
|
} |
|
}); |
|
} |
|
} |
|
|
|
// Read-only setter |
|
if (properties.includes("readonly")) { |
|
Object.defineProperty(this, prop, { |
|
set() { throw TypeError(`${prop} is read-only`); } |
|
}); |
|
} |
|
|
|
// Mark as no-longer configurable |
|
Object.defineProperty(this, prop, {configurable: false}); |
|
} |
|
|
|
|
|
// GROUP ATTRIBUTE CHANGES CALLBACK /////////////////////////////////// |
|
// Gets populated by attributeChangedCallback |
|
this.__attributesMap = {}; |
|
|
|
this.__waitingOnAttr = (this.constructor.observedAttributes |
|
? this.constructor.observedAttributes |
|
: [] |
|
).filter(name => { |
|
if (!this.attributes.getNamedItem(name)) { |
|
this.__attributesMap[name] = this.constructor.defaultAttributeValue(name); |
|
} |
|
return !!this.attributes.getNamedItem(name); |
|
}); |
|
|
|
// No attributes so update attribute never called. |
|
// SO fire this anyway. |
|
if (this.__waitingOnAttr.length === 0) { |
|
this.allAttributesChangedCallback(this.__attributesMap); |
|
} |
|
} |
|
|
|
//#endregion . |
|
//#region SHADOW ROOT REFERENCE ACCESS . |
|
|
|
/** |
|
* Get an element from the shadow root using its reference ID. |
|
* @private |
|
* @param {HTMLElement} target |
|
* @param {string} name |
|
*/ |
|
__getFromShadowRoot(target, name) { |
|
return this.shadowRoot ? this.shadowRoot.querySelector('[ref="' + name + '"]') : []; |
|
} |
|
|
|
//#endregion . |
|
//#region GROUP ATTRIBUTE CHANGES INTO A SINGLE CALLBACK . |
|
|
|
/** |
|
* Get the default value of an attribute |
|
* @param {string} name name of the attribute |
|
*/ |
|
static defaultAttributeValue(name) { |
|
return; |
|
} |
|
|
|
|
|
/** |
|
* Parse an attribute's value for further use |
|
* @param {string} name - name of the attribute |
|
* @param {*} value - the attribute's value which is to be parse |
|
*/ |
|
static parseAttributeValue(name, value) { |
|
return value; |
|
} |
|
|
|
|
|
/** |
|
* Handle tracked attribute changes → Sends to {@link allAttributesChangedCallback} |
|
* @param {string} attr |
|
* @param {*} oldValue |
|
* @param {*} newValue |
|
*/ |
|
attributeChangedCallback(attr, oldValue, newValue) { |
|
this.__attributesMap[attr] = this.constructor.parseAttributeValue.call(this, |
|
attr, |
|
newValue |
|
); |
|
|
|
if (this.__waitingOnAttr.length) { |
|
const index = this.__waitingOnAttr.indexOf(attr); |
|
if (index !== -1) { |
|
// Remove it from array. |
|
this.__waitingOnAttr.splice(index, 1); |
|
} |
|
} |
|
|
|
// All attributes parsed |
|
if (this.__waitingOnAttr.length === 0) { |
|
this.allAttributesChangedCallback(this.__attributesMap); |
|
} |
|
} |
|
|
|
|
|
/** |
|
* Receive a callback with attribute changes only once all changes have occurred. |
|
*/ |
|
allAttributesChangedCallback() {} |
|
|
|
//#endregion . |
|
//#region SIMPLIFIED EVENT EMITING . |
|
|
|
/** |
|
* Emit a custom event on this object. |
|
* @param {string} name - event's name |
|
* @param {*} detail - detail to be passed as part of event |
|
*/ |
|
emitEvent(name, detail) { |
|
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true })); |
|
} |
|
|
|
//#endregion . |
|
//#region HTML/CSS RENDERING . |
|
|
|
/** |
|
* Render the HTML and CSS available in {@link HTML} and {@link CSS} into host node. |
|
* @param {boolean} inheritCSS - whether to inherit the CSS from parent HTMLElementPlus objects |
|
* @param {HTMLElement} parentNode - optionally overide where the HTML will be rendered |
|
*/ |
|
async render(inheritCSS=false, parentNode=null) { |
|
let string, CSS; |
|
|
|
// Gather the up the inherited CSS if required |
|
if (inheritCSS) { |
|
|
|
let cssPromises = []; |
|
let cls = this.constructor; |
|
while (cls.name !== "HTMLElementPlus") { |
|
let promise = cls.__getFragmentString(cls.CSS, "css"); |
|
cssPromises.push(promise); |
|
cls = Object.getPrototypeOf(cls); |
|
} |
|
|
|
let cssParts = await Promise.all(cssPromises); |
|
cssParts.reverse(); |
|
CSS = cssParts.join("\n"); |
|
} |
|
else { |
|
CSS = await this.constructor.__getFragmentString(this.constructor.CSS, "css"); |
|
} |
|
|
|
// Build the HTML Content |
|
let html = await this.constructor.__getFragmentString(this.HTML, "html"); |
|
if (CSS.trim().length > 0) { |
|
string = `<style>${CSS}</style>${html}`; |
|
} |
|
else { |
|
string = html; |
|
} |
|
|
|
// Apply the HTML |
|
if (parentNode !== null) { |
|
parentNode.innerHTML = string; |
|
} |
|
else if (this.shadowRoot !== null) { |
|
this.shadowRoot.innerHTML = string; |
|
} |
|
else { |
|
this.innerHTML = string; |
|
} |
|
|
|
await this.domReady; |
|
} |
|
|
|
|
|
/** |
|
* Holder for the CSS or a path to a CSS file used when calling {@link render}. |
|
*/ |
|
static get CSS() { |
|
return ''; |
|
} |
|
|
|
|
|
/** |
|
* Holder for the HTML or a path to a CSS file used when calling {@link render}. |
|
* May include references to the content of this class instance. |
|
*/ |
|
get HTML() { |
|
return ''; |
|
} |
|
|
|
|
|
/** |
|
* Get the CSS/HTML string directly from the property or import it from the path. |
|
* @private |
|
* @param {string} holder - The data from the CSS or HTML properties. |
|
* @param {string} type - either css or html |
|
*/ |
|
static async __getFragmentString(holder, type) { |
|
if (this.holdersContainPath) { |
|
if (holder && holder.trim().length > 0) { |
|
let data = await importFragment(holder, type); |
|
return data; |
|
} |
|
return ""; |
|
} |
|
else { |
|
return holder; |
|
} |
|
} |
|
|
|
|
|
/** |
|
* Indicate that {@link HTML}/{@link CSS} holders contain a path to be loaded. |
|
* Should only be used to ease development. Once completed, the HTML and CSS should be moved |
|
* into the {@link HTML}/{@link CSS} properties as string. |
|
*/ |
|
static get holdersContainPath() { |
|
return false; |
|
} |
|
|
|
//#endregion . |
|
//#region ATTRIBUTE-PROPERTY REFLECTING . |
|
|
|
/** |
|
* List all attributes to be reflected (attribute ←→ property) |
|
* { attrName: ["boolean", "readonly"]} |
|
* @example {"name": [], "disabled": [boolean]} |
|
*/ |
|
static get reflectedAttributes() { |
|
return {}; |
|
} |
|
|
|
//#endregion . |
|
//#region CONTENT READY WITH DELAY . |
|
|
|
/** |
|
* Promise resolved once the DOM has been loaded. |
|
*/ |
|
static get domReady() { |
|
if (!this._domReadyPromise) { |
|
this._domReadyPromise = new Promise((resolve) => { |
|
let resolver = () => { |
|
resolve(); |
|
}; |
|
|
|
document.addEventListener("DOMContentLoaded", resolver, {once: true}); |
|
|
|
if (document.readyState == "complete") { |
|
resolver(); |
|
document.removeEventListener("DOMContentLoaded", resolver, {once: true}); |
|
} |
|
}); |
|
} |
|
|
|
return this._domReadyPromise; |
|
} |
|
|
|
|
|
/** |
|
* Promise resolved once the DOM has been loaded. |
|
*/ |
|
get domReady() { |
|
return HTMLElementPlus.domReady; |
|
} |
|
|
|
//#endregion . |
|
} |
|
|
|
|
|
//#region HTML/CSS FRAGMENT IMPORTER . |
|
|
|
/* |
|
* Fragments that have already been loaded, ready for re-use. |
|
*/ |
|
let importedFragments = { |
|
html: {}, |
|
css: {} |
|
}; |
|
|
|
|
|
/** |
|
* Load an HTML/CSS fragment from file, storing it for re-use. To be used to ease development only. |
|
* @param {string} path - path (absolute or relative to page) to file |
|
* @param {string} type - either "css" or "html" |
|
*/ |
|
async function importFragment(path, type) { |
|
if (!["html", "css"].includes(type)) { |
|
throw Error(`Unable to import fragment as type '${type}' is not valid (html, css).`); |
|
} |
|
|
|
if (!(path in importedFragments[type])) { |
|
let response = await fetch(path); |
|
if (response.ok) { |
|
let text = await response.text(); |
|
importedFragments[type][path] = text; |
|
} |
|
else { |
|
throw new Error(`Unable to import HTML Fragment at '${path}' >> ${response.status} - ${response.statusText}.`); |
|
} |
|
} |
|
|
|
return importedFragments[type][path]; |
|
} |
|
|
|
//#endregion . |