✨ Initial implementation of basic functionality
This commit is contained in:
commit
e7a34fa970
3 changed files with 257 additions and 0 deletions
25
example.html
Normal file
25
example.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
<script type="module">
|
||||
import controllers from "./src/controller-registry.js"
|
||||
import {html} from "https://cdn.jsdelivr.net/npm/nyooom/render.js"
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "controllers", {
|
||||
get() { return controllers.list(this) }
|
||||
})
|
||||
|
||||
controllers.define("red", async (element, disconnected) => {
|
||||
const before = element.style.color
|
||||
element.style.color = "red"
|
||||
element.addEventListener("click", () => element.controllers.remove("red"), disconnected)
|
||||
await disconnected
|
||||
element.style.color = before
|
||||
})
|
||||
|
||||
controllers.define("asterisk", async (element, disconnected) => {
|
||||
const asterisk = html.span("*", {controller: ["red"]})
|
||||
element.append(asterisk)
|
||||
await disconnected
|
||||
asterisk.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
<h1 controller="asterisk">Hello, World!</h1>
|
38
readme.md
Normal file
38
readme.md
Normal file
|
@ -0,0 +1,38 @@
|
|||
# Controller-Registry
|
||||
|
||||
A prototype for an alternative to custom elements.
|
||||
|
||||
## Concept
|
||||
|
||||
Similar to a custom element, a controller defines custom behaviours for HTML
|
||||
elements and is managed automatically by a registry.
|
||||
|
||||
Like custom built-in elements, controllers are controlled via an attribute on
|
||||
the element.
|
||||
|
||||
Unlike custom elements, controllers are external objects or functions that are
|
||||
attached to an object, meaning several different controllers can be managing the
|
||||
same element at once, and the class of the element does not change in the
|
||||
process.
|
||||
|
||||
Controllers can be added and removed as necessary.
|
||||
|
||||
## API
|
||||
|
||||
The library exports a global `ControllerRegistry` attached to the document root
|
||||
with a similar API to the `CustomElementRegistry` class.
|
||||
|
||||
Controllers can be registered under any name as either a callback which gets
|
||||
called when the controller is added to an element or a constructor which gets
|
||||
called with `new` and passed a revokable proxy to the element.
|
||||
|
||||
If the controller is a function, the second argument is a promise that resolves
|
||||
when the controller is removed again. This promise has an additional property
|
||||
`"signal"` which returns an `AbortSignal`. This means the promise can be passed
|
||||
directly as the third argument to `addEventListener` function calls.
|
||||
|
||||
The registry also exposes a `list` function which, given an element, returns an
|
||||
object similar to a `DomTokenList` for easier management of the controller list.
|
||||
|
||||
The `controller` attribute is a space-separated list of controller names as
|
||||
registered in the registry.
|
194
src/controller-registry.js
Normal file
194
src/controller-registry.js
Normal file
|
@ -0,0 +1,194 @@
|
|||
export class ControllerList {
|
||||
/** @type {HTMLElement} */
|
||||
#element
|
||||
|
||||
/** @type {String} */
|
||||
#attribute
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @param {String} attribute
|
||||
* */
|
||||
constructor(element, attribute="controller") {
|
||||
this.#element = element
|
||||
this.#attribute = attribute
|
||||
}
|
||||
|
||||
get #set() {
|
||||
return new Set(this.#element.getAttribute(this.#attribute)?.split(" ") ?? [])
|
||||
}
|
||||
|
||||
set #set(set) {
|
||||
this.#element.setAttribute(this.#attribute, [...set].join(" "))
|
||||
}
|
||||
|
||||
contains(name) {
|
||||
return this.#set.has(name)
|
||||
}
|
||||
|
||||
add(name) {
|
||||
this.toggle(name, true)
|
||||
}
|
||||
|
||||
remove(name) {
|
||||
this.toggle(name, false)
|
||||
}
|
||||
|
||||
replace(name, replacement) {
|
||||
const set = this.#set
|
||||
if (set.has(name)) {
|
||||
set.delete(name)
|
||||
set.add(replacement)
|
||||
this.#set = set
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
toggle(name, force) {
|
||||
const set = this.#set
|
||||
if (force === true) {
|
||||
if (!set.has(name)) {
|
||||
set.add(name)
|
||||
this.#set = set
|
||||
}
|
||||
} else if (force === false) {
|
||||
if (set.has(name)) {
|
||||
set.delete(name)
|
||||
this.#set = set
|
||||
}
|
||||
} else {
|
||||
if (set.has(name)) set.delete(name)
|
||||
else set.add(name)
|
||||
this.#set = set
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ControllerRegistry {
|
||||
#observer = new MutationObserver(mutations => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "childList") {
|
||||
for (const node of mutation.addedNodes) {
|
||||
this.upgrade(node)
|
||||
this.#update(node)
|
||||
}
|
||||
} else {
|
||||
this.#update(mutation.target)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** @type {WeakMap<HTMLElement,Map<String,Object>>} */
|
||||
#attached = new WeakMap()
|
||||
|
||||
/** @type {Map<String,Set<HTMLElement>} */
|
||||
#waiting = new Map()
|
||||
|
||||
/** @type {Map<Strong,(HTMLElement, AbortSignal)=>void>} */
|
||||
#defined = new Map()
|
||||
|
||||
#attribute
|
||||
|
||||
constructor(root, attribute="controller") {
|
||||
this.#attribute = attribute
|
||||
this.#observer.observe(root, {subtree: true, childList: true, attributes: true, attributeFilter: [attribute], attributeOldValue: false})
|
||||
this.upgrade(root)
|
||||
}
|
||||
|
||||
upgrade(root) {
|
||||
root.querySelectorAll(`[${this.#attribute}]`).forEach(element => this.#update(element))
|
||||
}
|
||||
|
||||
define(name, callback) {
|
||||
if (("function" == typeof callback) && callback.prototype) {
|
||||
callback = async (element, disconnected) => {
|
||||
const {proxy, revoke} = Proxy.reevocable(element)
|
||||
const controller = new callback(proxy)
|
||||
await disconnected
|
||||
revoke()
|
||||
if ("detach" in controller) controller.detach(element)
|
||||
}
|
||||
}
|
||||
|
||||
this.#defined.set(name, callback)
|
||||
|
||||
const waitingList = this.#waiting.get(name)
|
||||
|
||||
if (waitingList) for (const element of waitingList) {
|
||||
try { this.#attach(element, name) } catch(e) { console.error(e) }
|
||||
}
|
||||
this.#waiting.delete(name)
|
||||
}
|
||||
|
||||
get(name) {
|
||||
return this.#defined.get(name)
|
||||
}
|
||||
|
||||
list(element) {
|
||||
return new ControllerList(element, this.#attribute)
|
||||
}
|
||||
|
||||
getName(controller) {
|
||||
// Return name of controller
|
||||
}
|
||||
|
||||
whenDefined(name) {
|
||||
// Return a promise
|
||||
}
|
||||
|
||||
#update(element) {
|
||||
const names = this.#getControllerNames(element)
|
||||
const attached = this.#attached.get(element)
|
||||
if (attached) {
|
||||
const current = new Set(names)
|
||||
for (const [name, abortController] of attached) {
|
||||
if (!current.has(name)) {
|
||||
try { this.#detach(element, name) } catch(e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of names) try { this.#attach(element, name) } catch(e) { console.error(e) }
|
||||
}
|
||||
|
||||
#attach(element, name) {
|
||||
if (!this.#attached.has(element)) this.#attached.set(element, new Map())
|
||||
const attached = this.#attached.get(element)
|
||||
|
||||
const callback = this.get(name)
|
||||
|
||||
if (callback) {
|
||||
if (attached.has("name") && attached.get("name")) return console.warn(`Controller ${name} already fully attached`, element)
|
||||
const abortController = new AbortController()
|
||||
attached.set(name, abortController)
|
||||
const promise = new Promise(resolve => abortController.signal.addEventListener("abort", () => resolve()))
|
||||
Object.defineProperty(promise, "signal", {value: abortController.signal})
|
||||
callback(element, promise)
|
||||
} else {
|
||||
if (attached.has("name")) return console.warn(`Controller ${name} already attached`, element)
|
||||
attached.set(name, undefined)
|
||||
let waitingList = this.#waiting.get(name)
|
||||
if (!waitingList) {
|
||||
waitingList = new Set()
|
||||
this.#waiting.set(name, waitingList)
|
||||
}
|
||||
waitingList.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
#detach(element, name) {
|
||||
const references = this.#attached.get(element)
|
||||
if (!references || !references.has(name)) return console.warn(`Controller ${name} not attached`, element)
|
||||
|
||||
references.get(name)?.abort()
|
||||
references.delete(name)
|
||||
}
|
||||
|
||||
#getControllerNames(element) {
|
||||
return new Set(element.getAttribute(this.#attribute)?.split(" ") ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
export default new ControllerRegistry(document)
|
Loading…
Reference in a new issue