diff --git a/CHANGELOG.md b/CHANGELOG.md index 527b2bd..a31e186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to @livetemplate/client will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- feat: `data-lvt-target` attribute for cross-element targeting — `lvt-el:` methods can now operate on a different element via `#id` or `closest:selector` + ## [v0.8.18] - 2026-04-05 ### Changes diff --git a/dom/event-delegation.ts b/dom/event-delegation.ts index 16311c2..b0414b9 100644 --- a/dom/event-delegation.ts +++ b/dom/event-delegation.ts @@ -1,6 +1,6 @@ import { debounce, throttle } from "../utils/rate-limit"; import { lvtSelector } from "../utils/lvt-selector"; -import { executeAction, processElementInteraction, isDOMEventTrigger, type ReactiveAction } from "./reactive-attributes"; +import { executeAction, resolveTarget, processElementInteraction, isDOMEventTrigger, type ReactiveAction } from "./reactive-attributes"; import type { Logger } from "../utils/logger"; // Methods supported by click-away, derived from ReactiveAction values @@ -575,7 +575,7 @@ export class EventDelegator { if (!match) return; const method = CLICK_AWAY_METHOD_MAP[match[1].toLowerCase()]; if (!method) return; - executeAction(element, method, attr.value); + executeAction(resolveTarget(element), method, attr.value); }); }); }; diff --git a/dom/reactive-attributes.ts b/dom/reactive-attributes.ts index 565bc37..9b8a8cc 100644 --- a/dom/reactive-attributes.ts +++ b/dom/reactive-attributes.ts @@ -110,6 +110,25 @@ export function parseReactiveAttribute( return null; } +/** + * Resolve the target element for an lvt-el: action. + * If the element has data-lvt-target, resolves to the specified element: + * - "#id" → document.getElementById(id) + * - "closest:sel" → element.closest(sel) + * Falls back to the element itself if no target or target not found. + */ +export function resolveTarget(element: Element): Element { + const selector = element.getAttribute("data-lvt-target"); + if (!selector) return element; + if (selector.startsWith("#")) { + return document.getElementById(selector.slice(1)) || element; + } + if (selector.startsWith("closest:")) { + return element.closest(selector.slice(8)) || element; + } + return element; +} + /** * Execute a reactive action on an element. */ @@ -227,7 +246,7 @@ export function processReactiveAttributes( const binding = parseReactiveAttribute(attr.name, attr.value); if (binding && matchesEvent(binding, lifecycle, actionName)) { - executeAction(element, binding.action, binding.param); + executeAction(resolveTarget(element), binding.action, binding.param); } }); }); @@ -246,7 +265,7 @@ export function processElementInteraction(element: Element, trigger: string): vo const action = METHOD_MAP[methodKey]; if (!action) continue; - executeAction(element, action, attr.value); + executeAction(resolveTarget(element), action, attr.value); } } diff --git a/tests/reactive-attributes.test.ts b/tests/reactive-attributes.test.ts index e81bd27..e31e206 100644 --- a/tests/reactive-attributes.test.ts +++ b/tests/reactive-attributes.test.ts @@ -1,6 +1,7 @@ import { parseReactiveAttribute, executeAction, + resolveTarget, matchesEvent, processReactiveAttributes, setupReactiveAttributeListeners, @@ -546,4 +547,92 @@ describe("Reactive Attributes", () => { expect(div.classList.contains("visible")).toBe(false); }); }); + + describe("data-lvt-target", () => { + it("resolves #id to document.getElementById", () => { + const target = document.createElement("div"); + target.id = "my-target"; + document.body.appendChild(target); + + const button = document.createElement("button"); + button.setAttribute("data-lvt-target", "#my-target"); + document.body.appendChild(button); + + expect(resolveTarget(button)).toBe(target); + + target.remove(); + button.remove(); + }); + + it("resolves closest:selector to element.closest", () => { + const parent = document.createElement("div"); + parent.setAttribute("data-dropdown", "test"); + const button = document.createElement("button"); + button.setAttribute("data-lvt-target", "closest:[data-dropdown]"); + parent.appendChild(button); + document.body.appendChild(parent); + + expect(resolveTarget(button)).toBe(parent); + + parent.remove(); + }); + + it("falls back to self when no data-lvt-target", () => { + const button = document.createElement("button"); + document.body.appendChild(button); + + expect(resolveTarget(button)).toBe(button); + + button.remove(); + }); + + it("falls back to self when target not found", () => { + const button = document.createElement("button"); + button.setAttribute("data-lvt-target", "#nonexistent"); + document.body.appendChild(button); + + expect(resolveTarget(button)).toBe(button); + + button.remove(); + }); + + it("processElementInteraction targets resolved element", () => { + const target = document.createElement("div"); + target.id = "modal"; + target.setAttribute("hidden", ""); + document.body.appendChild(target); + + const button = document.createElement("button"); + button.setAttribute("lvt-el:toggleAttr:on:click", "hidden"); + button.setAttribute("data-lvt-target", "#modal"); + document.body.appendChild(button); + + processElementInteraction(button, "click"); + expect(target.hasAttribute("hidden")).toBe(false); + + processElementInteraction(button, "click"); + expect(target.hasAttribute("hidden")).toBe(true); + + target.remove(); + button.remove(); + }); + + it("toggleClass on closest ancestor", () => { + const parent = document.createElement("div"); + parent.setAttribute("data-dropdown", "test"); + const button = document.createElement("button"); + button.setAttribute("lvt-el:toggleClass:on:click", "open"); + button.setAttribute("data-lvt-target", "closest:[data-dropdown]"); + parent.appendChild(button); + document.body.appendChild(parent); + + processElementInteraction(button, "click"); + expect(parent.classList.contains("open")).toBe(true); + + processElementInteraction(button, "click"); + expect(parent.classList.contains("open")).toBe(false); + + parent.remove(); + }); + }); });