mirror of
https://github.com/ae-utbm/sith.git
synced 2026-04-02 16:59:43 +00:00
Merge pull request #1326 from ae-utbm/fix-link-script-once
Fix link-once and script-once not triggering when another one disappears
This commit is contained in:
@@ -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<K extends keyof HTMLElementTagNameMap>
|
||||
extends InheritedHtmlElement<K> {
|
||||
getElementQuerySelector(): string;
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an abstract class for ElementOnce types Web Components
|
||||
**/
|
||||
export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
||||
abstract class ElementOnceImpl
|
||||
extends inheritHtmlElement(tagName)
|
||||
implements ElementOnce<K>
|
||||
{
|
||||
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<string> = 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<ElementOnce<T>>,
|
||||
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}']`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<K extends keyof HTMLElementTagNameMap>
|
||||
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<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
||||
return class Inherited extends HTMLElement {
|
||||
protected node: HTMLElementTagNameMap[K];
|
||||
return class InheritedHtmlElementImpl
|
||||
extends HTMLElement
|
||||
implements InheritedHtmlElement<K>
|
||||
{
|
||||
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<K extends keyof HTMLElementTagNameMap>(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);
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{%- block additional_js -%}
|
||||
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block additional_css -%}
|
||||
<link rel="stylesheet" href="{{ static('user/user_preferences.scss') }}">
|
||||
{# importing ajax-select-index is necessary for it to be applied after HTMX reload #}
|
||||
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
|
||||
{%- endblock -%}
|
||||
|
||||
{% block title %}
|
||||
|
||||
Reference in New Issue
Block a user