Initial implementation of basic functionality

This commit is contained in:
Talia 2025-08-08 13:55:40 +02:00
commit e7a34fa970
Signed by: darkwiiplayer
GPG key ID: 7808674088232B3E
3 changed files with 257 additions and 0 deletions

25
example.html Normal file
View 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
View 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
View 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)