Skip to content

feat: add data-lvt-target for cross-element targeting#53

Merged
adnaan merged 1 commit intomainfrom
el-target
Apr 5, 2026
Merged

feat: add data-lvt-target for cross-element targeting#53
adnaan merged 1 commit intomainfrom
el-target

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Apr 5, 2026

Summary

  • Adds data-lvt-target attribute that redirects any lvt-el: method to a different element
  • Supports #id (by ID) and closest:selector (walk up ancestors)
  • Falls back to self when no target attribute or target not found
  • Enables modal open/close and dropdown toggles without inline JS or the unsupported command/commandfor API

Examples

<!-- Modal toggle: toggleAttr hidden on #add-modal -->
<button lvt-el:toggleAttr:on:click="hidden" data-lvt-target="#add-modal">Open</button>

<!-- Dropdown toggle: toggleClass on closest ancestor -->
<button lvt-el:toggleClass:on:click="open" data-lvt-target="closest:[data-dropdown]">Toggle</button>

Test plan

  • Unit tests for resolveTarget with #id, closest:, no target, invalid target
  • Integration test: processElementInteraction with data-lvt-target toggles attribute on remote element
  • Integration test: toggleClass on closest ancestor
  • CI passes

🤖 Generated with Claude Code

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) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 5, 2026 10:46
@claude
Copy link
Copy Markdown

claude bot commented Apr 5, 2026

Overall: Clean implementation, well-tested. One behavioral bug in the click-away case worth addressing before merge.

BUG: click-away boundary uses source element, not target (dom/event-delegation.ts, click-away handler)

The containment check (element.contains(target)) still uses the source element, but the action runs on resolveTarget(element). Example: with a button that has data-lvt-target='modal' and lvt-el:removeAttr:on:click-away='hidden', click-away fires when clicking outside the small button, not outside the modal. So any click except on the button itself immediately closes the modal.

Fix - use the resolved target for the containment check too:
const actionTarget = resolveTarget(element);
if (actionTarget.contains(target)) return;
executeAction(actionTarget, method, attr.value);

MISSING TEST: No coverage for click-away + data-lvt-target combination. Worth adding once the bug above is fixed, since this is the easiest path to regressing it.

resolveTarget itself looks good - correct fallback behavior, no injection surface, closest: slice offset is right.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds data-lvt-target support so lvt-el:* actions can be redirected to operate on a different DOM element (via #id or closest:selector), enabling cross-element toggles without inline JS.

Changes:

  • Introduces resolveTarget() to map an action’s source element to an effective target element using data-lvt-target.
  • Updates lifecycle/interaction processing and click-away execution to run actions against the resolved target.
  • Adds unit + integration tests covering data-lvt-target behaviors and updates the changelog.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
dom/reactive-attributes.ts Adds resolveTarget() and applies it in lifecycle + interaction execution paths.
dom/event-delegation.ts Applies resolveTarget() to click-away action execution.
tests/reactive-attributes.test.ts Adds tests for target resolution and cross-element action behavior.
CHANGELOG.md Adds an Unreleased entry describing data-lvt-target.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +116 to +128
* - "#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;
}
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.
Comment on lines 569 to +578
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;
const match = attr.name.match(/^lvt-el:(\w+):on:click-away$/);
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);
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.

With data-lvt-target, click-away now applies the action to resolveTarget(element), but the click-away containment check still uses the source element.contains(target). This can lead to surprising behavior when the action target differs from the source (e.g., click inside the resolved target but outside the source still counts as “away”). Consider performing the containment check against the resolved target (or otherwise documenting/encoding which element defines the click-away boundary).

Copilot uses AI. Check for mistakes.
Comment on lines +8 to 14
## [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
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.
@adnaan adnaan merged commit 89aa203 into main Apr 5, 2026
11 checks passed
@adnaan adnaan deleted the el-target branch April 5, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants