Create InheritedHtmlElements interface and create an ElementOnce interface

This commit is contained in:
2026-04-01 19:47:54 +02:00
parent 39dee782cc
commit 6ef8b6b159
2 changed files with 98 additions and 72 deletions

View File

@@ -1,17 +1,30 @@
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 * Create an abstract class for ElementOnce types Web Components
*
* Those class aren't really abstract because that would be complicated with the
* multiple inheritance involved
* Instead, we just raise an unimplemented error
**/ **/
function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) { export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class ElementOnce extends inheritHtmlElement(tagName) { abstract class ElementOnceImpl
getElementQuerySelector(): string { extends inheritHtmlElement(tagName)
throw new Error("Unimplemented"); implements ElementOnce<K>
} {
abstract getElementQuerySelector(): string;
clearNode() { clearNode() {
while (this.firstChild) { while (this.firstChild) {
@@ -37,7 +50,8 @@ function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
// We need to manually clear the containing node to trigger the observer // We need to manually clear the containing node to trigger the observer
this.clearNode(); this.clearNode();
} }
}; }
return ElementOnceImpl;
} }
// Set of ElementOnce type components to refresh with the observer // Set of ElementOnce type components to refresh with the observer
@@ -47,12 +61,34 @@ const registeredComponents: Set<string> = new Set();
* Helper to register ElementOnce types Web Components * Helper to register ElementOnce types Web Components
* It's a wrapper around registerComponent that registers that component on * It's a wrapper around registerComponent that registers that component on
* a MutationObserver that activates a refresh on them when elements are removed * a MutationObserver that activates a refresh on them when elements are removed
*
* You are not supposed to unregister an element
**/ **/
function registerElementOnce(name: string, options?: ElementDefinitionOptions) { export function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
registeredComponents.add(name); registeredComponents.add(name);
return registerComponent(name, options); 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) => { const startObserver = (observer: MutationObserver) => {
observer.observe(document, { observer.observe(document, {
// We want to also listen for elements contained in the header (eg: link) // We want to also listen for elements contained in the header (eg: link)
@@ -61,27 +97,9 @@ const startObserver = (observer: MutationObserver) => {
}); });
}; };
// Refresh all ElementOnce components on the document based on their tag name
// They should all be be extended from the `elementOnce` factory
const refreshElement = (componentName: string, tagName: string) => {
for (const element of document.getElementsByTagName(componentName)) {
const node = element as unknown as {
inheritedTagName: string;
refresh(): null;
};
// We can't guess if an element is compatible before we get one
// We exit the function completely if it's not compatible
if (node.inheritedTagName.toUpperCase() !== tagName.toUpperCase()) {
return;
}
node.refresh();
}
};
// Refresh ElementOnce components when changes happens // Refresh ElementOnce components when changes happens
const observer = new MutationObserver((mutations: MutationRecord[]) => { const observer = new MutationObserver((mutations: MutationRecord[]) => {
// To avoid infinite recursion, we need to pause the observer while manipulation nodes
observer.disconnect(); observer.disconnect();
for (const mutation of mutations) { for (const mutation of mutations) {
for (const node of mutation.removedNodes) { for (const node of mutation.removedNodes) {
@@ -89,10 +107,16 @@ const observer = new MutationObserver((mutations: MutationRecord[]) => {
continue; continue;
} }
for (const registered of registeredComponents) { for (const registered of registeredComponents) {
refreshElement(registered, (node as HTMLElement).tagName); 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);
}); });

View File

@@ -19,16 +19,35 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
} }
/** /**
* Abstract class used to create HTML tags inheriting from HTMLElements * Safari doesn't support inheriting from HTML tags on web components
* You should not use this class outside of typing ! * The technique is to:
* * create a new web component
* Please, see the `inheritHtmlElement` factory if you want to use this class. * create the desired type inside
* move all attributes to the child component
* store is at as `node` inside the parent
**/ **/
export abstract class InheritedHtmlElement< export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
K extends keyof HTMLElementTagNameMap, extends HTMLElement {
> extends HTMLElement { readonly inheritedTagName: K;
abstract readonly inheritedTagName: K; node: HTMLElementTagNameMap[K];
protected node: HTMLElementTagNameMap[K]; }
/**
* Generator function that creates an InheritedHtmlElement compatible class
*
* ```js
* class MyClass extends inheritHtmlElement("select") {
* // do whatever
* }
* ```
**/
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class InheritedHtmlElementImpl
extends HTMLElement
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) { connectedCallback(autoAddNode?: boolean) {
this.node = document.createElement(this.inheritedTagName); this.node = document.createElement(this.inheritedTagName);
@@ -39,6 +58,10 @@ export abstract class InheritedHtmlElement<
} }
} }
// 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) { for (const attr of attributes) {
this.removeAttributeNode(attr); this.removeAttributeNode(attr);
this.node.setAttributeNode(attr); this.node.setAttributeNode(attr);
@@ -52,26 +75,5 @@ export abstract class InheritedHtmlElement<
this.appendChild(this.node); this.appendChild(this.node);
} }
} }
}
/**
* Safari doesn't support inheriting from HTML tags on web components
* The technique is to:
* create a new web component
* create the desired type inside
* pass 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
*
* ```js
* class MyClass extends inheritHtmlElement("select") {
* // do whatever
* }
* ```
**/
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class InheritedHtmlElementImpl extends InheritedHtmlElement<K> {
readonly inheritedTagName = tagName;
}; };
} }