/**
* 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
}
}