/** * SortableList — minimal HTML5 drag-and-drop hook for reorderable lists. * * Markup contract: *
*
...
*
...
*
* * On drop, the hook pushes one event named by `data-event` with payload * `{ ordered_ids: [...] }` carrying the new order of every direct child * with a `data-id` attribute. * * No external dependency — uses native HTML5 drag-and-drop. Mirrors the * existing DeviceListReorder hook in style. */ export const SortableList = { draggedEl: null as HTMLElement | null, handleDragStart: null as ((e: DragEvent) => void) | null, handleDragEnd: null as ((e: DragEvent) => void) | null, handleDragOver: null as ((e: DragEvent) => void) | null, handleDrop: null as ((e: DragEvent) => void) | null, mounted(this: any) { const container: HTMLElement = this.el const eventName: string = container.dataset.event || "reorder" const itemFromTarget = (t: EventTarget | null): HTMLElement | null => { const el = t as HTMLElement | null return el ? (el.closest("[data-id]") || null) : null } const itemIsDirectChild = (item: HTMLElement | null): item is HTMLElement => { return !!item && item.parentElement === container } this.handleDragStart = (e: DragEvent) => { const item = itemFromTarget(e.target) if (!itemIsDirectChild(item) || item.getAttribute("draggable") !== "true") return this.draggedEl = item item.classList.add("opacity-50") e.dataTransfer!.effectAllowed = "move" } this.handleDragEnd = (_e: DragEvent) => { if (this.draggedEl) { this.draggedEl.classList.remove("opacity-50") this.draggedEl = null } container.querySelectorAll(".sortable-drop-target").forEach((el: Element) => { el.classList.remove("sortable-drop-target", "border-t-4", "border-b-4", "border-blue-500") }) } this.handleDragOver = (e: DragEvent) => { e.preventDefault() if (!this.draggedEl) return const target = itemFromTarget(e.target) if (!itemIsDirectChild(target) || target === this.draggedEl) return const rect = target.getBoundingClientRect() const isBottomHalf = e.clientY > rect.top + rect.height / 2 container.querySelectorAll(".sortable-drop-target").forEach((el: Element) => { el.classList.remove("sortable-drop-target", "border-t-4", "border-b-4", "border-blue-500") }) target.classList.add( "sortable-drop-target", "border-blue-500", isBottomHalf ? "border-b-4" : "border-t-4" ) } this.handleDrop = (e: DragEvent) => { e.preventDefault() if (!this.draggedEl) return const target = itemFromTarget(e.target) if (!itemIsDirectChild(target) || target === this.draggedEl) return const rect = target.getBoundingClientRect() const isBottomHalf = e.clientY > rect.top + rect.height / 2 // Reorder DOM optimistically so the user sees the result // immediately; the server-side patch will reconcile. if (isBottomHalf) { container.insertBefore(this.draggedEl, target.nextSibling) } else { container.insertBefore(this.draggedEl, target) } const orderedIds = Array.from( container.querySelectorAll(":scope > [data-id]") ).map(el => el.dataset.id!) this.pushEvent(eventName, { ordered_ids: orderedIds }) } container.addEventListener("dragstart", this.handleDragStart) container.addEventListener("dragend", this.handleDragEnd) container.addEventListener("dragover", this.handleDragOver) container.addEventListener("drop", this.handleDrop) }, destroyed(this: any) { if (this.handleDragStart) this.el.removeEventListener("dragstart", this.handleDragStart) if (this.handleDragEnd) this.el.removeEventListener("dragend", this.handleDragEnd) if (this.handleDragOver) this.el.removeEventListener("dragover", this.handleDragOver) if (this.handleDrop) this.el.removeEventListener("drop", this.handleDrop) this.draggedEl = null } }