Compare commits
4 commits
ac51fe41c7
...
4ae1c90f1a
Author | SHA1 | Date | |
---|---|---|---|
4ae1c90f1a | |||
74142e375f | |||
a5c0502666 | |||
07783696f8 |
4 changed files with 202 additions and 41 deletions
|
@ -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 => {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
73
readme.md
73
readme.md
|
@ -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.
|
||||||
|
|
|
@ -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(" ") ?? [])
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue