This is my first web component. I implement it as an autonomous custom HTML element. I cannot use a customized built-in element because it's not supported by Safari. Nevertheless, as it mainly behaves as a details HTML element, and as I do not want to reinvent the wheel, I use this type of element as a delegate. My example is self-contained to be easier to be tested, but I will obviously move the JavaScript code into a module later. It must work only in very modern web browsers.
It reproduces at least one behavior of the Java Swing JTree by using a padding. I would like to know whether it is possible to write it more concisely without sacrificing performance, I do not want to insert one style element per instance of my custom element.
<!DOCTYPE html>
<html lang="en">
<head>
<script>
customElements.define("custom-tree", class extends HTMLElement {
// style sheet shared by all HTML elements created by this class
static #sharedStyleSheet = new CSSStyleSheet();
// previous owner document, tracked to handle adoption cases (see Document.adoptNode())
#previousOwnerDocument;
constructor() {
super();
// attaches the shadow DOM here as it must be done only once, keeps it open so that it can be accessed later through the shadowRoot property
this.attachShadow({mode: "open"});
// the previous owner document is not known yet as the constructor is called before the connection
this.#previousOwnerDocument = null;
}
#ensuresOwnerDocumentSharedStyleSheetSupportEnabledIfNeeded() {
// if the shared style sheet is not in the adopted style sheets of the owner document
if (this.ownerDocument.adoptedStyleSheets.includes(this.constructor.#sharedStyleSheet) === false) {
// adds the shared style sheet into the adopted style sheets of the owner document
this.ownerDocument.adoptedStyleSheets.push(this.constructor.#sharedStyleSheet);
}
}
#ensuresPreviousOwnerDocumentSharedStyleSheetSupportDisabledIfUnneeded() {
// if this custom element is absent of the owner document
if (this.#previousOwnerDocument.querySelectorAll(this.localName).length === 0) {
// looks for the index of the shared style sheet in the adopted style sheets of the owner document
const indexOfSharedStyleSheet = this.#previousOwnerDocument.adoptedStyleSheets.indexOf(this.constructor.#sharedStyleSheet);
// if the shared style sheet is still in the adopted style sheets of the document
if (indexOfSharedStyleSheet !== -1) {
// removes the shared style sheet from the adopted style sheets of the owner document
this.#previousOwnerDocument.adoptedStyleSheets.splice(indexOfSharedStyleSheet, 1);
}
}
}
connectedCallback() {
// if the shared style sheet is empty
if (this.constructor.#sharedStyleSheet.cssRules.length === 0) {
// inserts a single rule, uses the lowercase tag name
this.constructor.#sharedStyleSheet.insertRule(`${this.localName} > details details { padding-left: 1em; }`);
// prevents the shared style sheet from being modified later
Object.freeze(this.constructor.#sharedStyleSheet);
}
this.#ensuresOwnerDocumentSharedStyleSheetSupportEnabledIfNeeded();
// uses a template with a single unnamed slot to use the content of this slot as a delegate and to avoid extending its class as it is unsupported by some web browsers (Safari)
const templateElement = this.ownerDocument.createElement("template");
templateElement.innerHTML = "<slot></slot>";
this.shadowRoot.appendChild(templateElement.content.cloneNode(true));
// ensures that the slot accepts only one details HTML element by using a slot change listener to detect an illegal change early
this.shadowRoot.lastElementChild.addEventListener("slotchange", slotChangeEvent => {
const assignedElements = slotChangeEvent.currentTarget.assignedElements();
if (1 < assignedElements.length || assignedElements.some(assignedElement => assignedElement.localName !== "details")) {
// removes all children from the custom element
this.replaceChildren();
throw new Error("This custom element can only contain at most one details HTML element");
}
if (assignedElements.length === 1) {
// lets the summary markers open and close the details but not their respective contents
assignedElements[0].querySelectorAll(`:scope summary > *`).forEach(summaryChild => summaryChild.addEventListener("click", summaryChildClickEvent => summaryChildClickEvent.preventDefault()));
}
});
// updates the previous owner document
this.#previousOwnerDocument = this.ownerDocument;
}
connectedMoveCallback() {
// the previous owner document remains unchanged as this element stays in the same document
}
adoptedCallback() {
// this.ownerDocument contains the adopter (i.e the current owner document), not the previous owner document
// ensures that the previous owner document stops supporting the shared style sheet if unneeded as this element is no longer in this document
this.#ensuresPreviousOwnerDocumentSharedStyleSheetSupportDisabledIfUnneeded();
// updates the previous owner document
this.#previousOwnerDocument = this.ownerDocument;
// ensures the current owner document supports the shared style sheet
this.#ensuresOwnerDocumentSharedStyleSheetSupportEnabledIfNeeded();
}
disconnectedCallback() {
// ensures that the previous owner document stops supporting the shared style sheet if unneeded as this element is no longer in this document
this.#ensuresPreviousOwnerDocumentSharedStyleSheetSupportDisabledIfUnneeded();
// updates the previous owner document
this.#previousOwnerDocument = null;
}
});
</script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Test</title>
</head>
<body>
<custom-tree><details>
<summary><span>Test 0</span></summary>
<details>
<summary><span>Test 1</span></summary>
<details>
<summary><span>Test 2</span></summary>
</details>
</details>
</details></custom-tree>
</body>
</html>