From e7a34fa9707958590f62a92072ac818917544d6f Mon Sep 17 00:00:00 2001 From: DarkWiiPlayer Date: Fri, 8 Aug 2025 13:55:40 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Initial=20implementation=20of=20bas?= =?UTF-8?q?ic=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example.html | 25 +++++ readme.md | 38 ++++++++ src/controller-registry.js | 194 +++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 example.html create mode 100644 readme.md create mode 100644 src/controller-registry.js diff --git a/example.html b/example.html new file mode 100644 index 0000000..37e20a0 --- /dev/null +++ b/example.html @@ -0,0 +1,25 @@ + + +

Hello, World!

diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..bad654f --- /dev/null +++ b/readme.md @@ -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. diff --git a/src/controller-registry.js b/src/controller-registry.js new file mode 100644 index 0000000..7b44060 --- /dev/null +++ b/src/controller-registry.js @@ -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>} */ + #attached = new WeakMap() + + /** @type {Map} */ + #waiting = new Map() + + /** @type {Mapvoid>} */ + #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)