From 2fe1d0e8cfb1f5a17582a12f5a644026db692368 Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Wed, 4 Feb 2026 18:05:24 -0800 Subject: [PATCH 1/3] feature: Add Drag and Drop support to uni-dom Add DragDrop object for building drag-and-drop UIs: - DragData/DragState for type-safe data transfer and state tracking - draggable() to make elements draggable with data - dropZone() for receiving drops with optional type filtering - fileDropZone() for handling file drops from the OS - Reactive state tracking with isDragging and state streams - Event handlers for custom drag behavior Also adds: - DomNode.group() for combining multiple DomNodes - DomNodeGroup support in DomRenderer Co-Authored-By: Claude Opus 4.5 --- plans/2026-02-04-drag-drop.md | 113 +++++ .../scala/wvlet/uni/dom/DragDropTest.scala | 197 +++++++++ .../main/scala/wvlet/uni/dom/DomNode.scala | 11 + .../scala/wvlet/uni/dom/DomRenderer.scala | 3 + .../main/scala/wvlet/uni/dom/DragDrop.scala | 385 ++++++++++++++++++ .../src/main/scala/wvlet/uni/dom/all.scala | 5 + 6 files changed, 714 insertions(+) create mode 100644 plans/2026-02-04-drag-drop.md create mode 100644 uni-dom-test/src/test/scala/wvlet/uni/dom/DragDropTest.scala create mode 100644 uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala diff --git a/plans/2026-02-04-drag-drop.md b/plans/2026-02-04-drag-drop.md new file mode 100644 index 00000000..9b94b60a --- /dev/null +++ b/plans/2026-02-04-drag-drop.md @@ -0,0 +1,113 @@ +# 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: Any, // The actual data + 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: Any): 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] + + // Event handlers for custom behavior + def onDragStart(handler: DragData => Unit): DomNode + def onDragEnd(handler: () => Unit): DomNode + def onDragOver(handler: dom.DragEvent => Unit): DomNode + def onDragEnter(handler: dom.DragEvent => Unit): DomNode + def onDragLeave(handler: dom.DragEvent => Unit): DomNode +``` + +### 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 diff --git a/uni-dom-test/src/test/scala/wvlet/uni/dom/DragDropTest.scala b/uni-dom-test/src/test/scala/wvlet/uni/dom/DragDropTest.scala new file mode 100644 index 00000000..a5891c3f --- /dev/null +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/DragDropTest.scala @@ -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 diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/DomNode.scala b/uni/.js/src/main/scala/wvlet/uni/dom/DomNode.scala index 82a8fe6d..468f819b 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/DomNode.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/DomNode.scala @@ -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. * @@ -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 diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala b/uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala index 412c805e..b35408da 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/DomRenderer.scala @@ -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)) diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala b/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala new file mode 100644 index 00000000..c6138471 --- /dev/null +++ b/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala @@ -0,0 +1,385 @@ +/* + * 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 org.scalajs.dom.DataTransfer +import wvlet.uni.rx.{Cancelable, Rx, RxVar} + +import scala.scalajs.js +import scala.scalajs.js.JSON + +/** + * Data being transferred during a drag operation. + * + * @param kind + * Type identifier for the data (e.g., "item", "task", "file") + * @param data + * The actual data being transferred (will be JSON serialized for transfer) + * @param effectAllowed + * Allowed drop effects: "none", "copy", "move", "link", "copyMove", "copyLink", "linkMove", + * "all" + */ +case class DragData(kind: String, data: String, effectAllowed: String = "all") + +/** + * Current state of a drag operation. + * + * @param isDragging + * Whether a drag is currently in progress + * @param data + * The data being dragged, if any + * @param overElement + * The element currently being dragged over, if any + */ +case class DragState(isDragging: Boolean, data: Option[DragData], overElement: Option[dom.Element]) + +object DragState: + val empty: DragState = DragState(isDragging = false, data = None, overElement = None) + +/** + * Drag and drop utilities for building interactive UIs. + * + * Usage: + * {{{ + * import wvlet.uni.dom.all.* + * + * // Make an element draggable + * div( + * DragDrop.draggable("item", "item-123"), + * "Drag me" + * ) + * + * // Create a drop zone + * 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" + * ) + * + * // React to drag state + * DragDrop.isDragging.map { dragging => + * if dragging then div(cls -> "drag-overlay", "Drop anywhere") + * else DomNode.empty + * } + * }}} + */ +object DragDrop: + private val dataTypePrefix = "application/x-uni-" + + private class DragDropState extends Cancelable: + private val stateVar: RxVar[DragState] = Rx.variable(DragState.empty) + + def state: Rx[DragState] = stateVar + def currentState: DragState = stateVar.get + def isDragging: Rx[Boolean] = stateVar.map(_.isDragging) + def currentlyDragging: Boolean = stateVar.get.isDragging + + def startDrag(data: DragData): Unit = + stateVar := DragState(isDragging = true, data = Some(data), overElement = None) + + def setOverElement(elem: Option[dom.Element]): Unit = + val current = stateVar.get + if current.isDragging then + stateVar := current.copy(overElement = elem) + + def endDrag(): Unit = stateVar := DragState.empty + + override def cancel: Unit = () + + end DragDropState + + private lazy val instance: DragDropState = DragDropState() + + /** + * Reactive stream of the current drag state. + */ + def state: Rx[DragState] = instance.state + + /** + * Reactive stream indicating if a drag is in progress. + */ + def isDragging: Rx[Boolean] = instance.isDragging + + /** + * Check if a drag is currently in progress (synchronous). + */ + def isDraggingNow: Boolean = instance.currentlyDragging + + /** + * Get the current drag state (synchronous). + */ + def currentState: DragState = instance.currentState + + /** + * Make an element draggable with the specified data. + * + * @param data + * The DragData to transfer + * @return + * DomNode modifier + */ + def draggable(data: DragData): DomNode = + val draggableAttr = HtmlTags.attr("draggable") + val ondragstart = HtmlTags.handler[dom.DragEvent]("ondragstart") + val ondragend = HtmlTags.handler[dom.DragEvent]("ondragend") + + DomNode.group( + draggableAttr("true"), + ondragstart { (e: dom.DragEvent) => + val dt = e.dataTransfer + dt.effectAllowed = data.effectAllowed.asInstanceOf[dom.DataTransferEffectAllowedKind] + dt.setData(s"${dataTypePrefix}${data.kind}", data.data) + dt.setData("text/plain", data.data) // Fallback + instance.startDrag(data) + }, + ondragend { (_: dom.DragEvent) => + instance.endDrag() + } + ) + + /** + * Make an element draggable with the specified kind and data. + * + * @param kind + * Type identifier for the data + * @param data + * The data to transfer (as string) + * @return + * DomNode modifier + */ + def draggable(kind: String, data: String): DomNode = draggable(DragData(kind, data)) + + /** + * Create a drop zone that accepts any draggable. + * + * @param onDrop + * Handler called when data is dropped + * @return + * DomNode modifier + */ + def dropZone(onDrop: DragData => Unit): DomNode = dropZoneInternal( + acceptAll = true, + Seq.empty, + onDrop + ) + + /** + * Create a drop zone that accepts specific kinds of data. + * + * @param accept + * Kinds of data to accept + * @param onDrop + * Handler called when matching data is dropped + * @return + * DomNode modifier + */ + def dropZone(accept: String*)(onDrop: DragData => Unit): DomNode = dropZoneInternal( + acceptAll = false, + accept, + onDrop + ) + + private def dropZoneInternal( + acceptAll: Boolean, + accept: Seq[String], + onDrop: DragData => Unit + ): DomNode = + val ondragover = HtmlTags.handler[dom.DragEvent]("ondragover") + val ondragenter = HtmlTags.handler[dom.DragEvent]("ondragenter") + val ondragleave = HtmlTags.handler[dom.DragEvent]("ondragleave") + val ondropH = HtmlTags.handler[dom.DragEvent]("ondrop") + + DomNode.group( + ondragover { (e: dom.DragEvent) => + e.preventDefault() // Required to allow drop + e.dataTransfer.dropEffect = "move".asInstanceOf[dom.DataTransferDropEffectKind] + }, + ondragenter { (e: dom.DragEvent) => + e.preventDefault() + e.currentTarget match + case elem: dom.Element => + instance.setOverElement(Some(elem)) + case _ => + () + }, + ondragleave { (e: dom.DragEvent) => + // Only clear if leaving the actual element (not a child) + if e.currentTarget == e.target then + instance.setOverElement(None) + }, + ondropH { (e: dom.DragEvent) => + e.preventDefault() + instance.setOverElement(None) + + val dt = e.dataTransfer + + // Try to find matching data + val dataOpt = + if acceptAll then + // Try to get any uni data type + val types = dt.types.toSeq.filter(_.startsWith(dataTypePrefix)) + types + .headOption + .map { t => + val kind = t.stripPrefix(dataTypePrefix) + DragData(kind, dt.getData(t)) + } + .orElse { + // Fallback to text/plain + val text = dt.getData("text/plain") + if text.nonEmpty then + Some(DragData("text", text)) + else + None + } + else + // Look for specific accepted types + accept + .flatMap { kind => + val data = dt.getData(s"${dataTypePrefix}${kind}") + if data.nonEmpty then + Some(DragData(kind, data)) + else + None + } + .headOption + + dataOpt.foreach(onDrop) + instance.endDrag() + } + ) + + end dropZoneInternal + + /** + * Create a drop zone for files from the operating system. + * + * @param onFiles + * Handler called with dropped files + * @return + * DomNode modifier + */ + def fileDropZone(onFiles: Seq[dom.File] => Unit): DomNode = + val ondragover = HtmlTags.handler[dom.DragEvent]("ondragover") + val ondragenter = HtmlTags.handler[dom.DragEvent]("ondragenter") + val ondragleave = HtmlTags.handler[dom.DragEvent]("ondragleave") + val ondropH = HtmlTags.handler[dom.DragEvent]("ondrop") + + DomNode.group( + ondragover { (e: dom.DragEvent) => + e.preventDefault() + e.dataTransfer.dropEffect = "copy".asInstanceOf[dom.DataTransferDropEffectKind] + }, + ondragenter { (e: dom.DragEvent) => + e.preventDefault() + e.currentTarget match + case elem: dom.Element => + instance.setOverElement(Some(elem)) + case _ => + () + }, + ondragleave { (e: dom.DragEvent) => + if e.currentTarget == e.target then + instance.setOverElement(None) + }, + ondropH { (e: dom.DragEvent) => + e.preventDefault() + instance.setOverElement(None) + + val files = e.dataTransfer.files + if files.length > 0 then + val fileSeq = (0 until files.length).map(files(_)) + onFiles(fileSeq) + } + ) + + end fileDropZone + + /** + * Create a handler for drag start events. + * + * @param handler + * Function called when drag starts + * @return + * DomNode modifier + */ + def onDragStart(handler: dom.DragEvent => Unit): DomNode = + val ondragstart = HtmlTags.handler[dom.DragEvent]("ondragstart") + ondragstart(handler) + + /** + * Create a handler for drag end events. + * + * @param handler + * Function called when drag ends + * @return + * DomNode modifier + */ + def onDragEnd(handler: dom.DragEvent => Unit): DomNode = + val ondragend = HtmlTags.handler[dom.DragEvent]("ondragend") + ondragend(handler) + + /** + * Create a handler for drag over events. + * + * @param handler + * Function called when dragging over the element + * @return + * DomNode modifier + */ + def onDragOver(handler: dom.DragEvent => Unit): DomNode = + val ondragover = HtmlTags.handler[dom.DragEvent]("ondragover") + ondragover(handler) + + /** + * Create a handler for drag enter events. + * + * @param handler + * Function called when drag enters the element + * @return + * DomNode modifier + */ + def onDragEnter(handler: dom.DragEvent => Unit): DomNode = + val ondragenter = HtmlTags.handler[dom.DragEvent]("ondragenter") + ondragenter(handler) + + /** + * Create a handler for drag leave events. + * + * @param handler + * Function called when drag leaves the element + * @return + * DomNode modifier + */ + def onDragLeave(handler: dom.DragEvent => Unit): DomNode = + val ondragleave = HtmlTags.handler[dom.DragEvent]("ondragleave") + ondragleave(handler) + + /** + * Stop tracking drag state. Call this when the application is shutting down. + */ + def stop(): Unit = instance.cancel + +end DragDrop diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala index 6c3e68c5..c65438b8 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala @@ -123,6 +123,11 @@ object all extends HtmlTags with HtmlAttrs with SvgTags with SvgAttrs: // Clipboard export wvlet.uni.dom.Clipboard + // Drag and Drop + export wvlet.uni.dom.DragDrop + export wvlet.uni.dom.DragData + export wvlet.uni.dom.DragState + /** * Re-export helper functions. */ From 948a13d2dee7b62e2d2194c3e4f8b490febcfdec Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Thu, 5 Feb 2026 09:39:46 -0800 Subject: [PATCH 2/3] fix: Address codex review feedback for DragDrop - Remove hardcoded dropEffect="move" to allow browser to determine appropriate effect based on effectAllowed - Track drag state for external file drags in fileDropZone Co-Authored-By: Claude Opus 4.5 --- uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala b/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala index c6138471..029623cf 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala @@ -214,7 +214,8 @@ object DragDrop: DomNode.group( ondragover { (e: dom.DragEvent) => e.preventDefault() // Required to allow drop - e.dataTransfer.dropEffect = "move".asInstanceOf[dom.DataTransferDropEffectKind] + // Let browser determine appropriate dropEffect based on effectAllowed + // Don't override dropEffect to avoid blocking copy/link drags }, ondragenter { (e: dom.DragEvent) => e.preventDefault() @@ -294,6 +295,9 @@ object DragDrop: }, ondragenter { (e: dom.DragEvent) => e.preventDefault() + // Start tracking drag state for external file drags + if !instance.currentlyDragging then + instance.startDrag(DragData("file", "", "copy")) e.currentTarget match case elem: dom.Element => instance.setOverElement(Some(elem)) @@ -303,6 +307,8 @@ object DragDrop: ondragleave { (e: dom.DragEvent) => if e.currentTarget == e.target then instance.setOverElement(None) + // End drag state when leaving (for external file drags) + instance.endDrag() }, ondropH { (e: dom.DragEvent) => e.preventDefault() @@ -312,6 +318,8 @@ object DragDrop: if files.length > 0 then val fileSeq = (0 until files.length).map(files(_)) onFiles(fileSeq) + + instance.endDrag() } ) From 1a8ef36a837c30d1a8c1316fa2827de8ed676b5e Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Thu, 5 Feb 2026 10:03:11 -0800 Subject: [PATCH 3/3] fix: Address Gemini review feedback for DragDrop - Make cancel method reset drag state instead of no-op - Update plan document to match actual implementation - DragData.data is String, not Any - Event handlers take dom.DragEvent, not DragData - Add missing API methods to plan Co-Authored-By: Claude Opus 4.5 --- plans/2026-02-04-drag-drop.md | 13 +++++++++---- uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/plans/2026-02-04-drag-drop.md b/plans/2026-02-04-drag-drop.md index 9b94b60a..e2011deb 100644 --- a/plans/2026-02-04-drag-drop.md +++ b/plans/2026-02-04-drag-drop.md @@ -19,7 +19,7 @@ Add a `DragDrop` object that provides higher-level drag and drop abstractions on // Data being transferred during drag case class DragData( kind: String, // Type identifier (e.g., "item", "file", "text") - data: Any, // The actual data + data: String, // The actual data (as string, JSON serialized for transfer) effectAllowed: String = "all" // copy, move, link, all, etc. ) @@ -37,7 +37,7 @@ case class DragState( object DragDrop: // Make an element draggable def draggable(data: DragData): DomNode - def draggable(kind: String, data: Any): DomNode + def draggable(kind: String, data: String): DomNode // Create a drop zone def dropZone(onDrop: DragData => Unit): DomNode @@ -49,13 +49,18 @@ object DragDrop: // Reactive state def state: Rx[DragState] def isDragging: Rx[Boolean] + def isDraggingNow: Boolean + def currentState: DragState // Event handlers for custom behavior - def onDragStart(handler: DragData => Unit): DomNode - def onDragEnd(handler: () => Unit): DomNode + 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 diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala b/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala index 029623cf..d8377526 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/DragDrop.scala @@ -106,7 +106,7 @@ object DragDrop: def endDrag(): Unit = stateVar := DragState.empty - override def cancel: Unit = () + override def cancel: Unit = stateVar := DragState.empty end DragDropState