From 4fcd3b75793489a8655347bfa1d8dd61dd43d9d2 Mon Sep 17 00:00:00 2001 From: Adnaan Date: Sun, 5 Apr 2026 16:16:16 +0530 Subject: [PATCH] feat: add data-lvt-target for cross-element targeting in lvt-el: methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a general targeting mechanism so lvt-el: methods can operate on a different element instead of self: - data-lvt-target="#id" → targets document.getElementById(id) - data-lvt-target="closest:selector" → targets element.closest(selector) - Falls back to self if no target or target not found This enables: - Modal open/close: lvt-el:toggleAttr:on:click="hidden" data-lvt-target="#modal-id" - Dropdown toggle: lvt-el:toggleClass:on:click="open" data-lvt-target="closest:[data-dropdown]" Replaces the need for inline onclick handlers and the unsupported command/commandfor HTML Invoker Commands API. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 +++ dom/event-delegation.ts | 4 +- dom/reactive-attributes.ts | 23 +++++++- tests/reactive-attributes.test.ts | 89 +++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 4 deletions(-) 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(); + }); + }); });