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
118 changes: 118 additions & 0 deletions plans/2026-02-04-drag-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Drag & Drop Support for uni-dom

## Overview

Add a `DragDrop` object that provides higher-level drag and drop abstractions on top of the existing HTML5 drag events.

## Goals

1. Simplify common drag-and-drop patterns
2. Provide reactive state tracking for drag operations
3. Support data transfer between drag sources and drop targets
4. Handle file drops from the OS

## API Design

### Core Types

```scala
// Data being transferred during drag
case class DragData(
kind: String, // Type identifier (e.g., "item", "file", "text")
data: String, // The actual data (as string, JSON serialized for transfer)
effectAllowed: String = "all" // copy, move, link, all, etc.
)

// Current state of drag operation
case class DragState(
isDragging: Boolean,
data: Option[DragData],
overElement: Option[dom.Element]
)
```

### DragDrop Object

```scala
object DragDrop:
// Make an element draggable
def draggable(data: DragData): DomNode
def draggable(kind: String, data: String): DomNode

// Create a drop zone
def dropZone(onDrop: DragData => Unit): DomNode
def dropZone(accept: String*)(onDrop: DragData => Unit): DomNode

// File drop zone (for files from OS)
def fileDropZone(onFiles: Seq[dom.File] => Unit): DomNode

// Reactive state
def state: Rx[DragState]
def isDragging: Rx[Boolean]
def isDraggingNow: Boolean
def currentState: DragState

// Event handlers for custom behavior
def onDragStart(handler: dom.DragEvent => Unit): DomNode
def onDragEnd(handler: dom.DragEvent => Unit): DomNode
def onDragOver(handler: dom.DragEvent => Unit): DomNode
def onDragEnter(handler: dom.DragEvent => Unit): DomNode
def onDragLeave(handler: dom.DragEvent => Unit): DomNode

// Cleanup
def stop(): Unit
```
Comment on lines 16 to 64
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The API design described in this document has several inconsistencies with the actual implementation in DragDrop.scala. It's important to keep design documents up-to-date to avoid confusion for future developers.

Specifically:

  • DragData.data is defined with type Any, but implemented as String.
  • draggable(kind: String, data: Any) is defined, but implemented as draggable(kind: String, data: String).
  • onDragStart handler is defined as DragData => Unit, but implemented as dom.DragEvent => Unit.
  • onDragEnd handler is defined as () => Unit, but implemented as dom.DragEvent => Unit.

Please update this design document to reflect the implemented API.


### Usage Examples

```scala
import wvlet.uni.dom.all.*

// Simple draggable item
div(
DragDrop.draggable("item", itemId),
"Drag me"
)

// Drop zone that accepts items
div(
DragDrop.dropZone("item") { data =>
println(s"Dropped: ${data.data}")
},
cls -> "drop-area",
"Drop here"
)

// File drop zone
div(
DragDrop.fileDropZone { files =>
files.foreach(f => println(s"File: ${f.name}"))
},
"Drop files here"
)

// Visual feedback during drag
div(
DragDrop.isDragging.map { dragging =>
if dragging then cls -> "drop-highlight" else cls -> ""
}
)
```

## Implementation Notes

- Use `dataTransfer.setData/getData` with JSON for structured data
- Prefix data types with "application/x-uni-" for namespacing
- Track drag state globally for reactive updates
- Handle `dragover` with `preventDefault()` to allow drops
- Support both internal data and external files

## Test Plan

- Test draggable modifier returns DomNode
- Test dropZone modifier returns DomNode
- Test fileDropZone modifier returns DomNode
- Test DragState case class
- Test DragData case class
- Test reactive state emissions
- Test rendering with drag handlers
197 changes: 197 additions & 0 deletions uni-dom-test/src/test/scala/wvlet/uni/dom/DragDropTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package wvlet.uni.dom

import org.scalajs.dom
import wvlet.uni.test.UniTest
import wvlet.uni.dom.all.*
import wvlet.uni.dom.all.given
import wvlet.uni.rx.Rx

class DragDropTest extends UniTest:

test("DragData case class holds data"):
val data = DragData("item", "item-123")
data.kind shouldBe "item"
data.data shouldBe "item-123"
data.effectAllowed shouldBe "all"

test("DragData with custom effectAllowed"):
val data = DragData("task", "task-456", "copy")
data.effectAllowed shouldBe "copy"

test("DragState.empty has correct defaults"):
val state = DragState.empty
state.isDragging shouldBe false
state.data shouldBe None
state.overElement shouldBe None

test("DragState case class holds state"):
val data = DragData("item", "123")
val state = DragState(isDragging = true, data = Some(data), overElement = None)
state.isDragging shouldBe true
state.data shouldBe Some(data)

test("DragDrop.draggable with DragData returns DomNode"):
val data = DragData("item", "item-123")
val node = DragDrop.draggable(data)
node shouldMatch { case _: DomNode =>
}

test("DragDrop.draggable with kind and data returns DomNode"):
val node = DragDrop.draggable("item", "item-123")
node shouldMatch { case _: DomNode =>
}

