diff --git a/dom/directives.ts b/dom/directives.ts index d945370..1a89564 100644 --- a/dom/directives.ts +++ b/dom/directives.ts @@ -1,17 +1,24 @@ /** - * 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) */ +const VALID_SCROLL_BEHAVIORS = new Set(["auto", "smooth", "instant"]); + 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 behavior = - (htmlElement.getAttribute("lvt-scroll-behavior") as ScrollBehavior) || - "auto"; + const mode = htmlElement.getAttribute("lvt-fx:scroll"); + const computed = getComputedStyle(htmlElement); + const rawBehavior = computed.getPropertyValue("--lvt-scroll-behavior").trim(); + const behavior: ScrollBehavior = VALID_SCROLL_BEHAVIORS.has(rawBehavior) + ? (rawBehavior as ScrollBehavior) + : "auto"; const threshold = parseInt( - htmlElement.getAttribute("lvt-scroll-threshold") || "100", + computed.getPropertyValue("--lvt-scroll-threshold").trim() || "100", 10 ); @@ -51,24 +58,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 +101,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 +136,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..a1d145e 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -1,7 +1,19 @@ import { debounce, throttle } from "../utils/rate-limit"; -import { checkLvtConfirm } from "../utils/confirm"; +import { lvtSelector } from "../utils/lvt-selector"; +import { executeAction, type ReactiveAction } from "./reactive-attributes"; import type { Logger } from "../utils/logger"; +// 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; getRateLimitedHandlers(): WeakMap>; @@ -12,8 +24,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 +112,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 +132,7 @@ export class EventDelegator { // Orphan button detection (Tier 1: formless standalone buttons). // A + `; document.body.appendChild(wrapper); @@ -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..db87602 --- /dev/null +++ b/utils/lvt-selector.ts @@ -0,0 +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, "\\:"); + 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}"]`; +}