diff --git a/club/templates/club/club_members.jinja b/club/templates/club/club_members.jinja index 3aa43d56..7e37b04e 100644 --- a/club/templates/club/club_members.jinja +++ b/club/templates/club/club_members.jinja @@ -1,11 +1,7 @@ {% extends "core/base.jinja" %} {% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %} -{% block additional_js %} - -{% endblock %} {% block additional_css %} - {% endblock %} diff --git a/core/static/bundled/core/components/include-index.ts b/core/static/bundled/core/components/include-index.ts index c1989f4b..9ee44bb8 100644 --- a/core/static/bundled/core/components/include-index.ts +++ b/core/static/bundled/core/components/include-index.ts @@ -1,18 +1,136 @@ -import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts"; +import { + type InheritedHtmlElement, + inheritHtmlElement, + registerComponent, +} from "#core:utils/web-components.ts"; + +/** + * ElementOnce web components + * + * Those elements ensures that their content is always included only once on a document + * They are compatible with elements that are not managed with our Web Components + **/ +export interface ElementOnce + extends InheritedHtmlElement { + getElementQuerySelector(): string; + refresh(): void; +} + +/** + * Create an abstract class for ElementOnce types Web Components + **/ +export function elementOnce(tagName: K) { + abstract class ElementOnceImpl + extends inheritHtmlElement(tagName) + implements ElementOnce + { + abstract getElementQuerySelector(): string; + + clearNode() { + while (this.firstChild) { + this.removeChild(this.lastChild); + } + } + + refresh() { + this.clearNode(); + if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) { + this.appendChild(this.node); + } + } + + connectedCallback() { + super.connectedCallback(false); + this.refresh(); + } + + disconnectedCallback() { + // The MutationObserver can't see web components being removed + // It also can't see if something is removed inside after the component gets deleted + // We need to manually clear the containing node to trigger the observer + this.clearNode(); + } + } + return ElementOnceImpl; +} + +// Set of ElementOnce type components to refresh with the observer +const registeredComponents: Set = new Set(); + +/** + * Helper to register ElementOnce types Web Components + * It's a wrapper around registerComponent that registers that component on + * a MutationObserver that activates a refresh on them when elements are removed + * + * You are not supposed to unregister an element + **/ +export function registerElementOnce(name: string, options?: ElementDefinitionOptions) { + registeredComponents.add(name); + return registerComponent(name, options); +} + +// Refresh all ElementOnce components on the document based on the tag name of the removed element +const refreshElement = < + T extends keyof HTMLElementTagNameMap, + K extends keyof HTMLElementTagNameMap, +>( + components: HTMLCollectionOf>, + removedTagName: K, +) => { + for (const element of components) { + // We can't guess if an element is compatible before we get one + // We exit the function completely if it's not compatible + if (element.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) { + return; + } + + element.refresh(); + } +}; + +// Since we need to pause the observer, we make an helper to start it with consistent arguments +const startObserver = (observer: MutationObserver) => { + observer.observe(document, { + // We want to also listen for elements contained in the header (eg: link) + subtree: true, + childList: true, + }); +}; + +// Refresh ElementOnce components when changes happens +const observer = new MutationObserver((mutations: MutationRecord[]) => { + // To avoid infinite recursion, we need to pause the observer while manipulation nodes + observer.disconnect(); + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + if (node.nodeType !== node.ELEMENT_NODE) { + continue; + } + for (const registered of registeredComponents) { + refreshElement( + document.getElementsByTagName(registered) as HTMLCollectionOf< + ElementOnce<"html"> // The specific tag doesn't really matter + >, + (node as HTMLElement).tagName as keyof HTMLElementTagNameMap, + ); + } + } + } + // We then resume the observer + startObserver(observer); +}); + +startObserver(observer); /** * Web component used to import css files only once * If called multiple times or the file was already imported, it does nothing **/ -@registerComponent("link-once") -export class LinkOnce extends inheritHtmlElement("link") { - connectedCallback() { - super.connectedCallback(false); +@registerElementOnce("link-once") +export class LinkOnce extends elementOnce("link") { + getElementQuerySelector(): string { // We get href from node.attributes instead of node.href to avoid getting the domain part - const href = this.node.attributes.getNamedItem("href").nodeValue; - if (document.querySelectorAll(`link[href='${href}']`).length === 0) { - this.appendChild(this.node); - } + return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`; } } @@ -20,14 +138,10 @@ export class LinkOnce extends inheritHtmlElement("link") { * Web component used to import javascript files only once * If called multiple times or the file was already imported, it does nothing **/ -@registerComponent("script-once") +@registerElementOnce("script-once") export class ScriptOnce extends inheritHtmlElement("script") { - connectedCallback() { - super.connectedCallback(false); - // We get src from node.attributes instead of node.src to avoid getting the domain part - const src = this.node.attributes.getNamedItem("src").nodeValue; - if (document.querySelectorAll(`script[src='${src}']`).length === 0) { - this.appendChild(this.node); - } + getElementQuerySelector(): string { + // We get href from node.attributes instead of node.src to avoid getting the domain part + return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`; } } diff --git a/core/static/bundled/utils/web-components.ts b/core/static/bundled/utils/web-components.ts index 8d1dbc47..d7792e76 100644 --- a/core/static/bundled/utils/web-components.ts +++ b/core/static/bundled/utils/web-components.ts @@ -23,10 +23,17 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio * The technique is to: * create a new web component * create the desired type inside - * pass all attributes to the child component + * move all attributes to the child component * store is at as `node` inside the parent - * - * Since we can't use the generic type to instantiate the node, we create a generator function + **/ +export interface InheritedHtmlElement + extends HTMLElement { + readonly inheritedTagName: K; + node: HTMLElementTagNameMap[K]; +} + +/** + * Generator function that creates an InheritedHtmlElement compatible class * * ```js * class MyClass extends inheritHtmlElement("select") { @@ -35,11 +42,15 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio * ``` **/ export function inheritHtmlElement(tagName: K) { - return class Inherited extends HTMLElement { - protected node: HTMLElementTagNameMap[K]; + return class InheritedHtmlElementImpl + extends HTMLElement + implements InheritedHtmlElement + { + readonly inheritedTagName = tagName; + node: HTMLElementTagNameMap[K]; connectedCallback(autoAddNode?: boolean) { - this.node = document.createElement(tagName); + this.node = document.createElement(this.inheritedTagName); const attributes: Attr[] = []; // We need to make a copy to delete while iterating for (const attr of this.attributes) { if (attr.name in this.node) { @@ -47,6 +58,10 @@ export function inheritHtmlElement(tagNam } } + // We move compatible attributes to the child element + // This avoids weird inconsistencies between attributes + // when we manipulate the dom in the future + // This is especially important when using attribute based reactivity for (const attr of attributes) { this.removeAttributeNode(attr); this.node.setAttributeNode(attr); diff --git a/core/templates/core/user_preferences.jinja b/core/templates/core/user_preferences.jinja index 1aed4d94..f3694ff0 100644 --- a/core/templates/core/user_preferences.jinja +++ b/core/templates/core/user_preferences.jinja @@ -1,14 +1,7 @@ {% extends "core/base.jinja" %} -{%- block additional_js -%} - -{%- endblock -%} - {%- block additional_css -%} - {# importing ajax-select-index is necessary for it to be applied after HTMX reload #} - - {%- endblock -%} {% block title %} diff --git a/subscription/templates/subscription/subscription.jinja b/subscription/templates/subscription/subscription.jinja index 9046c296..45e88e18 100644 --- a/subscription/templates/subscription/subscription.jinja +++ b/subscription/templates/subscription/subscription.jinja @@ -6,14 +6,8 @@ {% trans %}New subscription{% endtrans %} {% endblock %} -{# The following statics are bundled with our autocomplete select. - However, if one tries to swap a form by another, then the urls in script-once - and link-once disappear. - So we give them here. - If the aforementioned bug is resolved, you can remove this. #} {% block additional_js %} -