test("DragDrop.dropZone returns DomNode"):
val node = DragDrop.dropZone { data =>
()
}
node shouldMatch { case _: DomNode =>
}

test("DragDrop.dropZone with accept filter returns DomNode"):
val node =
DragDrop.dropZone("item", "task") { data =>
()
}
node shouldMatch { case _: DomNode =>
}

test("DragDrop.fileDropZone returns DomNode"):
val node = DragDrop.fileDropZone { files =>
()
}
node shouldMatch { case _: DomNode =>
}

test("DragDrop.state returns Rx[DragState]"):
val state = DragDrop.state
state shouldMatch { case _: Rx[?] =>
}

test("DragDrop.isDragging returns Rx[Boolean]"):
val dragging = DragDrop.isDragging
dragging shouldMatch { case _: Rx[?] =>
}

test("DragDrop.isDraggingNow returns Boolean"):
val dragging = DragDrop.isDraggingNow
dragging shouldBe false

test("DragDrop.currentState returns DragState"):
val state = DragDrop.currentState
state shouldMatch { case DragState(_, _, _) =>
}

test("DragDrop.onDragStart returns DomNode"):
val node = DragDrop.onDragStart(_ => ())
node shouldMatch { case _: DomNode =>
}

test("DragDrop.onDragEnd returns DomNode"):
val node = DragDrop.onDragEnd(_ => ())
node shouldMatch { case _: DomNode =>
}

test("DragDrop.onDragOver returns DomNode"):
val node = DragDrop.onDragOver(_ => ())
node shouldMatch { case _: DomNode =>
}

test("DragDrop.onDragEnter returns DomNode"):
val node = DragDrop.onDragEnter(_ => ())
node shouldMatch { case _: DomNode =>
}

test("DragDrop.onDragLeave returns DomNode"):
val node = DragDrop.onDragLeave(_ => ())
node shouldMatch { case _: DomNode =>
}

test("Draggable can be attached to element"):
val elem = div(DragDrop.draggable("item", "item-1"), "Drag me")
elem shouldMatch { case _: RxElement =>
}

test("Drop zone can be attached to element"):
var dropped: Option[DragData] = None
val elem = div(
DragDrop.dropZone { data =>
dropped = Some(data)
},
"Drop here"
)
elem shouldMatch { case _: RxElement =>
}

test("File drop zone can be attached to element"):
var files: Seq[dom.File] = Seq.empty
val elem = div(
DragDrop.fileDropZone { f =>
files = f
},
"Drop files here"
)
elem shouldMatch { case _: RxElement =>
}

test("Draggable renders with draggable attribute"):
val elem = div(DragDrop.draggable("item", "123"), "Drag")
val (node, cancel) = DomRenderer.createNode(elem)

node match
case e: dom.Element =>
e.getAttribute("draggable") shouldBe "true"
case _ =>
fail("Expected Element")

cancel.cancel

test("DragDrop.state emits values reactively"):
var result: DragState = DragState.empty
val cancel = DragDrop
.state
.run { v =>
result = v
}
result shouldMatch { case DragState(_, _, _) =>
}
cancel.cancel

test("DomNode.group creates DomNodeGroup"):
val group = DomNode.group(
HtmlTags.attr("draggable")("true"),
HtmlTags.attr("class")("draggable")
)
group shouldMatch { case _: DomNodeGroup =>
}

test("DomNodeGroup renders all nodes"):
val elem = div(
DomNode.group(HtmlTags.attr("data-a")("1"), HtmlTags.attr("data-b")("2")),
"Content"
)
val (node, cancel) = DomRenderer.createNode(elem)

node match
case e: dom.Element =>
e.getAttribute("data-a") shouldBe "1"
e.getAttribute("data-b") shouldBe "2"
case _ =>
fail("Expected Element")

cancel.cancel

end DragDropTest
11 changes: 11 additions & 0 deletions uni/.js/src/main/scala/wvlet/uni/dom/DomNode.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ object DomNode:
*/
object empty extends DomNode

/**
* Group multiple DomNodes together as a single DomNode.
*/
def group(nodes: DomNode*): DomNode = DomNodeGroup(nodes)

/**
* Represents raw HTML content that will be inserted directly into the DOM.
*
Expand All @@ -37,3 +42,9 @@ case class RawHtml(html: String) extends DomNode
* Represents an HTML entity reference (e.g.,  , &).
*/
case class EntityRef(entityName: String) extends DomNode

/**
* Groups multiple DomNodes together as a single DomNode. Useful for returning multiple modifiers
* from a single function.
*/
case class DomNodeGroup(nodes: Seq[DomNode]) extends DomNode
3 changes: 3 additions & 0 deletions uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ object DomRenderer extends LogSupport:
v match
case DomNode.empty =>
Cancelable.empty
case g: DomNodeGroup =>
val cancelables = g.nodes.map(n => traverse(n, anchor, localContext))
Cancelable.merge(cancelables)
case e: DomElement =>
val elem = createDomNode(e, parentName, parentNs)
val c = e.traverseModifiers(m => renderToInternal(localContext, elem, m))
Expand Down
Loading
Loading