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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,34 @@
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;
document.addEventListener("turbo:visit", (event) => {