// Minimal drag-to-reorder for vertical lists. Works with mouse, touch and pen // via pointer events; the handle also supports ArrowUp/ArrowDown. /** * @param {HTMLElement} container element whose children are sortable * @param {object} opts * @param {string} opts.handle selector for the drag handle inside a child * @param {string} opts.placeholderClass classes for the gap left while dragging * @param {(el: HTMLElement, from: number, to: number) => void} opts.onMove */ export function sortable(container, { handle = ".drag-handle", placeholderClass = "is-placeholder", onMove }) { const children = () => [...container.children]; container.addEventListener("pointerdown", (e) => { const hdl = e.target.closest(handle); if (!hdl || !container.contains(hdl) || (e.pointerType === "mouse" && e.button !== 0)) return; const el = [...container.children].find((c) => c.contains(hdl)); if (!el) return; e.preventDefault(); const from = children().indexOf(el); const rect = el.getBoundingClientRect(); const grabOffset = e.clientY - rect.top; let pointerY = e.clientY; const ph = el.cloneNode(true); ph.className = el.className + " " + placeholderClass; ph.removeAttribute("id"); ph.setAttribute("aria-hidden", "true"); ph.style.height = rect.height + "px"; el.after(ph); Object.assign(el.style, { position: "fixed", left: rect.left + "px", top: rect.top + "px", width: rect.width + "px", zIndex: "50", pointerEvents: "none", }); el.classList.add("is-dragging"); document.documentElement.classList.add("dragging-active"); hdl.setPointerCapture(e.pointerId); const place = () => { el.style.top = pointerY - grabOffset + "px"; const others = children().filter((c) => c !== el && c !== ph); let before = null; for (const c of others) { const r = c.getBoundingClientRect(); if (pointerY < r.top + r.height / 2) { before = c; break; } } if (before) { if (ph.nextElementSibling !== before) container.insertBefore(ph, before); } else if (container.lastElementChild !== ph) { container.append(ph); } }; // Scroll the page when dragging near the top or bottom edge. let raf = 0; const autoscroll = () => { const edge = 70; let dy = 0; if (pointerY < edge + 60) dy = -Math.ceil((edge + 60 - pointerY) / 6); else if (pointerY > innerHeight - edge) dy = Math.ceil((pointerY - (innerHeight - edge)) / 6); if (dy) { scrollBy(0, dy); place(); } raf = requestAnimationFrame(autoscroll); }; raf = requestAnimationFrame(autoscroll); const move = (ev) => { pointerY = ev.clientY; place(); }; const end = () => { cancelAnimationFrame(raf); hdl.removeEventListener("pointermove", move); hdl.removeEventListener("pointerup", end); hdl.removeEventListener("pointercancel", end); ph.replaceWith(el); el.removeAttribute("style"); el.classList.remove("is-dragging"); document.documentElement.classList.remove("dragging-active"); const to = children().indexOf(el); if (to !== from) onMove(el, from, to); }; hdl.addEventListener("pointermove", move); hdl.addEventListener("pointerup", end); hdl.addEventListener("pointercancel", end); }); container.addEventListener("keydown", (e) => { if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; const hdl = e.target.closest(handle); if (!hdl) return; const el = children().find((c) => c.contains(hdl)); const from = children().indexOf(el); const to = e.key === "ArrowUp" ? from - 1 : from + 1; if (to < 0 || to >= children().length) return; e.preventDefault(); if (e.key === "ArrowUp") el.previousElementSibling.before(el); else el.nextElementSibling.after(el); hdl.focus(); onMove(el, from, to); }); }