Fix web component initialization timing

This commit is contained in:
Sascha Ißbrücker
2026-01-01 01:08:54 +01:00
parent b82d07c588
commit df595f2219
8 changed files with 99 additions and 99 deletions

View File

@@ -1,5 +1,7 @@
class BookmarkPage extends HTMLElement { import { HeadlessElement } from "../utils/element.js";
connectedCallback() {
class BookmarkPage extends HeadlessElement {
init() {
this.update = this.update.bind(this); this.update = this.update.bind(this);
this.onToggleNotes = this.onToggleNotes.bind(this); this.onToggleNotes = this.onToggleNotes.bind(this);
this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this); this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this);
@@ -17,13 +19,11 @@ class BookmarkPage extends HTMLElement {
} }
update() { update() {
requestAnimationFrame(() => { const items = this.querySelectorAll("ul.bookmark-list > li");
const items = this.querySelectorAll("ul.bookmark-list > li"); this.updateTooltips(items);
this.updateTooltips(items); this.updateNotesToggles(items, this.oldItems);
this.updateNotesToggles(items, this.oldItems); this.updateBulkEdit(items, this.oldItems);
this.updateBulkEdit(items, this.oldItems); this.oldItems = items;
this.oldItems = items;
});
} }
updateTooltips(items) { updateTooltips(items) {

View File

@@ -1,28 +1,19 @@
class ClearButton extends HTMLElement { import { HeadlessElement } from "../utils/element";
connectedCallback() {
requestAnimationFrame(() => {
this.field = document.getElementById(this.dataset.for);
if (!this.field) {
console.error(`Field with ID ${this.dataset.for} not found`);
return;
}
this.update = this.update.bind(this);
this.clear = this.clear.bind(this);
this.addEventListener("click", this.clear); class ClearButton extends HeadlessElement {
this.field.addEventListener("input", this.update); init() {
this.field.addEventListener("value-changed", this.update); this.field = document.getElementById(this.dataset.for);
this.update();
});
}
disconnectedCallback() {
if (!this.field) { if (!this.field) {
console.error(`Field with ID ${this.dataset.for} not found`);
return; return;
} }
this.removeEventListener("click", this.clear); this.update = this.update.bind(this);
this.field.removeEventListener("input", this.update); this.clear = this.clear.bind(this);
this.field.removeEventListener("value-changed", this.update);
this.addEventListener("click", this.clear);
this.field.addEventListener("input", this.update);
this.field.addEventListener("value-changed", this.update);
this.update();
} }
update() { update() {

View File

@@ -1,4 +1,6 @@
class Dropdown extends HTMLElement { import { HeadlessElement } from "../utils/element.js";
class Dropdown extends HeadlessElement {
constructor() { constructor() {
super(); super();
this.opened = false; this.opened = false;
@@ -8,26 +10,20 @@ class Dropdown extends HTMLElement {
this.onFocusOut = this.onFocusOut.bind(this); this.onFocusOut = this.onFocusOut.bind(this);
} }
connectedCallback() { init() {
// Defer setup to next frame when children are available in the DOM // Prevent opening the dropdown automatically on focus, so that it only
requestAnimationFrame(() => { // opens on click when JS is enabled
// Prevent opening the dropdown automatically on focus, so that it only this.style.setProperty("--dropdown-focus-display", "none");
// opens on click when JS is enabled this.addEventListener("keydown", this.onEscape);
this.style.setProperty("--dropdown-focus-display", "none"); this.addEventListener("focusout", this.onFocusOut);
this.addEventListener("keydown", this.onEscape);
this.addEventListener("focusout", this.onFocusOut);
this.toggle = this.querySelector(".dropdown-toggle"); this.toggle = this.querySelector(".dropdown-toggle");
this.toggle.setAttribute("aria-expanded", "false"); this.toggle.setAttribute("aria-expanded", "false");
this.toggle.addEventListener("click", this.onClick); this.toggle.addEventListener("click", this.onClick);
});
} }
disconnectedCallback() { disconnectedCallback() {
this.close(); this.close();
this.toggle?.removeEventListener("click", this.onClick);
this.removeEventListener("keydown", this.onEscape);
this.removeEventListener("focusout", this.onFocusOut);
} }
open() { open() {

View File

@@ -1,17 +1,14 @@
import { html, render } from "lit"; import { html, render } from "lit";
import { Modal } from "./modal.js"; import { Modal } from "./modal.js";
import { HeadlessElement } from "../utils/element.js";
import { isKeyboardActive } from "../utils/focus.js"; import { isKeyboardActive } from "../utils/focus.js";
class FilterDrawerTrigger extends HTMLElement { class FilterDrawerTrigger extends HeadlessElement {
connectedCallback() { init() {
this.onClick = this.onClick.bind(this); this.onClick = this.onClick.bind(this);
this.addEventListener("click", this.onClick.bind(this)); this.addEventListener("click", this.onClick.bind(this));
} }
disconnectedCallback() {
this.removeEventListener("click", this.onClick.bind(this));
}
onClick() { onClick() {
const modal = document.createElement("ld-filter-drawer"); const modal = document.createElement("ld-filter-drawer");
document.body.querySelector(".modals").appendChild(modal); document.body.querySelector(".modals").appendChild(modal);
@@ -67,8 +64,8 @@ class FilterDrawer extends Modal {
this.getBoundingClientRect(); this.getBoundingClientRect();
// Add active class to start slide-in animation // Add active class to start slide-in animation
requestAnimationFrame(() => this.classList.add("active")); requestAnimationFrame(() => this.classList.add("active"));
// Call super after rendering to ensure elements are available // Call super.init() after rendering to ensure elements are available
super.connectedCallback(); super.init();
} }
disconnectedCallback() { disconnectedCallback() {

View File

@@ -1,28 +1,26 @@
class Form extends HTMLElement { import { HeadlessElement } from "../utils/element.js";
class Form extends HeadlessElement {
constructor() { constructor() {
super(); super();
this.onKeyDown = this.onKeyDown.bind(this); this.onKeyDown = this.onKeyDown.bind(this);
this.onChange = this.onChange.bind(this); this.onChange = this.onChange.bind(this);
} }
connectedCallback() { init() {
this.addEventListener("keydown", this.onKeyDown); this.addEventListener("keydown", this.onKeyDown);
this.addEventListener("change", this.onChange); this.addEventListener("change", this.onChange);
requestAnimationFrame(() => { if (this.hasAttribute("data-form-reset")) {
if (this.hasAttribute("data-form-reset")) { // Resets form controls to their initial values before Turbo caches the DOM.
// Resets form controls to their initial values before Turbo caches the DOM. // Useful for filter forms where navigating back would otherwise still show
// Useful for filter forms where navigating back would otherwise still show // values from after the form submission, which means the filters would be out
// values from after the form submission, which means the filters would be out // of sync with the URL.
// of sync with the URL. this.initFormReset();
this.initFormReset(); }
}
});
} }
disconnectedCallback() { disconnectedCallback() {
this.removeEventListener("keydown", this.onKeyDown);
this.removeEventListener("change", this.onChange);
if (this.hasAttribute("data-form-reset")) { if (this.hasAttribute("data-form-reset")) {
this.resetForm(); this.resetForm();
} }

View File

@@ -1,29 +1,23 @@
import { FocusTrapController } from "../utils/focus.js"; import { FocusTrapController } from "../utils/focus.js";
import { HeadlessElement } from "../utils/element.js";
export class Modal extends HTMLElement { export class Modal extends HeadlessElement {
connectedCallback() { init() {
requestAnimationFrame(() => { this.onClose = this.onClose.bind(this);
this.onClose = this.onClose.bind(this); this.onKeyDown = this.onKeyDown.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.querySelectorAll("[data-close-modal]").forEach((btn) => { this.querySelectorAll("[data-close-modal]").forEach((btn) => {
btn.addEventListener("click", this.onClose); btn.addEventListener("click", this.onClose);
});
document.addEventListener("keydown", this.onKeyDown);
this.setupScrollLock();
this.focusTrap = new FocusTrapController(
this.querySelector(".modal-container"),
);
}); });
this.addEventListener("keydown", this.onKeyDown);
this.setupScrollLock();
this.focusTrap = new FocusTrapController(
this.querySelector(".modal-container"),
);
} }
disconnectedCallback() { disconnectedCallback() {
this.querySelectorAll("[data-close-modal]").forEach((btn) => {
btn.removeEventListener("click", this.onClose);
});
document.removeEventListener("keydown", this.onKeyDown);
this.removeScrollLock(); this.removeScrollLock();
this.focusTrap.destroy(); this.focusTrap.destroy();
} }

View File

@@ -1,20 +1,15 @@
class UploadButton extends HTMLElement { import { HeadlessElement } from "../utils/element.js";
connectedCallback() {
class UploadButton extends HeadlessElement {
init() {
this.onClick = this.onClick.bind(this); this.onClick = this.onClick.bind(this);
this.onChange = this.onChange.bind(this); this.onChange = this.onChange.bind(this);
requestAnimationFrame(() => { this.button = this.querySelector('button[type="submit"]');
this.button = this.querySelector('button[type="submit"]'); this.button.addEventListener("click", this.onClick);
this.button.addEventListener("click", this.onClick);
this.fileInput = this.querySelector('input[type="file"]'); this.fileInput = this.querySelector('input[type="file"]');
this.fileInput.addEventListener("change", this.onChange); this.fileInput.addEventListener("change", this.onChange);
});
}
disconnectedCallback() {
this.button.removeEventListener("click", this.onClick);
this.fileInput.removeEventListener("change", this.onChange);
} }
onClick(event) { onClick(event) {

View File

@@ -1,5 +1,34 @@
import { LitElement } from "lit"; import { LitElement } from "lit";
/**
* Base class for custom elements that wrap existing server-rendered DOM.
*
* Handles timing issues where connectedCallback fires before child elements
* are parsed during initial page load. With Turbo navigation, children are
* always available, but on fresh page loads they may not be.
*
* Subclasses should override init() instead of connectedCallback().
*/
export class HeadlessElement extends HTMLElement {
connectedCallback() {
if (this.__initialized) {
return;
}
this.__initialized = true;
if (document.readyState === "loading") {
document.addEventListener("turbo:load", () => this.init(), {
once: true,
});
} else {
this.init();
}
}
init() {
// Override in subclass
}
}
let isTopFrameVisit = false; let isTopFrameVisit = false;
document.addEventListener("turbo:visit", (event) => { document.addEventListener("turbo:visit", (event) => {