Conversation
Adds six HTML5 drag events to the lvt-on:* delegation surface:
dragstart, dragover, drop, dragend, dragenter, dragleave.
The two non-trivial behaviors:
- dragstart auto-stashes the dragged element's nearest data-key into
event.dataTransfer (under "application/x-lvt-key" plus "text/plain"
for older Firefox compat) and sets effectAllowed = "move".
- drop reads that key back as data.dragSourceKey, and pulls the drop
target's nearest data-key as data.dragTargetKey, so a single drop
action message carries everything a sortable controller needs:
{action:"reorder", data:{dragSourceKey, dragTargetKey}}
dragover and drop also call preventDefault unconditionally — required
by the HTML5 spec for drop to fire at all. preventDefault runs in a
synchronous side-effect block before any throttle/debounce gate, so
lvt-mod:throttle on dragover suppresses only the action send, never
the spec-mandated preventDefault.
dragend/dragenter/dragleave forward as plain action messages with no
drag-specific payload — left to the controller to use for visual
feedback if desired.
The wire reorder protocol is unchanged: server controllers mutate the
state slice and the diff engine auto-emits ["o", newKeys].
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a follow-up to dc040cd: when a drag binding has an empty action value (e.g. lvt-on:dragover=""), the spec-mandated side-effects in Layer A still run — preventDefault, dataTransfer.setData — but the WS round-trip is skipped. This unlocks the marker pattern for the common case where a sortable list only needs to react on drop: <li data-key="..." draggable="true" lvt-on:dragstart="" <!-- stash key in DataTransfer --> lvt-on:dragover="" <!-- preventDefault so drop fires --> lvt-on:drop="reorder"> <!-- the only meaningful action --> Without the gate, the example pays for one (no-op) Reorder call per dragstart and per throttled dragover tick. With the gate, it pays for exactly one Reorder call per drop. Net effect on the sortable e2e test: ~40% faster. Adds two Jest tests covering dragover and dragstart empty-action paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review pass: shrink the LVT_DRAG_MIME and DRAG_EVENTS preambles, collapse the 10-line Layer A rationale into a 6-line summary that preserves only the load-bearing WHY (preventDefault must run pre-tick or drop is silently dropped), and drop the redundant Layer B prose that was restating field names already visible two lines below. No behavior change. 46 tests still pass; minified bundle byte-identical. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review: drag-and-drop event delegationOverall the implementation is solid — the side-effect-before-throttle pattern is correct and well-explained, and the test coverage is thorough. Bug: // dom/event-delegation.ts ~line 394
message.data.dragTargetKey = tgtEl.getAttribute("data-key");
const key = tgtEl.getAttribute("data-key");
if (key) message.data.dragTargetKey = key;Security: external drag sources can inject arbitrary The const src = dropDt.getData(LVT_DRAG_MIME) || dropDt.getData("text/plain");If a user drags text from outside the app (another tab, a text editor, etc.), Minor: These events bubble through child elements, so a drop target with child nodes will see spurious Minor: empty-action early return applies to all drag events, not just The comment only mentions |
There was a problem hiding this comment.
Pull request overview
Adds HTML5 drag-and-drop support to the lvt-on:* event delegation surface, including drag-specific side effects (DataTransfer key stashing, preventDefault, and effect/dropEffect settings) and drop payload augmentation for sortable/reorder use cases.
Changes:
- Register six drag event types (
dragstart,dragover,drop,dragend,dragenter,dragleave) in the delegated event listener set. - Implement drag side-effects (DataTransfer serialization + spec-required
preventDefault) and a marker pattern (lvt-on:drag*="") to opt into side-effects without sending WS messages. - Add Jest coverage for drag serialization, throttling interaction, marker pattern behavior, and end-to-end payload shape; update README feature list.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
dom/event-delegation.ts |
Adds delegated drag event types, drag side-effects, marker pattern early-return, and drop payload injection for source/target keys. |
tests/event-delegation.test.ts |
Introduces comprehensive drag-and-drop delegation tests with a DataTransfer mock and throttling assertions. |
README.md |
Documents drag-and-drop support in the Event Delegation feature bullet. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const tgtEl = actionElement.closest("[data-key]"); | ||
| if (tgtEl) { | ||
| message.data.dragTargetKey = tgtEl.getAttribute("data-key"); |
There was a problem hiding this comment.
In the drop payload injection, dragTargetKey is currently only derived when dataTransfer is present because the target lookup is inside if (dropDt). This means a drop event with a missing/null dataTransfer (possible in some environments, and matches the test file’s note about JSDOM) would omit dragTargetKey even though it can be derived from the DOM alone. Consider moving the closest('[data-key]') / getAttribute('data-key') block outside the dropDt conditional, and only conditionally injecting dragSourceKey based on dataTransfer contents (also avoid assigning a potential null attribute value).
| const tgtEl = actionElement.closest("[data-key]"); | |
| if (tgtEl) { | |
| message.data.dragTargetKey = tgtEl.getAttribute("data-key"); | |
| } | |
| const tgtEl = actionElement.closest("[data-key]"); | |
| if (tgtEl) { | |
| const tgt = tgtEl.getAttribute("data-key"); | |
| if (tgt !== null) { | |
| message.data.dragTargetKey = tgt; |
- dragTargetKey: use optional-chain + truthy guard, mirroring how
dragSourceKey is handled. tgtEl.getAttribute("data-key") returns
string | null per the type signature, so the prior assignment could
technically write null into the action data.
- text/plain fallback: add an inline comment that cross-app drags can
inject arbitrary dragSourceKey text. Server-side validation is the
only safe boundary; documented in README too.
- Marker pattern: clarify the comment that the empty-action gate
applies to ALL drag events (lvt-on:drop="" works the same as
lvt-on:dragover=""), not just dragover.
- README: document (a) cross-app drag trust boundary, (b) dragenter /
dragleave bubble through child elements (so highlight-on-hover
handlers see spurious pairs), (c) empty-action marker pattern.
No behavior change beyond the null guard. 46 Jest tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review: feat/drag-and-drop event supportOverall the implementation is solid — the Layer-A side-effects-before-throttle design is correct, the Bug: test DOM pollution causes false positivesEvery drag test appends elements to Security:
|
Round-2 review feedback from Claude bot and Copilot on PR #106: - Drop the text/plain fallback on the READ side. Cross-app drags (where text/plain may carry arbitrary external text) now produce no dragSourceKey at all — controllers don't have to parse untrusted input. dragstart still SETS text/plain so older Firefox can start the drag, but drop only TRUSTS the LVT MIME. - Move the dragTargetKey lookup outside the `if (dropDt)` block so it works even when DataTransfer is missing (some embedded/synthetic environments). Added a regression test. - Update the test that previously asserted the text/plain fallback — invert it to assert dragSourceKey is NOT injected from text/plain. - README: document (a) the same-app-only dragSourceKey trust boundary in clearer terms, (b) the recommendation to throttle dragover at ~100ms, (c) the v1 limitation that effectAllowed/dropEffect are hardcoded to "move". Skipped Claude bot's "test DOM pollution" item: the outer afterEach already does `document.body.innerHTML = ""` between tests, so shared inner IDs don't actually leak. All 44 pre-existing tests in the file use the same pattern. 47 Jest tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review: Drag-and-Drop Event DelegationGood implementation overall — the MIME-based source verification (LVT vs. text/plain) is a solid security boundary, and running side-effects before the throttle gate is the right call for 1.
|
Round-3 review feedback from Claude bot on PR #106: - Stop setting text/plain on dragstart entirely. The previous compat layer was speculative (Firefox >= 60 — our es2018 target — handles custom MIME types fine), and writing the data-key into text/plain meant any external drop (URL bar, text editor, another app) would receive the internal key as a paste payload. Drop side has already ignored text/plain since round 2; this closes the loop on the source side. Tests updated to assert text/plain is NOT set. - Filter dragenter/dragleave through relatedTarget: skip the event when the pointer is just crossing into / out of a descendant of the same actionElement. This eliminates the spec's bubble-model noise that would otherwise fire highlight handlers ~5x per real boundary crossing. Two new tests cover the noise-suppression case and the genuine-boundary-crossing case. - README: update the trust-boundary note to reflect that the key never lives in text/plain at all, and document the new relatedTarget filter. Skipped Claude bot's "warn on unthrottled dragover" suggestion: adding runtime warnings is feature creep; the README guidance is the right boundary for v1. 49 Jest tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review: HTML5 Drag-and-Drop SupportOverall this is well-implemented. The security design (custom MIME only, never One behavioral edge case to verify
The check However, Silent ambiguity on drop: no-key dragstart vs. cross-app dragWhen READMEThe feature bullet is a wall of text with 5 inline caveats. Consider a short bullet + a separate doc section or Verdict: LGTM — no blockers. |
Round-4 readability nit: the Event Delegation feature bullet had become a wall of text with five inline caveats. Move them to a 'Drag-and-drop notes' subsection under API/Events, leaving a short feature bullet that links to it. Skipped two other round-4 nits: - Edge-case comment on relatedTarget self-equality: the bot calls it "probably harmless in practice" — adding a 1-line comment for an edge case that doesn't occur in real DnD is bikeshedding. - Distinguishing no-key dragstart from cross-app drag on the server: both produce the same payload (no dragSourceKey) by design — the server is meant to react identically (no-op + return state). It's not a v1 limitation, it's the intended trust model. This is the convergence signal per docs/claude-md guidance: round 4 flagged only style/phrasing, no new functional issues. Stopping here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Round-4 review comments triaged: ✅ Applied: README readability — the inline 5-caveat bullet is now a dedicated "Drag-and-drop notes" subsection (commit ❌ Declined with reason:
Treating round 4 as the convergence signal per project CLAUDE.md (round 3 had two functional fixes — text/plain write removal + dragenter |
Drag-and-drop reviewOverall this is solid work. The MIME-only trust boundary, side-effects-before-throttle ordering, and marker pattern are all correct. Test coverage is thorough. Two things worth addressing: Bug: empty dragSourceKey silently treated as a cross-app dragIn const key = keyEl?.getAttribute("data-key") ?? "";
dragEvent.dataTransfer.setData(LVT_DRAG_MIME, key); // sets MIME -> ""On drop, const src = (e as DragEvent).dataTransfer?.getData(LVT_DRAG_MIME);
if (src) { // false for ""
message.data.dragSourceKey = src;
}A same-app drag from an unkeyed element is now indistinguishable from a cross-app drag at the server. The test "dragstart with no enclosing data-key sends empty key without throwing" verifies no crash and that dragenter suppression also fires on "entering from a child"The containment check: if (related && actionElement.contains(related)) return;For MinorREADME docs are clear and the "v1 limitation" callout for |
|
Round-5 review essentially restates round-4 nits:
This is the convergence signal per project CLAUDE.md: rounds 1–3 produced functional feedback (all addressed); rounds 4–5 produced only style/phrasing on content already accepted. Stopping the bot loop here. |
Summary
Closes #101.
Adds six HTML5 drag events to the
lvt-on:*delegation surface —dragstart,dragover,drop,dragend,dragenter,dragleave— with smart source/target serialization built on the nativeDataTransferAPI.dragstartauto-stashes the dragged element's nearestdata-keyintodataTransfer(application/x-lvt-keyMIME, plustext/plainfor older Firefox compatibility) and setseffectAllowed = "move".dropreads that key back asdata.dragSourceKeyand lifts the drop target's nearestdata-keyasdata.dragTargetKey. The result is a single drop action message that carries everything a sortable controller needs:{"action": "reorder", "data": {"dragSourceKey": "task-3", "dragTargetKey": "task-1"}}dragoveranddropcallpreventDefault()unconditionally — required by the HTML5 spec fordropto fire at all. The side-effects run synchronously in a Layer-A block before any throttle gate, solvt-mod:throttleondragoveronly suppresses the WS send, never the spec-mandatedpreventDefault.lvt-on:dragover=""(orlvt-on:dragstart="") opts an element into the side-effects without paying for a WS round-trip — useful when only the drop event needs server-side handling.dragend/dragenter/dragleaveforward as plain action messages (no drag-specific payload) for visual-feedback use cases.The wire reorder protocol is unchanged: server controllers mutate the state slice and the diff engine auto-emits
["o", newKeys]. No core library work needed.Test plan
tests/event-delegation.test.ts(cast to 16 with the marker-pattern follow-up); covers dragstart key-stash, dragover preventDefault under throttling, drop source/target injection, text/plain Firefox fallback, nested drop targets, dragend/dragenter/dragleave forwarding, empty-action skip, end-to-end reorder roundtripnpm test— all 477 tests pass, no regressionsnpm run build— IIFE bundle clean (84306 → 85064 bytes, +758)Companion / dependency
A companion PR in
livetemplate/examplesadds a Sortable List pattern that demonstrates the new events end-to-end. That PR depends on this one being merged + released to npm before its CI can pass against@latest.🤖 Generated with Claude Code