From 70c702efc76d64407fe3ebf5e8ec9f0db0d1886e Mon Sep 17 00:00:00 2001 From: DarkWiiPlayer Date: Thu, 10 Mar 2022 05:59:59 +0100 Subject: [PATCH] Add element helper --- element.js | 70 ++++++++++++++++++++++ index.html | 25 ++++++++ page/element.html | 147 ++++++++++++++++++++++++++++++++++++++++++++++ page/style.css | 5 ++ readme.md | 5 ++ 5 files changed, 252 insertions(+) create mode 100644 element.js create mode 100644 page/element.html diff --git a/element.js b/element.js new file mode 100644 index 0000000..23e5b9a --- /dev/null +++ b/element.js @@ -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}) +} diff --git a/index.html b/index.html index b1accdc..0f705d6 100644 --- a/index.html +++ b/index.html @@ -30,6 +30,31 @@ Read more +
+

Element

+

+ A helper function that adds many convenient features to classes for custom elements. +

+ +

+

Code Sample:

+ + 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() { /* ... */ } + }) + +

+ + Read More +
+

Debounce

diff --git a/page/element.html b/page/element.html new file mode 100644 index 0000000..7f6af51 --- /dev/null +++ b/page/element.html @@ -0,0 +1,147 @@ + + + + + +

element.js

+ +import element from 'element.js' + +
+

Description

+

+ The element 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. +

+
+
+

Basic Usage

+ + + element(class MyElement extends HTMLElement { + constructor() { + super() + console.log("An element walks into a bar...") + } + }) + + +

Features

+ +
+

Attributes

+

+ The static attributes property, when present, + is used to automatically add the static observedAttributes + property for the custom element API, as well as define getters and setters + for the listed attributes. +

+ 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). +

+ Object keys in camelCase will be converted to kebab-case before being + used as attribute names in the auto-generated observedAttributes. +

+ The rules are: +

    +
  • Any truthy value registers the attribute with getter and setter +
  • If the value has a get property, it will be incorporated as a filter into the getter. +
  • If the value has a set property, it will be incorporated as a filter into the setter. +
  • Unless this value is false no setter will be added (read only attribute). +
+

+ + 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 }, + } + }) + +
+ +
+

Change Methods

+

+ The attributeChangedCallback method is added automatically to the class. + This auto-generated callback will first look up a ${attributeName}Changed + method and call it, if found, with the old and new values as arguments. + Then it will look for a changed method and call it with the same arguments + as attributeChangedCallback normally receives. +

+ 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 foo-bar will look for a fooBarChanged method. +

+ + element(class extends HTMLElement { + static attributes = { fooBar = true } + fooBarChanged(oldValue, newValue) { + console.log(`foo-bar changed from ${oldValue} to ${newValue}`) + } + }) + +
+ +
+

Batched Methods

+

+ 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. +

+ To avoid having to repeatedly add complicated checks to handle these + event bursts, element introduces the concept of dollar-methods. +

+ + element(class extends HTMLElement { + $render() { + console.warn("Full Re-render...") + } + }) + +

+ Any method with a name starting with $ 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. +

+ The original method will then be called with the argument-lists of the individual + calls to its dollar-less counterpart all at once. +

+ It is of course still possible to manually call the dollar-method when + immediate execution is wanted. +

+ +

+ 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 foo, bar + and baz defined. +

+ + + element(class extends HTMLElement { + $render() { console.warn("Full Re-render...") } + + fooChanged() { this.render() } + barChanged() { this.render() } + bazChanged() { this.render() } + }) + + +

+ Here, this.render() will not instantly re-render the whole component + but instead schedule the re-render in a micro-task, potentially avoiding lots of + repeated work. +

+
+
diff --git a/page/style.css b/page/style.css index 4ef675d..b102ab9 100644 --- a/page/style.css +++ b/page/style.css @@ -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; diff --git a/readme.md b/readme.md index fa04e60..079980b 100644 --- a/readme.md +++ b/readme.md @@ -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