Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +8 to 14
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change introduces a second ## [Unreleased] section at the top of the file while an existing ## [Unreleased] section already exists further down. Please merge these into a single Unreleased section to avoid duplicated headings and keep changelog tooling/readers consistent.

Copilot uses AI. Check for mistakes.

### Changes
Expand Down
4 changes: 2 additions & 2 deletions dom/event-delegation.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
});
});
};
Expand Down
23 changes: 21 additions & 2 deletions dom/reactive-attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +116 to +128
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveTarget() calls element.closest(...) with unvalidated selector text. If data-lvt-target is closest: (empty) or contains an invalid selector, closest() throws a SyntaxError and will break event handling. Consider trimming the attribute value, validating the selector part is non-empty, and wrapping the closest() call in a try/catch (falling back to element on error). Also consider using element.ownerDocument.getElementById(...) instead of the global document for better compatibility in iframes/tests.

Suggested change
* - "#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;
}
* - "#id" element.ownerDocument.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 target = element.getAttribute("data-lvt-target")?.trim();
if (!target) return element;
if (target.startsWith("#")) {
const id = target.slice(1).trim();
if (!id) return element;
return element.ownerDocument.getElementById(id) || element;
}
if (target.startsWith("closest:")) {
const closestSelector = target.slice(8).trim();
if (!closestSelector) return element;
try {
return element.closest(closestSelector) || element;
} catch {
return element;
}
}

Copilot uses AI. Check for mistakes.
return element;
}

/**
* Execute a reactive action on an element.
*/
Expand Down Expand Up @@ -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);
}
});
});
Expand All @@ -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);
}
}

Expand Down
89 changes: 89 additions & 0 deletions tests/reactive-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
parseReactiveAttribute,
executeAction,
resolveTarget,
matchesEvent,
processReactiveAttributes,
setupReactiveAttributeListeners,
Expand Down Expand Up @@ -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();
});
});
});
Loading