-
Notifications
You must be signed in to change notification settings - Fork 0
feature: Add Drag and Drop support to uni-dom #405
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| ``` | ||
|
|
||
| ### 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
197
uni-dom-test/src/test/scala/wvlet/uni/dom/DragDropTest.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.datais defined with typeAny, but implemented asString.draggable(kind: String, data: Any)is defined, but implemented asdraggable(kind: String, data: String).onDragStarthandler is defined asDragData => Unit, but implemented asdom.DragEvent => Unit.onDragEndhandler is defined as() => Unit, but implemented asdom.DragEvent => Unit.Please update this design document to reflect the implemented API.