Created
May 4, 2021 01:18
-
-
Save ndugger/0605cdf226fb5b7b53e552990a74f109 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Document } from './Document' | |
import { Node } from './Node' | |
import { Style } from './Style' | |
/** | |
* Symbol which represents a flag to determine whether a component is connected | |
*/ | |
const connected = Symbol('connected') | |
/** | |
* Symbol which represents whether or not there are changes during update | |
*/ | |
const flagged = Symbol('flagged') | |
/** | |
* Symbol which represents a component's internal node tree | |
*/ | |
const tree = Symbol('tree') | |
/** | |
* Proxy used in order to define a custom element as it is instantiated for the first time | |
*/ | |
const DefinedElement = new Proxy(HTMLElement, { | |
construct(html, args, custom) { | |
const tagName = Document.guessTagName(custom) | |
if (!globalThis.customElements.get(tagName)) { | |
globalThis.customElements.define(tagName, custom as CustomElementConstructor) | |
} | |
return Reflect.construct(html, args, custom) | |
} | |
}) | |
/** | |
* Base custom element class from which all custom elements should extend | |
*/ | |
export class CustomElement extends DefinedElement { | |
/** | |
* (Optional) Name to serve as the local tag name of the document element | |
*/ | |
public static alias = undefined as string | undefined | |
/** | |
* Field which represents the element's connected status | |
*/ | |
private [ connected ] = false | |
/** | |
* Field which represents the element's update status | |
*/ | |
private [ flagged ] = false | |
/** | |
* Field which represents the element's virtual structure | |
*/ | |
private [ tree ] = [] as Node[] | |
/** | |
* Part of custom elements API: called when element mounts to a document | |
*/ | |
protected connectedCallback(): void { | |
this[ connected ] = true | |
this.dispatchEvent(new Event(CustomElement.LifecycleEvent.Connect)) | |
this.update().then(() => { | |
this.dispatchEvent(new Event(CustomElement.LifecycleEvent.Ready)) | |
}) | |
} | |
/** | |
* Part of custom elements API: called when element is removed from its DOM | |
*/ | |
protected disconnectedCallback(): void { | |
this[ connected ] = false | |
window.requestAnimationFrame(() => { | |
this.dispatchEvent(new Event(CustomElement.LifecycleEvent.Disconnect)) | |
}) | |
} | |
/** | |
* Custom lifecycle hook: called when element is ready or updated | |
*/ | |
protected updatedCallback(): void { | |
Document.hookRef(this) | |
/** | |
* No need to render if component isn't connected to a document | |
*/ | |
if (!this[ connected ] || !this.shadowRoot) { | |
Document.releaseRef() | |
return | |
} | |
const painted = this.paint() | |
const sheets = painted.filter(style => style instanceof CSSStyleSheet) as CSSStyleSheet[] | |
const css = painted.filter(style => typeof style === 'string').map(style => Document.createNode(HTMLStyleElement, { textContent: style as string })) | |
const rendered = this.render().concat(css) | |
const diffed = Document.diffNodes(this[ tree ], rendered) | |
const pruned = Document.commitNodeDiff(this[ tree ], diffed) | |
this[ tree ] = Document.mergeChanges(this.shadowRoot, diffed, pruned) | |
this.shadowRoot.adoptedStyleSheets = sheets | |
this.dispatchEvent(new Event(CustomElement.LifecycleEvent.Render)) | |
Document.releaseRef() | |
} | |
/** | |
* Used to hook into the connection lifecycle | |
* @param event Connect lifecycle event | |
*/ | |
protected handleComponentConnect(event: Event): void {} | |
/** | |
* Used to hook into the create lifecycle | |
* @param event Create lifecycle event | |
*/ | |
protected handleComponentCreate(event: Event): void {} | |
/** | |
* Used to hook into the disconnect lifecycle | |
* @param event Disconnect lifecycle event | |
*/ | |
protected handleComponentDisconnect(event: Event): void {} | |
/** | |
* Used to hook into the ready lifecycle | |
* @param event Ready lifecycle event | |
*/ | |
protected handleComponentReady(event: Event): void {} | |
/** | |
* Used to hook into the render lifecycle | |
* @param event Render lifecycle event | |
*/ | |
protected handleComponentRender(event: Event): void {} | |
/** | |
* Used to hook into the update lifecycle | |
* @param event Update lifecycle event | |
*/ | |
protected handleComponentUpdate(event: Event): void {} | |
/** | |
* Constructs a component's stylesheet | |
*/ | |
protected paint(): Style[] { | |
return [] | |
} | |
/** | |
* Constructs a component's template | |
*/ | |
protected render(): Node[] { | |
return [] | |
} | |
/** | |
* Public readonly access to the element's virtual structure | |
*/ | |
public get virtualTree(): readonly Node[] { | |
return this[ tree ] | |
} | |
/** | |
* Creates a component, attaches lifecycle listeners upon instantiation, and initializes shadow root | |
*/ | |
public constructor() { | |
super() | |
this.addEventListener(CustomElement.LifecycleEvent.Connect, e => this.handleComponentConnect(e)) | |
this.addEventListener(CustomElement.LifecycleEvent.Create, e => this.handleComponentCreate(e)) | |
this.addEventListener(CustomElement.LifecycleEvent.Disconnect, e => this.handleComponentDisconnect(e)) | |
this.addEventListener(CustomElement.LifecycleEvent.Ready, e => this.handleComponentReady(e)) | |
this.addEventListener(CustomElement.LifecycleEvent.Render, e => this.handleComponentRender(e)) | |
this.addEventListener(CustomElement.LifecycleEvent.Update, e => this.handleComponentUpdate(e)) | |
this.attachShadow({ mode: 'open' }) | |
if (this.shadowRoot) { // @ts-ignore (Type defs out of date) | |
this.shadowRoot.adoptedStyleSheets = [] | |
} | |
window.requestAnimationFrame(() => { | |
this.dispatchEvent(new Event(CustomElement.LifecycleEvent.Create)) | |
}) | |
} | |
/** | |
* Triggers an update | |
* @param props Optional properties to update with | |
* @param immediate Whether or not to attempt an update this frame | |
*/ | |
public update(props: object = {}, immediate = false): Promise<void> { | |
this[ flagged ] = true | |
/** | |
* Update provided fields | |
*/ | |
for (const prop of Object.keys(props)) { | |
if (this[ prop ] === props[ prop ]) { | |
continue | |
} | |
if (typeof this[ prop ] === 'object') { | |
Object.assign(this[ prop ], props[ prop ]) | |
} | |
else { | |
Object.assign(this, { [ prop ]: props[ prop ] }) | |
} | |
} | |
/** | |
* If immediate mode enabled, don't batch update | |
*/ | |
if (immediate) { | |
this[ flagged ] = false | |
this.dispatchEvent(new Event(CustomElement.LifecycleEvent.Update)) | |
try { | |
this.updatedCallback() | |
return Promise.resolve() | |
} | |
catch (error) { | |
return Promise.reject(error) | |
} | |
} | |
/** | |
* If immediate mode not enabled, batch updates | |
*/ | |
return new Promise((resolve, reject) => { | |
window.requestAnimationFrame(() => { | |
if (!this[ flagged ]) { | |
return | |
} | |
this[ flagged ] = false; | |
this.dispatchEvent(new Event(CustomElement.LifecycleEvent.Update)) | |
try { | |
this.updatedCallback() | |
resolve() | |
} | |
catch (error) { | |
reject(error) | |
} | |
}) | |
}) | |
} | |
} | |
export namespace CustomElement { | |
export enum LifecycleEvent { | |
Connect = 'element-connect', | |
Create = 'element-create', | |
Disconnect = 'element-disconnect', | |
Ready = 'element-ready', | |
Render = 'element-render', | |
Update = 'element-update' | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment