Created
December 16, 2020 12:38
-
-
Save spiffytech/4bdbbf1f87845e168fef4300742fbe07 to your computer and use it in GitHub Desktop.
Experimental HTML custom element library
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 * as h from "hyperscript"; | |
import morphdom from "morphdom"; | |
import { nanoid } from "nanoid"; | |
abstract class Coordinator< | |
Attrs extends Record<string, any> | null, | |
State extends Record<string, any> | |
> extends HTMLElement { | |
static readonly forwardedAttrs: Map<string, unknown> = new Map(); | |
static attrsAttr = "data-attrs"; | |
protected abstract readonly state: State; | |
private animationFrameNumber: number | null = null; | |
private lastHandledAttrsKey: string | null = null; | |
get attrs(): Attrs | null { | |
if (!this.hasAttribute(Coordinator.attrsAttr)) return null; | |
return Coordinator.forwardedAttrs.get( | |
this.getAttribute(Coordinator.attrsAttr)! | |
) as Attrs; | |
} | |
protected watchState(state: State): State { | |
const render = () => this._render(); | |
return new Proxy(state, { | |
set(...args) { | |
Reflect.set(...args); | |
render(); | |
return true; | |
}, | |
}); | |
} | |
private _render() { | |
this.animationFrameNumber ||= requestAnimationFrame(() => { | |
// Stuff the render output into a template element so morphdom can modify | |
// the children of this and the render output, instead of morphing this | |
// into the first child of the render output (essentially removing this | |
// from the DOM) | |
const el = h("template", this.render()); | |
morphdom(this, el, { childrenOnly: true }); | |
this.animationFrameNumber = null; | |
}); | |
} | |
abstract render(): Element | Element[]; | |
connectedCallback() { | |
const attrsKey = this.getAttribute(Coordinator.attrsAttr); | |
// connectedCallback and attributeChangedCallback both receive events for | |
// the element's initial attributes, but we don't want to double render. | |
if (attrsKey && attrsKey !== this.lastHandledAttrsKey) { | |
this.lastHandledAttrsKey = attrsKey; | |
this._render(); | |
} | |
} | |
disconnectedCallback() { | |
setTimeout(() => { | |
if (this.isConnected) return; // We got reattached to something | |
if (this.hasAttribute(Coordinator.attrsAttr)) { | |
Coordinator.forwardedAttrs.delete( | |
this.getAttribute(Coordinator.attrsAttr)! | |
); | |
} | |
}, 1000); | |
} | |
attributeChangedCallback( | |
name: string, | |
oldValue: string | undefined, | |
value: string | |
) { | |
// Morphdom mucks with our attributes when shuffling nodes around. We don't | |
// want to double render while that's happening. | |
if (!this.isConnected) return; | |
if (name !== Coordinator.attrsAttr) return; | |
if (oldValue) Coordinator.forwardedAttrs.delete(oldValue); | |
// connectedCallback and attributeChangedCallback both receive events for | |
// the element's initial attributes, but we don't want to double render. | |
if (value !== this.lastHandledAttrsKey) { | |
this.lastHandledAttrsKey = value; | |
this._render(); | |
} | |
} | |
protected forwardAttrs(args: Record<string, unknown>) { | |
const id = nanoid(); | |
Coordinator.forwardedAttrs.set(id, args); | |
return { [Coordinator.attrsAttr]: id }; | |
} | |
} | |
export class MyElementChild extends Coordinator< | |
{ time: string }, | |
{ id: string } | |
> { | |
state = this.watchState({ id: nanoid() }); | |
private renderCount = 0; | |
render() { | |
this.renderCount += 1; | |
function onclick(e: any) { | |
e.preventDefault(); | |
alert("Oh no, sire! A click!"); | |
} | |
return h( | |
"p", | |
"The time is: ", | |
this.attrs?.time, | |
h("button", { onclick }, "Click me!"), | |
h("p", "My ID is: ", this.state.id), | |
h("p", `I have been rendered ${this.renderCount} times`) | |
); | |
} | |
static get observedAttributes() { | |
return [Coordinator.attrsAttr]; | |
} | |
} | |
class MyElementParent extends Coordinator<null, { counter: number }> { | |
state = this.watchState({ counter: 0 }); | |
connectedCallback() { | |
super.connectedCallback(); | |
setInterval(() => (this.state.counter += 1), 1000); | |
} | |
render() { | |
return [ | |
...(new Date().getSeconds() % 2 === 0 | |
? [ | |
h("my-child", { | |
id: "child-1", | |
...this.forwardAttrs({ time: "the beginning" }), | |
}), | |
] | |
: []), | |
h("my-child", { | |
id: "child-2", | |
...this.forwardAttrs({ time: new Date().toISOString() }), | |
}), | |
]; | |
} | |
} | |
customElements.define("my-child", MyElementChild); | |
customElements.define("my-parent", MyElementParent); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment