Compare commits

...

4 commits

4 changed files with 202 additions and 41 deletions

View file

@ -14,6 +14,8 @@
// The actual library // The actual library
import controllers from "controller-registry" import controllers from "controller-registry"
window.controllers = controllers
// Define a basic word filter controller for input elements // Define a basic word filter controller for input elements
controllers.define("filter", (element, detach) => { controllers.define("filter", (element, detach) => {
element.addEventListener("input", event => { element.addEventListener("input", event => {

View file

@ -5,7 +5,7 @@
}, },
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "0.0.1", "version": "0.0.2",
"url": "https://git.but.gay/darkwiiplayer/controller-registry", "url": "https://git.but.gay/darkwiiplayer/controller-registry",
"scripts": { "scripts": {
"definitions": "tsc src/*.js --declaration --allowJs --emitDeclarationOnly" "definitions": "tsc src/*.js --declaration --allowJs --emitDeclarationOnly"

View file

@ -2,6 +2,23 @@
A prototype for an alternative to custom elements. A prototype for an alternative to custom elements.
```js
// example.js
import controllers from "controller-registry"
controllers.define("clickable", (element, detached) => {
element.addEventListener("click", () => {
alert("The element has been clicked!")
}, detached)
})
```
```html
<script type="module" src="example.js"></script>
<button controller="clickable">Try clicking this button</button>
```
## Concept ## Concept
Similar to a custom element, a controller defines custom behaviours for HTML Similar to a custom element, a controller defines custom behaviours for HTML
@ -26,13 +43,63 @@ 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 when the controller is added to an element or a constructor which gets
called with `new` and passed a revokable proxy to the element. called with `new` and passed a revokable proxy to the element.
```js
controllers.define("showcase", class ShowcaseController {
constructor(element, detached) {
this.method(element)
// detached promise is passed here too for convenience,
// but the `detached` method is the preferred place
// to put cleanup code.
}
method(element) {
console.log("Calling method on:", element)
}
detached(element) {
// Cleanup if necessary
}
})
```
Note that only class controllers are given a revocable proxy: this is because
their stateful nature and suitability for more complex handling makes them more
likely candidates to retain references to the target past their detachment.
For complex function controllers, this can easily be done manually using
`Proxy.revocable(element, {})`.
This behaviour might change in the future.
If the controller is a function, the second argument is a promise that resolves 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 to the element when the controller is removed again. This promise has an
`"signal"` which returns an `AbortSignal`. This means the promise can be passed additional property `"signal"` which returns an `AbortSignal`. This means the
directly as the third argument to `addEventListener` function calls. promise can be passed directly as the third argument to `addEventListener`
function calls.
```js
controllers.define("showcase", async (element, detached) => {
console.log("Attached to element:", element)
console.log("Detached promise:", detached)
console.log("Detached signal:", detached.signal)
element === await detached
console.log("Detached from element:", element)
}
```
The registry also exposes a `list` function which, given an element, returns an 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. object similar to a `DomTokenList` for easier management of the controller list.
The `controller` attribute is a space-separated list of controller names as The `controller` attribute is a space-separated list of controller names as
registered in the registry. registered in the registry.
## Interactions between controllers
There is no direct way for controllers to interact with each other, as they
should be mostly independent.
When signalling is needed, events are the way to go; when data needs to be
shared, the element's `dataset` or a more semantic attribute should be used.
For anything even more complex, a custom element or a higher level component
framework might be the better solution.

View file

@ -1,14 +1,19 @@
/** @typedef {Promise & {signal: AbortSignal}} PromiseWithSignal */
/** @typedef {(element: HTMLElement, detached: PromiseWithSignal) => void} Callback */
/** @typedef {new (element: HTMLElement, detached: PromiseWithSignal) => Object} ControllerClass */
/** @typedef {Callback|ControllerClass} Controller */
export class ControllerList { export class ControllerList {
/** @type {HTMLElement} */ /** @type {HTMLElement} */
#element #element
/** @type {String} */ /** @type {string} */
#attribute #attribute
/** /**
* @param {HTMLElement} element * @param {HTMLElement} element
* @param {String} attribute * @param {string} attribute
* */ * */
constructor(element, attribute="controller") { constructor(element, attribute="controller") {
this.#element = element this.#element = element
this.#attribute = attribute this.#attribute = attribute
@ -22,24 +27,24 @@ export class ControllerList {
this.#element.setAttribute(this.#attribute, [...set].join(" ")) this.#element.setAttribute(this.#attribute, [...set].join(" "))
} }
/** @param {String} name */ /** @param {string} name */
contains(name) { contains(name) {
return this.#set.has(name) return this.#set.has(name)
} }
/** @param {String} name */ /** @param {string} name */
add(name) { add(name) {
this.toggle(name, true) this.toggle(name, true)
} }
/** @param {String} name */ /** @param {string} name */
remove(name) { remove(name) {
this.toggle(name, false) this.toggle(name, false)
} }
/** /**
* @param {String} name * @param {string} name
* @param {String} replacement * @param {string} replacement
*/ */
replace(name, replacement) { replace(name, replacement) {
const set = this.#set const set = this.#set
@ -54,7 +59,7 @@ export class ControllerList {
} }
/** /**
* @param {String} name * @param {string} name
* @param {Boolean} force * @param {Boolean} force
*/ */
toggle(name, force) { toggle(name, force) {
@ -81,32 +86,39 @@ export class ControllerRegistry {
#observer = new MutationObserver(mutations => { #observer = new MutationObserver(mutations => {
for (const mutation of mutations) { for (const mutation of mutations) {
if (mutation.type === "childList") { if (mutation.type === "childList") {
for (const node of mutation.addedNodes) { for (const node of mutation.addedNodes) if (node instanceof HTMLElement) {
this.upgrade(node) this.upgrade(node)
this.#update(node)
} }
} else { for (const node of mutation.removedNodes) if (node instanceof HTMLElement) {
this.#downgrade(node)
}
} else if (mutation.target instanceof HTMLElement) {
this.#update(mutation.target) this.#update(mutation.target)
} }
} }
}) })
/** @type {WeakMap<HTMLElement,Map<String,Object>>} */ /** @type {WeakMap<HTMLElement,Map<string,Object>>} */
#attached = new WeakMap() #attached = new WeakMap()
/** @type {Map<String,Set<HTMLElement>>} */ /** @type {Map<string,Set<HTMLElement>>} */
#waiting = new Map() #waiting = new Map()
/** @type {Map<String,(element: HTMLElement, signal: AbortSignal)=>void>} */ /** @type {Map<string,Callback>} */
#defined = new Map() #defined = new Map()
/** @type {Map<string,Controller>} */
#lookup = new Map()
/** @type {Map<Controller,string>} */
#nameLookup = new Map()
#attribute #attribute
/** @typedef {HTMLElement} Root */ /** @typedef {Document|DocumentFragment|HTMLElement} Root */
/** /**
* @param {Root} root * @param {Root} root
* @param {String} attribute * @param {string} attribute
*/ */
constructor(root, attribute="controller") { constructor(root, attribute="controller") {
this.#attribute = attribute this.#attribute = attribute
@ -118,39 +130,94 @@ export class ControllerRegistry {
* @param {Root} root * @param {Root} root
*/ */
upgrade(root) { upgrade(root) {
root.querySelectorAll(`[${this.#attribute}]`).forEach(element => this.#update(element)) if (root instanceof HTMLElement) this.#update(root)
for (const element of root.querySelectorAll(`[${this.#attribute}]`)) {
this.#update(/** @type {HTMLElement} */(element))
}
} }
/** /**
* @param {String} name * @param {Root} root
* @param {Function} callback */
#downgrade(root) {
if (root instanceof HTMLElement) this.#clear(root)
for (const element of root.querySelectorAll(`[${this.#attribute}]`)) {
this.#clear(/** @type {HTMLElement} */(element))
}
}
/**
* @param {string} name
* @param {Controller} callback
*/ */
define(name, callback) { define(name, callback) {
if (this.#nameLookup.has(callback)) console.warn(`Redefining controller ${this.#nameLookup.get(callback)} under new name ${name}:`, callback)
this.#lookup.set(name, callback)
this.#nameLookup.set(callback, name)
if (("function" == typeof callback) && callback.prototype) { if (("function" == typeof callback) && callback.prototype) {
callback = async (element, disconnected) => { callback = async (element, disconnected) => {
const {proxy, revoke} = Proxy.revocable(element, {}) const {proxy, revoke} = Proxy.revocable(element, {})
const controller = new callback(proxy, disconnected) const controller = new /** @type {ControllerClass} */(callback)(proxy, disconnected)
await disconnected await disconnected
revoke() revoke()
if ("detach" in controller) controller.detach(element) if ("detach" in controller) controller.detach(element)
} }
} }
this.#defined.set(name, callback) this.#defined.set(name, /** @type {Callback} */(callback))
const waitingList = this.#waiting.get(name) const waitingList = this.#waiting.get(name)
if (waitingList) for (const element of waitingList) { if (waitingList) for (const element of waitingList) {
try { this.#attach(element, name) } catch(e) { console.error(e) } this.#attach(element, name)
} }
this.#waiting.delete(name) this.#waiting.delete(name)
if (this.#whenDefined.has(name)) {
this.#whenDefined.get(name)[1]?.()
}
} }
/** Gets a controller associated with a given name
* @param {string} name
*/
get(name) { get(name) {
return this.#defined.get(name) return this.#lookup.get(name)
} }
/** Gets the name a controller is registered with
* @param {Controller} controller
*/
getName(controller) {
return this.#nameLookup.get(controller)
}
/** @type {Map<string,[Promise, ()=>void]>} */
#whenDefined = new Map()
/**
* @param {string} name
*/
whenDefined(name) {
if (!this.#whenDefined.has(name)) {
if (this.#defined.has(name)) {
this.#whenDefined.set(name, [Promise.resolve(), undefined])
} else {
let resolve
const promise = new Promise(_resolve => {resolve = _resolve})
this.#whenDefined.set(name, [promise, resolve])
}
}
return this.#whenDefined.get(name)[0]
}
/** @type {WeakMap<HTMLElement,ControllerList>} */
#listMap = new WeakMap() #listMap = new WeakMap()
/**
* @param {HTMLElement} element
* @return {ControllerList}
*/
list(element) { list(element) {
if (this.#listMap.has(element)) { if (this.#listMap.has(element)) {
return this.#listMap.get(element) return this.#listMap.get(element)
@ -161,42 +228,62 @@ export class ControllerRegistry {
} }
} }
getName(controller) { /** @param {HTMLElement} element */
// Return name of controller attached(element) {
} const attached = this.#attached.get(element)
if (attached)
whenDefined(name) { return [...attached.entries().filter(pair => pair[1]).map(pair => pair[0])]
// Return a promise else
return []
} }
/** @param {HTMLElement} element */
#update(element) { #update(element) {
const names = this.#getControllerNames(element) const names = this.#getControllerNames(element)
const attached = this.#attached.get(element) const attached = this.#attached.get(element)
if (attached) { if (attached) {
const current = new Set(names) const current = new Set(names)
for (const [name, abortController] of attached) { for (const [name] of attached) {
if (!current.has(name)) { if (!current.has(name)) {
try { this.#detach(element, name) } catch(e) { console.error(e) } this.#detach(element, name)
} }
} }
} }
for (const name of names) try { this.#attach(element, name) } catch(e) { console.error(e) } for (const name of names) this.#attach(element, name)
} }
/** @param {HTMLElement} element */
#clear(element) {
const attached = this.#attached.get(element)
if (attached) {
for (const [name] of attached) {
this.#detach(element, name)
}
}
}
/**
* @param {HTMLElement} element
* @param {string} name
*/
#attach(element, name) { #attach(element, name) {
if (!this.#attached.has(element)) this.#attached.set(element, new Map()) if (!this.#attached.has(element)) this.#attached.set(element, new Map())
const attached = this.#attached.get(element) const attached = this.#attached.get(element)
const callback = this.get(name) const callback = this.#defined.get(name)
if (callback) { if (callback) {
if (attached.has("name") && attached.get("name")) return console.warn(`Controller ${name} already fully attached`, element) if (attached.has("name") && attached.get("name")) return console.warn(`Controller ${name} already fully attached`, element)
const abortController = new AbortController() const abortController = new AbortController()
attached.set(name, abortController)
const promise = new Promise(resolve => abortController.signal.addEventListener("abort", () => resolve())) const promise = new Promise(resolve => abortController.signal.addEventListener("abort", () => resolve()))
Object.defineProperty(promise, "signal", {value: abortController.signal}) Object.defineProperty(promise, "signal", {value: abortController.signal})
callback(element, promise) try {
callback(element, /** @type {PromiseWithSignal} */(promise))
attached.set(name, abortController)
} catch(error) {
console.error(error)
}
} else { } else {
if (attached.has("name")) return console.warn(`Controller ${name} already attached`, element) if (attached.has("name")) return console.warn(`Controller ${name} already attached`, element)
attached.set(name, undefined) attached.set(name, undefined)
@ -209,6 +296,10 @@ export class ControllerRegistry {
} }
} }
/**
* @param {HTMLElement} element
* @param {string} name
*/
#detach(element, name) { #detach(element, name) {
const references = this.#attached.get(element) const references = this.#attached.get(element)
if (!references || !references.has(name)) return console.warn(`Controller ${name} not attached`, element) if (!references || !references.has(name)) return console.warn(`Controller ${name} not attached`, element)
@ -217,6 +308,7 @@ export class ControllerRegistry {
references.delete(name) references.delete(name)
} }
/** @param {HTMLElement} element */
#getControllerNames(element) { #getControllerNames(element) {
return new Set(element.getAttribute(this.#attribute)?.split(" ") ?? []) return new Set(element.getAttribute(this.#attribute)?.split(" ") ?? [])
} }