Conversation
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>
|
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: 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. |
There was a problem hiding this comment.
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 usingdata-lvt-target. - Updates lifecycle/interaction processing and click-away execution to run actions against the resolved target.
- Adds unit + integration tests covering
data-lvt-targetbehaviors 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.
| * - "#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; | ||
| } |
There was a problem hiding this comment.
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.
| * - "#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; | |
| } | |
| } |
| 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); |
There was a problem hiding this comment.
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).
| ## [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 |
There was a problem hiding this comment.
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.
Summary
data-lvt-targetattribute that redirects anylvt-el:method to a different element#id(by ID) andclosest:selector(walk up ancestors)command/commandforAPIExamples
Test plan
🤖 Generated with Claude Code