Add element helper

This commit is contained in:
Talia 2022-03-10 05:59:59 +01:00
parent 737fb6d155
commit 70c702efc7
Signed by: darkwiiplayer
GPG key ID: 7808674088232B3E
5 changed files with 252 additions and 0 deletions

70
element.js Normal file
View file

@ -0,0 +1,70 @@
export default Class => {
const proto = Class.prototype
const attributes = Class.attributes || {}
const props = []
Object.entries(attributes).forEach(([name, attribute]) => {
let htmlName = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
props.push(htmlName)
let prop = {}
prop.get = typeof attribute.get == "function"
? function() { return attribute.get.call(this, this.getAttribute(htmlName)) }
: function() { return this.getAttribute(htmlName) }
prop.set = typeof attribute.set == "function"
? function(val) { return this.setAttribute(htmlName, attribute.set.call(this, val)) }
: attribute.set === false
? function(val) { throw(Error(`Attribute ${name} cannot be set`)) }
: function(val) { this.setAttribute(htmlName, val) }
Object.defineProperty(proto, name, prop)
})
Object.freeze(props)
Object.defineProperty(Class.prototype, "props", { get() { return Object.fromEntries(props.map(prop => [prop, this[prop]])) } })
const observedAttributes = Object.freeze([...Object.keys(attributes)])
Object.defineProperty(Class, "observedAttributes", {
get() { return observedAttributes }
})
Class.prototype.attributeChangedCallback = function(name, oldValue, newValue) {
name = name.replaceAll(/-(.)/g, (a, b) => b.toUpperCase())
if (`${name}Changed` in this) this[`${name}Changed`](oldValue, newValue)
if (`changed` in this) this.changed(name, oldValue, newValue)
}
/* Enable batch-processing for dollar-methods */
/* This would be much nicer if decorators were a thing */
for (const name of Object.getOwnPropertyNames(Class.prototype)) {
if (name[0] == "$") {
const prop = Object.getOwnPropertyDescriptor(Class.prototype, name)
if (typeof prop.value == "function") {
const queue = []
Class.prototype[name.slice(1)] = function(...args) {
if (!queue.length) queueMicrotask(() => {
this[name](...queue)
queue.length = 0
})
queue.push(args)
}
}
}
}
Object.prototype[Symbol.toPrimitive] = function(hint) {
const name = `to${hint.replace(/./, e => e.toUpperCase())}`
return name in this
? this[name]()
: "toDefault" in this
? this.toDefault()
: `[object ${Class.name}]`
}
name = Class.name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()
customElements.define(name, Class, {extends: Class.is})
}

View file

@ -30,6 +30,31 @@
<a class="button" href="page/skooma.html">Read more</a>
</section>
<section>
<h2>Element</h2>
<p>
A helper function that adds many convenient features to classes for custom elements.
</p>
<p>
<h3 class="all-unset"><b>Code Sample</b>:</h3>
<code-block>
import element from 'element.js'
element(class MyElement extends HTMLElement {
static attributes = { foo: true }
fooChanged(oldFoo, newFoo) {
console.log(`Foo changed from ${oldFoo} to ${newFoo}`)
render()
}
$render() { /* ... */ }
})
</code-block>
</p>
<a class="button" href="page/element.html">Read More</a>
</section>
<section>
<h2>Debounce</h2>
<p>

147
page/element.html Normal file
View file

@ -0,0 +1,147 @@
<link rel="stylesheet" href="style.css">
<script type="module" src="codeblock.js"></script>
<script type="module" src="filesize.js"></script>
<h1 class="js module">element.js</h1>
<code-block>import element from 'element.js'</code-block>
<section>
<h2>Description</h2>
<p>
The <code>element</code> helper automates many utility features that
often have to be added to every new custom element, like dispatching
to different handler methods depending on the name of a changed attribute
or adding getters and setters for these attributes.
</p>
</section>
<section>
<h2>Basic Usage</h2>
<code-block>
element(class MyElement extends HTMLElement {
constructor() {
super()
console.log("An element walks into a bar...")
}
})
</code-block>
<h2>Features</h2>
<section>
<h3>Attributes</h3>
<p>
The static <code>attributes</code> property, when present,
is used to automatically add the static <code>observedAttributes</code>
property for the custom element API, as well as define getters and setters
for the listed attributes.
</p><p>
A pair of filters can be given to modify the value after getting or before
setting it. This can be used to convert between the string values that
attributes restrict us to and a more sensible representation as well as
some validation (attributes can be changed externally, after all).
</p><p>
Object keys in camelCase will be converted to kebab-case before being
used as attribute names in the auto-generated <code>observedAttributes</code>.
</p><p>
The rules are:
<ul>
<li>Any truthy value registers the attribute with getter and setter
<li>If the value has a <code>get</code> property, it will be incorporated as a filter into the getter.
<li>If the value has a <code>set</code> property, it will be incorporated as a filter into the setter.
<li><b>Unless</b> this value is <code>false</code> no setter will be added (read only attribute).
</ul>
</p>
<code-block>
element(class extends HTMLElement {
static attributes = {
plain = true,
filtered = {
get(string) { return Number(string) }
set(value) { return Math.floor(value*100+0.5)/100 }
},
plainReadOnly = { set: false },
}
})
</code-block>
</section>
<section>
<h3>Change Methods</h3>
<p>
The <code>attributeChangedCallback</code> method is added automatically to the class.
This auto-generated callback will first look up a <code>${attributeName}Changed</code>
method and call it, if found, with the old and new values as arguments.
Then it will look for a <code>changed</code> method and call it with the same arguments
as <code>attributeChangedCallback</code> normally receives.
</p><p>
Attribute names will be converted from camelCase to kebab-case when needed before
being used to form a method name to look up.
Changing an attribute <code>foo-bar</code> will look for a <code>fooBarChanged</code> method.
</p>
<code-block>
element(class extends HTMLElement {
static attributes = { fooBar = true }
fooBarChanged(oldValue, newValue) {
console.log(`foo-bar changed from ${oldValue} to ${newValue}`)
}
})
</code-block>
</section>
<section>
<h3>Batched Methods</h3>
<p>
Certain methods, like re-rendering the content of a component
should often happen in response to events that can happen repeatedly,
or in response to many different events that can all happen at once.
</p><p>
To avoid having to repeatedly add complicated checks to handle these
event bursts, <code>element</code> introduces the concept of <em>dollar-methods</em>.
</p>
<code-block>
element(class extends HTMLElement {
$render() {
console.warn("Full Re-render...")
}
})
</code-block>
<p>
Any method with a name starting with <code>$</code> will automatically
have a sibling-method defined, with the dollar removed. Each time this
auto-generated method is called, its argument list will be pushed to an array
and a call of the original dollar-method will be scheduled as a micro-task.
</p><p>
The original method will then be called with the argument-lists of the individual
calls to its dollar-less counterpart all at once.
</p><p>
It is of course still possible to manually call the dollar-method when
immediate execution is wanted.
</p>
<p>
Note how in the following example, all three change methods would be called
in rapid succession if the browser applies the custom element class to an element
it found on the page with all three attributes <code>foo</code>, <code>bar</code>
and <code>baz</code> defined.
</p>
<code-block>
element(class extends HTMLElement {
$render() { console.warn("Full Re-render...") }
fooChanged() { this.render() }
barChanged() { this.render() }
bazChanged() { this.render() }
})
</code-block>
<p>
Here, <code>this.render()</code> will not instantly re-render the whole component
but instead schedule the re-render in a micro-task, potentially avoiding lots of
repeated work.
</p>
</section>
</section>

View file

@ -41,6 +41,11 @@ h2 {
border-bottom: .1em solid var(--color-ac);
}
h3::before {
content: "\2023" ' ';
color: var(--color-ac);
}
em {
font-style: normal;
font-weight: bold;

View file

@ -13,6 +13,11 @@ So what does it all do?
an "improved" version of the builtin HTMLElement that's hopefully a lot easier
to build actual things with. It's really just another utility layer.
## Element
the second iteration of improved `HTMLElement` but this time in a function to
support inheriting from other classes for extending builtin elements.
## CSS
Generate CSS from JS objects. Yes, you can generate CSS from JSON now. Or from