Skip to content

Instantly share code, notes, and snippets.

@dblanchardDev
Forked from AdaRoseCannon/HTMLElementPlus.js
Last active May 13, 2020 21:49
Show Gist options
  • Save dblanchardDev/1e66a30a26f14aee7384cdfb85fc5978 to your computer and use it in GitHub Desktop.
Save dblanchardDev/1e66a30a26f14aee7384cdfb85fc5978 to your computer and use it in GitHub Desktop.
HTML Element Plus for Web Components
/**
* 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 .

Changes by dblanchardDev

  • Converted to an ES-Module
  • Added simple templating for CSS and HTML content
  • Added automated attribute reflecting
  • Added quick method to await for DOM being ready and attributes reflected
  • Added ability to fetch HTML & CSS from files

My Custom Elements Reusable Bits

I have extended HTMLElement as HTMLElementPlus to contain the bits of functionality I keep reimplementing.

There is an example usage in the HTML file below. And a Glitch here: Edit on Glitch

Attribute Callbacks

class MyEl extends HTMLElementPlus {...

Provides a callback when all attributes have been parsed, rather than one-by-one. allAttributesChangedCallback useful for waiting to handle all at once.

allAttributesChangedCallback gets called with an object with parsed attributes.

The parser can be set by setting the function static parseAttributeValue(name, value) in the class.

Default attribute values can be provided by setting the static defaultAttributeValue(name) function, so you can provide sensible fallback values.

Query the shadow dom by reference

E.g. an element in the shadow dom: <span ref="foobar"></span> can be queried using this.refs.foobar;

Easy event firing.

Fire an event using this.emitEvent('event-name', {foo: 'bar'});

This can be listed for using, el.addEventListener;

Static Rendering of built in HTML/CSS

Call the this.render() method after defining your CSS and HTML in their respective getters.

The render method takes 2 optional parameters:

  • Inherit CSS: Will integrate the CSS from parents of the current class (when HTMLElementPlus is several levels deep) [default: false]
  • Parent Node: The node into which to write the HTML content. Will default to using the shadow DOM if available, or the light DOM otherwiser.

The render method is asynchronous and will resolve once the content and the DOM is rendered.

  static get CSS() {
    return `
      :host {

      }
    `;
  }

  get HTML() {
    return `
      <div>…</div>
    `;
  }

  connectedCallback() {
		await this.render();
	}

For dynamic rendering, look at tools such as lit-html;

Fetching HTML/CSS from file

To ease development, the HTML and CSS can be set to be fetched from file when the page is loaded. This is done by returning true in the holdersContainPath getter and setting the CSS and HTML getters to return the path (or empty string if none).

For a production environment, the HTML and CSS should be moved into the main JS class as the fetching approach is quite slow.

Attribute-Property Reflecting

Attributes can be quickly reflected as properties by defining them in the reflectedAttributes static getter. They can be marked as boolean or read-only properties.

Using this built-in reflecting will also harness the default values and parsing defined in the Attribute Callback section.

Names with dashes will follow the typtical DOM approach of using dashes in the attribute, and will be converted to camel-case in the property.

static get reflectedAttributes() {
  return {
    "name": [],
    "disabled": ["boolean"],
    "position": ["readonly"],
    "visible": ["boolean", "readonly"],
    "aria-hidden": ["boolean"],
  }
}

A rendering bug occurs in Firefox when the HTMLElementPlus class is inherited up through 2+ other classes and a property is reflected while the shadow DOM is being constructed. This required the addition of the setTimeout in the setter.

DOM Ready

Promise which resolves once the DOM is ready. Available both as a static and instance property.

await HTMLElementPlus.domReady;
// or
await this.domReady;
import HTMLElementPlus from "./HTMLElementPlus.js";
class GCComponent extends HTMLElementPlus {
/* HTML TEMPLATE & STYLING ********************************************************************/
static get CSS() {
return `
`;
}
get HTML() {
return `
`;
}
static get holdersContainPath() {
return true;
}
/* CONSTRUCTOR ********************************************************************************/
constructor() {
super();
this.attachShadow({ mode: "open" });
}
/* ADDED TO DOM *******************************************************************************/
connectedCallback() {
this.render();
}
/* REMOVED FROM DOM ***************************************************************************/
disconnectedCallback() {
//disconnect event listeners, timeouts, intervals, etc.
}
/* ATTRIBUTE HANDLING & MONITORING ************************************************************/
static defaultAttributeValue(name) {
return;
}
static parseAttributeValue(name, value) {
return value;
}
static get reflectedAttributes() {
return {"attr": ["boolean", "readonly"]};
}
static get observedAttributes() {
return ["attr"];
}
allAttributesChangedCallback(changes) {
//Do something
}
}
customElements.define('gc-component', GCComponent);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment