Skip to content

Commit 970e880

Browse files
xerialclaude
andauthored
feature: Add Drag and Drop support to uni-dom (#405)
## Summary - Add `DragDrop` object for building drag-and-drop UIs - Add `DomNode.group()` for combining multiple DomNodes as a single unit - Add `DomNodeGroup` support in DomRenderer ## Features ### Drag and Drop API ```scala import wvlet.uni.dom.all.* // Make elements draggable div( DragDrop.draggable("item", "item-123"), "Drag me" ) // Create drop zones div( DragDrop.dropZone("item") { data => println(s"Dropped: ${data.data}") }, cls -> "drop-area", "Drop here" ) // File drop zone (for files from OS) div( DragDrop.fileDropZone { files => files.foreach(f => println(s"File: ${f.name}")) }, "Drop files here" ) // React to drag state DragDrop.isDragging.map { dragging => if dragging then div(cls -> "drag-overlay", "Drop anywhere") else DomNode.empty } ``` ### DomNode.group ```scala // Combine multiple modifiers as a single DomNode div( DomNode.group( attr("data-x")("1"), attr("data-y")("2"), cls -> "combined" ), "Content" ) ``` ## API Summary | Method | Description | |--------|-------------| | `DragDrop.draggable(data)` | Make element draggable with data | | `DragDrop.dropZone(onDrop)` | Create drop zone accepting any data | | `DragDrop.dropZone(kinds...)(onDrop)` | Create drop zone with type filter | | `DragDrop.fileDropZone(onFiles)` | Create zone for file drops | | `DragDrop.state` | Reactive stream of DragState | | `DragDrop.isDragging` | Reactive boolean for drag status | | `DomNode.group(nodes...)` | Combine multiple DomNodes | ## Test plan - [x] All 282 tests pass (25 new drag-drop tests) - [x] Compilation successful - [x] Code formatted with scalafmtAll 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent bd0b174 commit 970e880

File tree

6 files changed

+727
-0
lines changed

6 files changed

+727
-0
lines changed

plans/2026-02-04-drag-drop.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Drag & Drop Support for uni-dom
2+
3+
## Overview
4+
5+
Add a `DragDrop` object that provides higher-level drag and drop abstractions on top of the existing HTML5 drag events.
6+
7+
## Goals
8+
9+
1. Simplify common drag-and-drop patterns
10+
2. Provide reactive state tracking for drag operations
11+
3. Support data transfer between drag sources and drop targets
12+
4. Handle file drops from the OS
13+
14+
## API Design
15+
16+
### Core Types
17+
18+
```scala
19+
// Data being transferred during drag
20+
case class DragData(
21+
kind: String, // Type identifier (e.g., "item", "file", "text")
22+
data: String, // The actual data (as string, JSON serialized for transfer)
23+
effectAllowed: String = "all" // copy, move, link, all, etc.
24+
)
25+
26+
// Current state of drag operation
27+
case class DragState(
28+
isDragging: Boolean,
29+
data: Option[DragData],
30+
overElement: Option[dom.Element]
31+
)
32+
```
33+
34+
### DragDrop Object
35+
36+
```scala
37+
object DragDrop:
38+
// Make an element draggable
39+
def draggable(data: DragData): DomNode
40+
def draggable(kind: String, data: String): DomNode
41+
42+
// Create a drop zone
43+
def dropZone(onDrop: DragData => Unit): DomNode
44+
def dropZone(accept: String*)(onDrop: DragData => Unit): DomNode
45+
46+
// File drop zone (for files from OS)
47+
def fileDropZone(onFiles: Seq[dom.File] => Unit): DomNode
48+
49+
// Reactive state
50+
def state: Rx[DragState]
51+
def isDragging: Rx[Boolean]
52+
def isDraggingNow: Boolean
53+
def currentState: DragState
54+
55+
// Event handlers for custom behavior
56+
def onDragStart(handler: dom.DragEvent => Unit): DomNode
57+
def onDragEnd(handler: dom.DragEvent => Unit): DomNode
58+
def onDragOver(handler: dom.DragEvent => Unit): DomNode
59+
def onDragEnter(handler: dom.DragEvent => Unit): DomNode
60+
def onDragLeave(handler: dom.DragEvent => Unit): DomNode
61+
62+
// Cleanup
63+
def stop(): Unit
64+
```
65+
66+
### Usage Examples
67+
68+
```scala
69+
import wvlet.uni.dom.all.*
70+
71+
// Simple draggable item
72+
div(
73+
DragDrop.draggable("item", itemId),
74+
"Drag me"
75+
)
76+
77+
// Drop zone that accepts items
78+
div(
79+
DragDrop.dropZone("item") { data =>
80+
println(s"Dropped: ${data.data}")
81+
},
82+
cls -> "drop-area",
83+
"Drop here"
84+
)
85+
86+
// File drop zone
87+
div(
88+
DragDrop.fileDropZone { files =>
89+
files.foreach(f => println(s"File: ${f.name}"))
90+
},
91+
"Drop files here"
92+
)
93+
94+
// Visual feedback during drag
95+
div(
96+
DragDrop.isDragging.map { dragging =>
97+
if dragging then cls -> "drop-highlight" else cls -> ""
98+
}
99+
)
100+
```
101+
102+
## Implementation Notes
103+
104+
- Use `dataTransfer.setData/getData` with JSON for structured data
105+
- Prefix data types with "application/x-uni-" for namespacing
106+
- Track drag state globally for reactive updates
107+
- Handle `dragover` with `preventDefault()` to allow drops
108+
- Support both internal data and external files
109+
110+
## Test Plan
111+
112+
- Test draggable modifier returns DomNode
113+
- Test dropZone modifier returns DomNode
114+
- Test fileDropZone modifier returns DomNode
115+
- Test DragState case class
116+
- Test DragData case class
117+
- Test reactive state emissions
118+
- Test rendering with drag handlers
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package wvlet.uni.dom
15+
16+
import org.scalajs.dom
17+
import wvlet.uni.test.UniTest
18+
import wvlet.uni.dom.all.*
19+
import wvlet.uni.dom.all.given
20+
import wvlet.uni.rx.Rx
21+
22+
class DragDropTest extends UniTest:
23+
24+
test("DragData case class holds data"):
25+
val data = DragData("item", "item-123")
26+
data.kind shouldBe "item"
27+
data.data shouldBe "item-123"
28+
data.effectAllowed shouldBe "all"
29+
30+
test("DragData with custom effectAllowed"):
31+
val data = DragData("task", "task-456", "copy")
32+
data.effectAllowed shouldBe "copy"
33+
34+
test("DragState.empty has correct defaults"):
35+
val state = DragState.empty
36+
state.isDragging shouldBe false
37+
state.data shouldBe None
38+
state.overElement shouldBe None
39+
40+
test("DragState case class holds state"):
41+
val data = DragData("item", "123")
42+
val state = DragState(isDragging = true, data = Some(data), overElement = None)
43+
state.isDragging shouldBe true
44+
state.data shouldBe Some(data)
45+
46+
test("DragDrop.draggable with DragData returns DomNode"):
47+
val data = DragData("item", "item-123")
48+
val node = DragDrop.draggable(data)
49+
node shouldMatch { case _: DomNode =>
50+
}
51+
52+
test("DragDrop.draggable with kind and data returns DomNode"):
53+
val node = DragDrop.draggable("item", "item-123")
54+
node shouldMatch { case _: DomNode =>
55+
}
56+
57+
test("DragDrop.dropZone returns DomNode"):
58+
val node = DragDrop.dropZone { data =>
59+
()
60+
}
61+
node shouldMatch { case _: DomNode =>
62+
}
63+
64+
test("DragDrop.dropZone with accept filter returns DomNode"):
65+
val node =
66+
DragDrop.dropZone("item", "task") { data =>
67+
()
68+
}
69+
node shouldMatch { case _: DomNode =>
70+
}
71+
72+
test("DragDrop.fileDropZone returns DomNode"):
73+
val node = DragDrop.fileDropZone { files =>
74+
()
75+
}
76+
node shouldMatch { case _: DomNode =>
77+
}
78+
79+
test("DragDrop.state returns Rx[DragState]"):
80+
val state = DragDrop.state
81+
state shouldMatch { case _: Rx[?] =>
82+
}
83+
84+
test("DragDrop.isDragging returns Rx[Boolean]"):
85+
val dragging = DragDrop.isDragging
86+
dragging shouldMatch { case _: Rx[?] =>
87+
}
88+
89+
test("DragDrop.isDraggingNow returns Boolean"):
90+
val dragging = DragDrop.isDraggingNow
91+
dragging shouldBe false
92+
93+
test("DragDrop.currentState returns DragState"):
94+
val state = DragDrop.currentState
95+
state shouldMatch { case DragState(_, _, _) =>
96+
}
97+
98+
test("DragDrop.onDragStart returns DomNode"):
99+
val node = DragDrop.onDragStart(_ => ())
100+
node shouldMatch { case _: DomNode =>
101+
}
102+
103+
test("DragDrop.onDragEnd returns DomNode"):
104+
val node = DragDrop.onDragEnd(_ => ())
105+
node shouldMatch { case _: DomNode =>
106+
}
107+
108+
test("DragDrop.onDragOver returns DomNode"):
109+
val node = DragDrop.onDragOver(_ => ())
110+
node shouldMatch { case _: DomNode =>
111+
}
112+
113+
test("DragDrop.onDragEnter returns DomNode"):
114+
val node = DragDrop.onDragEnter(_ => ())
115+
node shouldMatch { case _: DomNode =>
116+
}
117+
118+
test("DragDrop.onDragLeave returns DomNode"):
119+
val node = DragDrop.onDragLeave(_ => ())
120+
node shouldMatch { case _: DomNode =>
121+
}
122+
123+
test("Draggable can be attached to element"):
124+
val elem = div(DragDrop.draggable("item", "item-1"), "Drag me")
125+
elem shouldMatch { case _: RxElement =>
126+
}
127+
128+
test("Drop zone can be attached to element"):
129+
var dropped: Option[DragData] = None
130+
val elem = div(
131+
DragDrop.dropZone { data =>
132+
dropped = Some(data)
133+
},
134+
"Drop here"
135+
)
136+
elem shouldMatch { case _: RxElement =>
137+
}
138+
139+
test("File drop zone can be attached to element"):
140+
var files: Seq[dom.File] = Seq.empty
141+
val elem = div(
142+
DragDrop.fileDropZone { f =>
143+
files = f
144+
},
145+
"Drop files here"
146+
)
147+
elem shouldMatch { case _: RxElement =>
148+
}
149+
150+
test("Draggable renders with draggable attribute"):
151+
val elem = div(DragDrop.draggable("item", "123"), "Drag")
152+
val (node, cancel) = DomRenderer.createNode(elem)
153+
154+
node match
155+
case e: dom.Element =>
156+
e.getAttribute("draggable") shouldBe "true"
157+
case _ =>
158+
fail("Expected Element")
159+
160+
cancel.cancel
161+
162+
test("DragDrop.state emits values reactively"):
163+
var result: DragState = DragState.empty
164+
val cancel = DragDrop
165+
.state
166+
.run { v =>
167+
result = v
168+
}
169+
result shouldMatch { case DragState(_, _, _) =>
170+
}
171+
cancel.cancel
172+
173+
test("DomNode.group creates DomNodeGroup"):
174+
val group = DomNode.group(
175+
HtmlTags.attr("draggable")("true"),
176+
HtmlTags.attr("class")("draggable")
177+
)
178+
group shouldMatch { case _: DomNodeGroup =>
179+
}
180+
181+
test("DomNodeGroup renders all nodes"):
182+
val elem = div(
183+
DomNode.group(HtmlTags.attr("data-a")("1"), HtmlTags.attr("data-b")("2")),
184+
"Content"
185+
)
186+
val (node, cancel) = DomRenderer.createNode(elem)
187+
188+
node match
189+
case e: dom.Element =>
190+
e.getAttribute("data-a") shouldBe "1"
191+
e.getAttribute("data-b") shouldBe "2"
192+
case _ =>
193+
fail("Expected Element")
194+
195+
cancel.cancel
196+
197+
end DragDropTest

uni/.js/src/main/scala/wvlet/uni/dom/DomNode.scala

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ object DomNode:
2424
*/
2525
object empty extends DomNode
2626

27+
/**
28+
* Group multiple DomNodes together as a single DomNode.
29+
*/
30+
def group(nodes: DomNode*): DomNode = DomNodeGroup(nodes)
31+
2732
/**
2833
* Represents raw HTML content that will be inserted directly into the DOM.
2934
*
@@ -37,3 +42,9 @@ case class RawHtml(html: String) extends DomNode
3742
* Represents an HTML entity reference (e.g., &nbsp;, &amp;).
3843
*/
3944
case class EntityRef(entityName: String) extends DomNode
45+
46+
/**
47+
* Groups multiple DomNodes together as a single DomNode. Useful for returning multiple modifiers
48+
* from a single function.
49+
*/
50+
case class DomNodeGroup(nodes: Seq[DomNode]) extends DomNode

uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ object DomRenderer extends LogSupport:
215215
v match
216216
case DomNode.empty =>
217217
Cancelable.empty
218+
case g: DomNodeGroup =>
219+
val cancelables = g.nodes.map(n => traverse(n, anchor, localContext))
220+
Cancelable.merge(cancelables)
218221
case e: DomElement =>
219222
val elem = createDomNode(e, parentName, parentNs)
220223
val c = e.traverseModifiers(m => renderToInternal(localContext, elem, m))

0 commit comments

Comments
 (0)