Mobile drag and re-order fixes (#408)

* FIX mobile scroll and drag

* reorder fixes

* Add hold delay for mobile
This commit is contained in:
soky srm
2025-12-02 17:10:55 +01:00
committed by GitHub
parent 888fa3684a
commit 1727b772ed
4 changed files with 132 additions and 22 deletions

View File

@@ -1,7 +1,12 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["section"];
static targets = ["section", "handle"];
// Short delay to prevent accidental touches on the grip handle
static values = {
holdDelay: { type: Number, default: 150 },
};
connect() {
this.draggedElement = null;
@@ -10,6 +15,8 @@ export default class extends Controller {
this.currentTouchY = 0;
this.isTouching = false;
this.keyboardGrabbedElement = null;
this.holdTimer = null;
this.holdActivated = false;
}
// ===== Mouse Drag Events =====
@@ -60,16 +67,44 @@ export default class extends Controller {
}
// ===== Touch Events =====
// Touch events are bound to the drag handle only, with a short hold delay
// to prevent accidental touches.
touchStart(event) {
this.draggedElement = event.currentTarget;
// Find the parent section element from the handle
const section = event.currentTarget.closest(
"[data-dashboard-sortable-target='section']",
);
if (!section) return;
this.pendingSection = section;
this.touchStartY = event.touches[0].clientY;
this.currentTouchY = this.touchStartY;
this.holdActivated = false;
// Start hold timer
this.holdTimer = setTimeout(() => {
this.activateDrag();
}, this.holdDelayValue);
}
activateDrag() {
if (!this.pendingSection) return;
this.holdActivated = true;
this.isTouching = true;
this.draggedElement.classList.add("opacity-50", "scale-105");
this.draggedElement = this.pendingSection;
this.draggedElement.classList.add("opacity-50", "scale-[1.02]");
this.draggedElement.setAttribute("aria-grabbed", "true");
// Haptic feedback if available
if (navigator.vibrate) {
navigator.vibrate(30);
}
}
touchMove(event) {
if (!this.isTouching || !this.draggedElement) return;
if (!this.holdActivated || !this.isTouching || !this.draggedElement) return;
event.preventDefault();
this.currentTouchY = event.touches[0].clientY;
@@ -84,8 +119,13 @@ export default class extends Controller {
}
}
touchEnd(event) {
if (!this.isTouching || !this.draggedElement) return;
touchEnd() {
this.cancelHold();
if (!this.holdActivated || !this.isTouching || !this.draggedElement) {
this.resetTouchState();
return;
}
const afterElement = this.getDragAfterElement(this.currentTouchY);
const container = this.element;
@@ -96,13 +136,26 @@ export default class extends Controller {
container.insertBefore(this.draggedElement, afterElement);
}
this.draggedElement.classList.remove("opacity-50", "scale-105");
this.draggedElement.classList.remove("opacity-50", "scale-[1.02]");
this.draggedElement.setAttribute("aria-grabbed", "false");
this.clearPlaceholders();
this.saveOrder();
this.resetTouchState();
}
cancelHold() {
if (this.holdTimer) {
clearTimeout(this.holdTimer);
this.holdTimer = null;
}
}
resetTouchState() {
this.isTouching = false;
this.draggedElement = null;
this.pendingSection = null;
this.holdActivated = false;
}
// ===== Keyboard Navigation =====

View File

@@ -1,7 +1,12 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["section"];
static targets = ["section", "handle"];
// Short delay to prevent accidental touches on the grip handle
static values = {
holdDelay: { type: Number, default: 150 },
};
connect() {
this.draggedElement = null;
@@ -10,6 +15,8 @@ export default class extends Controller {
this.currentTouchY = 0;
this.isTouching = false;
this.keyboardGrabbedElement = null;
this.holdTimer = null;
this.holdActivated = false;
}
// ===== Mouse Drag Events =====
@@ -60,16 +67,44 @@ export default class extends Controller {
}
// ===== Touch Events =====
// Touch events are bound to the drag handle only, with a short hold delay
// to prevent accidental touches.
touchStart(event) {
this.draggedElement = event.currentTarget;
// Find the parent section element from the handle
const section = event.currentTarget.closest(
"[data-reports-sortable-target='section']",
);
if (!section) return;
this.pendingSection = section;
this.touchStartY = event.touches[0].clientY;
this.currentTouchY = this.touchStartY;
this.holdActivated = false;
// Start hold timer
this.holdTimer = setTimeout(() => {
this.activateDrag();
}, this.holdDelayValue);
}
activateDrag() {
if (!this.pendingSection) return;
this.holdActivated = true;
this.isTouching = true;
this.draggedElement.classList.add("opacity-50", "scale-105");
this.draggedElement = this.pendingSection;
this.draggedElement.classList.add("opacity-50", "scale-[1.02]");
this.draggedElement.setAttribute("aria-grabbed", "true");
// Haptic feedback if available
if (navigator.vibrate) {
navigator.vibrate(30);
}
}
touchMove(event) {
if (!this.isTouching || !this.draggedElement) return;
if (!this.holdActivated || !this.isTouching || !this.draggedElement) return;
event.preventDefault();
this.currentTouchY = event.touches[0].clientY;
@@ -84,8 +119,13 @@ export default class extends Controller {
}
}
touchEnd(event) {
if (!this.isTouching || !this.draggedElement) return;
touchEnd() {
this.cancelHold();
if (!this.holdActivated || !this.isTouching || !this.draggedElement) {
this.resetTouchState();
return;
}
const afterElement = this.getDragAfterElement(this.currentTouchY);
const container = this.element;
@@ -96,13 +136,26 @@ export default class extends Controller {
container.insertBefore(this.draggedElement, afterElement);
}
this.draggedElement.classList.remove("opacity-50", "scale-105");
this.draggedElement.classList.remove("opacity-50", "scale-[1.02]");
this.draggedElement.setAttribute("aria-grabbed", "false");
this.clearPlaceholders();
this.saveOrder();
this.resetTouchState();
}
cancelHold() {
if (this.holdTimer) {
clearTimeout(this.holdTimer);
this.holdTimer = null;
}
}
resetTouchState() {
this.isTouching = false;
this.draggedElement = null;
this.pendingSection = null;
this.holdActivated = false;
}
// ===== Keyboard Navigation =====