Files
einkaufsapp/web/html/js/sortable.js
2026-08-08 20:31:58 +02:00

131 lines
4.0 KiB
JavaScript

// Zeilen per Anfasser umsortieren, ohne Fremdbibliothek.
//
// Umgesetzt mit Pointer Events statt der HTML5-Drag-and-Drop-API: Letztere
// funktioniert auf Touchgeräten praktisch nicht, und die App wird
// überwiegend am Telefon bedient.
//
// Verfahren: Das gezogene Element folgt dem Finger per transform. Sobald
// es den Mittelpunkt eines Nachbarn überschreitet, wird es im DOM davor
// oder dahinter einsortiert und der Bezugspunkt zurückgesetzt - dadurch
// springt nichts.
"use strict";
const DRAG_THRESHOLD = 4; // Pixel, bevor aus einem Tippen ein Ziehen wird
/**
* @param {HTMLElement} container Elternknoten der sortierbaren Zeilen
* @param {object} options
* @param {string} options.handleSelector Auswahl für den Anfasser
* @param {string} options.itemSelector Auswahl für eine Zeile
* @param {(ids: string[]) => void} options.onReorder neue Reihenfolge
*/
export function makeSortable(container, { handleSelector, itemSelector, onReorder }) {
let dragging = null;
let startY = 0;
let offset = 0;
let moved = false;
let originalOrder = [];
const rows = () => [...container.querySelectorAll(itemSelector)];
const idsOf = () => rows().map((r) => r.dataset.id);
function begin(ev) {
const handle = ev.target.closest(handleSelector);
if (!handle || !container.contains(handle)) return;
if (ev.button !== undefined && ev.button !== 0) return;
dragging = handle.closest(itemSelector);
if (!dragging) return;
startY = ev.clientY;
offset = 0;
moved = false;
originalOrder = idsOf();
handle.setPointerCapture(ev.pointerId);
ev.preventDefault();
}
function move(ev) {
if (!dragging) return;
offset = ev.clientY - startY;
if (!moved && Math.abs(offset) < DRAG_THRESHOLD) return;
if (!moved) {
moved = true;
dragging.classList.add("dragging");
container.classList.add("sorting");
}
dragging.style.transform = `translateY(${offset}px)`;
const box = dragging.getBoundingClientRect();
const middle = box.top + box.height / 2;
const previous = dragging.previousElementSibling;
const next = dragging.nextElementSibling;
if (previous && middle < previous.getBoundingClientRect().top + previous.offsetHeight / 2) {
container.insertBefore(dragging, previous);
reanchor(ev);
} else if (next && middle > next.getBoundingClientRect().top + next.offsetHeight / 2) {
container.insertBefore(next, dragging);
reanchor(ev);
}
}
/** Nach einem Umsortieren sitzt das Element an neuer Stelle. Ohne das
* Zurücksetzen des Bezugspunkts würde es um seine eigene Höhe springen. */
function reanchor(ev) {
startY = ev.clientY;
offset = 0;
dragging.style.transform = "";
}
function end() {
if (!dragging) return;
dragging.style.transform = "";
dragging.classList.remove("dragging");
container.classList.remove("sorting");
const wasMoved = moved;
dragging = null;
moved = false;
if (!wasMoved) return;
const order = idsOf();
if (order.join() !== originalOrder.join()) onReorder(order);
}
container.addEventListener("pointerdown", begin);
container.addEventListener("pointermove", move);
container.addEventListener("pointerup", end);
container.addEventListener("pointercancel", end);
// Bedienbar auch ohne Zeigegerät: Anfasser fokussieren, dann Pfeiltasten.
container.addEventListener("keydown", (ev) => {
const handle = ev.target.closest(handleSelector);
if (!handle) return;
if (ev.key !== "ArrowUp" && ev.key !== "ArrowDown") return;
const row = handle.closest(itemSelector);
const before = idsOf();
if (ev.key === "ArrowUp" && row.previousElementSibling) {
container.insertBefore(row, row.previousElementSibling);
} else if (ev.key === "ArrowDown" && row.nextElementSibling) {
container.insertBefore(row.nextElementSibling, row);
} else {
return;
}
ev.preventDefault();
handle.focus();
const after = idsOf();
if (after.join() !== before.join()) onReorder(after);
});
}