From 13611df8a27d87d19c9695ad876e3d9640842301 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sat, 4 Apr 2026 03:47:02 +0530 Subject: [PATCH 1/6] =?UTF-8?q?Phase=201A:=20Client=20attribute=20reductio?= =?UTF-8?q?n=20=E2=80=94=20generic=20event=20router=20+=20removals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the attribute-reduction-proposal Phase 1A changes: Generic event router: - Replace 16 individual event attrs (lvt-click, lvt-keydown, etc.) with lvt-on[:{scope}]:{event} pattern (scopes: window, document) - Add parseLvtOn() parser with ordered greedy scope consumption Prefix consolidation: - lvt-scroll/highlight/animate → lvt-fx:scroll/highlight/animate - lvt-debounce/throttle → lvt-mod:debounce/throttle - lvt-preserve → lvt-form:preserve - lvt-disable-with → lvt-form:disable-with - lvt-no-intercept → lvt-form:no-intercept (link-interceptor.ts) - lvt-{action}-on:{event} → lvt-el:{method}:on:{event} Removals: - Delete modal-manager.ts — replaced by native + command/commandfor - Delete utils/confirm.ts — remove lvt-confirm and lvt-data-* extraction - Remove lvt-data-*/lvt-value-* extraction loops - Remove lvt-change fallback logic - Remove disable/enable from reactive attributes New features: - lvt-el:*:on:click-away — client-side DOM manipulation (not server action) - CSS custom properties for directive config (--lvt-scroll-behavior, etc.) - lvtSelector() utility for CSS colon escaping - livetemplate.css with custom property defaults FormLifecycleManager: - Remove ModalManager dependency, use native dialog.close() - Rename lvt-preserve → lvt-form:preserve Stats: 15 files changed, -379 net lines Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dom/directives.ts | 43 ++-- dom/event-delegation.ts | 281 ++++++++------------------- dom/link-interceptor.ts | 2 +- dom/modal-manager.ts | 72 ------- dom/reactive-attributes.ts | 138 +++++-------- livetemplate-client.ts | 13 +- livetemplate.css | 24 +++ state/form-lifecycle-manager.ts | 12 +- tests/directives.test.ts | 66 +++---- tests/event-delegation.test.ts | 18 +- tests/form-lifecycle-manager.test.ts | 58 +++--- tests/modal-manager.test.ts | 108 ---------- tests/reactive-attributes.test.ts | 154 ++++++--------- utils/confirm.ts | 33 ---- utils/lvt-selector.ts | 5 + 15 files changed, 324 insertions(+), 703 deletions(-) delete mode 100644 dom/modal-manager.ts create mode 100644 livetemplate.css delete mode 100644 tests/modal-manager.test.ts delete mode 100644 utils/confirm.ts create mode 100644 utils/lvt-selector.ts diff --git a/dom/directives.ts b/dom/directives.ts index d945370..10a8911 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -1,17 +1,21 @@ /** - * Apply scroll directives on elements with lvt-scroll attributes. + * Apply scroll directives on elements with lvt-fx:scroll attributes. + * Configuration read from CSS custom properties: + * --lvt-scroll-behavior: auto | smooth (default: auto) + * --lvt-scroll-threshold: (default: 100) */ export function handleScrollDirectives(rootElement: Element): void { - const scrollElements = rootElement.querySelectorAll("[lvt-scroll]"); + const scrollElements = rootElement.querySelectorAll("[lvt-fx\\:scroll]"); scrollElements.forEach((element) => { const htmlElement = element as HTMLElement; - const mode = htmlElement.getAttribute("lvt-scroll"); + const mode = htmlElement.getAttribute("lvt-fx:scroll"); + const computed = getComputedStyle(htmlElement); const behavior = - (htmlElement.getAttribute("lvt-scroll-behavior") as ScrollBehavior) || + (computed.getPropertyValue("--lvt-scroll-behavior").trim() as ScrollBehavior) || "auto"; const threshold = parseInt( - htmlElement.getAttribute("lvt-scroll-threshold") || "100", + computed.getPropertyValue("--lvt-scroll-threshold").trim() || "100", 10 ); @@ -51,24 +55,28 @@ export function handleScrollDirectives(rootElement: Element): void { break; default: - console.warn(`Unknown lvt-scroll mode: ${mode}`); + console.warn(`Unknown lvt-fx:scroll mode: ${mode}`); } }); } /** - * Apply highlight directives to elements with lvt-highlight attributes. + * Apply highlight directives to elements with lvt-fx:highlight attributes. + * Configuration read from CSS custom properties: + * --lvt-highlight-duration: (default: 500) + * --lvt-highlight-color: (default: #ffc107) */ export function handleHighlightDirectives(rootElement: Element): void { - const highlightElements = rootElement.querySelectorAll("[lvt-highlight]"); + const highlightElements = rootElement.querySelectorAll("[lvt-fx\\:highlight]"); highlightElements.forEach((element) => { - const mode = element.getAttribute("lvt-highlight"); + const mode = element.getAttribute("lvt-fx:highlight"); + const computed = getComputedStyle(element); const duration = parseInt( - element.getAttribute("lvt-highlight-duration") || "500", + computed.getPropertyValue("--lvt-highlight-duration").trim() || "500", 10 ); - const color = element.getAttribute("lvt-highlight-color") || "#ffc107"; + const color = computed.getPropertyValue("--lvt-highlight-color").trim() || "#ffc107"; if (!mode) return; @@ -90,15 +98,18 @@ export function handleHighlightDirectives(rootElement: Element): void { } /** - * Apply animation directives to elements with lvt-animate attributes. + * Apply animation directives to elements with lvt-fx:animate attributes. + * Configuration read from CSS custom properties: + * --lvt-animate-duration: (default: 300) */ export function handleAnimateDirectives(rootElement: Element): void { - const animateElements = rootElement.querySelectorAll("[lvt-animate]"); + const animateElements = rootElement.querySelectorAll("[lvt-fx\\:animate]"); animateElements.forEach((element) => { - const animation = element.getAttribute("lvt-animate"); + const animation = element.getAttribute("lvt-fx:animate"); + const computed = getComputedStyle(element); const duration = parseInt( - element.getAttribute("lvt-animate-duration") || "300", + computed.getPropertyValue("--lvt-animate-duration").trim() || "300", 10 ); @@ -122,7 +133,7 @@ export function handleAnimateDirectives(rootElement: Element): void { break; default: - console.warn(`Unknown lvt-animate mode: ${animation}`); + console.warn(`Unknown lvt-fx:animate mode: ${animation}`); } htmlElement.addEventListener( diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 9192f07..72ac199 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -1,7 +1,26 @@ import { debounce, throttle } from "../utils/rate-limit"; -import { checkLvtConfirm } from "../utils/confirm"; +import { lvtSelector } from "../utils/lvt-selector"; import type { Logger } from "../utils/logger"; +const SCOPE_KEYWORDS = new Set(["window", "document"]); + +/** + * Parse an lvt-on:* attribute name into scope and event. + * Grammar: lvt-on[:{scope}]:{event} + * - scope: "window" | "document" | (omitted = "element") + * - event: any native DOM event name + */ +export function parseLvtOn(attr: string): { scope: string; event: string } | null { + if (!attr.startsWith("lvt-on:")) return null; + const segs = attr.slice(7).split(":"); + if (segs.length === 0 || segs[0] === "") return null; + let scope = "element"; + if (SCOPE_KEYWORDS.has(segs[0])) scope = segs.shift()!; + const event = segs.join(":"); + if (!event) return null; + return { scope, event }; +} + export interface EventDelegationContext { getWrapperElement(): Element | null; getRateLimitedHandlers(): WeakMap>; @@ -12,8 +31,6 @@ export interface EventDelegationContext { button: HTMLButtonElement | null, originalButtonText: string | null ): void; - openModal(modalId: string): void; - closeModal(modalId: string): void; getWebSocketReadyState(): number | undefined; triggerPendingUploads(uploadName: string): void; } @@ -102,7 +119,7 @@ export class EventDelegator { if (!inWrapper) return; - const attrName = `lvt-${eventType}`; + const attrName = `lvt-on:${eventType}`; element = target; while (element && element !== currentWrapper.parentElement) { @@ -122,7 +139,7 @@ export class EventDelegator { // Orphan button detection (Tier 1: formless standalone buttons). // A + `; document.body.appendChild(wrapper); @@ -63,7 +61,7 @@ describe("EventDelegator", () => { expect(context.send).toHaveBeenCalledTimes(1); expect(context.send).toHaveBeenCalledWith({ action: "save", - data: { id: "42" }, + data: {}, }); }); @@ -73,7 +71,7 @@ describe("EventDelegator", () => { const wrapper = document.createElement("div"); wrapper.setAttribute("data-lvt-id", "wrapper-2"); wrapper.innerHTML = ` - + `; document.body.appendChild(wrapper); @@ -100,7 +98,7 @@ describe("EventDelegator", () => { const wrapper = document.createElement("div"); wrapper.setAttribute("data-lvt-id", "wrapper-password"); wrapper.innerHTML = ` -
+ @@ -152,10 +150,10 @@ describe("EventDelegator", () => { const wrapper = document.createElement("div"); wrapper.setAttribute("data-lvt-id", "wrapper-3"); wrapper.innerHTML = ` - + - +
`; document.body.appendChild(wrapper); @@ -634,10 +632,10 @@ describe("EventDelegator", () => { }); }); - it("lvt-click takes priority over orphan button name", () => { + it("lvt-on:click takes priority over orphan button name", () => { const wrapper = document.createElement("div"); wrapper.setAttribute("data-lvt-id", "wrapper-orphan-10"); - wrapper.innerHTML = ``; + wrapper.innerHTML = ``; document.body.appendChild(wrapper); const context = createContext(wrapper); diff --git a/tests/form-lifecycle-manager.test.ts b/tests/form-lifecycle-manager.test.ts index e44c599..2f4435d 100644 --- a/tests/form-lifecycle-manager.test.ts +++ b/tests/form-lifecycle-manager.test.ts @@ -1,47 +1,42 @@ import { FormLifecycleManager } from "../state/form-lifecycle-manager"; -import { ModalManager } from "../dom/modal-manager"; -import { createLogger } from "../utils/logger"; import type { ResponseMetadata } from "../types"; describe("FormLifecycleManager", () => { - let modalCloseSpy: jest.SpyInstance; - let modalManager: ModalManager; - beforeEach(() => { - modalManager = new ModalManager( - createLogger({ scope: "ModalManagerTest", level: "silent" }) - ); - modalCloseSpy = jest - .spyOn(modalManager, "close") - .mockImplementation(() => {}); document.body.innerHTML = ""; }); afterEach(() => { - modalCloseSpy.mockRestore(); document.body.innerHTML = ""; }); const createForm = () => { - const modal = document.createElement("div"); - modal.id = "modal-1"; - modal.setAttribute("role", "dialog"); + const dialog = document.createElement("dialog"); + dialog.id = "dialog-1"; + // JSDOM doesn't implement dialog.showModal() — polyfill + if (!dialog.showModal) { + dialog.showModal = function () { this.setAttribute("open", ""); }; + } + if (!dialog.close) { + dialog.close = function () { this.removeAttribute("open"); }; + } const form = document.createElement("form"); - modal.appendChild(form); + dialog.appendChild(form); const button = document.createElement("button"); button.textContent = "Submit"; form.appendChild(button); - document.body.appendChild(modal); + document.body.appendChild(dialog); + dialog.showModal(); - return { form, button, modal }; + return { form, button, dialog }; }; - it("dispatches success events, resets the form, and closes the modal on success", () => { - const { form, button, modal } = createForm(); - const manager = new FormLifecycleManager(modalManager); + it("dispatches success events, resets the form, and closes the dialog on success", () => { + const { form, button, dialog } = createForm(); + const manager = new FormLifecycleManager(); const doneListener = jest.fn(); const successListener = jest.fn(); @@ -55,21 +50,21 @@ describe("FormLifecycleManager", () => { expect(doneListener).toHaveBeenCalledWith(expect.any(CustomEvent)); expect(successListener).toHaveBeenCalledWith(expect.any(CustomEvent)); - expect(modalCloseSpy).toHaveBeenCalledWith("modal-1"); + expect(dialog.open).toBe(false); expect(form.elements.length).toBeGreaterThan(0); expect(button.disabled).toBe(false); expect(button.textContent).toBe("Submit"); }); - it("respects lvt-preserve and keeps the fields intact", () => { + it("respects lvt-form:preserve and keeps the fields intact", () => { const { form, button } = createForm(); - form.setAttribute("lvt-preserve", ""); + form.setAttribute("lvt-form:preserve", ""); const input = document.createElement("input"); input.value = "Keep me"; form.appendChild(input); - const manager = new FormLifecycleManager(modalManager); + const manager = new FormLifecycleManager(); manager.setActiveSubmission(form, button, "Submit"); const metadata: ResponseMetadata = { success: true, errors: {} }; @@ -79,8 +74,8 @@ describe("FormLifecycleManager", () => { }); it("dispatches error events and keeps the form when the response fails", () => { - const { form, button } = createForm(); - const manager = new FormLifecycleManager(modalManager); + const { form, button, dialog } = createForm(); + const manager = new FormLifecycleManager(); const doneListener = jest.fn(); const errorListener = jest.fn(); @@ -97,14 +92,14 @@ describe("FormLifecycleManager", () => { expect(doneListener).toHaveBeenCalledWith(expect.any(CustomEvent)); expect(errorListener).toHaveBeenCalledWith(expect.any(CustomEvent)); - expect(modalCloseSpy).not.toHaveBeenCalled(); + expect(dialog.open).toBe(true); expect(button.disabled).toBe(false); expect(button.textContent).toBe("Submit"); }); it("reset simply clears active submission state", () => { - const { form, button } = createForm(); - const manager = new FormLifecycleManager(modalManager); + const { form, button, dialog } = createForm(); + const manager = new FormLifecycleManager(); manager.setActiveSubmission(form, button, "Submit"); manager.reset(); @@ -112,6 +107,7 @@ describe("FormLifecycleManager", () => { const metadata: ResponseMetadata = { success: true, errors: {} }; manager.handleResponse(metadata); - expect(modalCloseSpy).not.toHaveBeenCalled(); + // Dialog should still be open since reset was called before handleResponse + expect(dialog.open).toBe(true); }); }); diff --git a/tests/modal-manager.test.ts b/tests/modal-manager.test.ts deleted file mode 100644 index 5ae0ce7..0000000 --- a/tests/modal-manager.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { ModalManager } from "../dom/modal-manager"; -import { createLogger } from "../utils/logger"; - -describe("ModalManager", () => { - let consoleLogSpy: jest.SpyInstance; - let consoleWarnSpy: jest.SpyInstance; - - beforeEach(() => { - consoleLogSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); - document.body.innerHTML = ""; - }); - - afterEach(() => { - consoleLogSpy.mockRestore(); - consoleWarnSpy.mockRestore(); - jest.useRealTimers(); - document.body.innerHTML = ""; - }); - - it("opens a modal and focuses the first input when no element is active", () => { - jest.useFakeTimers(); - - const modal = document.createElement("div"); - modal.id = "test-modal"; - modal.setAttribute("hidden", ""); - modal.style.display = "none"; - modal.setAttribute("aria-hidden", "true"); - - const input = document.createElement("input"); - input.type = "text"; - modal.appendChild(input); - - document.body.appendChild(modal); - - const manager = new ModalManager( - createLogger({ scope: "ModalManagerTest", level: "silent" }) - ); - const openedListener = jest.fn(); - modal.addEventListener("lvt:modal-opened", openedListener); - - manager.open("test-modal"); - - expect(modal.hasAttribute("hidden")).toBe(false); - expect(modal.style.display).toBe("flex"); - expect(modal.getAttribute("aria-hidden")).toBe("false"); - expect(openedListener).toHaveBeenCalled(); - - jest.runAllTimers(); - - expect(document.activeElement).toBe(input); - }); - - it("respects the current focus when a visible element inside the modal is already active", () => { - jest.useFakeTimers(); - - const modal = document.createElement("div"); - modal.id = "test-modal-visible"; - modal.setAttribute("hidden", ""); - modal.style.display = "none"; - modal.setAttribute("aria-hidden", "true"); - - const firstInput = document.createElement("input"); - firstInput.type = "text"; - const secondInput = document.createElement("input"); - secondInput.type = "text"; - - // Emulate layout visibility checks performed in ModalManager. - Object.defineProperty(secondInput, "offsetParent", { - get: () => modal, - }); - secondInput.getClientRects = () => [{ width: 10, height: 10 }] as any; - - modal.appendChild(firstInput); - modal.appendChild(secondInput); - document.body.appendChild(modal); - - const manager = new ModalManager( - createLogger({ scope: "ModalManagerTest", level: "silent" }) - ); - manager.open("test-modal-visible"); - - secondInput.focus(); - - jest.runAllTimers(); - - expect(document.activeElement).toBe(secondInput); - }); - - it("closes a modal and emits the closed event", () => { - const modal = document.createElement("div"); - modal.id = "test-modal-close"; - document.body.appendChild(modal); - - const manager = new ModalManager( - createLogger({ scope: "ModalManagerTest", level: "silent" }) - ); - const closedListener = jest.fn(); - modal.addEventListener("lvt:modal-closed", closedListener); - - manager.close("test-modal-close"); - - expect(modal.hasAttribute("hidden")).toBe(true); - expect(modal.style.display).toBe("none"); - expect(modal.getAttribute("aria-hidden")).toBe("true"); - expect(closedListener).toHaveBeenCalled(); - }); -}); diff --git a/tests/reactive-attributes.test.ts b/tests/reactive-attributes.test.ts index 6ae79f5..8d51ffa 100644 --- a/tests/reactive-attributes.test.ts +++ b/tests/reactive-attributes.test.ts @@ -18,9 +18,9 @@ describe("Reactive Attributes", () => { }); describe("parseReactiveAttribute", () => { - describe("valid attribute parsing", () => { + describe("valid attribute parsing (new lvt-el: pattern)", () => { it("parses global lifecycle event", () => { - const result = parseReactiveAttribute("lvt-reset-on:success", ""); + const result = parseReactiveAttribute("lvt-el:reset:on:success", ""); expect(result).toEqual({ action: "reset", lifecycle: "success", @@ -30,7 +30,7 @@ describe("Reactive Attributes", () => { }); it("parses action-specific lifecycle event", () => { - const result = parseReactiveAttribute("lvt-reset-on:create-todo:success", ""); + const result = parseReactiveAttribute("lvt-el:reset:on:create-todo:success", ""); expect(result).toEqual({ action: "reset", lifecycle: "success", @@ -40,7 +40,7 @@ describe("Reactive Attributes", () => { }); it("parses parameterized action with value", () => { - const result = parseReactiveAttribute("lvt-addClass-on:pending", "loading opacity-50"); + const result = parseReactiveAttribute("lvt-el:addclass:on:pending", "loading opacity-50"); expect(result).toEqual({ action: "addClass", lifecycle: "pending", @@ -50,7 +50,7 @@ describe("Reactive Attributes", () => { }); it("parses action-specific parameterized action", () => { - const result = parseReactiveAttribute("lvt-addClass-on:save:pending", "loading"); + const result = parseReactiveAttribute("lvt-el:addclass:on:save:pending", "loading"); expect(result).toEqual({ action: "addClass", lifecycle: "pending", @@ -62,30 +62,28 @@ describe("Reactive Attributes", () => { it("parses all lifecycle events", () => { const lifecycles: LifecycleEvent[] = ["pending", "success", "error", "done"]; lifecycles.forEach((lifecycle) => { - const result = parseReactiveAttribute(`lvt-reset-on:${lifecycle}`, ""); + const result = parseReactiveAttribute(`lvt-el:reset:on:${lifecycle}`, ""); expect(result?.lifecycle).toBe(lifecycle); }); }); - it("parses all action types", () => { - const actions = [ - "reset", - "disable", - "enable", - "addClass", - "removeClass", - "toggleClass", - "setAttr", - "toggleAttr", + it("parses all method types", () => { + const methods = [ + ["reset", "reset"], + ["addclass", "addClass"], + ["removeclass", "removeClass"], + ["toggleclass", "toggleClass"], + ["setattr", "setAttr"], + ["toggleattr", "toggleAttr"], ]; - actions.forEach((action) => { - const result = parseReactiveAttribute(`lvt-${action}-on:success`, "value"); - expect(result?.action).toBe(action); + methods.forEach(([input, expected]) => { + const result = parseReactiveAttribute(`lvt-el:${input}:on:success`, "value"); + expect(result?.action).toBe(expected); }); }); it("handles action names with hyphens", () => { - const result = parseReactiveAttribute("lvt-reset-on:create-new-todo:success", ""); + const result = parseReactiveAttribute("lvt-el:reset:on:create-new-todo:success", ""); expect(result).toEqual({ action: "reset", lifecycle: "success", @@ -95,7 +93,7 @@ describe("Reactive Attributes", () => { }); it("handles action names with colons", () => { - const result = parseReactiveAttribute("lvt-reset-on:todos:create:success", ""); + const result = parseReactiveAttribute("lvt-el:reset:on:todos:create:success", ""); expect(result).toEqual({ action: "reset", lifecycle: "success", @@ -105,21 +103,32 @@ describe("Reactive Attributes", () => { }); }); + describe("click-away interaction keyword", () => { + it("returns null for click-away (handled by click-away delegation)", () => { + expect(parseReactiveAttribute("lvt-el:removeclass:on:click-away", "open")).toBeNull(); + }); + }); + describe("invalid attribute parsing", () => { it("returns null for non-reactive attributes", () => { - expect(parseReactiveAttribute("lvt-click", "action")).toBeNull(); - expect(parseReactiveAttribute("lvt-submit", "save")).toBeNull(); + expect(parseReactiveAttribute("lvt-on:click", "action")).toBeNull(); + expect(parseReactiveAttribute("lvt-on:submit", "save")).toBeNull(); expect(parseReactiveAttribute("class", "foo")).toBeNull(); }); - it("returns null for unknown actions", () => { - expect(parseReactiveAttribute("lvt-foo-on:success", "")).toBeNull(); - expect(parseReactiveAttribute("lvt-hide-on:pending", "")).toBeNull(); + it("returns null for unknown methods", () => { + expect(parseReactiveAttribute("lvt-el:foo:on:success", "")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:hide:on:pending", "")).toBeNull(); }); it("returns null for unknown lifecycle events", () => { - expect(parseReactiveAttribute("lvt-reset-on:loading", "")).toBeNull(); - expect(parseReactiveAttribute("lvt-reset-on:complete", "")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:reset:on:loading", "")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:reset:on:complete", "")).toBeNull(); + }); + + it("returns null for removed actions (disable/enable)", () => { + expect(parseReactiveAttribute("lvt-el:disable:on:pending", "")).toBeNull(); + expect(parseReactiveAttribute("lvt-el:enable:on:done", "")).toBeNull(); }); }); }); @@ -143,47 +152,10 @@ describe("Reactive Attributes", () => { const div = document.createElement("div"); document.body.appendChild(div); - // Should not throw executeAction(div, "reset"); }); }); - describe("disable/enable", () => { - it("disables a button", () => { - const button = document.createElement("button"); - document.body.appendChild(button); - - executeAction(button, "disable"); - expect(button.disabled).toBe(true); - }); - - it("enables a button", () => { - const button = document.createElement("button"); - button.disabled = true; - document.body.appendChild(button); - - executeAction(button, "enable"); - expect(button.disabled).toBe(false); - }); - - it("disables an input", () => { - const input = document.createElement("input"); - document.body.appendChild(input); - - executeAction(input, "disable"); - expect(input.disabled).toBe(true); - }); - - it("enables an input", () => { - const input = document.createElement("input"); - input.disabled = true; - document.body.appendChild(input); - - executeAction(input, "enable"); - expect(input.disabled).toBe(false); - }); - }); - describe("addClass", () => { it("adds a single class", () => { const div = document.createElement("div"); @@ -369,7 +341,7 @@ describe("Reactive Attributes", () => { describe("processReactiveAttributes", () => { it("processes global success bindings", () => { const form = document.createElement("form"); - form.setAttribute("lvt-reset-on:success", ""); + form.setAttribute("lvt-el:reset:on:success", ""); const input = document.createElement("input"); input.name = "title"; input.value = "test"; @@ -383,7 +355,7 @@ describe("Reactive Attributes", () => { it("processes action-specific bindings", () => { const form = document.createElement("form"); - form.setAttribute("lvt-reset-on:create-todo:success", ""); + form.setAttribute("lvt-el:reset:on:create-todo:success", ""); const input = document.createElement("input"); input.name = "title"; input.value = "test"; @@ -400,45 +372,45 @@ describe("Reactive Attributes", () => { }); it("processes multiple bindings on same element", () => { - const button = document.createElement("button"); - button.setAttribute("lvt-disable-on:pending", ""); - button.setAttribute("lvt-addClass-on:pending", "loading"); - document.body.appendChild(button); + const div = document.createElement("div"); + div.setAttribute("lvt-el:addclass:on:pending", "loading"); + div.setAttribute("lvt-el:setattr:on:pending", "aria-busy:true"); + document.body.appendChild(div); processReactiveAttributes("pending", "save"); - expect(button.disabled).toBe(true); - expect(button.classList.contains("loading")).toBe(true); + expect(div.classList.contains("loading")).toBe(true); + expect(div.getAttribute("aria-busy")).toBe("true"); }); it("processes bindings on multiple elements", () => { const form = document.createElement("form"); - form.setAttribute("lvt-reset-on:success", ""); + form.setAttribute("lvt-el:reset:on:success", ""); const input = document.createElement("input"); input.value = "test"; form.appendChild(input); - const button = document.createElement("button"); - button.disabled = true; - button.setAttribute("lvt-enable-on:success", ""); + const div = document.createElement("div"); + div.setAttribute("lvt-el:removeclass:on:success", "loading"); + div.className = "loading"; document.body.appendChild(form); - document.body.appendChild(button); + document.body.appendChild(div); processReactiveAttributes("success", "save"); expect(input.value).toBe(""); - expect(button.disabled).toBe(false); + expect(div.classList.contains("loading")).toBe(false); }); it("ignores bindings for different lifecycles", () => { - const button = document.createElement("button"); - button.setAttribute("lvt-disable-on:pending", ""); - document.body.appendChild(button); + const div = document.createElement("div"); + div.setAttribute("lvt-el:addclass:on:pending", "loading"); + document.body.appendChild(div); processReactiveAttributes("success", "save"); - expect(button.disabled).toBe(false); + expect(div.classList.contains("loading")).toBe(false); }); }); @@ -446,10 +418,10 @@ describe("Reactive Attributes", () => { it("sets up listeners for all lifecycle events", () => { setupReactiveAttributeListeners(); - const button = document.createElement("button"); - button.setAttribute("lvt-disable-on:pending", ""); - button.setAttribute("lvt-enable-on:done", ""); - document.body.appendChild(button); + const div = document.createElement("div"); + div.setAttribute("lvt-el:addclass:on:pending", "loading"); + div.setAttribute("lvt-el:removeclass:on:done", "loading"); + document.body.appendChild(div); // Simulate pending event document.dispatchEvent( @@ -458,7 +430,7 @@ describe("Reactive Attributes", () => { bubbles: true, }) ); - expect(button.disabled).toBe(true); + expect(div.classList.contains("loading")).toBe(true); // Simulate done event document.dispatchEvent( @@ -467,17 +439,16 @@ describe("Reactive Attributes", () => { bubbles: true, }) ); - expect(button.disabled).toBe(false); + expect(div.classList.contains("loading")).toBe(false); }); it("handles events without action name", () => { setupReactiveAttributeListeners(); const div = document.createElement("div"); - div.setAttribute("lvt-addClass-on:success", "success-state"); + div.setAttribute("lvt-el:addclass:on:success", "success-state"); document.body.appendChild(div); - // Event without action in detail document.dispatchEvent( new CustomEvent("lvt:success", { detail: {}, @@ -485,7 +456,6 @@ describe("Reactive Attributes", () => { }) ); - // Global binding should still work expect(div.classList.contains("success-state")).toBe(true); }); }); diff --git a/utils/confirm.ts b/utils/confirm.ts deleted file mode 100644 index 763781a..0000000 --- a/utils/confirm.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Check if an element has lvt-confirm attribute and prompt user if needed. - * Returns true if action should proceed, false if cancelled. - */ -export function checkLvtConfirm(element: HTMLElement): boolean { - if (element.hasAttribute("lvt-confirm")) { - const confirmMessage = element.getAttribute("lvt-confirm"); - if (confirmMessage && !confirm(confirmMessage)) { - return false; // User cancelled - } - } - return true; // Proceed -} - -/** - * Extract lvt-data-* attributes from an element. - * lvt-data-id="123" becomes { id: "123" } - * lvt-data-user-name="john" becomes { "user-name": "john" } - */ -export function extractLvtData(element: HTMLElement): Record { - const data: Record = {}; - const attributes = element.attributes; - - for (let i = 0; i < attributes.length; i++) { - const attr = attributes[i]; - if (attr.name.startsWith("lvt-data-")) { - const key = attr.name.substring(9); // Remove "lvt-data-" prefix - data[key] = attr.value; - } - } - - return data; -} diff --git a/utils/lvt-selector.ts b/utils/lvt-selector.ts new file mode 100644 index 0000000..ae786a1 --- /dev/null +++ b/utils/lvt-selector.ts @@ -0,0 +1,5 @@ +/** Escapes colons in attribute names for use in CSS attribute selectors. */ +export function lvtSelector(attr: string, value?: string): string { + const escaped = attr.replace(/:/g, "\\:"); + return value !== undefined ? `[${escaped}="${value}"]` : `[${escaped}]`; +} From 042963b117773380db0d8e9b555d13337a35e271 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sat, 4 Apr 2026 08:50:08 +0530 Subject: [PATCH 2/6] fix: address bot review comments - Add data-* attribute extraction for non-form element events (fixes asymmetry where window handler extracted data-* but main handler did not after lvt-data-* removal) - Scope click-away delegation to targeted attribute selectors instead of querySelectorAll('*') for better performance on large DOMs - Restore test for data-* extraction on click events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dom/event-delegation.ts | 18 ++++++++++++++---- tests/event-delegation.test.ts | 4 ++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 72ac199..ad4a2fd 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -293,6 +293,16 @@ export class EventDelegator { this.extractButtonData(actionElement as HTMLButtonElement, message.data); } + // Extract standard data-* attributes from the action element + if (!(targetElement instanceof HTMLFormElement) && !isOrphanButton) { + Array.from(actionElement.attributes).forEach((attr) => { + if (attr.name.startsWith("data-") && attr.name !== "data-key" && attr.name !== "data-lvt-id") { + const key = attr.name.slice(5); + message.data[key] = this.context.parseValue(attr.value); + } + }); + } + if ( eventType === "submit" && targetElement instanceof HTMLFormElement @@ -542,14 +552,14 @@ export class EventDelegator { const target = e.target as Element; - // Scan all elements in the wrapper for lvt-el:*:on:click-away attributes - const allElements = currentWrapper.querySelectorAll("*"); - allElements.forEach((element) => { + // Pre-filter: only scan elements that have lvt-el: attributes with click-away + // Use attribute substring selector to avoid scanning all DOM elements + const clickAwayElements = currentWrapper.querySelectorAll("[lvt-el\\:addclass\\:on\\:click-away], [lvt-el\\:removeclass\\:on\\:click-away], [lvt-el\\:toggleclass\\:on\\:click-away], [lvt-el\\:setattr\\:on\\:click-away], [lvt-el\\:toggleattr\\:on\\:click-away], [lvt-el\\:reset\\:on\\:click-away]"); + clickAwayElements.forEach((element) => { if (element.contains(target)) return; // Click was inside, not away Array.from(element.attributes).forEach((attr) => { if (!attr.name.includes(":on:click-away")) return; - // Parse: lvt-el:{method}:on:click-away="param" const match = attr.name.match(/^lvt-el:(\w+):on:click-away$/); if (!match) return; const method = match[1].toLowerCase(); diff --git a/tests/event-delegation.test.ts b/tests/event-delegation.test.ts index ff0555c..ba3515d 100644 --- a/tests/event-delegation.test.ts +++ b/tests/event-delegation.test.ts @@ -44,7 +44,7 @@ describe("EventDelegator", () => { const wrapper = document.createElement("div"); wrapper.setAttribute("data-lvt-id", "wrapper-1"); wrapper.innerHTML = ` - + `; document.body.appendChild(wrapper); @@ -61,7 +61,7 @@ describe("EventDelegator", () => { expect(context.send).toHaveBeenCalledTimes(1); expect(context.send).toHaveBeenCalledWith({ action: "save", - data: {}, + data: { id: "42" }, }); }); From 471ea9267a582740fa362f9821e451b1c7dde414 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sat, 4 Apr 2026 09:07:45 +0530 Subject: [PATCH 3/6] fix: address round 2 bot review comments - Remove dead code: parseLvtOn() and SCOPE_KEYWORDS (unused exports) - Fix misleading docstring in reactive-attributes.ts (no legacy pattern) - Refactor click-away to reuse executeAction() from reactive-attributes - Generate click-away selectors from method map instead of hardcoding - Escape CSS attribute values in lvtSelector() to prevent injection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dom/event-delegation.ts | 67 +++++++++++--------------------------- dom/reactive-attributes.ts | 3 +- utils/lvt-selector.ts | 5 ++- 3 files changed, 24 insertions(+), 51 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index ad4a2fd..03c0e78 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -1,25 +1,18 @@ import { debounce, throttle } from "../utils/rate-limit"; import { lvtSelector } from "../utils/lvt-selector"; +import { executeAction, type ReactiveAction } from "./reactive-attributes"; import type { Logger } from "../utils/logger"; -const SCOPE_KEYWORDS = new Set(["window", "document"]); - -/** - * Parse an lvt-on:* attribute name into scope and event. - * Grammar: lvt-on[:{scope}]:{event} - * - scope: "window" | "document" | (omitted = "element") - * - event: any native DOM event name - */ -export function parseLvtOn(attr: string): { scope: string; event: string } | null { - if (!attr.startsWith("lvt-on:")) return null; - const segs = attr.slice(7).split(":"); - if (segs.length === 0 || segs[0] === "") return null; - let scope = "element"; - if (SCOPE_KEYWORDS.has(segs[0])) scope = segs.shift()!; - const event = segs.join(":"); - if (!event) return null; - return { scope, event }; -} +// Methods supported by click-away, derived from ReactiveAction values +const CLICK_AWAY_METHOD_MAP: Record = { + reset: "reset", + addclass: "addClass", + removeclass: "removeClass", + toggleclass: "toggleClass", + setattr: "setAttr", + toggleattr: "toggleAttr", +}; +const CLICK_AWAY_METHODS = Object.keys(CLICK_AWAY_METHOD_MAP); export interface EventDelegationContext { getWrapperElement(): Element | null; @@ -533,7 +526,7 @@ export class EventDelegator { /** * Sets up click-away detection for lvt-el:*:on:click-away attributes. * Instead of routing to a server action, click-away triggers client-side - * DOM manipulation (addClass, removeClass, toggleClass, etc.). + * DOM manipulation via executeAction from reactive-attributes. */ setupClickAwayDelegation(): void { const wrapperElement = this.context.getWrapperElement(); @@ -552,9 +545,10 @@ export class EventDelegator { const target = e.target as Element; - // Pre-filter: only scan elements that have lvt-el: attributes with click-away - // Use attribute substring selector to avoid scanning all DOM elements - const clickAwayElements = currentWrapper.querySelectorAll("[lvt-el\\:addclass\\:on\\:click-away], [lvt-el\\:removeclass\\:on\\:click-away], [lvt-el\\:toggleclass\\:on\\:click-away], [lvt-el\\:setattr\\:on\\:click-away], [lvt-el\\:toggleattr\\:on\\:click-away], [lvt-el\\:reset\\:on\\:click-away]"); + const clickAwaySelector = CLICK_AWAY_METHODS + .map(m => `[lvt-el\\:${m}\\:on\\:click-away]`) + .join(", "); + const clickAwayElements = currentWrapper.querySelectorAll(clickAwaySelector); clickAwayElements.forEach((element) => { if (element.contains(target)) return; // Click was inside, not away @@ -562,32 +556,9 @@ export class EventDelegator { if (!attr.name.includes(":on:click-away")) return; const match = attr.name.match(/^lvt-el:(\w+):on:click-away$/); if (!match) return; - const method = match[1].toLowerCase(); - const param = attr.value; - - switch (method) { - case "addclass": - if (param) element.classList.add(...param.split(/\s+/).filter(Boolean)); - break; - case "removeclass": - if (param) element.classList.remove(...param.split(/\s+/).filter(Boolean)); - break; - case "toggleclass": - if (param) param.split(/\s+/).filter(Boolean).forEach(c => element.classList.toggle(c)); - break; - case "setattr": - if (param) { - const ci = param.indexOf(":"); - if (ci > 0) element.setAttribute(param.substring(0, ci), param.substring(ci + 1)); - } - break; - case "toggleattr": - if (param) element.toggleAttribute(param); - break; - case "reset": - if (element instanceof HTMLFormElement) element.reset(); - break; - } + const method = CLICK_AWAY_METHOD_MAP[match[1].toLowerCase()]; + if (!method) return; + executeAction(element, method, attr.value); }); }); }; diff --git a/dom/reactive-attributes.ts b/dom/reactive-attributes.ts index 0b54ef6..ad1f9c0 100644 --- a/dom/reactive-attributes.ts +++ b/dom/reactive-attributes.ts @@ -58,8 +58,7 @@ const METHOD_MAP: Record = { /** * Parse a reactive attribute name and value into a binding. * - * New pattern: lvt-el:{method}:on:[{action}:]{state} - * Also supports legacy: lvt-{method}-on:[{action}:]{state} + * Supported pattern: lvt-el:{method}:on:[{action}:]{state} * * Examples: * parseReactiveAttribute("lvt-el:reset:on:success", "") => { action: "reset", lifecycle: "success" } diff --git a/utils/lvt-selector.ts b/utils/lvt-selector.ts index ae786a1..db87602 100644 --- a/utils/lvt-selector.ts +++ b/utils/lvt-selector.ts @@ -1,5 +1,8 @@ /** Escapes colons in attribute names for use in CSS attribute selectors. */ export function lvtSelector(attr: string, value?: string): string { const escaped = attr.replace(/:/g, "\\:"); - return value !== undefined ? `[${escaped}="${value}"]` : `[${escaped}]`; + if (value === undefined) return `[${escaped}]`; + // Escape backslashes and double-quotes in the value to prevent CSS selector injection + const safeValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `[${escaped}="${safeValue}"]`; } From 07f01ff410884bf915094ac6c5dde4d38c5fb613 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sat, 4 Apr 2026 09:13:34 +0530 Subject: [PATCH 4/6] fix: address round 3 Claude review comments - Replace querySelectorAll('*') in processReactiveAttributes with targeted attribute selectors built from known method names and lifecycle events - Use lvtSelector() in click-away delegation for consistent colon escaping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dom/event-delegation.ts | 2 +- dom/reactive-attributes.ts | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 03c0e78..8406887 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -546,7 +546,7 @@ export class EventDelegator { const target = e.target as Element; const clickAwaySelector = CLICK_AWAY_METHODS - .map(m => `[lvt-el\\:${m}\\:on\\:click-away]`) + .map(m => lvtSelector(`lvt-el:${m}:on:click-away`)) .join(", "); const clickAwayElements = currentWrapper.querySelectorAll(clickAwaySelector); clickAwayElements.forEach((element) => { diff --git a/dom/reactive-attributes.ts b/dom/reactive-attributes.ts index ad1f9c0..1c2c3f1 100644 --- a/dom/reactive-attributes.ts +++ b/dom/reactive-attributes.ts @@ -175,11 +175,32 @@ export function processReactiveAttributes( lifecycle: LifecycleEvent, actionName?: string ): void { - const allElements = document.querySelectorAll("*"); + // Target only elements with lvt-el: attributes instead of scanning all DOM elements. + // CSS doesn't support attribute-name-starts-with, so we build selectors from known + // method prefixes. This covers both unscoped (lvt-el:reset:on:success) and + // action-scoped (lvt-el:reset:on:create-todo:success) patterns. + const methodKeys = Object.keys(METHOD_MAP); + const selectorParts: string[] = []; + for (const m of methodKeys) { + // Match the unscoped pattern: lvt-el:{method}:on:{lifecycle} + selectorParts.push(`[lvt-el\\:${m}\\:on\\:${lifecycle}]`); + // For action-scoped patterns, we can't predict action names in CSS, + // so we fall back to a broader match if actionName is provided. + if (actionName) { + selectorParts.push(`[lvt-el\\:${m}\\:on\\:${actionName.replace(/:/g, "\\:")}\\:${lifecycle}]`); + } + } + const selector = selectorParts.join(", "); + + let candidates: NodeListOf; + try { + candidates = document.querySelectorAll(selector); + } catch { + candidates = document.querySelectorAll("*"); + } - allElements.forEach((element) => { + candidates.forEach((element) => { Array.from(element.attributes).forEach((attr) => { - // Quick filter: only process lvt-el:*:on:* attributes if (!attr.name.startsWith("lvt-el:") || !attr.name.includes(":on:")) { return; } From b8a4c2d9e156213dd7f92d2427d6f6785b55c32a Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sat, 4 Apr 2026 09:17:14 +0530 Subject: [PATCH 5/6] fix: validate ScrollBehavior and add data-* exclusion comments - Validate --lvt-scroll-behavior CSS property against known values (auto, smooth, instant) instead of raw cast - Add clarifying comments for data-key/data-lvt-id exclusion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dom/directives.ts | 8 +++++--- dom/event-delegation.ts | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index 10a8911..0fcb57a 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -11,9 +11,11 @@ export function handleScrollDirectives(rootElement: Element): void { const htmlElement = element as HTMLElement; const mode = htmlElement.getAttribute("lvt-fx:scroll"); const computed = getComputedStyle(htmlElement); - const behavior = - (computed.getPropertyValue("--lvt-scroll-behavior").trim() as ScrollBehavior) || - "auto"; + const rawBehavior = computed.getPropertyValue("--lvt-scroll-behavior").trim(); + const VALID_SCROLL_BEHAVIORS = new Set(["auto", "smooth", "instant"]); + const behavior: ScrollBehavior = VALID_SCROLL_BEHAVIORS.has(rawBehavior) + ? (rawBehavior as ScrollBehavior) + : "auto"; const threshold = parseInt( computed.getPropertyValue("--lvt-scroll-threshold").trim() || "100", 10 diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 8406887..a1d145e 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -286,7 +286,9 @@ export class EventDelegator { this.extractButtonData(actionElement as HTMLButtonElement, message.data); } - // Extract standard data-* attributes from the action element + // Extract standard data-* attributes from the action element. + // Exclude data-key (list reconciliation) and data-lvt-id (internal framework ID) + // since these are LiveTemplate internals, not user-provided action data. if (!(targetElement instanceof HTMLFormElement) && !isOrphanButton) { Array.from(actionElement.attributes).forEach((attr) => { if (attr.name.startsWith("data-") && attr.name !== "data-key" && attr.name !== "data-lvt-id") { From 2f1c7404ac6a42898cd12e0600878a1239af99b3 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sat, 4 Apr 2026 09:23:14 +0530 Subject: [PATCH 6/6] fix: address round 4 bot review comments - Fix lvt-form:no-intercept on links: use generic lvt-no-intercept for link interceptor since form: prefix is semantically wrong for tags - Properly escape CSS-special chars in actionName for attribute selectors and avoid querySelectorAll('*') fallback on invalid selectors - Hoist VALID_SCROLL_BEHAVIORS Set to module level to avoid re-allocation - Validate ScrollBehavior against known values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dom/directives.ts | 3 ++- dom/link-interceptor.ts | 4 ++-- dom/reactive-attributes.ts | 22 ++++++++++++++++------ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/dom/directives.ts b/dom/directives.ts index 0fcb57a..1a89564 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -4,6 +4,8 @@ * --lvt-scroll-behavior: auto | smooth (default: auto) * --lvt-scroll-threshold: (default: 100) */ +const VALID_SCROLL_BEHAVIORS = new Set(["auto", "smooth", "instant"]); + export function handleScrollDirectives(rootElement: Element): void { const scrollElements = rootElement.querySelectorAll("[lvt-fx\\:scroll]"); @@ -12,7 +14,6 @@ export function handleScrollDirectives(rootElement: Element): void { const mode = htmlElement.getAttribute("lvt-fx:scroll"); const computed = getComputedStyle(htmlElement); const rawBehavior = computed.getPropertyValue("--lvt-scroll-behavior").trim(); - const VALID_SCROLL_BEHAVIORS = new Set(["auto", "smooth", "instant"]); const behavior: ScrollBehavior = VALID_SCROLL_BEHAVIORS.has(rawBehavior) ? (rawBehavior as ScrollBehavior) : "auto"; diff --git a/dom/link-interceptor.ts b/dom/link-interceptor.ts index 98c265b..1573fc8 100644 --- a/dom/link-interceptor.ts +++ b/dom/link-interceptor.ts @@ -58,8 +58,8 @@ export class LinkInterceptor { if (link.target && link.target !== "_self") return true; // Download links if (link.hasAttribute("download")) return true; - // Opt-out - if (link.hasAttribute("lvt-form:no-intercept")) return true; + // Opt-out (lvt-no-intercept is the generic opt-out for both forms and links) + if (link.hasAttribute("lvt-no-intercept")) return true; // Hash-only links (scroll anchors) if (link.pathname === window.location.pathname && link.hash) return true; // mailto/tel/javascript diff --git a/dom/reactive-attributes.ts b/dom/reactive-attributes.ts index 1c2c3f1..3b504ae 100644 --- a/dom/reactive-attributes.ts +++ b/dom/reactive-attributes.ts @@ -181,13 +181,16 @@ export function processReactiveAttributes( // action-scoped (lvt-el:reset:on:create-todo:success) patterns. const methodKeys = Object.keys(METHOD_MAP); const selectorParts: string[] = []; + + // Escape CSS-special characters in actionName for use in attribute selectors + const escapedAction = actionName + ? actionName.replace(/([^\w-])/g, "\\$1") + : undefined; + for (const m of methodKeys) { - // Match the unscoped pattern: lvt-el:{method}:on:{lifecycle} selectorParts.push(`[lvt-el\\:${m}\\:on\\:${lifecycle}]`); - // For action-scoped patterns, we can't predict action names in CSS, - // so we fall back to a broader match if actionName is provided. - if (actionName) { - selectorParts.push(`[lvt-el\\:${m}\\:on\\:${actionName.replace(/:/g, "\\:")}\\:${lifecycle}]`); + if (escapedAction) { + selectorParts.push(`[lvt-el\\:${m}\\:on\\:${escapedAction}\\:${lifecycle}]`); } } const selector = selectorParts.join(", "); @@ -196,7 +199,14 @@ export function processReactiveAttributes( try { candidates = document.querySelectorAll(selector); } catch { - candidates = document.querySelectorAll("*"); + // If selector is still invalid despite escaping, scan targeted elements only + // by matching unscoped patterns (without actionName) + const fallbackParts = methodKeys.map(m => `[lvt-el\\:${m}\\:on\\:${lifecycle}]`); + try { + candidates = document.querySelectorAll(fallbackParts.join(", ")); + } catch { + return; // Cannot construct any valid selector + } } candidates.forEach((element) => {