Enable detaching controllers on DOM removal

This commit is contained in:
Talia 2025-08-18 10:38:00 +02:00
parent 7e766a288b
commit bfb9b560ca

View file

@ -1,14 +1,17 @@
/** @typedef {Promise & {signal: AbortSignal}} PromiseWithSignal */
/** @typedef {(element: HTMLElement, detach: PromiseWithSignal) => void} Callback */
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 +25,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 +57,7 @@ export class ControllerList {
} }
/** /**
* @param {String} name * @param {string} name
* @param {Boolean} force * @param {Boolean} force
*/ */
toggle(name, force) { toggle(name, force) {
@ -81,32 +84,34 @@ 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()
#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,12 +123,25 @@ 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 {Callback} callback
*/ */
define(name, callback) { define(name, callback) {
if (("function" == typeof callback) && callback.prototype) { if (("function" == typeof callback) && callback.prototype) {
@ -141,16 +159,24 @@ export class ControllerRegistry {
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)
} }
/** Gets a controller associated with a given name
* @param {string} name
*/
get(name) { get(name) {
return this.#defined.get(name) return this.#defined.get(name)
} }
/** @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,29 +187,53 @@ export class ControllerRegistry {
} }
} }
/** @param {HTMLElement} element */
attached(element) {
const attached = this.#attached.get(element)
if (attached)
return [...attached.entries().filter(pair => pair[1]).map(pair => pair[0])]
else
return []
}
getName(controller) { getName(controller) {
// Return name of controller // TODO: Return name of controller
} }
whenDefined(name) { whenDefined(name) {
// Return a promise // TODO: Return a promise
} }
/** @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)
@ -193,10 +243,14 @@ export class ControllerRegistry {
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 +263,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 +275,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(" ") ?? [])
} }